commit 3e287bda1a1d09774a3af04630d1655c15317fad Author: jpmvaz Date: Sun Sep 13 19:48:16 2026 +0100 v3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5030a4e --- /dev/null +++ b/README.md @@ -0,0 +1,314 @@ +# πŸ“¦ 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. Each folder gets a numbered job header and its own progress bar. As soon as one job finishes and displays its stats, the next one starts automatically: + +``` +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) + + + [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 point during a batch to stop 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. diff --git a/compress_folder.py b/compress_folder.py new file mode 100644 index 0000000..94a90a2 --- /dev/null +++ b/compress_folder.py @@ -0,0 +1,427 @@ +#!/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, allow Ctrl+C between jobs to abort remaining work + if batch and i < len(targets): + try: + time.sleep(0) # yield to allow KeyboardInterrupt to surface + except KeyboardInterrupt: + print(f"\n {yellow('⚠ Batch interrupted by user.')}") + break + + if batch: + print_batch_summary(results) + else: + print() + + +if __name__ == "__main__": + main() diff --git a/install_dependencies.py b/install_dependencies.py new file mode 100644 index 0000000..66de6ff --- /dev/null +++ b/install_dependencies.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +install_dependencies.py +Installs all requirements for compress_folder.py: + - Checks the Python version (3.10+) + - Detects the OS and installs 7-Zip via the appropriate method + - Verifies the installation when done +""" + +import os +import sys +import shutil +import subprocess +import platform + + +# ── Terminal colors (no third-party deps needed here either) ───────────────── + +IS_TTY = sys.stdout.isatty() + +def _c(code, text): + return f"\033[{code}m{text}\033[0m" if IS_TTY 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) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def header(text): + print(f"\n{bold('── ' + text + ' ' + '─' * max(0, 54 - len(text)))}") + +def ok(text): print(f" {green('βœ”')} {text}") +def info(text): print(f" {cyan('β„Ή')} {text}") +def warn(text): print(f" {yellow('⚠')} {text}") +def fail(text): print(f" {red('✘')} {text}") + +def run(cmd, check=True, capture=False): + """Run a shell command, print it first, and return the CompletedProcess.""" + print(f"\n {dim('$')} {dim(' '.join(cmd))}") + return subprocess.run( + cmd, + check=check, + capture_output=capture, + text=True, + ) + + +# ── Step 1: Python version ──────────────────────────────────────────────────── + +def check_python(): + header("Checking Python version") + major, minor = sys.version_info[:2] + ver = f"{major}.{minor}.{sys.version_info.micro}" + if (major, minor) >= (3, 10): + ok(f"Python {ver} β€” requirement satisfied (3.10+)") + return True + else: + fail(f"Python {ver} is too old. compress_folder.py requires Python 3.10 or higher.") + info("Download the latest Python from https://www.python.org/downloads/") + return False + + +# ── Step 2: Detect OS and pick install strategy ─────────────────────────────── + +def detect_os() -> dict: + """Return a dict describing the OS and the best install method.""" + system = platform.system() + + if system == "Windows": + return {"os": "windows"} + + if system == "Darwin": + brew = shutil.which("brew") + port = shutil.which("port") + method = "homebrew" if brew else ("macports" if port else "manual") + return {"os": "macos", "method": method, "brew": brew, "port": port} + + if system == "Linux": + # Identify distro family + for mgr, cmd in ( + ("apt", ["apt", "--version"]), + ("dnf", ["dnf", "--version"]), + ("yum", ["yum", "--version"]), + ("pacman", ["pacman", "--version"]), + ("zypper", ["zypper", "--version"]), + ): + if shutil.which(mgr): + return {"os": "linux", "manager": mgr} + return {"os": "linux", "manager": None} + + return {"os": "unknown"} + + +# ── Step 3: Install 7-Zip ───────────────────────────────────────────────────── + +def already_installed() -> str | None: + """Return the path to 7-Zip if it is already on PATH.""" + for name in ("7z", "7za", "7zz"): + 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 + + +def get_7zip_version(path: str) -> str: + """Return the version string reported by 7-Zip.""" + try: + result = subprocess.run( + [path, "i"], capture_output=True, text=True, check=False + ) + for line in (result.stdout + result.stderr).splitlines(): + if "7-Zip" in line: + return line.strip() + except Exception: + pass + return "(version unknown)" + + +def install_linux(manager: str) -> bool: + install_cmds = { + "apt": ["sudo", "apt", "install", "-y", "p7zip-full"], + "dnf": ["sudo", "dnf", "install", "-y", "p7zip", "p7zip-plugins"], + "yum": ["sudo", "yum", "install", "-y", "p7zip", "p7zip-plugins"], + "pacman": ["sudo", "pacman", "-S", "--noconfirm", "p7zip"], + "zypper": ["sudo", "zypper", "install", "-y", "p7zip"], + } + cmd = install_cmds.get(manager) + if not cmd: + return False + try: + run(cmd) + return True + except subprocess.CalledProcessError: + return False + + +def install_macos(method: str, brew: str | None, port: str | None) -> bool: + if method == "homebrew": + try: + run([brew, "install", "p7zip"]) + return True + except subprocess.CalledProcessError: + return False + if method == "macports": + try: + run(["sudo", port, "install", "p7zip"]) + return True + except subprocess.CalledProcessError: + return False + return False + + +def install_windows() -> bool: + """Try winget first, then chocolatey, then fall back to manual.""" + if shutil.which("winget"): + try: + run(["winget", "install", "--id", "7zip.7zip", "-e", "--silent"]) + return True + except subprocess.CalledProcessError: + pass + + if shutil.which("choco"): + try: + run(["choco", "install", "7zip", "-y"]) + return True + except subprocess.CalledProcessError: + pass + + return False + + +def install_7zip(os_info: dict) -> bool: + header("Installing 7-Zip") + + # Already present? + path = already_installed() + if path: + ver = get_7zip_version(path) + ok(f"7-Zip is already installed: {dim(path)}") + ok(f"{dim(ver)}") + return True + + system = os_info["os"] + + if system == "linux": + manager = os_info.get("manager") + if not manager: + fail("No supported package manager found (apt / dnf / yum / pacman / zypper).") + info("Install 7-Zip manually: https://www.7-zip.org/download.html") + return False + info(f"Using package manager: {bold(manager)}") + success = install_linux(manager) + + elif system == "macos": + method = os_info.get("method", "manual") + if method == "manual": + fail("Neither Homebrew nor MacPorts was found.") + info("Install Homebrew first: https://brew.sh") + info("Then re-run this script, or install p7zip manually.") + return False + info(f"Using: {bold(method)}") + success = install_macos(method, os_info.get("brew"), os_info.get("port")) + + elif system == "windows": + info("Trying winget β†’ chocolatey …") + success = install_windows() + if not success: + fail("Automatic install failed.") + info("Download 7-Zip manually from: https://www.7-zip.org/download.html") + info("After installing, re-run this script to verify.") + return False + + else: + fail(f"Unsupported OS: {platform.system()}") + info("Install 7-Zip manually: https://www.7-zip.org/download.html") + return False + + if not success: + fail("Installation command failed. Check the output above for details.") + return False + + return True + + +# ── Step 4: Verify everything ───────────────────────────────────────────────── + +def verify(): + header("Verifying installation") + + path = already_installed() + if path: + ver = get_7zip_version(path) + ok(f"7-Zip found: {dim(path)}") + ok(f"{dim(ver)}") + return True + else: + fail("7-Zip still not found on PATH after installation.") + warn("You may need to restart your terminal or add 7-Zip to your PATH manually.") + if sys.platform == "win32": + info("Typical Windows location: C:\\Program Files\\7-Zip\\") + info("Add that directory to your PATH environment variable.") + return False + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main(): + print(f"\n{bold('πŸ”§ compress_folder.py β€” Dependency Installer')}") + print(f" Platform: {cyan(platform.system())} {platform.release()} | " + f"Python {sys.version.split()[0]}") + + all_ok = True + + # 1. Python check + if not check_python(): + all_ok = False + + # 2. Detect OS + header("Detecting operating system") + os_info = detect_os() + system = os_info["os"] + + if system == "linux": + mgr = os_info.get("manager", "unknown") + ok(f"Linux detected β€” package manager: {bold(mgr)}") + elif system == "macos": + method = os_info.get("method", "manual") + ok(f"macOS detected β€” install method: {bold(method)}") + elif system == "windows": + ok("Windows detected") + else: + warn(f"Unrecognised OS: {platform.system()}") + + # 3. Install 7-Zip + if not install_7zip(os_info): + all_ok = False + + # 4. Verify + verified = verify() + if not verified: + all_ok = False + + # 5. Final report + header("Summary") + if all_ok and verified: + print(f"\n {green('βœ… All dependencies satisfied.')}") + print(f" You are ready to run: {bold('python compress_folder.py')}\n") + else: + print(f"\n {yellow('⚠ One or more steps did not complete successfully.')}") + print( " Review the output above and resolve any issues before running") + print(f" {bold('compress_folder.py')}.\n") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/old_01/README.md b/old_01/README.md new file mode 100644 index 0000000..1061695 --- /dev/null +++ b/old_01/README.md @@ -0,0 +1,253 @@ +# πŸ“¦ Folder Compressor + +A command-line Python script that lets you pick any sub-folder and compress it into a `.7z` archive using **7-Zip** with **no compression (store mode)** β€” maximum speed, zero CPU overhead. + +--- + +## Features + +- Browse and select from all sub-folders in any directory +- Displays folder sizes before you choose +- Real-time progress bar with: + - Percentage complete + - Elapsed time + - Live transfer speed (MB/s) + - File count + - Currently processing file name +- Summary report on completion (source size, archive size, time, avg speed) +- 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 + +**1. Launch the script** +``` +πŸ“¦ Folder Compressor + Scanning: /home/user/projects +``` + +**2. Browse the folder list** + +All sub-folders are listed with their sizes: +``` +Available folders: + + [ 1] archive (240.0 MB) + [ 2] builds (1.4 GB) + [ 3] logs (88.3 MB) + [ 4] source (320.5 MB) + +Enter the number of the folder to compress: +``` + +**3. Select a folder** + +Type the number and press Enter: +``` +Enter the number of the folder to compress: 2 + + βœ” Selected: builds + Calculating source size… 1.4 GB +``` + +**4. Watch real-time progress** + +The progress display updates live in your terminal: +``` + β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘β–‘ 62% elapsed 0m 08s 178.4 MB/s files: 312 + ↳ builds/release/v2.1.0/installer.exe +``` + +| Element | Description | +|---------|-------------| +| `β–ˆβ–ˆβ–ˆβ–ˆβ–‘β–‘β–‘` | Progress bar filling left to right | +| `62%` | Percentage of files processed | +| `elapsed 0m 08s` | Time since compression started | +| `178.4 MB/s` | Rolling average throughput speed | +| `files: 312` | Number of files added so far | +| `↳ filename` | The file currently being processed | + +**5. Completion summary** +``` + βœ… 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 +``` + +The `.7z` archive is saved in the **same parent directory** as the folder you selected. + +--- + +## 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 + +The archive is always created in the **same directory that contains the selected folder**. + +Example: +``` +/home/user/projects/ ← scanned directory + builds/ ← selected folder + builds.7z ← archive created here +``` + +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. + +--- + +## 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** +- This was a known bug that has been fixed. Make sure you are using the latest version of the script. +- The root cause was that Windows 7-Zip uses `\r` to update progress lines while Linux/macOS uses `\x08` (backspace). The old version only handled backspaces, so on Windows the progress stream was never parsed. 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 folder in the current directory (interactive) +python compress_folder.py + +# Compress a folder 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. diff --git a/old_01/compress_folder.py b/old_01/compress_folder.py new file mode 100644 index 0000000..5414c80 --- /dev/null +++ b/old_01/compress_folder.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +""" +Folder Compressor using 7-Zip +Lists available folders, lets you select one, and compresses it +using 7-Zip with no compression (store) at fastest speed. +Real-time progress bar shown during compression. +""" + +import os +import re +import sys +import shutil +import subprocess +import time +from pathlib import Path + + +# ── 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) + + +# ── 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 / selection ──────────────────────────────────────────────── + +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(), + ) + + +def prompt_folder_selection(folders: list[Path]) -> Path: + 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() + while True: + try: + raw = input("Enter the number of the folder to compress: ").strip() + idx = int(raw) + if 1 <= idx <= len(folders): + return folders[idx - 1] + print(f" Please enter a number between 1 and {len(folders)}.") + except ValueError: + print(" Invalid input β€” please enter a number.") + except (EOFError, KeyboardInterrupt): + print("\nAborted.") + sys.exit(0) + + +# ── Progress bar renderer ───────────────────────────────────────────────────── + +BAR_WIDTH = 36 + +# Matches 7-Zip progress segments like "47% 3 + path/to/file.bin" +# or just "47%" when no file is on the same segment +_PROGRESS_RE = re.compile(r"(\d{1,3})%(?:\s+(\d+)\s*\+?\s*(.*))?") + +# Split on ANY run of backspaces (\x08) or carriage returns (\r). +# - Linux/macOS 7-Zip uses \x08 to erase and rewrite the progress line +# - Windows 7-Zip uses \r to overwrite the progress line +# Both need to be handled so the script works cross-platform. +_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): + """Overwrite the two reserved progress lines in-place.""" + 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) + + # Line 1 β€” bar + stats + _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" + ) + + # Line 2 β€” current file + _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() + + +# ── Compression with real-time progress ─────────────────────────────────────── + +def compress_folder(seven_zip: str, folder: Path, output_dir: Path) -> Path: + archive_path = output_dir / (folder.name + ".7z") + + 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", # store – no compression + "-ms=off", # no solid archive + "-bsp1", # stream progress to stdout + str(archive_path), + str(folder), + ] + + print(f"\n {dim('Command: ' + ' '.join(cmd))}") + print() + print(" " + "─" * 64) + + # Reserve two blank lines for the live display + print() + print() + + start_time = time.monotonic() + last_pct = 0 + 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, + # Raw binary β€” we parse bytes ourselves to handle \x08 and \r + ) + + buf = b"" + + while True: + chunk = proc.stdout.read(256) + if not chunk: + break + + buf += chunk + elapsed = time.monotonic() - start_time + + # Split on runs of \x08 (Linux/macOS) or \r (Windows). + # Each segment between separators is one "visible write" from 7-Zip. + segments = _SPLIT_RE.split(buf) + + # Keep the last (potentially incomplete) segment for the next iteration + 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 + + # Rolling speed estimate (recalculate every 0.4 s) + 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 + + last_pct = pct + _redraw(pct, elapsed, files_done, current_file, speed_bps) + + proc.wait() + elapsed_total = time.monotonic() - start_time + + # Lock bar at 100% on success + 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") + else: + print(f"\n {red(f'❌ 7-Zip exited with code {proc.returncode}.')}") + sys.exit(proc.returncode) + + return archive_path + + +# ── 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) + + chosen = prompt_folder_selection(folders) + print(f"\n {green('βœ”')} Selected: {bold(chosen.name)}") + + compress_folder(seven_zip, chosen, chosen.parent) + print() + + +if __name__ == "__main__": + main() diff --git a/old_02/README.md b/old_02/README.md new file mode 100644 index 0000000..e4103c1 --- /dev/null +++ b/old_02/README.md @@ -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. diff --git a/old_02/compress_folder.py b/old_02/compress_folder.py new file mode 100644 index 0000000..cce0a89 --- /dev/null +++ b/old_02/compress_folder.py @@ -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()