This commit is contained in:
jpmvaz
2026-09-13 19:48:16 +01:00
commit 3e287bda1a
7 changed files with 2375 additions and 0 deletions
+316
View File
@@ -0,0 +1,316 @@
# 📦 Folder Compressor
A command-line Python script that lets you pick any sub-folder to compress, or compress **all folders in one go** — each one individually with its own live progress bar. Uses **7-Zip store mode** (no compression) for maximum speed and zero CPU overhead.
---
## Features
- Browse all sub-folders with their sizes before choosing
- **Two modes at runtime:**
- **Single folder** — select one folder by number
- **Compress all** — process every folder sequentially, one by one
- Real-time progress bar per job showing:
- Percentage complete
- Elapsed time
- Live transfer speed (MB/s)
- File count
- Currently processing file name
- Pause between jobs in batch mode so you can read each result before continuing
- Batch summary table at the end showing all jobs, sizes, times, and speeds
- Color-coded terminal output (auto-disabled if not supported)
- Cross-platform: Windows, macOS, Linux
---
## Requirements
### Python
Version **3.10 or higher** is required (uses `str | None` union type syntax).
Check your version:
```bash
python --version
# or
python3 --version
```
No third-party packages are needed — only Python's standard library is used.
### 7-Zip
7-Zip must be installed and accessible on your system PATH.
| Platform | Install command |
|----------|----------------|
| **Linux** (Debian/Ubuntu) | `sudo apt install p7zip-full` |
| **Linux** (Fedora/RHEL) | `sudo dnf install p7zip p7zip-plugins` |
| **macOS** (Homebrew) | `brew install p7zip` |
| **macOS** (MacPorts) | `sudo port install p7zip` |
| **Windows** | Download installer from [https://www.7-zip.org/download.html](https://www.7-zip.org/download.html) |
> **Windows note:** After installing, make sure `C:\Program Files\7-Zip\` is added to your system PATH, or the script will auto-detect it from the default install location automatically.
> **Minimum version:** 7-Zip **15.06 or newer** is required for the `-bsp1` progress flag used for real-time output. Most current installs will meet this requirement.
Verify 7-Zip is available:
```bash
7z i
```
---
## Installation
No installation needed. Just download the script:
```bash
# Download compress_folder.py to any directory, then run it directly with Python
```
Optionally make it executable on Linux/macOS:
```bash
chmod +x compress_folder.py
```
---
## Usage
### Basic — scan the current directory
```bash
python compress_folder.py
```
### Specify a directory to scan
```bash
python compress_folder.py /path/to/directory
```
### On Linux/macOS (if made executable)
```bash
./compress_folder.py
./compress_folder.py /path/to/directory
```
### On Windows
```bash
python compress_folder.py
python compress_folder.py C:\Users\YourName\Documents
```
---
## Step-by-step walkthrough
### Step 1 — Launch the script
```
📦 Folder Compressor
Scanning: /home/user/projects
```
### Step 2 — Browse folders and choose a mode
All sub-folders are listed with their sizes, followed by the option to compress all:
```
Available folders:
[ 1] archive (240.0 MB)
[ 2] builds (1.4 GB)
[ 3] logs (88.3 MB)
[ 4] source (320.5 MB)
[ A] Compress ALL folders one by one
Enter a folder number or A to compress all:
```
**To compress a single folder:** type its number and press Enter.
**To compress all folders:** type `A` and press Enter.
---
### Single folder mode
```
Enter a folder number or A to compress all: 2
✔ Mode: Single folder → builds
Calculating source size… 1.4 GB
────────────────────────────────────────────────────────────────
████████████████░░░░░░░░░░░░░░░░░░░ 62% elapsed 0m 08s 178.4 MB/s files: 312
↳ builds/release/v2.1.0/installer.exe
────────────────────────────────────────────────────────────────
✅ Archive created successfully!
Path : /home/user/projects/builds.7z
Source : 1.4 GB
Archive : 1.4 GB
Time : 0m 09s
Avg speed: 159.2 MB/s
```
---
### Compress all mode
Each folder gets a numbered job header and its own progress bar. After each job finishes, the script pauses and waits for you to press Enter before starting the next one:
```
Enter a folder number or A to compress all: A
✔ Mode: Compress all folders sequentially
[1/4] archive ────────────────────────────────────────────
Calculating source size… 240.0 MB
...
✅ Archive created successfully! (240.0 MB · 1s · 240.0 MB/s)
Press Enter to continue to the next folder…
[2/4] builds ─────────────────────────────────────────────
...
```
After all jobs complete, a **batch summary** is printed:
```
══════════════════════════════════════════════════════════════════
BATCH SUMMARY — 4 folder(s) processed
══════════════════════════════════════════════════════════════════
✅ archive 240.0 MB → 1s @ 240.0 MB/s
✅ builds 1.4 GB → 9s @ 159.2 MB/s
✅ logs 88.3 MB → 0s @ 310.5 MB/s
✅ source 320.5 MB → 2s @ 192.1 MB/s
──────────────────────────────────────────────────────────────────
Total source size : 2.0 GB
Total archive size: 2.0 GB
Total time : 12s
Overall avg speed : 170.4 MB/s
══════════════════════════════════════════════════════════════════
```
If any jobs fail, they are flagged in the summary with their error code.
---
## Progress bar reference
| Element | Description |
|---------|-------------|
| `████░░░` | Progress bar filling left to right |
| `62%` | Percentage of data processed |
| `elapsed 0m 08s` | Time since this job started |
| `178.4 MB/s` | Rolling average throughput speed |
| `files: 312` | Number of files added so far |
| `↳ filename` | The file currently being processed |
---
## Compression settings
The script uses the following 7-Zip flags:
| Flag | Value | Meaning |
|------|-------|---------|
| `-t7z` | — | Output format: 7z |
| `-mx=0` | 0 | Compression level 0 = **store** (no compression) |
| `-ms=off` | off | Solid archive **disabled** (faster for many files) |
| `-bsp1` | — | Stream progress output to stdout (enables live display) |
**Why no compression?**
Store mode (`-mx=0`) copies files into the archive as-is without compressing them. This is ideal when:
- Speed matters more than file size reduction
- The contents are already compressed (videos, images, zip files, etc.)
- You want to bundle files for transfer without the CPU cost of compression
---
## How the real-time progress works
7-Zip does not write progress on separate lines — instead it continuously overwrites the same terminal line using special control characters. The exact character it uses depends on the platform:
| Platform | Character used | Code |
|----------|---------------|------|
| Linux / macOS | Backspace | `\x08` |
| Windows | Carriage return | `\r` |
The script reads 7-Zip's output as a raw binary stream and splits on **both** characters, so progress is captured correctly on every platform. The percentage, file count, and current filename are extracted from each segment using a regex and rendered live into two reserved terminal lines that update in place.
---
## Output location
Each archive is created in the **same directory that contains the source folder**.
Example:
```
/home/user/projects/ ← scanned directory
archive/ ← source folder
archive.7z ← archive created here
builds/
builds.7z
```
If an archive with the same name already exists, 7-Zip will update it (adding/replacing files). Delete the existing `.7z` first if you want a clean archive.
---
## Interrupting a batch
Press `Ctrl+C` at any "Press Enter to continue" prompt to stop the batch early. Any jobs already completed will have their archives saved. A partial batch summary will be printed for the jobs that ran.
---
## Troubleshooting
**`7-Zip not found` error**
- Ensure 7-Zip is installed (see Requirements above)
- Confirm `7z` is on your PATH: run `which 7z` (Linux/macOS) or `where 7z` (Windows)
- On Windows, try re-installing 7-Zip and ticking the "Add to PATH" option, or place `7z.exe` in `C:\Program Files\7-Zip\` which the script checks automatically
**Progress bar stays at 0% / no stats shown**
- Make sure you are using the latest version of the script
- The root cause of this bug was that Windows 7-Zip uses `\r` to update progress lines while Linux/macOS uses `\x08` (backspace). The current version handles both
- If you still see 0% after updating, confirm your 7-Zip version is 15.06 or newer: run `7z i` and check the version line at the top
**No progress bar / garbled output**
- The live progress display requires a terminal that supports ANSI escape codes
- On older Windows CMD, switch to Windows Terminal or PowerShell
- Progress and stats are still printed even if color/ANSI is not supported
**`SyntaxError` on startup**
- Your Python version is below 3.10 — upgrade to Python 3.10+
**Permission denied on folder**
- Run the script with elevated permissions (`sudo` on Linux/macOS, Run as Administrator on Windows)
- Or select a folder you have read access to
---
## Examples
```bash
# Compress a single folder or all folders in the current directory
python compress_folder.py
# Compress a single folder or all folders inside a specific path
python compress_folder.py /mnt/data/backups
# Windows example
python compress_folder.py "C:\Users\Alice\Desktop"
```
---
## License
This script is provided as-is for personal and commercial use. No warranty is expressed or implied.
+428
View File
@@ -0,0 +1,428 @@
#!/usr/bin/env python3
"""
Folder Compressor using 7-Zip
- Select a single folder to compress, OR
- Compress all folders one by one automatically
Uses 7-Zip store mode (no compression) for maximum speed.
Real-time progress bar shown during each compression job.
"""
import os
import re
import sys
import shutil
import subprocess
import time
from pathlib import Path
from dataclasses import dataclass
# ── Terminal color helpers ────────────────────────────────────────────────────
SUPPORTS_COLOR = (
sys.stdout.isatty()
and (sys.platform != "win32" or os.environ.get("WT_SESSION") or os.environ.get("TERM"))
)
def _c(code: str, text: str) -> str:
return f"\033[{code}m{text}\033[0m" if SUPPORTS_COLOR else text
def green(t): return _c("32", t)
def yellow(t): return _c("33", t)
def cyan(t): return _c("36", t)
def bold(t): return _c("1", t)
def dim(t): return _c("2", t)
def red(t): return _c("31", t)
def magenta(t): return _c("35", t)
# ── Size / time formatters ────────────────────────────────────────────────────
def fmt_size(b: float) -> str:
for unit in ("B", "KB", "MB", "GB", "TB"):
if b < 1024:
return f"{b:.1f} {unit}"
b /= 1024
return f"{b:.1f} PB"
def fmt_time(seconds: float) -> str:
s = int(seconds)
if s < 60:
return f"{s}s"
m, s = divmod(s, 60)
if m < 60:
return f"{m}m {s:02d}s"
h, m = divmod(m, 60)
return f"{h}h {m:02d}m {s:02d}s"
# ── Folder size calculator ────────────────────────────────────────────────────
def folder_size(path: Path) -> int:
total = 0
for root, _, files in os.walk(path):
for f in files:
try:
total += os.path.getsize(os.path.join(root, f))
except OSError:
pass
return total
# ── 7-Zip locator ─────────────────────────────────────────────────────────────
def find_7zip() -> str | None:
for name in ("7z", "7za", "7zz", "7zip"):
path = shutil.which(name)
if path:
return path
if sys.platform == "win32":
for p in (
r"C:\Program Files\7-Zip\7z.exe",
r"C:\Program Files (x86)\7-Zip\7z.exe",
):
if os.path.isfile(p):
return p
return None
# ── Folder listing ────────────────────────────────────────────────────────────
def list_folders(base_dir: str) -> list[Path]:
base = Path(base_dir)
return sorted(
(e for e in base.iterdir() if e.is_dir()),
key=lambda p: p.name.lower(),
)
# ── Mode + folder selection menu ─────────────────────────────────────────────
def prompt_mode_and_selection(folders: list[Path]) -> list[Path]:
"""
Print the folder list then ask the user what to do:
A — compress ALL folders sequentially
1…N — compress a single specific folder
Returns the list of folders to compress (one or all).
"""
print("\nAvailable folders:\n")
for i, folder in enumerate(folders, start=1):
try:
sz = folder_size(folder)
size_label = dim(f" ({fmt_size(sz)})")
except PermissionError:
size_label = dim(" (no access)")
print(f" {dim(f'[{i:>3}]')} {folder.name}{size_label}")
print()
print(f" {dim('[ A]')} {bold('Compress ALL folders one by one')}")
print()
while True:
try:
raw = input("Enter a folder number or A to compress all: ").strip().upper()
if raw == "A":
print(f"\n {green('')} Mode: {bold('Compress all folders sequentially')}")
return list(folders)
idx = int(raw)
if 1 <= idx <= len(folders):
chosen = folders[idx - 1]
print(f"\n {green('')} Mode: {bold('Single folder')}{chosen.name}")
return [chosen]
print(f" Please enter a number between 1 and {len(folders)}, or A.")
except ValueError:
print(" Invalid input — enter a number or A.")
except (EOFError, KeyboardInterrupt):
print("\nAborted.")
sys.exit(0)
# ── Progress bar renderer ─────────────────────────────────────────────────────
BAR_WIDTH = 36
_PROGRESS_RE = re.compile(r"(\d{1,3})%(?:\s+(\d+)\s*\+?\s*(.*))?")
# Split on backspaces (\x08, Linux/macOS) or carriage returns (\r, Windows)
_SPLIT_RE = re.compile(rb"[\x08\r]+")
def _move_up(n: int):
if SUPPORTS_COLOR:
sys.stdout.write(f"\033[{n}A")
def _clear_line():
if SUPPORTS_COLOR:
sys.stdout.write("\033[2K\r")
else:
sys.stdout.write("\r" + " " * 78 + "\r")
def _redraw(pct: int, elapsed: float, files_done: int,
current_file: str, speed_bps: float):
filled = int(BAR_WIDTH * pct / 100)
bar = "" * filled + "" * (BAR_WIDTH - filled)
color = green if pct == 100 else cyan
speed_s = f"{fmt_size(speed_bps)}/s" if speed_bps > 0 else ""
_move_up(2)
_clear_line()
sys.stdout.write(
f" {color(bar)} {bold(f'{pct:>3}%')}"
f" {dim('elapsed')} {dim(fmt_time(elapsed))}"
f" {yellow(speed_s)}"
f" {dim(f'files: {files_done}')}\n"
)
_clear_line()
label = current_file.strip() or "starting…"
if len(label) > 60:
label = "" + label[-59:]
sys.stdout.write(f" {dim('' + label)}\n")
sys.stdout.flush()
# ── Result record ─────────────────────────────────────────────────────────────
@dataclass
class JobResult:
folder: Path
archive: Path | None
src_size: int
arc_size: int
elapsed: float
avg_speed: float
success: bool
error: str = ""
# ── Single compression job ────────────────────────────────────────────────────
def compress_folder(seven_zip: str, folder: Path, output_dir: Path,
job_label: str = "") -> JobResult:
archive_path = output_dir / (folder.name + ".7z")
if job_label:
print(f"\n{job_label}")
print(f"\n {dim('Calculating source size…')} ", end="", flush=True)
src_size = folder_size(folder)
print(fmt_size(src_size))
cmd = [
seven_zip,
"a",
"-t7z",
"-mx=0",
"-ms=off",
"-bsp1",
str(archive_path),
str(folder),
]
print(f"\n {dim('Command: ' + ' '.join(cmd))}")
print()
print(" " + "" * 64)
print()
print()
start_time = time.monotonic()
files_done = 0
current_file = ""
speed_bps = 0.0
last_bytes = 0.0
speed_ts = start_time
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
buf = b""
while True:
chunk = proc.stdout.read(256)
if not chunk:
break
buf += chunk
elapsed = time.monotonic() - start_time
segments = _SPLIT_RE.split(buf)
buf = segments[-1]
for seg in segments[:-1]:
text = seg.decode("utf-8", errors="replace").strip()
if not text:
continue
m = _PROGRESS_RE.search(text)
if m:
pct = min(int(m.group(1)), 100)
files_done = int(m.group(2)) if m.group(2) else files_done
fname = (m.group(3) or "").strip()
if fname:
current_file = fname
if src_size > 0 and pct > 0:
bytes_done = src_size * pct / 100
now = time.monotonic()
dt = now - speed_ts
if dt >= 0.4:
speed_bps = (bytes_done - last_bytes) / dt
last_bytes = bytes_done
speed_ts = now
_redraw(pct, elapsed, files_done, current_file, speed_bps)
proc.wait()
elapsed_total = time.monotonic() - start_time
if proc.returncode == 0:
_redraw(100, elapsed_total, files_done, current_file, speed_bps)
print()
print(" " + "" * 64)
if proc.returncode == 0:
arc_size = archive_path.stat().st_size
avg_speed = src_size / elapsed_total if elapsed_total > 0 else 0
print(f"\n {green('✅ Archive created successfully!')}")
print(f" {bold('Path')} : {archive_path}")
print(f" {bold('Source')} : {fmt_size(src_size)}")
print(f" {bold('Archive')} : {fmt_size(arc_size)}")
print(f" {bold('Time')} : {fmt_time(elapsed_total)}")
print(f" {bold('Avg speed')}: {fmt_size(avg_speed)}/s")
return JobResult(
folder=folder, archive=archive_path,
src_size=src_size, arc_size=arc_size,
elapsed=elapsed_total, avg_speed=avg_speed,
success=True,
)
else:
msg = f"7-Zip exited with code {proc.returncode}"
print(f"\n {red(f'{msg}')}")
return JobResult(
folder=folder, archive=None,
src_size=src_size, arc_size=0,
elapsed=elapsed_total, avg_speed=0,
success=False, error=msg,
)
# ── Batch summary ─────────────────────────────────────────────────────────────
def print_batch_summary(results: list[JobResult]):
succeeded = [r for r in results if r.success]
failed = [r for r in results if not r.success]
total_src = sum(r.src_size for r in results)
total_arc = sum(r.arc_size for r in results)
total_t = sum(r.elapsed for r in results)
width = 66
print()
print(" " + "" * width)
print(f" {bold('BATCH SUMMARY')}{len(results)} folder(s) processed")
print(" " + "" * width)
for r in results:
icon = green("") if r.success else red("")
label = r.folder.name
if len(label) > 38:
label = label[:35] + ""
if r.success:
detail = f"{fmt_size(r.src_size)}{fmt_time(r.elapsed)} @ {fmt_size(r.avg_speed)}/s"
else:
detail = red(r.error)
print(f" {icon} {label:<38} {dim(detail)}")
print(" " + "" * width)
print(f" {bold('Total source size')} : {fmt_size(total_src)}")
print(f" {bold('Total archive size')}: {fmt_size(total_arc)}")
print(f" {bold('Total time')} : {fmt_time(total_t)}")
if total_t > 0:
avg = total_src / total_t
print(f" {bold('Overall avg speed')} : {fmt_size(avg)}/s")
if failed:
print()
print(f" {red(f'{len(failed)} job(s) failed:')}")
for r in failed:
print(f"{r.folder.name}{r.error}")
print(" " + "" * width)
print()
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
scan_dir = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else os.getcwd())
if not os.path.isdir(scan_dir):
print(f"Error: '{scan_dir}' is not a valid directory.")
sys.exit(1)
print(f"\n{bold('📦 Folder Compressor')}")
print(f" Scanning: {cyan(scan_dir)}\n")
seven_zip = find_7zip()
if not seven_zip:
print(
f"\n{red('❌ 7-Zip not found.')}\n"
" Install it and make sure '7z' (or '7za') is on your PATH.\n"
" • Linux : sudo apt install p7zip-full\n"
" • macOS : brew install p7zip\n"
" • Windows: https://www.7-zip.org/download.html"
)
sys.exit(1)
print(f" {green('')} 7-Zip: {dim(seven_zip)}")
folders = list_folders(scan_dir)
if not folders:
print(f"\nNo sub-folders found in '{scan_dir}'.")
sys.exit(0)
targets = prompt_mode_and_selection(folders)
batch = len(targets) > 1
results: list[JobResult] = []
for i, folder in enumerate(targets, start=1):
label = ""
if batch:
label = (
f"\n {magenta(f'[{i}/{len(targets)}]')} "
f"{bold(folder.name)}"
f" {dim('' * max(0, 44 - len(folder.name)))}"
)
result = compress_folder(seven_zip, folder, folder.parent, job_label=label)
results.append(result)
# In batch mode, pause briefly between jobs so the user can read each result
if batch and i < len(targets):
print()
try:
input(f" {dim('Press Enter to continue to the next folder…')}")
except (EOFError, KeyboardInterrupt):
print(f"\n {yellow('⚠ Batch interrupted by user.')}")
break
if batch:
print_batch_summary(results)
else:
print()
if __name__ == "__main__":
main()