#!/usr/bin/env python from __future__ import annotations import argparse import hashlib import json import os import shutil import tempfile import zipfile from collections.abc import Iterable from datetime import UTC, datetime from pathlib import Path ROOT = Path(__file__).resolve().parent.parent ROOT_NAME = "VacatureRadar_Project" EXCLUDED_DIRS = { ".git", ".venv", ".pytest_cache", ".ruff_cache", "__pycache__", "backups", "htmlcov", "logs", "media", "staticfiles", } EXCLUDED_FILES = {".coverage", ".env", "celerybeat-schedule", "db.sqlite3"} EXCLUDED_SUFFIXES = {".pyc", ".pyo"} def is_included(path: Path) -> bool: relative = path.relative_to(ROOT) if any(part in EXCLUDED_DIRS for part in relative.parts): return False if path.name in EXCLUDED_FILES or path.suffix in EXCLUDED_SUFFIXES: return False if relative.parts and relative.parts[0] == "local" and path.name != ".gitkeep": return False return path.is_file() def iter_files() -> Iterable[Path]: files: list[Path] = [] for directory, dirnames, filenames in os.walk(ROOT): dirnames[:] = sorted(name for name in dirnames if name not in EXCLUDED_DIRS) base = Path(directory) if base == ROOT / "local": dirnames.clear() for filename in sorted(filenames): path = base / filename if is_included(path): files.append(path) return sorted(files) def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def write_manifest(staging_root: Path, paths: Iterable[Path] | None = None) -> None: entries = [] candidates = sorted(paths) if paths is not None else sorted(staging_root.rglob("*")) for path in candidates: if not path.is_file() or path.name == "PROJECT_MANIFEST.json": continue entries.append( { "path": path.relative_to(staging_root).as_posix(), "bytes": path.stat().st_size, "sha256": sha256(path), } ) manifest = { "project": "VacatureRadar", "artifact_root": ROOT_NAME, "generated_at": datetime.now(UTC).isoformat(), "file_count_excluding_manifest": len(entries), "total_bytes_excluding_manifest": sum(item["bytes"] for item in entries), "files": entries, } (staging_root / "PROJECT_MANIFEST.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def package(output: Path) -> tuple[int, str]: output = output.resolve() output.parent.mkdir(parents=True, exist_ok=True) if output.exists(): output.unlink() with tempfile.TemporaryDirectory(prefix="vacatureradar-package-") as temporary: staging_root = Path(temporary) / ROOT_NAME staging_root.mkdir() for source in iter_files(): relative = source.relative_to(ROOT) destination = staging_root / relative destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) write_manifest(staging_root) with zipfile.ZipFile( output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 ) as archive: for path in sorted(staging_root.rglob("*")): if path.is_file(): archive.write(path, path.relative_to(staging_root.parent)) return output.stat().st_size, sha256(output) def main() -> int: parser = argparse.ArgumentParser(description="Maak een schone VacatureRadar-project-ZIP") parser.add_argument( "--manifest-only", action="store_true", help="Werk alleen PROJECT_MANIFEST.json in de repository bij.", ) parser.add_argument( "output", nargs="?", type=Path, default=ROOT.parent / "VacatureRadar_Project.zip", ) args = parser.parse_args() if args.manifest_only: write_manifest(ROOT, iter_files()) print(f"Manifest: {(ROOT / 'PROJECT_MANIFEST.json').resolve()}") return 0 size, checksum = package(args.output) print(f"ZIP: {args.output.resolve()}") print(f"Bytes: {size}") print(f"SHA256: {checksum}") return 0 if __name__ == "__main__": raise SystemExit(main())