diff --git a/.binder/postBuild b/.binder/postBuild index 2d5b161..7769051 100755 --- a/.binder/postBuild +++ b/.binder/postBuild @@ -9,7 +9,6 @@ rm -rf rcpchgrowth/tests || true rm -f rcpchgrowth/data_tables/bayley-pineau.pdf || true rm -f rcpchgrowth/data_tables/uk-who_resources/GrowthCharts.xls || true rm -f rcpchgrowth/data_tables/uk-who_resources/*.bas || true -rm -f rcpchgrowth/data_tables/fenton/*.* || true echo "[postBuild] Warm up matplotlib font cache" python - <<'PY' @@ -17,4 +16,4 @@ import matplotlib.pyplot as plt print("Matplotlib imported; font cache built.") PY -echo "[postBuild] Done" \ No newline at end of file +echo "[postBuild] Done" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 84c11ce..feaa17c 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -78,6 +78,7 @@ jobs: [[ "${sdists[0]}" == "dist/rcpchgrowth-${EXPECTED_VERSION}.tar.gz" ]] [[ "${wheels[0]}" == "dist/rcpchgrowth-${EXPECTED_VERSION}-"*.whl ]] python -m twine check dist/* + python tests/distribution/check-sdist.py "${sdists[0]}" python tests/distribution/check-wheel.py "${wheels[0]}" python -m venv /tmp/release-wheel-venv wheel_path="$PWD/${wheels[0]}" diff --git a/.gitignore b/.gitignore index 57a04a6..73b6818 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,3 @@ dist/ .private/ .playwright/ .playwright-mcp/ - -# Licensed data - Fenton chart data must not be shared -rcpchgrowth/data_tables/fenton/*.* diff --git a/AGENTS.md b/AGENTS.md index 81369f2..1036066 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,6 +92,10 @@ Release PRs may change only `pyproject.toml` and `CITATION.cff`, must carry exac ## Important Considerations for LLM Development +### Licensed Reference Data + +Fenton growth-chart data, LMS tables, chart images, and copies of source publications must not be added to this repository, its Git history, build context, wheel, or source distribution unless a documented licence explicitly permits repository and PyPI redistribution. Public availability or publication of an article is not permission to redistribute it. Do not copy Fenton material from private workspaces or other repositories. Keep the package-data declaration as an explicit runtime allowlist, and keep distribution tests fail-closed against Fenton-named content and unapproved resources; `.gitignore` is not a distribution control. + ### Test Fixture Strategy The test fixture is **fixed and finite** (3984 cases). When modifying calculation logic: diff --git a/pyproject.toml b/pyproject.toml index b06e464..5342ba1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,18 +73,34 @@ select = ["E9", "F63", "F7", "F82"] "rcpchgrowth/dynamic_growth.py" = ["F821"] [tool.setuptools] -include-package-data = true +include-package-data = false [tool.setuptools.packages.find] include = ["rcpchgrowth*"] -exclude = ["notebooks"] +exclude = ["notebooks", "rcpchgrowth.tests", "rcpchgrowth.tests.*"] [project.readme] file = "README.md" content-type = "text/markdown" [tool.setuptools.package-data] -rcpchgrowth = ["data_tables/**/*.csv", "data_tables/**/*.json", "data_tables/**/*.xls", "data_tables/**/*.pdf", "data_tables/**/*.bas", "data_tables/**/*.xlsx"] +rcpchgrowth = [ + "data_tables/cdc2-20.json", + "data_tables/cdc_infants.json", + "data_tables/trisomy_21.json", + "data_tables/trisomy_21_aap_children.json", + "data_tables/trisomy_21_aap_infants.json", + "data_tables/turner.json", + "data_tables/uk90_child.json", + "data_tables/uk90_preterm.json", + "data_tables/uk90_term.json", + "data_tables/uk_who_weight_correlation_matrices/weight_correlation_by_month.json", + "data_tables/uk_who_weight_correlation_matrices/weight_correlation_by_week.json", + "data_tables/who/pre_2025/who_infants.json", + "data_tables/who/who_2007_children.json", + "data_tables/who/who_children.json", + "data_tables/who/who_infants.json", +] # Version bumping. `version` above is the single source of truth; this keeps # the duplicated version in CITATION.cff (and its release date) in sync so it diff --git a/rcpchgrowth/data_tables/cdc_resources/GrowthchartLMSmethod07.pdf b/rcpchgrowth/data_tables/cdc_resources/GrowthchartLMSmethod07.pdf deleted file mode 100644 index fcc3a0a..0000000 Binary files a/rcpchgrowth/data_tables/cdc_resources/GrowthchartLMSmethod07.pdf and /dev/null differ diff --git a/s/test-wheel b/s/test-wheel index 7940094..b773496 100755 --- a/s/test-wheel +++ b/s/test-wheel @@ -31,7 +31,14 @@ if (($#)); then docker compose cp "$host_wheel" "rcpchgrowth-python:$work_dir/dist/$wheel_name" >/dev/null wheel_path="$work_dir/dist/$wheel_name" else - docker compose exec -T rcpchgrowth-python python -m build --wheel --outdir "$work_dir/dist" + docker compose exec -T rcpchgrowth-python cp -a /app "$work_dir/source" + docker compose exec -T rcpchgrowth-python rm -rf \ + "$work_dir/source/build" \ + "$work_dir/source/dist" \ + "$work_dir/source/rcpchgrowth.egg-info" + docker compose exec -T rcpchgrowth-python sh -c \ + 'cd "$1" && exec python -m build --wheel --outdir "$2"' \ + sh "$work_dir/source" "$work_dir/dist" wheel_path="$(docker compose exec -T rcpchgrowth-python sh -c 'set -- "$1"/*.whl; test "$#" -eq 1; printf "%s" "$1"' sh "$work_dir/dist")" fi diff --git a/spec/roadmap.md b/spec/roadmap.md index 8ad2781..f48c872 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -26,7 +26,7 @@ Complete these items before broad formatting, import cleanup, packaging reductio - [x] **R19 - Characterize serialized measurement contracts.** Add golden or schema-focused tests for the complete `Measurement.measurement` dictionary across representative successful, corrected-age, out-of-range, and validation-error cases. Assert key presence, nesting, nullability, value types, reference identifiers, and clinically significant numeric values. - [~] **R20 - Fill calculation-vector and boundary gaps.** Independent representative vectors and documented error boundaries now cover every supported reference. Finalize the exact 23-, 25-, and 42-week boundaries through the existing PR #101 path recorded in `spec/queries.md`. - [~] **R21 - Characterize chart-builder outputs.** Direct tests now cover structure, series selection, age domains, boundary ownership, and representative coordinates for every supported reference. Finalize the exact preterm chart boundaries through the existing PR #101 path recorded in `spec/queries.md`. -- [~] **R22 - Add installed-wheel smoke and resource tests.** Build and install the candidate wheel into an isolated environment, verify public imports and version metadata, and execute representative calculations that load every required packaged data family. The isolated smoke and required-resource checks are active locally and in CI; finish the forbidden-content assertions after R4-R8 establish the approved package policy. +- [x] **R22 - Add installed-wheel smoke and resource tests.** Build and install the candidate wheel into an isolated environment, verify public imports and version metadata, and execute representative calculations that load every required packaged data family. The isolated smoke and exact resource-allowlist checks reject packaged tests, generated files, notebooks, unapproved resources, and Fenton-named content. - [~] **R23 - Add a downstream candidate-wheel compatibility gate.** The exact-wheel runner validates artifact hashes and provenance, the server calculation/API regression suite, dynamic charts, and both pinned React profiles against an immutable server revision. The runner passed against PR #280 commit `6918e3b8152963345f4c0996e7bd52bacf904583` using the published 4.6.0 wheel; repeat it with a commit-stamped candidate wheel against PR #280's immutable merge commit after that PR adopts the canonical engine identifier, then use that merge commit as the permanent release/CI pin. ## High priority @@ -39,11 +39,11 @@ Complete these items before broad formatting, import cleanup, packaging reductio - [ ] **R25 - Derive accepted age domains from reference metadata and table extents.** Replace duplicated age-limit literals with a central domain model for every reference segment, measurement, and sex. Distinguish observed table extent from clinically supported applicability, including endpoint inclusivity and overlap precedence for hybrid references such as UK-WHO and WHO. Add build-time checks against canonical source material in `rcpch/growth-references`, prohibit extrapolation beyond selected series, and test every first/last accepted endpoint plus `math.nextafter()` immediately outside it. Track implementation in issue #109. - [ ] **R3 - Decide the future of the unusable `BoneAge` class.** `rcpchgrowth/bone_age.py:12-48` returns a dictionary from `__init__`, so construction raises `TypeError: __init__() should return None`. The class has no internal consumers and is not exported from the package root, although related fields are supported by `Measurement`. Either implement and test a coherent object API or remove the module after checking external compatibility. -- [ ] **R4 - Define an explicit runtime package-data allowlist.** The published installation occupies roughly 15 MB and ships spreadsheets, a PDF, VBA source, validation CSVs, old WHO tables, experimental LMS tables, and other provenance material because `pyproject.toml:68-69` broadly includes file extensions beneath `data_tables`. Identify the JSON and correlation resources required at runtime, package only those files, and verify wheel and sdist contents in CI. -- [ ] **R5 - Exclude the test package from release wheels.** `pyproject.toml:60-62` discovers `rcpchgrowth*`, which includes 19 modules beneath `rcpchgrowth/tests` in the published wheel. Exclude `rcpchgrowth.tests` and its subpackages while retaining all tests in the source repository and sdist if deliberately required there. -- [ ] **R6 - Separate runtime data from source and validation provenance.** Review `rcpchgrowth/data_tables/uk-who_resources/` (about 6.2 MB), `test_lms_cole_tmp/`, `validation_who_vs_uk_who/`, `cdc_resources/`, WHO CSV sources, `who/pre_2025/`, AAP spreadsheets, `GrowthCharts.xls`, `huiqi_cole_methods.bas`, and `data_tables.txt`. Retain required provenance in the repository or documentation project, but move it outside runtime package data and document what generated each shipped table. -- [ ] **R7 - Retire or archive the legacy `cdc.json` table.** `rcpchgrowth/data_tables/cdc.json` is approximately 308 KB and is shipped, but runtime code uses `cdc_infants.json` and `cdc2-20.json`. Confirm that no external resource consumer relies on its path, then remove it from package data or move it to a clearly documented provenance archive. -- [ ] **R8 - Decide whether height-prediction data is a supported feature.** The Bayley-Pinneau CSV files and `constants/height_predictions_constants.py` are shipped and indirectly exposed through wildcard constant imports, but no implementation or internal consumer exists. Either implement and test the feature or stop packaging and exporting these placeholders after a compatibility review. +- [x] **R4 - Define an explicit runtime package-data allowlist.** Packaging now includes only the 15 JSON resources traced to production loads, and the artifact checkers reject every unapproved package resource. +- [x] **R5 - Exclude the test package from release wheels.** Package discovery excludes `rcpchgrowth.tests` and its subpackages, and the wheel checker rejects packaged test modules. +- [~] **R6 - Separate runtime data from source and validation provenance.** Source spreadsheets, validation CSVs, VBA, intermediate tables, and other provenance remain available in the repository but are excluded from runtime wheels. Finish documenting the generation and provenance of each shipped runtime table. +- [x] **R7 - Retire or archive the legacy `cdc.json` table.** The unused aggregate remains available as repository provenance but is excluded from package data; runtime continues to use `cdc_infants.json` and `cdc2-20.json`. +- [~] **R8 - Decide whether height-prediction data is a supported feature.** Bayley-Pinneau CSV files are no longer shipped in runtime wheels. Decide separately whether to implement the feature or remove its indirectly exposed placeholder constants through a compatibility-managed change. ## Low priority @@ -52,7 +52,7 @@ Complete these items before broad formatting, import cleanup, packaging reductio - [ ] **R11 - Remove confirmed unused imports and duplicate imports.** Clean `json` from `global_functions.py`; `CDC`, `HEIGHT`, `WEIGHT`, `rounded_sds_for_centile`, and duplicate `HEAD_CIRCUMFERENCE`/`BMI` imports from `centile_bands.py`; `FENTON` and `UK_WHO_CHILD` from `chart_functions.py`; `FEMALE`, `MALE`, and `z_score` from `dynamic_growth.py`; `scipy.stats.norm` from `test_centile_band_for_centile.py`; and `json`, `pprint`, and the local `math` import from `test_who.py`. Do not remove the root-package imports that intentionally define the public API. - [ ] **R12 - Remove unused WHO precalculated centile arrays after source review.** The large arrays `WHO_GIRL_WEIGHT_UNDER_FIVE_15`, `WHO_GIRL_BMI_OVER_FIVE_15`, `WHO_BOY_LENGTH_OVER_FIVE_15`, `WHO_BOY_WEIGHT_OVER_FIVE_15`, and `WHO_BOY_BMI_OVER_FIVE_15` in `rcpchgrowth/tests/who_test_data/who_chart_precalculated_centiles.py` have no test consumers. Confirm that they are not intentionally retained source vectors before deleting them. - [ ] **R13 - Make the public namespace explicit.** Add deliberate `__all__` declarations to `rcpchgrowth/__init__.py` and the constants package, then replace internal wildcard imports incrementally. Preserve the existing supported names unless a breaking API change is explicitly approved; the current Ruff reports for root re-exports are expected false positives rather than automatic deletion candidates. -- [ ] **R14 - Make release builds isolated and reproducible.** A local wheel built with an existing ignored `rcpchgrowth.egg-info/SOURCES.txt` contained `data_tables.txt`, while the published wheel did not, demonstrating that stale build metadata can affect package contents. Build releases from a clean source tree, inspect wheel and sdist manifests automatically, and ensure ignored metadata cannot alter artifacts. +- [x] **R14 - Make release builds isolated and reproducible.** Release CI builds from a clean checkout and validates both wheel and sdist manifests. Local wheel tests build from a temporary clean copy of the current worktree, preventing ignored `build/` and `rcpchgrowth.egg-info/` state from altering artifacts. - [ ] **R15 - Clear disposable generated artifacts from development worktrees.** The audited worktree contained ignored `build/`, `dist/`, `rcpchgrowth.egg-info/`, `.pytest_cache/`, `__pycache__/`, and notebook checkpoint directories, including an obsolete `2.1.9` distribution artifact. Provide or document a safe cleanup command that removes only ignored generated output and never reference or provenance data. ## Dependency audit result diff --git a/tests/distribution/check-sdist.py b/tests/distribution/check-sdist.py new file mode 100644 index 0000000..9f1da06 --- /dev/null +++ b/tests/distribution/check-sdist.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Validate source distribution structure and package contents.""" + +import sys +from email.parser import Parser +from pathlib import Path +from runpy import run_path +from tarfile import open as open_tar + + +WHEEL_CHECKER = Path(__file__).with_name("check-wheel.py") +WHEEL_POLICY = run_path(str(WHEEL_CHECKER)) +RUNTIME_RESOURCES = WHEEL_POLICY["RUNTIME_RESOURCES"] +REQUIRED_FILES = WHEEL_POLICY["REQUIRED_FILES"] | { + "LICENSE", + "README.md", + "pyproject.toml", +} +ALLOWED_BUILD_METADATA = { + "PKG-INFO", + "rcpchgrowth.egg-info/PKG-INFO", + "rcpchgrowth.egg-info/SOURCES.txt", + "rcpchgrowth.egg-info/dependency_links.txt", + "rcpchgrowth.egg-info/requires.txt", + "rcpchgrowth.egg-info/top_level.txt", + "setup.cfg", +} + + +def main() -> None: + sdist = Path(sys.argv[1]).resolve() + if not sdist.is_file() or not sdist.name.endswith(".tar.gz"): + raise SystemExit(f"Not a source distribution: {sdist}") + + with open_tar(sdist, "r:gz") as archive: + file_members = {member.name: member for member in archive if member.isfile()} + roots = {Path(name).parts[0] for name in file_members} + if len(roots) != 1: + raise AssertionError(f"Expected one sdist root, found {sorted(roots)}") + root = roots.pop() + metadata_name = f"{root}/PKG-INFO" + if metadata_name not in file_members: + raise AssertionError(f"Missing sdist metadata: {metadata_name}") + metadata_file = archive.extractfile(file_members[metadata_name]) + assert metadata_file is not None + metadata = Parser().parsestr(metadata_file.read().decode()) + + members = {name.removeprefix(f"{root}/") for name in file_members} + missing = sorted(REQUIRED_FILES - members) + unexpected_resources = sorted( + name + for name in members + if not name.endswith(".py") + and name not in REQUIRED_FILES + and name not in ALLOWED_BUILD_METADATA + ) + forbidden = sorted( + name + for name in members + if "__pycache__" in name + or name.endswith((".pyc", ".pyo")) + or "notebooks" in Path(name).parts + or name.startswith("rcpchgrowth/tests/") + or "fenton" in name.casefold() + ) + assert not missing, f"Required files missing from sdist: {missing}" + assert not unexpected_resources, ( + f"Unapproved package resources found in sdist: {unexpected_resources}" + ) + assert not forbidden, f"Forbidden files found in sdist: {forbidden}" + assert metadata["Name"] == "rcpchgrowth" + assert metadata["Version"] + expected_name = f"rcpchgrowth-{metadata['Version']}.tar.gz" + expected_root = f"rcpchgrowth-{metadata['Version']}" + assert root == expected_root, ( + f"Source distribution root and metadata version differ: " + f"{root}, {metadata['Version']}" + ) + assert sdist.name == expected_name, ( + f"Source distribution filename and metadata version differ: " + f"{sdist.name}, {metadata['Version']}" + ) + print( + f"Validated {sdist.name}: rcpchgrowth {metadata['Version']}, " + f"{len(members)} files" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/distribution/check-wheel.py b/tests/distribution/check-wheel.py index 4903486..2d28380 100755 --- a/tests/distribution/check-wheel.py +++ b/tests/distribution/check-wheel.py @@ -1,14 +1,16 @@ #!/usr/bin/env python3 -"""Validate the minimum wheel structure needed by runtime consumers.""" +"""Validate the exact wheel structure needed by runtime consumers.""" import sys from email.parser import Parser from pathlib import Path from zipfile import ZipFile -REQUIRED_FILES = { +REQUIRED_PYTHON_FILES = { "rcpchgrowth/__init__.py", "rcpchgrowth/_build_info.py", +} +RUNTIME_RESOURCES = { "rcpchgrowth/data_tables/cdc2-20.json", "rcpchgrowth/data_tables/cdc_infants.json", "rcpchgrowth/data_tables/trisomy_21.json", @@ -25,6 +27,7 @@ "rcpchgrowth/data_tables/who/who_children.json", "rcpchgrowth/data_tables/who/who_infants.json", } +REQUIRED_FILES = REQUIRED_PYTHON_FILES | RUNTIME_RESOURCES def main() -> None: @@ -42,15 +45,27 @@ def main() -> None: metadata = Parser().parsestr(archive.read(metadata_names[0]).decode()) missing = sorted(REQUIRED_FILES - members) + package_resources = { + name + for name in members + if name.startswith("rcpchgrowth/") + and not name.endswith((".py", "/")) + } + unexpected_resources = sorted(package_resources - RUNTIME_RESOURCES) forbidden = sorted( name for name in members if "__pycache__" in name or name.endswith((".pyc", ".pyo")) or "notebooks" in Path(name).parts + or name.startswith("rcpchgrowth/tests/") + or "fenton" in name.casefold() ) assert not missing, f"Required runtime files missing from wheel: {missing}" - assert not forbidden, f"Generated or notebook files found in wheel: {forbidden}" + assert not unexpected_resources, ( + f"Unapproved package resources found in wheel: {unexpected_resources}" + ) + assert not forbidden, f"Forbidden files found in wheel: {forbidden}" assert metadata["Name"] == "rcpchgrowth" assert metadata["Version"] assert wheel.name.startswith(f"rcpchgrowth-{metadata['Version']}-"), ( diff --git a/tests/distribution/test_check_sdist.py b/tests/distribution/test_check_sdist.py new file mode 100644 index 0000000..f3f5d25 --- /dev/null +++ b/tests/distribution/test_check_sdist.py @@ -0,0 +1,72 @@ +import io +import subprocess +import sys +from pathlib import Path +from runpy import run_path +from tarfile import TarInfo, open as open_tar + + +SCRIPT = Path(__file__).with_name("check-sdist.py") +REQUIRED_FILES = run_path(str(SCRIPT))["REQUIRED_FILES"] + + +def make_sdist(tmp_path, *, version="4.6.2", omitted=None, extra=()): + sdist = tmp_path / f"rcpchgrowth-{version}.tar.gz" + root = f"rcpchgrowth-{version}" + members = REQUIRED_FILES - ({omitted} if omitted else set()) + with open_tar(sdist, "w:gz") as archive: + for name in members: + info = TarInfo(f"{root}/{name}") + info.size = 0 + archive.addfile(info, io.BytesIO()) + metadata = f"Name: rcpchgrowth\nVersion: {version}\n".encode() + info = TarInfo(f"{root}/PKG-INFO") + info.size = len(metadata) + archive.addfile(info, io.BytesIO(metadata)) + for name in extra: + info = TarInfo(f"{root}/{name}") + info.size = 0 + archive.addfile(info, io.BytesIO()) + return sdist + + +def check(sdist): + return subprocess.run( + [sys.executable, str(SCRIPT), str(sdist)], + capture_output=True, + check=False, + text=True, + ) + + +def test_accepts_minimal_valid_sdist(tmp_path): + result = check(make_sdist(tmp_path)) + assert result.returncode == 0, result.stderr + + +def test_rejects_missing_runtime_resource(tmp_path): + missing = "rcpchgrowth/data_tables/who/who_infants.json" + result = check(make_sdist(tmp_path, omitted=missing)) + assert result.returncode != 0 + assert missing in result.stderr + + +def test_rejects_unapproved_package_resource(tmp_path): + extra = "rcpchgrowth/data_tables/source/reference.csv" + result = check(make_sdist(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr + + +def test_rejects_unapproved_top_level_resource(tmp_path): + extra = "references/source-publication.pdf" + result = check(make_sdist(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr + + +def test_rejects_fenton_path_case_insensitively(tmp_path): + extra = "reference/FENTON-paper.pdf" + result = check(make_sdist(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr diff --git a/tests/distribution/test_check_wheel.py b/tests/distribution/test_check_wheel.py index 571a840..f3218eb 100644 --- a/tests/distribution/test_check_wheel.py +++ b/tests/distribution/test_check_wheel.py @@ -51,6 +51,27 @@ def test_rejects_nested_notebook_content(tmp_path): assert "demo.ipynb" in result.stderr +def test_rejects_unapproved_package_resource(tmp_path): + extra = "rcpchgrowth/data_tables/who/csv/who_2006_weight_male.csv" + result = check(make_wheel(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr + + +def test_rejects_packaged_test_module(tmp_path): + extra = "rcpchgrowth/tests/test_reference.py" + result = check(make_wheel(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr + + +def test_rejects_fenton_path_case_insensitively(tmp_path): + extra = "rcpchgrowth/data_tables/FENTON/reference.py" + result = check(make_wheel(tmp_path, extra=(extra,))) + assert result.returncode != 0 + assert extra in result.stderr + + def test_rejects_filename_metadata_version_mismatch(tmp_path): wheel = make_wheel(tmp_path) mismatched = wheel.with_name("rcpchgrowth-4.6.1-py3-none-any.whl")