Skip to content

Commit 0a55a88

Browse files
Harden packaging: audit out-of-package runtime data + clean-install smoke test (#82)
* Harden packaging: audit out-of-package runtime data, add clean-install smoke test Audit (no runtime change): traced every user-reachable CLI entry point (permea, permea bench, permea bench diagnose, and the separate permea-drylab script) for reads of data living outside the shipped package trees (permea_core, permea_explain, permea_ui). Verdict: none. `permea bench diagnose` reads only user-supplied dataset/clusters; its downstream run()/diagnose()/warnings registry are pure Python. The many repo-root-relative path constants (ROOT = parents[3], Path("docs/examples/generated"), Path("benchmarks/*.yaml"), ...) all live in artifact-generation modules driven by scripts/, not exposed through the CLI. A clean-venv sweep from outside the repo confirmed no FileNotFoundError / ModuleNotFoundError / missing-resource on any command. Because nothing broke, no files were moved and no package-data rule was added -- moving unused data would ship files nothing needs. Changes: - pyproject.toml: add a `dev` optional-dependency group (build, twine, pytest) so a canonical sdist+wheel can be produced and CI can run the suite. No upload. - pyproject.toml: register `slow` and `packaging` pytest markers. - tests/test_packaging_smoke.py: one clean-install smoke test (marked slow+packaging) that builds a real wheel, installs it into a throwaway venv, and drives `permea bench diagnose` from outside the repo on files produced by permea_ui.fixtures.write_example, asserting exit 0 and a fired warning code. The existing 562 tests run against an editable install and structurally cannot catch a packaging regression; this closes that gap. It gates on a real `python -m build` capability probe (not `import build`) so it skips cleanly when the dev extra is absent and runs in CI where `.[dev]` is installed. Hygiene: - Remove stray src/permea_core/.DS_Store (untracked; .DS_Store already ignored). - Delete finished untracked doc docs/pr/PR-permea-explain-and-packaging.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add CI so the packaging smoke test actually runs The prior commit's tests/test_packaging_smoke.py claimed "CI installs the dev extra", but the repo had no .github/workflows. The test skips whenever `python -m build` is absent, so on the maintainer machine it skipped and ran nowhere else -- it guarded nothing and that comment was not verifiable. This adds the CI that makes the claim true. Audit (Phase A): the suite is fully offline -- no test makes a live LLM provider call and none requires a secret. The provider tests clear/monkeypatch PERMEA_LLM_* and use fakes; test_explain_provider_configured is "strictly offline" by construction; test_explain_boundary asserts openai/anthropic/httpx are NOT imported. So CI needs no secrets. Findings while wiring CI (pip install ".[dev]" alone could not even COLLECT the suite): - The test suite imports feature-extra deps unconditionally (fastapi in test_ui_drylab, jsonschema in several test_explain_*), and PyYAML -- an OPTIONAL runtime dep of permea_core guarded behind `try: import yaml` -- is declared in no extra at all. With only `.[dev]` these produced 14 collection errors, not skips. - Fix: make `dev` a self-referential superset -- `permea-core[explain,ui]` plus pyyaml plus build/twine/pytest -- so `pip install .[dev]` yields an environment that can run the ENTIRE suite in one command. Base `dependencies` are untouched: PyYAML stays optional for CLI users; the diagnose path never needs it. Changes: - .github/workflows/ci.yml: CI on pull_request + push to main, ubuntu-latest, matrix over Python 3.11/3.12/3.13 (per requires-python >=3.11). Steps: checkout, setup-python, `pip install ".[dev]"`, then two pytest steps -- `-m "not packaging"` (fast) and `-m packaging` (the wheel build/install test, kept separate so a packaging regression is distinguishable in the log). No secrets; only actions/checkout and actions/setup-python. - pyproject.toml: expand the `dev` extra as above. - tests/test_packaging_smoke.py: the "CI installs the dev extra" statement is now true (workflow added); docstring now states plainly that the test SKIPS when `python -m build` is unavailable, so a green local run does not imply it ran, and points at the CI workflow where it actually executes. Test not weakened. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 08bfdeb commit 0a55a88

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: CI
2+
3+
# Runs the test suite on every PR and on pushes to main. No secrets are used: the suite is
4+
# fully offline (provider tests clear/monkeypatch PERMEA_LLM_* and never make a network call),
5+
# so nothing here needs repository secrets.
6+
on:
7+
pull_request:
8+
push:
9+
branches: [main]
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
# The versions declared supported by `requires-python = ">=3.11"`.
18+
python-version: ["3.11", "3.12", "3.13"]
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: ${{ matrix.python-version }}
25+
26+
# `.[dev]` is self-referential: it pulls the explain+ui feature extras (the suite
27+
# imports fastapi/jsonschema unconditionally), PyYAML (optional runtime dep exercised
28+
# by the YAML tests), and build/twine/pytest. One command, whole suite runnable.
29+
- name: Install
30+
run: pip install ".[dev]"
31+
32+
# Fast lane: everything except the slow packaging build/install test.
33+
- name: Fast tests
34+
run: pytest -m "not packaging"
35+
36+
# Packaging lane, kept separate so a packaging regression is obvious in the CI log.
37+
# This is the step that makes tests/test_packaging_smoke.py actually RUN (it builds a
38+
# real wheel, installs it into a throwaway venv, and drives the console script): on the
39+
# maintainer machine it skips for lack of `python -m build`, so CI is where it counts.
40+
- name: Packaging tests
41+
run: pytest -m packaging

pyproject.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,27 @@ ui = [
4141
"python-multipart>=0.0.9",
4242
]
4343

44+
# The full develop-and-test bundle: `pip install .[dev]` is the single command CI runs to
45+
# get an environment that can execute the ENTIRE suite. Nothing here is needed to *use* the
46+
# CLI.
47+
dev = [
48+
# Self-referential: pull the feature extras, because the test suite imports their deps
49+
# unconditionally (tests/test_ui_drylab.py imports fastapi; several test_explain_*.py
50+
# import jsonschema). Without these, pytest fails at COLLECTION, not as skips.
51+
"permea-core[explain,ui]",
52+
# Release + test tooling. `build`/`twine` are what a release runs (no upload in-repo);
53+
# `pytest` runs the suite, including the packaging smoke test that shells out to
54+
# `python -m build`.
55+
"build>=1.0",
56+
"twine>=5.0",
57+
"pytest>=8.0",
58+
# PyYAML is an OPTIONAL runtime dep of permea_core: the registry/manifest/card loaders
59+
# guard `import yaml` and raise a clear error when it is absent, so it is deliberately
60+
# NOT in base `dependencies` (the diagnose CLI never needs it). The suite exercises those
61+
# YAML paths, so it must be present to develop/test -- hence here.
62+
"pyyaml>=6.0",
63+
]
64+
4465
[project.scripts]
4566
permea = "permea_core.cli.main:main"
4667
permea-drylab = "permea_ui.__main__:main"
@@ -59,3 +80,12 @@ permea_explain = ["schemas/*.schema.json"]
5980
# The UI serves these from disk at request time; a wheel without them installs a server
6081
# that returns 404 for its own front end.
6182
permea_ui = ["static/*"]
83+
84+
[tool.pytest.ini_options]
85+
# Registered so the packaging smoke test's markers are not "unknown". `packaging` (also
86+
# tagged `slow`) builds a wheel, spins up a throwaway venv, and drives the installed console
87+
# script -- deselect it in fast runs with: pytest -m "not packaging"
88+
markers = [
89+
"slow: test is slow (builds/install/subprocess); safe to skip in fast local runs",
90+
"packaging: exercises a real wheel build + clean-venv install (implies slow)",
91+
]

tests/test_packaging_smoke.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""Clean-install packaging smoke test.
2+
3+
Every other test in this suite runs against an *editable* install, where the whole repo is
4+
on ``sys.path``. That layout structurally cannot catch a packaging regression: a file that
5+
exists in the repo but is never declared to ship (missing ``package-data``, or a data file
6+
that lives outside the three package trees) is still importable in-place, so an editable
7+
test sees it and passes -- while a real ``pip install`` user gets a wheel without it.
8+
9+
This test closes that gap by taking the outside-user path exactly: build a real wheel,
10+
install it into a throwaway venv, then drive the public ``permea bench diagnose`` console
11+
script from a working directory OUTSIDE the repo, on inputs produced by the *installed*
12+
``permea_ui.fixtures.write_example``. If any runtime file failed to ship, this fails.
13+
14+
Marked ``packaging`` (and ``slow``) so fast unit runs can deselect it::
15+
16+
pytest -m "not packaging"
17+
18+
Needs no network beyond the PyPI dependency resolve that ``build`` (isolated backend) and
19+
``pip install`` perform -- i.e. it is CI-safe wherever PyPI is reachable.
20+
21+
IMPORTANT -- this test SKIPS when ``python -m build`` is unavailable (no pypa-build on the
22+
interpreter). So a green *local* run does NOT mean this test ran: on a maintainer machine
23+
without the ``dev`` extra it is skipped, not executed. Where it actually runs is CI --
24+
``.github/workflows/ci.yml`` installs ``.[dev]`` (which provides ``build``) and invokes it
25+
via the dedicated ``pytest -m packaging`` step. If you want to run it locally, install the
26+
dev extra first: ``pip install ".[dev]"``.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import subprocess
32+
import sys
33+
import venv
34+
from pathlib import Path
35+
36+
import pytest
37+
38+
pytestmark = [pytest.mark.slow, pytest.mark.packaging]
39+
40+
REPO_ROOT = Path(__file__).resolve().parents[1]
41+
42+
43+
def _run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
44+
"""Run a subprocess, capturing text output; raise with full output on failure."""
45+
proc = subprocess.run(cmd, capture_output=True, text=True, **kwargs) # type: ignore[call-overload]
46+
if proc.returncode != 0:
47+
raise AssertionError(
48+
f"command failed ({proc.returncode}): {' '.join(cmd)}\n"
49+
f"--- stdout ---\n{proc.stdout}\n--- stderr ---\n{proc.stderr}"
50+
)
51+
return proc
52+
53+
54+
def test_wheel_installs_and_diagnose_runs_from_clean_venv(tmp_path: Path) -> None:
55+
# `python -m build` is the canonical wheel builder; it ships in the `dev` extra. Gate on
56+
# the ACTUAL capability, not an `import build`: a bare env may have an unrelated module
57+
# also named `build` on sys.path, which imports fine yet has no runnable CLI. Probe the
58+
# real invocation and skip (don't fail) when unavailable -- CI's `.[dev]` install provides
59+
# it (.github/workflows/ci.yml), so the skip only ever happens off-CI.
60+
probe = subprocess.run(
61+
[sys.executable, "-m", "build", "--version"], capture_output=True, text=True
62+
)
63+
if probe.returncode != 0:
64+
pytest.skip("`python -m build` is unavailable (install the `dev` extra) -- cannot build a wheel")
65+
66+
# 1. Build a wheel from the repo into a temp dir (isolated backend pulls setuptools).
67+
dist = tmp_path / "dist"
68+
_run([sys.executable, "-m", "build", "--wheel", "--outdir", str(dist), str(REPO_ROOT)])
69+
wheels = list(dist.glob("*.whl"))
70+
assert len(wheels) == 1, f"expected exactly one wheel, got {wheels}"
71+
72+
# 2. Fresh venv; install the wheel (this resolves numpy/scikit-learn from PyPI).
73+
venv_dir = tmp_path / "venv"
74+
venv.create(venv_dir, with_pip=True)
75+
win = sys.platform == "win32"
76+
bin_dir = venv_dir / ("Scripts" if win else "bin")
77+
py = bin_dir / ("python.exe" if win else "python")
78+
_run([str(py), "-m", "pip", "install", "--quiet", str(wheels[0])])
79+
80+
# 3. Materialize the synthetic demo fixture using the INSTALLED package, not the repo.
81+
fixture_dir = tmp_path / "fixture"
82+
fixture_dir.mkdir()
83+
_run([
84+
str(py), "-c",
85+
"from pathlib import Path; from permea_ui.fixtures import write_example; "
86+
f"write_example(Path({str(fixture_dir)!r}))",
87+
])
88+
dataset = fixture_dir / "example_dataset.csv"
89+
clusters = fixture_dir / "clusters_family.tsv"
90+
assert dataset.exists() and clusters.exists(), "write_example did not produce the expected files"
91+
92+
# 4. Drive the console script from OUTSIDE the repo; assert clean exit + a fired code.
93+
permea = bin_dir / ("permea.exe" if win else "permea")
94+
proc = subprocess.run(
95+
[str(permea), "bench", "diagnose", str(dataset), str(clusters)],
96+
cwd=str(tmp_path), # deliberately not the repo root
97+
capture_output=True,
98+
text=True,
99+
)
100+
assert proc.returncode == 0, f"diagnose exited {proc.returncode}\n{proc.stderr}"
101+
assert "PERMEA-W" in proc.stdout, f"expected a warning code in output, got:\n{proc.stdout}"

0 commit comments

Comments
 (0)