Source code for femic.release_packaging

"""Student-facing release packaging helpers."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
from pathlib import Path
import shutil

REQUIRED_MODEL_INPUT_FILES = (
    "au_table.csv",
    "curve_table.csv",
    "curve_points_table.csv",
)

REQUIRED_PATCHWORKS_EXPORT_FILES = (
    "forestmodel.xml",
    "fragments/fragments.shp",
    "fragments/fragments.dbf",
    "fragments/fragments.shx",
    "fragments/fragments.prj",
    "fragments/fragments.cpg",
)
# Backward-compatible alias for existing callers/tests. This constant covers
# the export-bundle Patchworks minimum only, not the full standalone runtime
# package needed to launch a compiled model directly in Patchworks.
REQUIRED_PATCHWORKS_FILES = REQUIRED_PATCHWORKS_EXPORT_FILES


[docs] @dataclass(frozen=True) class ReleasePackageResult: """Metadata for a generated release package.""" release_id: str release_dir: Path manifest_path: Path handoff_notes_path: Path
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 _copy_file(src: Path, dst: Path) -> Path: dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) return dst def _copy_tree(src: Path, dst: Path) -> list[Path]: copied: list[Path] = [] for item in sorted(src.rglob("*")): if item.is_dir(): continue rel = item.relative_to(src) copied.append(_copy_file(item, dst / rel)) return copied def _resolve_release_id(case_id: str, run_id: str | None) -> str: token = ( run_id.strip() if run_id and run_id.strip() else datetime.now(UTC).strftime("%Y%m%d_%H%M%S") ) return f"{case_id}_{token}"
[docs] def build_release_package( *, case_id: str, output_root: Path, model_input_bundle_dir: Path, patchworks_output_dir: Path, woodstock_output_dir: Path | None, logs_dir: Path, run_id: str | None, strict: bool, ) -> ReleasePackageResult: """Create a versioned release bundle with core artifacts and notes.""" normalized_case = case_id.strip().lower() if not normalized_case: raise ValueError("case_id must not be empty") release_id = _resolve_release_id(normalized_case, run_id) release_dir = output_root.expanduser().resolve() / release_id if release_dir.exists(): raise ValueError(f"Release directory already exists: {release_dir}") bundle_dir = model_input_bundle_dir.expanduser().resolve() patchworks_dir = patchworks_output_dir.expanduser().resolve() woodstock_dir = ( woodstock_output_dir.expanduser().resolve() if woodstock_output_dir else None ) logs_root = logs_dir.expanduser().resolve() errors: list[str] = [] for required in REQUIRED_MODEL_INPUT_FILES: candidate = bundle_dir / required if not candidate.exists(): errors.append(f"Missing required model-input file: {candidate}") for required in REQUIRED_PATCHWORKS_EXPORT_FILES: candidate = patchworks_dir / required if not candidate.exists(): errors.append(f"Missing required Patchworks artifact: {candidate}") if strict and errors: raise FileNotFoundError("; ".join(errors)) copied_paths: list[Path] = [] warnings: list[str] = [] # model_input_bundle target_bundle = release_dir / "model_input_bundle" for required in REQUIRED_MODEL_INPUT_FILES: src = bundle_dir / required if src.exists(): copied_paths.append(_copy_file(src, target_bundle / required)) else: warnings.append(f"Skipped missing optional file: {src}") # patchworks target_patchworks = release_dir / "patchworks" if patchworks_dir.exists(): copied_paths.extend(_copy_tree(patchworks_dir, target_patchworks)) else: warnings.append(f"Patchworks output directory missing: {patchworks_dir}") # woodstock optional target_woodstock = release_dir / "woodstock" if woodstock_dir and woodstock_dir.exists(): copied_paths.extend(_copy_tree(woodstock_dir, target_woodstock)) # logs optional copy (just manifests + patchworks runtime logs) target_logs = release_dir / "logs" if logs_root.exists(): for candidate in sorted(logs_root.glob("run_manifest-*.json")): copied_paths.append(_copy_file(candidate, target_logs / candidate.name)) for pattern in ( "patchworks_matrixbuilder_manifest-*.json", "patchworks_matrixbuilder_stdout-*.log", "patchworks_matrixbuilder_stderr-*.log", ): for candidate in sorted(logs_root.glob(pattern)): copied_paths.append(_copy_file(candidate, target_logs / candidate.name)) file_entries = [] for path in sorted(set(copied_paths)): rel = path.relative_to(release_dir) file_entries.append( { "path": rel.as_posix(), "bytes": path.stat().st_size, "sha256": _sha256(path), } ) manifest = { "release_id": release_id, "created_utc": datetime.now(UTC).isoformat(), "case_id": normalized_case, "source": { "model_input_bundle_dir": str(bundle_dir), "patchworks_output_dir": str(patchworks_dir), "woodstock_output_dir": str(woodstock_dir) if woodstock_dir else None, "logs_dir": str(logs_root), }, "warnings": warnings, "files": file_entries, } manifest_path = release_dir / "release_manifest.json" manifest_path.parent.mkdir(parents=True, exist_ok=True) manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") handoff_notes = release_dir / "HANDOFF.md" handoff_notes.write_text( "\n".join( [ f"# FEMIC Release Package: {release_id}", "", "## Contents", "- `model_input_bundle/`: AU and curve tables", "- `patchworks/`: ForestModel XML + fragments shapefile", "- `woodstock/`: optional Woodstock compatibility CSVs (if present)", "- `logs/`: run manifests and Patchworks runtime logs", "- `release_manifest.json`: file inventory with SHA256 hashes", "", "## Quick Validation", "1. Confirm `patchworks/forestmodel.xml` exists.", "2. Confirm the full `patchworks/fragments/` shapefile sidecar set exists:", " `fragments.{shp,dbf,shx,prj,cpg}`.", "3. Confirm all three `model_input_bundle/*.csv` files exist.", "4. Treat this release as the export-bundle minimum, not the full standalone runtime package.", "5. For a launch-ready standalone Patchworks instance, also ship blocks/topology,", " analysis/PIN surfaces, and compiled tracks beside the validated XML/fragments pair.", "6. Review `release_manifest.json` warnings before delivery.", "", "## Suggested Operator Commands", "```bash", "PYTHONPATH=src python -m femic patchworks preflight --config config/patchworks.runtime.yaml", "PYTHONPATH=src python -m femic patchworks matrix-build --config config/patchworks.runtime.yaml", "```", "", ] ), encoding="utf-8", ) return ReleasePackageResult( release_id=release_id, release_dir=release_dir, manifest_path=manifest_path, handoff_notes_path=handoff_notes, )