144 lines
3.7 KiB
Python
144 lines
3.7 KiB
Python
#!/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())
|