Source code for modelly.provenance.bundle

"""Pack a recorded run into one deterministic zip a reviewer can verify.

The archive holds the manifest (its paths rewritten for the archive layout), the
outputs, the model's source files under ``code/``, the input files under ``inputs/``,
``requirements.lock``, ``seeds.json``, Zenodo metadata (``.zenodo.json``) and a
``CITATION.cff`` stub. Entries are sorted, stored uncompressed and stamped with a fixed
time and mode, so the same run gives the same bytes on every machine.
"""

from __future__ import annotations

import copy
import io
import zipfile
from pathlib import Path

from modelly.provenance.code import changed_files
from modelly.provenance.environment import lock_text
from modelly.provenance.files import (
    dump_json,
    from_posix,
    hash_file,
    write_bytes_atomic,
)
from modelly.provenance.manifest import MANIFEST_NAME, manifest_path, read_manifest
from modelly.provenance.metadata import citation_cff, zenodo_metadata

CODE_DIR, INPUTS_DIR = "code", "inputs"
# The earliest time a zip entry can carry (1980-01-01): no clock leaks into the bytes.
FIXED_TIME = (1980, 1, 1, 0, 0, 0)
FILE_MODE = 0o100644
MODE_SHIFT = 16
UNIX_SYSTEM = 3


[docs] class BundleError(ValueError): """The run folder does not hold what its manifest records."""
def _check_unchanged(manifest: dict, folder: Path) -> None: """Raise BundleError if outputs or code changed since the run.""" for item in manifest["outputs"]: path = from_posix(item["path"], folder) if not path.is_file() or hash_file(path) != item["hash"]: raise BundleError(f"output {item['path']} changed since the run: rerun it") root = from_posix(manifest["code"]["root"], folder) changed = changed_files(root, manifest["code"]["files"]) if changed: raise BundleError(f"code changed since the run: {', '.join(changed)}") def _input_entries(manifest: dict, folder: Path, entries: dict) -> dict: """Copy inputs and the scenario file into ``inputs/``; the rewritten manifest.""" packed = copy.deepcopy(manifest) items = list(packed["inputs"]) scenario = packed["scenario"] if scenario.get("file"): items.append(scenario) for item in items: key = "file" if item is scenario else "path" name = f"{INPUTS_DIR}/{Path(item[key]).name}" if name in entries: raise BundleError(f"two inputs are named {Path(item[key]).name}") entries[name] = from_posix(item[key], folder).read_bytes() item[key] = name return packed
[docs] def bundle_entries(folder: Path) -> dict[str, bytes]: """Archive name to bytes for the run recorded in ``folder``.""" path = manifest_path(folder) manifest = read_manifest(path) folder = path.parent _check_unchanged(manifest, folder) entries: dict[str, bytes] = {} for item in manifest["outputs"]: entries[item["path"]] = from_posix(item["path"], folder).read_bytes() root = from_posix(manifest["code"]["root"], folder) for name in manifest["code"]["files"]: entries[f"{CODE_DIR}/{name}"] = root.joinpath(*name.split("/")).read_bytes() packed = _input_entries(manifest, folder, entries) packed["code"]["root"] = CODE_DIR entries[MANIFEST_NAME] = dump_json(packed) entries["requirements.lock"] = lock_text( manifest["environment"]["packages"] ).encode("utf-8") entries["seeds.json"] = dump_json( {**manifest["seeds"], "steps": manifest["run"]["steps"]} ) entries[".zenodo.json"] = dump_json(zenodo_metadata(manifest)) entries["CITATION.cff"] = citation_cff(manifest).encode("utf-8") return entries
def zip_bytes(entries: dict[str, bytes]) -> bytes: """A zip of ``entries``: sorted, stored, fixed time and mode (reproducible bytes).""" buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_STORED) as archive: for name in sorted(entries): info = zipfile.ZipInfo(name, date_time=FIXED_TIME) info.create_system = UNIX_SYSTEM info.external_attr = FILE_MODE << MODE_SHIFT archive.writestr(info, entries[name]) return buffer.getvalue()
[docs] def bundle(folder: Path, out: Path) -> Path: """Write the bundle of the run in ``folder`` (or of a manifest path) to ``out``. Raises ManifestError for an unreadable manifest and BundleError when outputs or code changed since the run. """ write_bytes_atomic(Path(out), zip_bytes(bundle_entries(Path(folder)))) return Path(out)