From 74501bc9a2f8a178667d44c0df3b1cdc4c8adbeb Mon Sep 17 00:00:00 2001 From: JumpMaster Date: Tue, 16 Jun 2026 01:53:51 -0600 Subject: [PATCH] fix(packaging): ship the diagnosis prompt files in the wheel (regression from #80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The multistage image (#80) switched to a non-editable `pip install .`. setuptools packages only *.py by default, so `sentinel/diagnosis/prompts/v1.md` (+ its `.sha256` baseline) — read at boot by PromptBundle.load via a package-relative path — was dropped from the wheel, and the production image crashed on startup: FileNotFoundError: .../site-packages/sentinel/diagnosis/prompts/v1.md The #80 verification used a build_app() import smoke, which doesn't run the lifespan where PromptBundle.load lives — so the crash only surfaced on a real container boot. - Declare the prompts as package-data so the wheel ships them. The eval corpus/cassettes are deliberately left out (dev/CI-only, run from source). - Contract test guards the declaration (CI never builds the wheel). Verified: `pip wheel .` now contains v1.md + v1.sha256; the rebuilt image boots healthy and the full webhook → diagnosis demo works end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 8 ++++++ tests/unit/test_packaging.py | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/unit/test_packaging.py diff --git a/pyproject.toml b/pyproject.toml index 90c4fa5..e871aef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,14 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["sentinel*"] +[tool.setuptools.package-data] +# The non-editable wheel (the Docker image) must ship the versioned diagnosis +# prompt + its integrity baseline; setuptools includes only *.py by default, so +# without this the image crashes at PromptBundle.load. The eval corpus/cassettes +# are deliberately NOT packaged — they are dev/CI-only and run from the source +# tree, never from the installed wheel. +"sentinel.diagnosis" = ["prompts/*.md", "prompts/*.sha256"] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/tests/unit/test_packaging.py b/tests/unit/test_packaging.py new file mode 100644 index 0000000..359730f --- /dev/null +++ b/tests/unit/test_packaging.py @@ -0,0 +1,49 @@ +"""Contract test for the wheel's non-Python data files. + +setuptools packages only ``*.py`` by default. The diagnosis prompt bundle +(``sentinel/diagnosis/prompts/{version}.md`` + ``.sha256``) is read at runtime +via a package-relative path, so a non-editable install (the Docker image) must +ship those files — otherwise the app crashes at ``PromptBundle.load`` on boot. + +CI runs the app from a source checkout and never builds the wheel, so nothing +else guards this. This test pins the ``package-data`` declaration to the files +it must cover. (A build-and-boot smoke of the actual image would be stronger but +needs a Docker build in CI; tracked separately.) +""" + +from __future__ import annotations + +import tomllib +from pathlib import Path +from typing import cast + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PROMPTS_DIR = _REPO_ROOT / "sentinel" / "diagnosis" / "prompts" + + +def _package_data() -> dict[str, list[str]]: + cfg = tomllib.loads((_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + return cast("dict[str, list[str]]", cfg["tool"]["setuptools"]["package-data"]) + + +def test_diagnosis_prompts_are_declared_package_data() -> None: + patterns = _package_data().get("sentinel.diagnosis", []) + # The versioned prompt markdown is the file whose absence crashes the image. + assert any( + p.startswith("prompts/") and p.endswith(".md") for p in patterns + ), f"diagnosis prompts not declared as package-data: {patterns}" + + +def test_every_prompt_file_exists_to_be_packaged() -> None: + """A declared glob with no files would silently ship nothing.""" + md_files = list(_PROMPTS_DIR.glob("*.md")) + assert md_files, f"no prompt .md files under {_PROMPTS_DIR}" + # The loader also reads a sibling .sha256 integrity baseline per version; if a + # version ships a baseline it must be packaged too (declared via *.sha256). + for md in md_files: + baseline = md.with_suffix(".sha256") + if baseline.exists(): + patterns = _package_data().get("sentinel.diagnosis", []) + assert any( + p.endswith(".sha256") for p in patterns + ), f"{baseline.name} exists but *.sha256 is not declared package-data"