#!/usr/bin/env python3 """Extract 7z archives into a directory with the same filename. Behavior: - scans a directory for *.7z files - extracts album.7z -> album/ - optionally recurses into subdirectories Examples: python extract_7z.py ~/Music/inbox python extract_7z.py ~/Music/inbox --dry-run python extract_7z.py ~/Music/inbox --no-recursive """ from __future__ import annotations import argparse import shutil import subprocess import sys from pathlib import Path try: import py7zr # type: ignore except Exception: py7zr = None class ToolError(RuntimeError): pass def log(msg: str) -> None: print(msg, flush=True) def warn(msg: str) -> None: print(f"[warn] {msg}", file=sys.stderr, flush=True) def err(msg: str) -> None: print(f"[error] {msg}", file=sys.stderr, flush=True) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Extract 7z archives into sibling directories named after the archive stem." ) parser.add_argument("directory", help="Root directory to scan") parser.add_argument( "--no-recursive", action="store_true", help="Only scan the top-level directory", ) parser.add_argument( "--dry-run", action="store_true", help="Only print planned actions", ) return parser.parse_args() def find_7z_files(root: Path, recursive: bool) -> list[Path]: if recursive: return sorted(p for p in root.rglob("*.7z") if p.is_file()) return sorted(p for p in root.glob("*.7z") if p.is_file()) def find_7z_bin() -> str | None: for name in ("7z", "7za", "7zr"): path = shutil.which(name) if path: return path return None def run_extract_cli(archive: Path, dest_dir: Path, seven_z: str, dry_run: bool) -> None: dest_dir.mkdir(parents=True, exist_ok=True) cmd = [seven_z, "x", f"-o{str(dest_dir)}", "-y", str(archive)] printable = " ".join(shlex_quote(a) for a in cmd) if dry_run: log(f"[dry-run] {printable}") return proc = subprocess.run(cmd) if proc.returncode != 0: raise ToolError(f"extraction failed ({proc.returncode}): {archive}") def run_extract_py7zr(archive: Path, dest_dir: Path, dry_run: bool) -> None: dest_dir.mkdir(parents=True, exist_ok=True) if dry_run: log(f"[dry-run] py7zr extract {archive} -> {dest_dir}") return if py7zr is None: raise ToolError("py7zr is not installed") with py7zr.SevenZipFile(archive, mode="r") as zf: zf.extractall(path=dest_dir) def shlex_quote(text: str) -> str: import shlex return shlex.quote(text) def main() -> int: args = parse_args() root = Path(args.directory).expanduser().resolve() if not root.exists() or not root.is_dir(): err(f"directory not found: {root}") return 2 seven_z = find_7z_bin() archives = find_7z_files(root, recursive=not args.no_recursive) if not archives: log("no 7z archives found") return 0 if seven_z is None and py7zr is None: raise ToolError("missing required tool: 7z/7za/7zr and python module py7zr") ok = 0 failed = 0 for archive in archives: dest_dir = archive.with_suffix("") log(f"[archive] {archive}") log(f" output: {dest_dir}") try: if seven_z is not None: run_extract_cli(archive, dest_dir, seven_z, dry_run=args.dry_run) else: run_extract_py7zr(archive, dest_dir, dry_run=args.dry_run) ok += 1 except Exception as exc: failed += 1 err(f"{archive}: {exc}") log(f"done: {ok} ok, {failed} failed") return 0 if failed == 0 else 1 if __name__ == "__main__": sys.exit(main())