diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 414c08db..ef16e345 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -25,6 +25,25 @@ jobs: echo "Types: added, changed, fixed, removed, breaking" exit 1 fi + BundleMetadataContract: + name: Validate bundle metadata contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.14" + - name: Install uv + uses: astral-sh/setup-uv@v8.1.0 + - name: Install core + run: uv pip install --system . + - name: Install bundle validation tooling + # Pin the test-only bundle contract dependency until policyengine-bundles + # has published releases suitable for ordinary dependency specifiers. + run: uv pip install --system "policyengine-bundles @ git+https://github.com/PolicyEngine/policyengine-bundles@8ae9f56fefcf89f69b8a7e3bc49928509c6207be" + - name: Validate runtime metadata contract + run: python -m pytest tests/core/test_build_metadata.py Test: strategy: matrix: @@ -100,4 +119,4 @@ jobs: run: python -m pytest -m smoke --reruns 2 --reruns-delay 5 -v -s env: RUN_SMOKE_TESTS: "0" - POLICYENGINE_GITHUB_MICRODATA_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + POLICYENGINE_GITHUB_MICRODATA_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/changelog.d/add-runtime-metadata.added.md b/changelog.d/add-runtime-metadata.added.md new file mode 100644 index 00000000..f0236267 --- /dev/null +++ b/changelog.d/add-runtime-metadata.added.md @@ -0,0 +1 @@ +Added dependency-free runtime metadata for bundle validation. diff --git a/policyengine_core/__init__.py b/policyengine_core/__init__.py index 3745adf6..40273ff1 100644 --- a/policyengine_core/__init__.py +++ b/policyengine_core/__init__.py @@ -1,2 +1,3 @@ +from policyengine_core.build_metadata import get_runtime_metadata from policyengine_core.simulations import Microsimulation, Simulation from policyengine_core.taxbenefitsystems import TaxBenefitSystem diff --git a/policyengine_core/build_metadata.py b/policyengine_core/build_metadata.py new file mode 100644 index 00000000..6c0a535d --- /dev/null +++ b/policyengine_core/build_metadata.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import importlib.metadata +import json +from typing import Any, Dict, Optional + + +PACKAGE_NAME = "policyengine-core" + +__all__ = ["get_runtime_metadata"] + + +def get_runtime_metadata() -> Dict[str, Any]: + """Return JSON-compatible metadata describing this core runtime. + + The payload intentionally avoids importing the bundle orchestrator. It is + shaped to be validated by policyengine-bundles when that package is + available in release or integration-test workflows. + """ + + distribution = importlib.metadata.distribution(PACKAGE_NAME) + metadata: Dict[str, Any] = { + "name": PACKAGE_NAME, + "version": distribution.version, + } + + git_sha = _get_direct_url_git_sha(distribution) + if git_sha is not None: + metadata["git_sha"] = git_sha + + return metadata + + +def _get_direct_url_git_sha( + distribution: importlib.metadata.Distribution, +) -> Optional[str]: + direct_url = _read_direct_url(distribution) + if direct_url is None: + return None + + vcs_info = direct_url.get("vcs_info") + if not isinstance(vcs_info, dict): + return None + + commit_id = vcs_info.get("commit_id") + if isinstance(commit_id, str) and commit_id: + return commit_id + + return None + + +def _read_direct_url( + distribution: importlib.metadata.Distribution, +) -> Optional[Dict[str, Any]]: + direct_url_text = distribution.read_text("direct_url.json") + if direct_url_text is None: + return None + + try: + direct_url = json.loads(direct_url_text) + except json.JSONDecodeError: + return None + + if isinstance(direct_url, dict): + return direct_url + + return None diff --git a/tests/core/test_build_metadata.py b/tests/core/test_build_metadata.py new file mode 100644 index 00000000..5a14a52d --- /dev/null +++ b/tests/core/test_build_metadata.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import importlib.metadata +import json + +import pytest + +from policyengine_core import get_runtime_metadata +from policyengine_core import build_metadata + + +def test_runtime_metadata_has_core_identity(): + metadata = get_runtime_metadata() + + assert metadata["name"] == "policyengine-core" + assert metadata["version"] == importlib.metadata.version("policyengine-core") + + +def test_runtime_metadata_is_json_compatible(): + json.dumps(get_runtime_metadata()) + + +def test_runtime_metadata_does_not_include_local_source_path(): + assert "source_path" not in get_runtime_metadata() + + +def test_runtime_metadata_uses_pep_610_vcs_commit(monkeypatch): + class Distribution: + version = "1.2.3" + + def read_text(self, name): + if name == "direct_url.json": + return json.dumps( + { + "vcs_info": { + "vcs": "git", + "commit_id": "abc123", + } + } + ) + + return None + + monkeypatch.setattr( + build_metadata.importlib.metadata, + "distribution", + lambda name: Distribution(), + ) + + assert get_runtime_metadata() == { + "name": "policyengine-core", + "version": "1.2.3", + "git_sha": "abc123", + } + + +def test_runtime_metadata_uses_bundle_contract_when_available(): + validation = pytest.importorskip("policyengine_bundles") + + validation.load_component_metadata(get_runtime_metadata())