Files
moss/extract_7z.py
2026-05-22 10:51:18 +08:00

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())