from __future__ import annotations import argparse import hashlib from pathlib import Path import shutil import subprocess import tempfile import zipfile PROJECT_ROOT = Path(__file__).resolve().parents[1] DEFAULT_IMAGE = "interference-etl-runtime:1.1" DEFAULT_BASE_IMAGE = "python:3.13.11-slim" DEFAULT_OUTPUT = PROJECT_ROOT / "dist" / "InterferenceETL-offline-1.1.zip" PACKAGE_FILES = ("main.py", "requirements.txt", "README.md", "runtime_config.py") PACKAGE_ENTRYPOINT = "from main import main\nraise SystemExit(main())\n" COPY_DEPENDENCIES = r""" from importlib.metadata import distribution from pathlib import Path import shutil target = Path('/package/vendor') for name in ('openpyxl', 'et-xmlfile'): package = distribution(name) for item in package.files or (): if '__pycache__' in item.parts or item.suffix in ('.pyc', '.pyo'): continue source = Path(package.locate_file(item)) if not source.is_file(): continue destination = target / item destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source, destination) """ def build(output: Path, runtime_image: str, base_image: str) -> Path: output = output.resolve() output.parent.mkdir(parents=True, exist_ok=True) missing = [name for name in PACKAGE_FILES if not (PROJECT_ROOT / name).is_file()] if missing: raise FileNotFoundError(f"Missing package files: {', '.join(missing)}") with tempfile.TemporaryDirectory(prefix="interference-etl-package-") as temp: staging = Path(temp) subprocess.run( [ "docker", "run", "--rm", "-v", f"{staging}:/package", runtime_image, "python", "-c", COPY_DEPENDENCIES, ], check=True, ) for name in PACKAGE_FILES: shutil.copy2(PROJECT_ROOT / name, staging / name) (staging / "__main__.py").write_text(PACKAGE_ENTRYPOINT, encoding="utf-8") subprocess.run( [ "docker", "run", "--rm", "-e", "PYTHONDONTWRITEBYTECODE=1", "-v", f"{staging}:/workspace:ro", "-w", "/workspace", base_image, "python", "-c", "import main, openpyxl; print('offline_package_imports=ok')", ], check=True, ) if output.exists(): output.unlink() with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for path in sorted(staging.rglob("*")): if path.is_file(): archive.write(path, path.relative_to(staging).as_posix()) subprocess.run( [ "docker", "run", "--rm", "-e", "PYTHONDONTWRITEBYTECODE=1", "-v", f"{output.parent}:/dist:ro", base_image, "python", f"/dist/{output.name}", "--help", ], check=True, ) return output def main() -> int: parser = argparse.ArgumentParser(description="Build a self-contained Metrix Script Management upload ZIP") parser.add_argument("--runtime-image", default=DEFAULT_IMAGE) parser.add_argument("--base-image", default=DEFAULT_BASE_IMAGE) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) args = parser.parse_args() output = build(args.output, args.runtime_image, args.base_image) digest = hashlib.sha256(output.read_bytes()).hexdigest().upper() print(f"output={output}") print(f"size={output.stat().st_size}") print(f"sha256={digest}") return 0 if __name__ == "__main__": raise SystemExit(main())