v3
This commit is contained in:
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user