diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44242ff04..6c59c73c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -273,8 +273,23 @@ jobs: uv run pytest tests/unit/test_no_raw_text_logging.py -q - name: Test with pytest - run: | - uv run pytest --cov=openmed --cov-report=xml --cov-report=term-missing + timeout-minutes: 45 + run: >- + uv run --frozen python -c + "import faulthandler, pytest; diagnostics = type('CiSummaryDiagnostics', (), {'pytest_sessionfinish': lambda self, session, exitstatus: faulthandler.dump_traceback_later(120, repeat=True)})(); raise SystemExit(pytest.main(plugins=[diagnostics]))" + --cov=openmed --cov-report=xml --cov-report=term-missing + --tb=line --show-capture=no --color=no --code-highlight=no -ra + --junitxml=pytest-results.xml + + - name: Upload test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: pytest-${{ matrix.os }}-py${{ matrix.python-version }}-${{ github.run_attempt }} + path: | + pytest-results.xml + coverage.xml + if-no-files-found: warn - name: Upload coverage to Codecov uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 @@ -284,6 +299,76 @@ jobs: name: codecov-umbrella fail_ci_if_error: false + sdk-compatibility: + # Bounded core contracts and installed artifacts on every advertised lane. + # Model/runtime qualification remains separate; no weights are downloaded. + needs: repo-policy + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.10", "3.11", "3.12", "3.13"] + env: + OPENMED_OFFLINE: "1" + HF_HUB_OFFLINE: "1" + TRANSFORMERS_OFFLINE: "1" + HF_DATASETS_OFFLINE: "1" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + version: "0.11.28" + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Prepare frozen test and package dependencies + shell: bash + run: | + uv sync --frozen --extra dev --python "${{ matrix.python-version }}" + uv export --frozen --no-hashes --no-emit-project --extra cli --extra service --extra mcp --output-file "$RUNNER_TEMP/compat-constraints.txt" + - name: Core privacy and platform contracts + run: >- + uv run --frozen pytest -q --timeout=120 --timeout-method=thread + tests/unit/install/test_smoke_check.py + tests/unit/release/test_release_budget_workflow.py + tests/unit/service/test_brief_surfaces.py + tests/unit/ner/test_infer.py + tests/unit/test_no_raw_text_logging.py + - name: Build wheel and source distribution + shell: bash + run: uv build --out-dir "$RUNNER_TEMP/compat-dist" + - name: Verify clean core and adapter installs plus missing-resource controls + shell: bash + run: >- + python scripts/install/smoke_check.py + --artifacts "$RUNNER_TEMP/compat-dist" + --constraints "$RUNNER_TEMP/compat-constraints.txt" + --report sdk-compatibility.json + - name: Upload value-free compatibility evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: sdk-compatibility-${{ matrix.os }}-${{ matrix.python-version }} + path: sdk-compatibility.json + if-no-files-found: warn + + sdk-compatibility-gate: + # Stable required-check name; a missing/cancelled matrix is not success. + needs: sdk-compatibility + if: always() + runs-on: ubuntu-latest + steps: + - name: Require every advertised combination + env: + MATRIX_RESULT: ${{ needs.sdk-compatibility.result }} + run: test "$MATRIX_RESULT" = success + security: needs: repo-policy runs-on: ubuntu-latest @@ -496,6 +581,7 @@ jobs: - yasbd-integration - i18n-cjk-indic - browser-extension + - sdk-compatibility-gate if: >- always() && needs.repo-policy.result == 'success' && @@ -505,6 +591,7 @@ jobs: needs.spacy-integration.result == 'success' && needs.yasbd-integration.result == 'success' && needs.browser-extension.result == 'success' && + needs.sdk-compatibility-gate.result == 'success' && ( needs.i18n-cjk-indic.result == 'success' || needs.i18n-cjk-indic.result == 'skipped' @@ -531,6 +618,65 @@ jobs: run: | uv build + - name: Verify refreshed wheel-size baseline + id: wheel_baseline + shell: bash + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch --no-tags --depth=1 origin "$BASE_SHA" + if git diff --quiet "$BASE_SHA" HEAD -- gates/release_budgets.json; then + echo "Release budgets unchanged; no baseline refresh to verify." + exit 0 + else + diff_status=$? + if [ "$diff_status" -ne 1 ]; then + exit "$diff_status" + fi + fi + baseline_source="$(mktemp -d)" + git archive "$BASE_SHA" | tar -x -C "$baseline_source" + uv build --out-dir "$RUNNER_TEMP/openmed-baseline-dist" "$baseline_source" + echo "measured=true" >> "$GITHUB_OUTPUT" + python - "$RUNNER_TEMP/openmed-baseline-dist" <<'PY' + import hashlib + import json + import os + import platform + import subprocess + import sys + import zipfile + from pathlib import Path + + wheels = list(Path(sys.argv[1]).glob("openmed-*.whl")) + if len(wheels) != 1: + raise SystemExit("Expected exactly one frozen-base wheel") + wheel = wheels[0] + measured = wheel.stat().st_size + budget = json.loads(Path("gates/release_budgets.json").read_text()) + expected = budget["entries"]["package::openmed::wheel"]["metrics"]["baseline_bytes"] + with zipfile.ZipFile(wheel) as archive: + metadata_paths = [name for name in archive.namelist() if name.endswith(".dist-info/WHEEL")] + if len(metadata_paths) != 1: + raise SystemExit("Expected exactly one wheel metadata record") + wheel_metadata = archive.read(metadata_paths[0]).decode("utf-8") + receipt = { + "base_sha": os.environ["BASE_SHA"], + "python": platform.python_version(), + "uv": subprocess.check_output(["uv", "--version"], text=True).strip(), + "wheel_metadata": wheel_metadata, + "measured_bytes": measured, + "proposed_baseline_bytes": expected, + "sha256": hashlib.sha256(wheel.read_bytes()).hexdigest(), + "matches": measured == expected, + } + (wheel.parent / "baseline-receipt.json").write_text(json.dumps(receipt, indent=2) + "\n") + print(f"Frozen-base wheel: {measured} bytes, sha256={hashlib.sha256(wheel.read_bytes()).hexdigest()}") + if measured != expected: + raise SystemExit(f"Wheel baseline mismatch: measured {measured}, proposed {expected}") + PY + - name: Enforce wheel size budget and record language-extra footprints run: | python scripts/release/check_size_budget.py \ @@ -569,3 +715,10 @@ jobs: dist/ size-budget-report.json if: always() + + - name: Upload wheel baseline evidence + if: always() && steps.wheel_baseline.outputs.measured == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: wheel-size-baseline + path: ${{ runner.temp }}/openmed-baseline-dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d92f24371..a9c6aa9f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -313,6 +313,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Load prefetched Hugging Face models from the standard cache during offline inference, including Transformers 5.x pipeline and component loading (#1983). - Reject non-integer sharding counts before reading documents. +- Include ranked grounding alternatives in MedMentions top-k accuracy without + crediting withheld selections or turning abstentions into predictions (#3569). - Reject overlapping sibling items at nested list levels. - Require strict decoder validation before auto-detecting ISCII, preserving malformed Latin-1 strings through privacy preprocessing instead of raising diff --git a/README.ar.md b/README.ar.md index 1ce55d8cd..2a83e92c0 100644 --- a/README.ar.md +++ b/README.ar.md @@ -470,7 +470,7 @@ curl -X POST http://127.0.0.1:8080/pii/extract \ -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.de.md b/README.de.md index 36855a1f6..2e61a1ed4 100644 --- a/README.de.md +++ b/README.de.md @@ -393,7 +393,7 @@ Wenn OpenMed für deine Forschung nützlich ist, zitiere es bitte: Wenn OpenMed dir nützlich ist, hilft ein Stern anderen, es zu entdecken. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.es.md b/README.es.md index 2100e0f89..dbd96f595 100644 --- a/README.es.md +++ b/README.es.md @@ -393,7 +393,7 @@ Si OpenMed te resulta útil en tu investigación, cítalo: Si OpenMed te resulta útil, una estrella ayuda a que otros lo descubran. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.fa.md b/README.fa.md index c5b899164..60f0a41e6 100644 --- a/README.fa.md +++ b/README.fa.md @@ -472,7 +472,7 @@ OpenMed بر پایهٔ کارهای عالیِ متن‌باز ساخته شد -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.fr.md b/README.fr.md index 9982e4c89..75033ad33 100644 --- a/README.fr.md +++ b/README.fr.md @@ -393,7 +393,7 @@ Si OpenMed vous est utile dans vos recherches, merci de le citer : Si OpenMed vous est utile, une étoile aide les autres à le découvrir. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.hi.md b/README.hi.md index 5ea08af16..9b7a34313 100644 --- a/README.hi.md +++ b/README.hi.md @@ -750,7 +750,7 @@ OpenMed SDK का स्रोत [Apache-2.0 License](LICENSE) के अं यदि OpenMed आपके लिए उपयोगी है, तो एक star दूसरों को इसे खोजने में मदद करता है। -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.it.md b/README.it.md index e8d163b50..7d55a1812 100644 --- a/README.it.md +++ b/README.it.md @@ -393,7 +393,7 @@ Se OpenMed ti è utile nella tua ricerca, ti preghiamo di citarlo: Se OpenMed ti è utile, una stella aiuta altri a scoprirlo. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.ja.md b/README.ja.md index 1d872f1c8..9ffbb6086 100644 --- a/README.ja.md +++ b/README.ja.md @@ -393,7 +393,7 @@ OpenMed SDK のソースは [Apache-2.0 License](LICENSE) の下で公開され OpenMed が役立つと感じたら、スターを付けると他の人が見つけやすくなります。 -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.md b/README.md index 5298765ca..06a91a657 100644 --- a/README.md +++ b/README.md @@ -745,7 +745,7 @@ papers, posters, and derived documentation. If OpenMed is useful to you, a star helps others discover it. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.nl.md b/README.nl.md index aa98efa06..d63dbb24c 100644 --- a/README.nl.md +++ b/README.nl.md @@ -393,7 +393,7 @@ Als OpenMed nuttig is in je onderzoek, citeer het dan: Als OpenMed nuttig voor je is, helpt een ster anderen om het te ontdekken. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.pt.md b/README.pt.md index 90a140155..4addd8e28 100644 --- a/README.pt.md +++ b/README.pt.md @@ -393,7 +393,7 @@ Se o OpenMed for útil na sua pesquisa, por favor, cite: Se o OpenMed for útil para você, uma estrela ajuda outros a descobri-lo. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.sw.md b/README.sw.md index fe3d3f027..4802f1e69 100644 --- a/README.sw.md +++ b/README.sw.md @@ -610,7 +610,7 @@ OpenMed katika makala, mabango na nyaraka zinazotokana nayo. Ikiwa OpenMed inakufaa, nyota huwasaidia wengine kuipata. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.te.md b/README.te.md index 4bb2dec44..50cc90e1a 100644 --- a/README.te.md +++ b/README.te.md @@ -393,7 +393,7 @@ OpenMed SDK సోర్స్ [Apache-2.0 License](LICENSE) క్రింద OpenMed మీకు ఉపయోగకరంగా ఉంటే, ఒక స్టార్ ఇతరులు దాన్ని కనుగొనడంలో సహాయపడుతుంది. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.tr.md b/README.tr.md index e941b8d25..8a9c9b58c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -393,7 +393,7 @@ OpenMed araştırmanızda faydalı olduysa, lütfen atıfta bulunun: OpenMed sizin için faydalıysa, bir yıldız başkalarının onu keşfetmesine yardımcı olur. -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 0ee2ca6d1..24a754a6e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -726,7 +726,7 @@ OpenMed SDK 源代码基于 [Apache-2.0 License](LICENSE) 发布。第三方资 如果 OpenMed 对你有帮助,点个 star 能帮助更多人发现它。 -[5,100+ GitHub stars · 30 Aug 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) +[5,400+ GitHub stars · 3 Oct 2026 snapshot](https://github.com/maziyarpanahi/openmed/stargazers) --- diff --git a/deploy/security/cve-allowlist.yaml b/deploy/security/cve-allowlist.yaml index 97431f4b7..b6bb9ae04 100644 --- a/deploy/security/cve-allowlist.yaml +++ b/deploy/security/cve-allowlist.yaml @@ -8,8 +8,13 @@ allowlist: - id: CVE-2026-81726 package: nltk target: uv.lock - expires: 2026-09-28 + expires: 2026-10-17 reason: >- - Re-reviewed 2026-09-14: NLTK still has no fixed release; OpenMed does - not call the affected model-artifact APIs and the service image excludes - NLTK. + Owner-authorized review 2026-10-03: upstream GHSA-8mgp-746c-j5xp still + lists no patched release and PyPI latest is 3.10.3. NLTK is reachable + only through agents, llamaindex, medspacy, quickumls and scrubadub extras; + it is absent from the core closure and service image. OpenMed does not + call the affected model-artifact APIs. This temporary acceptance is not + remediation: do not pass untrusted model paths to those optional + integrations. The gate rejects this waiver when a fixed version is + reported, for any other package or target, or after 2026-10-17. diff --git a/docs/brand/social/manifest.json b/docs/brand/social/manifest.json index 8c2cf35ad..6cbec0b6f 100644 --- a/docs/brand/social/manifest.json +++ b/docs/brand/social/manifest.json @@ -5,7 +5,7 @@ "source_hashes": { "docs/brand/social/_src/exports.json": "385c4ed79de723b47e8af696bd83cd8e06abdd8a6f07edb60ffb150545a59353", "docs/brand/social/_src/profile-copy.json": "04255d0564edcc50363d952bdc4c0cc4945ddaad24cccfddd52e57596c7409cf", - "docs/brand/system/claims.yml": "152df7ffdf7ca519309c6d6a21cbc30108d063c3334d01e3ab77724421812e34", + "docs/brand/system/claims.yml": "543790f7284e7db09cfd6011a91abd9bf003712678bd99f689e355d3b368b388", "docs/brand/social/exports/README.md": "e28ac10689d4ae392509bad8a7102fba63d3895ab1c6d862ff2e13bb5d5178a0" }, "approved_exports": { diff --git a/docs/brand/system/claims.yml b/docs/brand/system/claims.yml index 69dcd3eb1..1d29ef9b3 100644 --- a/docs/brand/system/claims.yml +++ b/docs/brand/system/claims.yml @@ -1,6 +1,6 @@ { "schema_version": 2, - "generated_at": "2026-08-30", + "generated_at": "2026-10-03", "generation": { "command": "python scripts/brand/update_claims.py --write", "network": "forbidden", @@ -24,15 +24,15 @@ }, "github_stars_snapshot": { "status": "verified", - "value": 5181, - "display": "5,100+ GitHub stars", + "value": 5444, + "display": "5,400+ GitHub stars", "definition": "Exact stargazers_count from the committed GitHub API evidence; public display is conservatively floored to the preceding 100-star quantum.", "source": "docs/brand/system/evidence/github-repository.json", - "as_of": "2026-08-30", + "as_of": "2026-10-03", "owner": "repository-owner", - "public_wording": "5,100+ GitHub stars", - "qualification": "Dated offline snapshot, not a live counter; the exact raw value was 5,181 when captured.", - "review_by": "2026-09-30", + "public_wording": "5,400+ GitHub stars", + "qualification": "Dated offline snapshot, not a live counter; the exact raw value was 5,444 when captured.", + "review_by": "2026-11-03", "follow_up_by": null }, "repository_model_snapshot": { diff --git a/docs/brand/system/evidence/github-repository.json b/docs/brand/system/evidence/github-repository.json index 99bbddd6d..87e157bf6 100644 --- a/docs/brand/system/evidence/github-repository.json +++ b/docs/brand/system/evidence/github-repository.json @@ -3,17 +3,17 @@ "repository": "maziyarpanahi/openmed", "api_url": "https://api.github.com/repos/maziyarpanahi/openmed", "html_url": "https://github.com/maziyarpanahi/openmed", - "captured_at": "2026-08-30T09:11:06Z", - "repository_updated_at": "2026-08-30T05:51:53Z", - "stargazers_count": 5181, + "captured_at": "2026-10-03T10:58:57Z", + "repository_updated_at": "2026-10-03T04:35:07Z", + "stargazers_count": 5444, "display": { "method": "floor", "quantum": 100, - "value": 5100, - "label": "5,100+ GitHub stars" + "value": 5400, + "label": "5,400+ GitHub stars" }, "owner": "repository-owner", - "review_by": "2026-09-30", + "review_by": "2026-11-03", "refresh": { "command": "python scripts/brand/update_claims.py --refresh-github-stars", "network": "explicit opt-in only", diff --git a/docs/i18n/readme_section_hashes.json b/docs/i18n/readme_section_hashes.json index 71cc05361..1b1d116d5 100644 --- a/docs/i18n/readme_section_hashes.json +++ b/docs/i18n/readme_section_hashes.json @@ -138,9 +138,9 @@ }, { "source_heading": "Star History", - "source_sha256": "215a52664bd987bf5e60e4a13f57fe609372f697e4707ad50979256b72fa4312", + "source_sha256": "9ae063033f4a7921a1712676e343aaa39612f9225d83e1b30e61492c9948a3c5", "translation_heading": "स्टार इतिहास", - "translation_sha256": "9e2bbf09dc9d364f13d1569d451d5247b80876bf05c314e4114f91b10672f26c" + "translation_sha256": "56150ad7bf11325476bf654afc4284326495d8bcfaab56af9a81513dfa85ed7e" } ] }, @@ -280,9 +280,9 @@ }, { "source_heading": "Star History", - "source_sha256": "215a52664bd987bf5e60e4a13f57fe609372f697e4707ad50979256b72fa4312", + "source_sha256": "9ae063033f4a7921a1712676e343aaa39612f9225d83e1b30e61492c9948a3c5", "translation_heading": "Historia ya nyota", - "translation_sha256": "e9fdd644482c31f6b858e9b8fb793316768ff7e0a6c13bc6f1dc8cd0c3dad569" + "translation_sha256": "a8578b2deebe8585fbd2abd64e563ea7bf29d88151cc022d912f7932e54a3a56" } ] }, @@ -422,9 +422,9 @@ }, { "source_heading": "Star History", - "source_sha256": "215a52664bd987bf5e60e4a13f57fe609372f697e4707ad50979256b72fa4312", + "source_sha256": "9ae063033f4a7921a1712676e343aaa39612f9225d83e1b30e61492c9948a3c5", "translation_heading": "Star 历史", - "translation_sha256": "03819ab822f8f1aa23dc3043ad9dbb61ba9cfdfcd77b2a326efa52b0bd2e06fe" + "translation_sha256": "5089a7ad6acb38abf7ab9a5464642a0bdb8e4a83f6c955d5ac2d1fdfd79f7560" } ] } diff --git a/docs/install/smoke-check.md b/docs/install/smoke-check.md index 3c0ae78b0..bc76af060 100644 --- a/docs/install/smoke-check.md +++ b/docs/install/smoke-check.md @@ -33,3 +33,50 @@ python scripts/install/smoke_check.py --python /path/to/venv/bin/python This is an install/runtime evidence check, not a compliance certification or a clinical decision guarantee. + +## Clinical brief installation profiles + +Add `--brief-profile core` for a **minimal, non-editable** installation, or +`--brief-profile adapters` for an installation with exactly the declared +`[cli,service,mcp]` extras. Neither profile installs MLX, PyTorch, Transformers, +model weights or training data. The profiles intentionally reject environments +containing those runtimes: they verify the missing-runtime contract separately +from model qualification. + +Both profiles exercise the Python brief composer, reviewed synthetic provider +injection, the installed CLI entry point, private summary/audit separation, +exclusive output creation, typed missing-runtime errors and full-chain privacy +sentinels. The adapter profile additionally exercises REST, the Python REST +client and the read-only MCP adapter through in-memory transports. Test scores +are synthetic fixtures, not calibrated model evidence or clinical validation. + +Probes run under `python -I` in a temporary directory outside the checkout. +They reject editable installs, check imported module locations, and require +the repository root to be absent from `sys.path`. Python outbound connection +and DNS calls are blocked during application operations, with an empty cache +and offline flags. Windows event-loop self-pipe setup happens before the guard; +no application listener is started. These guards are test controls, not an +OS-level security sandbox for untrusted native code. + +Resource checks load the packaged review-packet schema, thresholds and synthetic +grounding vocabulary using `importlib.resources`. Unix outputs must have mode +`0600`; Windows checks readable/writable regular files and exclusive-create +behavior, **not** Unix permission bits or unverified Windows ACL guarantees. + +To reproduce the complete CI lane from the checkout: + +```bash +uv build --out-dir /path/to/artifacts +uv export --frozen --no-hashes --no-emit-project --extra cli --extra service --extra mcp --output-file /path/to/constraints.txt +python scripts/install/smoke_check.py --artifacts /path/to/artifacts --constraints /path/to/constraints.txt --report /path/to/smoke.json +``` + +The artifact directory must contain exactly one wheel and one sdist. This +**explicit artifact mode** uses `uv` to install package/build dependencies in +fresh disposable environments; dependency preparation may use the network. +Runtime probes do not. Each artifact is tested with core and adapter installs, +then a disposable copy with the required schema deliberately omitted is +installed. That negative control must fail specifically at resource resolution. +The original artifacts and existing environments are never modified. Installer +output, traceback contents and local paths are not relayed to reports; failures +use fixed codes. A successful lane has six passing rows, including two controls. diff --git a/docs/security/dependency-risk.md b/docs/security/dependency-risk.md index 6a136e0e6..771222994 100644 --- a/docs/security/dependency-risk.md +++ b/docs/security/dependency-risk.md @@ -101,3 +101,27 @@ The result has this shape: The report is a review aid, not a compliance certification or clinical decision guarantee. Use synthetic advisory snapshots in tests and keep any source snapshot governed separately from the report artifact. + +## Temporary NLTK exception for the universal lockfile + +On 2026-10-03, the maintainer approved a time-limited exception for +`CVE-2026-81726` / [GHSA-8mgp-746c-j5xp](https://github.com/nltk/nltk/security/advisories/GHSA-8mgp-746c-j5xp), +scoped to the `nltk` package in `uv.lock` through **2026-10-17**. At review, +upstream listed no patched release and the latest published NLTK was 3.10.3. +The finding remains present in scanner evidence; it is not considered fixed. + +The affected model-artifact import/export APIs can read or write outside +configured NLTK path-security roots when given untrusted paths. OpenMed has no +direct calls to these APIs. NLTK is not in the core dependency closure or the +reference service image. The universal lock includes it through the optional +`agents`, `llamaindex`, `medspacy`, `quickumls`, and `scrubadub` extras. This +review does not prove that every downstream configuration is unexploitable: +do not let untrusted workflows select model import/export paths in those +integrations, and use OS-level containment when running untrusted code. + +The policy in `deploy/security/cve-allowlist.yaml` remains at the HIGH +threshold. The exception cannot suppress a different package, artifact, or +advisory. It stops applying when the scanner reports a fixed version and +fails closed after its expiry. Upgrade to a verified patched release and +remove the exception when available; renewal requires a fresh maintainer +decision. Model training is unrelated to this dependency review. diff --git a/docs/security/license-inventory.md b/docs/security/license-inventory.md index e1900ea87..0f89658cd 100644 --- a/docs/security/license-inventory.md +++ b/docs/security/license-inventory.md @@ -125,6 +125,7 @@ a dependency also declared in another extra. | `strawberry-graphql` | `service` | `MIT` | | `tiktoken` | `mlx` | `MIT` | | `tokenizers` | `hf, mlx, onnx-runtime` | `Apache-2.0` | +| `tomli` | `default (Python < 3.11)` | `MIT` | | `torch` | `coreml, gliner, onnx` | `BSD-3-Clause` | | `transformers` | `awq, coreml, hf, mlx, onnx, openvino` | `Apache-2.0` | | `typer` | `cli` | `MIT` | diff --git a/docs/testing.md b/docs/testing.md index 46e1f5bf2..7530a1dd5 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3,6 +3,42 @@ Keep regressions away from your clinical workflows by leaning on the existing test suite and smoke runners. This page summarizes what already exists so you can extend it confidently. +## Actively tested Python and desktop matrix + +The core SDK supports **CPython 3.10, 3.11, 3.12 and 3.13** on Linux, Windows +and macOS. These versions and OS families are declared in `pyproject.toml`'s +classifiers. `requires-python >=3.10` is an installation floor, not a promise +that an unlisted future interpreter or alternative Python implementation is +qualified. Adding one requires a reviewed metadata, CI and documentation change. +Python 3.10 installs the MIT-licensed `tomli` backport required by the SDK's TOML +readers; newer interpreters use the standard-library `tomllib` instead. The +backport is a conditional core dependency, not an accidental development extra. + +| Lane | Required combinations | What it proves | +| --- | --- | --- | +| `sdk-compatibility` core contracts | All four versions × `ubuntu-latest`, `windows-latest`, `macos-latest` (12 jobs) | Privacy sentinels, full traceback rendering, platform filesystem contracts and smoke-runner negative tests | +| `sdk-compatibility` installed artifacts | The same 12 jobs; both wheel and sdist; core and `[cli,service,mcp]` installs separately | Installed CLI and brief API/adapters, packaged resources, missing-resource rejection and source-tree isolation | +| Optional-runtime absence | Both install profiles in every compatibility job | Typed missing-MLX error and fail-closed brief; no cloud fallback, weights or model downloads | +| `test` full regression | Linux 3.10–3.12; Windows/macOS 3.11–3.12 | The complete existing Python regression suite, in addition to bounded compatibility coverage | + +The stable `sdk-compatibility-gate` requires every compatibility job to succeed +and is a dependency of the existing `build` gate. Failed, skipped or cancelled +matrix execution cannot count as passing. Jobs run independently (`fail-fast: +false`) with a 25-minute limit. The matrix uses standard hosted-runner hardware; +it does not qualify every processor architecture, Windows ACL policy, GPU or +optional native backend. + +Successful core installation does **not** imply optional model-runtime support. +MLX/Core ML execution needs supported Apple hardware and its declared extras; +other native adapters have their own runtime requirements. Runtime-specific +integration/model evaluation remains separate from this no-weights SDK gate. +An unavailable optional runtime must return its documented typed error/refusal; +a core import, CLI, privacy or resource failure is always a broken lane, never +a blanket platform skip. No trained checkpoint is a prerequisite for SDK release. + +See [offline install smoke checks](install/smoke-check.md) for the exact local +artifact commands, synthetic-provider boundaries and value-free JSON reports. + ## Test taxonomy | Marker | Location | Purpose | diff --git a/docs/website/index.html b/docs/website/index.html index dfbf45366..757daf3e4 100644 --- a/docs/website/index.html +++ b/docs/website/index.html @@ -251,11 +251,11 @@ - 5.1k + 5.4k Docs @@ -427,7 +427,7 @@

Counted, not claimed · Hugging Face + PyPI · July 2026 - ★ 5.1K GitHub stars · counted live + ★ 5.4K GitHub stars · counted live
diff --git a/gates/release_budgets.json b/gates/release_budgets.json index 28f341ad6..861fd3963 100644 --- a/gates/release_budgets.json +++ b/gates/release_budgets.json @@ -16,9 +16,9 @@ "package::openmed::wheel": { "key": "package::openmed::wheel", "metrics": { - "baseline_bytes": 6319377, + "baseline_bytes": 7036059, "headroom_percent": 10, - "maximum_bytes": 6951315 + "maximum_bytes": 7739665 } } }, diff --git a/openmed/eval/medmentions_linking.py b/openmed/eval/medmentions_linking.py index f87794050..cd729c1bf 100644 --- a/openmed/eval/medmentions_linking.py +++ b/openmed/eval/medmentions_linking.py @@ -58,6 +58,10 @@ def evaluate_medmentions_st21pv( the report. The provider is responsible for using a caller-licensed local UMLS index; this function performs no download or credential lookup. + Non-abstaining ``GroundedSpan`` results contribute selected candidates + followed by their ranked alternatives. Abstained results and results + without a selection remain abstentions even when alternatives are retained. + Args: path: Local caller-created st21pv JSONL projection. provider: Local callback returning ranked UMLS candidates. @@ -81,7 +85,14 @@ def evaluate_medmentions_st21pv( abstentions = 0 for case in cases: output = provider(case.mention, top_k) - candidates = output.candidates if isinstance(output, GroundedSpan) else output + if isinstance(output, GroundedSpan): + candidates = ( + (*output.candidates, *output.ranked_alternatives) + if output.candidates and not output.abstained + else () + ) + else: + candidates = output codes = [ candidate.code for candidate in candidates diff --git a/packaging/standalone_manifest.py b/packaging/standalone_manifest.py index 56ce7bfdf..23cc14cdc 100644 --- a/packaging/standalone_manifest.py +++ b/packaging/standalone_manifest.py @@ -59,6 +59,7 @@ "jieba": "jieba>=0.42.1,<0.43", "pysbd": "pysbd>=0.3.4,<0.4", "pyyaml": "pyyaml>=6.0", + "tomli": "tomli>=2.0; python_version < '3.11'", } _OPTIONAL_BOUNDARY: Final = { "huggingface-hub": ("huggingface-hub>=0.30", "Apache-2.0", True), @@ -775,6 +776,12 @@ def render_manifest(manifest: StandalonePackageManifest | None = None) -> str: license="MIT", purpose="Reading local policy configuration.", ), + DependencySpec( + name="tomli", + requirement=_REQUIRED_REQUIREMENTS["tomli"], + license="MIT", + purpose="Reading local TOML metadata on Python 3.10.", + ), ) _OPTIONAL_DEPENDENCIES: Final = ( diff --git a/pyproject.toml b/pyproject.toml index 4cabb269b..abca99b5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,16 @@ dynamic = ["version"] description = "Local-first SDK for clinical extraction and de-identification workflows on hardware you control." readme = "README.md" requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", +] license = { text = "Apache-2.0" } keywords = [ "biomedical", @@ -30,6 +40,7 @@ dependencies = [ "faker>=22.0", "jieba>=0.42.1,<0.43", "pyyaml>=6.0", + "tomli>=2.0; python_version < '3.11'", ] authors = [ @@ -426,7 +437,11 @@ openmed-journey = "openmed.deploy.journey:main" [tool.uv] constraint-dependencies = [ + "tornado>=6.5.9", + "urllib3>=2.8.0", + "virtualenv>=21.7.13", "betterproto==2.0.0b6; python_version >= '3.11'", + "pyjwt>=2.14.0", ] override-dependencies = [ # Beam 2.75 caps these below their patched releases even though its diff --git a/scripts/docs/stage_pages.py b/scripts/docs/stage_pages.py index e3a6788c2..e934c5c8d 100644 --- a/scripts/docs/stage_pages.py +++ b/scripts/docs/stage_pages.py @@ -1762,6 +1762,34 @@ def prune_source_maps(output_dir: Path) -> list[str]: return removed +def compact_search_index(output_dir: Path) -> int: + """Serialize the search index as UTF-8 without changing its JSON data. + + MkDocs escapes non-ASCII search text. Keeping the same strings as UTF-8 + reduces transfer size while preserving every document, token, and setting. + """ + + index_path = output_dir / "docs" / "search" / "search_index.json" + if index_path.is_symlink() or not index_path.resolve().is_relative_to( + output_dir.resolve() + ): + raise PageStagingError("Staged search index must remain inside the artifact") + try: + original = index_path.read_bytes() + payload = json.loads(original) + compacted = ( + json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n" + ).encode("utf-8") + except (OSError, UnicodeError, ValueError) as exc: + raise PageStagingError("Could not compact staged search index") from exc + if len(compacted) >= len(original): + return 0 + index_path.write_bytes(compacted) + saved = len(original) - len(compacted) + print(f"[pages] compacted search index without data loss: {saved} bytes saved") + return saved + + def stage_pages( *, output_dir: Path = DEFAULT_OUTPUT_DIR, @@ -1782,6 +1810,7 @@ def stage_pages( copy_website(DEFAULT_WEBSITE_DIR, output_dir) copy_locale_sitemaps(output_dir, publication) prune_source_maps(output_dir) + compact_search_index(output_dir) required = { *normalized_expected_paths(publication), diff --git a/scripts/install/smoke_check.py b/scripts/install/smoke_check.py index 68547b523..40efd46cc 100644 --- a/scripts/install/smoke_check.py +++ b/scripts/install/smoke_check.py @@ -4,6 +4,8 @@ from __future__ import annotations import argparse +import contextlib +import io import json import os import re @@ -36,6 +38,19 @@ _VERSION_RE = re.compile(rf"^openmed ({_SAFE_VERSION_PATTERN})$") _SAFE_VERSION_RE = re.compile(rf"^{_SAFE_VERSION_PATTERN}$") _HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +BRIEF_PROFILES = ("core", "adapters") +BRIEF_RESOURCE = "core/schemas/json/clinical_review_packet.schema.json" +BRIEF_CHECKS = ( + "installed_origin", + "packaged_resources", + "brief_python", + "missing_runtime", + "privacy_sentinel", + "brief_cli", + "brief_rest_client", + "brief_mcp", + "no_network", +) # The probe carries only a synthetic marker. It emits hashes and offsets, never # the marker itself, so captured child output remains safe for release logs. @@ -91,6 +106,401 @@ CommandRunner = Callable[..., subprocess.CompletedProcess[str]] +def _brief_fixture(): + """Create synthetic reviewed evidence, never model-quality evidence.""" + import hashlib + from datetime import datetime + + from openmed.clinical.brief import BriefContext, BriefFact, brief_policy_fingerprint + from openmed.clinical.evidence_packet import ( + build_evidence_packet, + fingerprint_evidence_review, + ) + from openmed.clinical.nli_gate import NLIThresholds + from openmed.clinical.review_state_machine import ( + ReviewState, + ReviewStateMachine, + make_opaque_event_id, + ) + from openmed.core.pii import DeidentificationResult + + sentences = ( + "The admission problem was dehydration.", + "The discharge diagnosis was dehydration.", + "Symptoms improved after fluids.", + ) + text = " ".join(sentences) + fields = ("admission_reason", "discharge_diagnoses", "hospital_course") + facts = tuple( + BriefFact( + f"synthetic:ref-{i}", name, "affirmed", "certain", "recent", "patient" + ) + for i, name in enumerate(fields) + ) + policy = brief_policy_fingerprint(text, facts) + rows = [] + for fact, sentence in zip(facts, sentences): + start = text.index(sentence) + row = dict( + reference_id=fact.reference_id, + source_id="synthetic:install-document", + start=start, + end=start + len(sentence), + policy_fingerprint=policy, + ) + fingerprint = fingerprint_evidence_review(**row) + review = ReviewStateMachine() + for state in (ReviewState.IN_REVIEW, ReviewState.APPROVED): + review.transition( + state, + make_opaque_event_id((fact.reference_id, state.value)), + fingerprint, + ) + rows.append( + dict( + row, + synthetic=True, + verified=True, + review_state="approved", + review_transitions=review.transitions, + ) + ) + calibration = "synthetic-install-only" + context = BriefContext( + build_evidence_packet(rows, policy_fingerprint=policy), + "sha256:" + + hashlib.sha256(json.dumps(text, ensure_ascii=True).encode()).hexdigest(), + facts, + lambda p, h: dict( + entailment=1.0, contradiction=0.0, neutral=0.0, calibration_id=calibration + ), + NLIThresholds( + calibration_id=calibration, calibration_method="synthetic-fixture" + ), + lambda _: [], + ) + return DeidentificationResult(text, text, [], "mask", datetime(2026, 1, 1)), context + + +def _probe_cli(entry, value, provider, expected) -> None: + """Drive the installed console entry point, including trusted injection.""" + import stat + from types import ModuleType + + module = ModuleType("install_fixture_provider") + module.factory = lambda: provider + sys.modules[module.__name__] = module + with tempfile.TemporaryDirectory(prefix="brief-fixture-") as directory: + root = Path(directory) + source, summary, audit = (root / name for name in ("note", "summary", "audit")) + source.write_text(value.original_text, encoding="utf-8") + args = [ + "brief", + str(source), + "--model", + "extractive", + "--json", + "--summary-output", + str(summary), + "--review-output", + str(audit), + ] + reviewed = [ + *args, + "--review-id", + "a" * 64, + "--context-factory", + "install_fixture_provider:factory", + ] + try: + with contextlib.redirect_stdout(io.StringIO()) as output: + assert entry(reviewed) == 0 + assert "dehydration" not in output.getvalue() + assert summary.read_text(encoding="utf-8") == expected["summary"] + audit_text = audit.read_text(encoding="utf-8") + assert "summary" not in json.loads(audit_text) + assert "dehydration" not in audit_text + for path in (summary, audit): + mode = stat.S_IMODE(path.stat().st_mode) + if os.name == "posix": + assert mode == 0o600 + else: + # Windows stat exposes read/write attributes, not Unix ACLs. + assert mode & stat.S_IREAD and mode & stat.S_IWRITE + assert path.is_file() and not path.is_symlink() + # Exclusive-create safety is required on every OS, including Windows. + assert entry(reviewed) != 0 + assert summary.read_text(encoding="utf-8") == expected["summary"] + assert audit.read_text(encoding="utf-8") == audit_text + refused = [ + "brief", + str(source), + "--model", + "extractive", + "--review-id", + "a" * 64, + "--json", + "--summary-output", + str(root / "refused-summary"), + "--review-output", + str(root / "refused-audit"), + ] + assert entry(refused) == 1 + assert (root / "refused-summary").read_text(encoding="utf-8") == "" + assert ( + json.loads((root / "refused-audit").read_text(encoding="utf-8"))[ + "refusal_reason" + ] + == "review_required" + ) + finally: + del sys.modules[module.__name__] + + +def _probe_rest(value, provider, expected, loop) -> None: + """Use ASGI in memory; no listener, HTTP socket, or model is started.""" + import httpx + + from openmed.service.app import create_app + from openmed.service.client import OpenMedClient + + app = create_app() + app.state.brief_context_provider = provider + transport = httpx.ASGITransport(app=app) + + async def rest(): + async with httpx.AsyncClient( + transport=transport, base_url="http://127.0.0.1" + ) as client: + payload = dict( + text=value.original_text, model="extractive", review_id="a" * 64 + ) + response = await client.post("/brief", json=payload) + assert response.status_code == 200 and response.json() == expected + response = await client.post( + "/brief", json=dict(payload, review_id="invalid") + ) + assert response.status_code == 422 + assert "dehydration" not in response.text + + loop.run_until_complete(rest()) + + class InMemoryTransport(httpx.BaseTransport): + def handle_request(self, request): + async def send(): + response = await transport.handle_async_request(request) + return httpx.Response( + response.status_code, + headers=response.headers, + content=await response.aread(), + request=request, + ) + + return loop.run_until_complete(send()) + + with OpenMedClient(transport=InMemoryTransport()) as client: + assert ( + client.brief(value.original_text, model="extractive", review_id="a" * 64) + == expected + ) + loop.run_until_complete(transport.aclose()) + + +def _run_brief_probe(profile: str, source_root: Path) -> SmokeReport: + """Run only in an isolated child against a non-editable installation.""" + import asyncio + import importlib.util + import logging + import socket + import traceback + from dataclasses import replace + from importlib.metadata import distribution + from importlib.resources import files + from types import SimpleNamespace + from unittest.mock import patch + + checks = [] + active = "installed_origin" + network_attempts = [] + captured = io.StringIO() + + def blocked(*args, **kwargs): + network_attempts.append(True) + raise RuntimeError("network_forbidden") + + # Windows may use sockets for its event-loop self-pipe. Construct it before + # the outbound guard; all application operations below remain guarded. + loop = asyncio.new_event_loop() + handler = logging.StreamHandler(captured) + logging.getLogger().addHandler(handler) + try: + with contextlib.ExitStack() as stack: + stack.enter_context(contextlib.redirect_stdout(captured)) + stack.enter_context(contextlib.redirect_stderr(captured)) + for target in ( + "socket.socket.connect", + "socket.socket.connect_ex", + "socket.create_connection", + "socket.getaddrinfo", + ): + stack.enter_context(patch(target, blocked)) + import openmed + from openmed.clinical.brief import BriefRefusal, build_clinical_brief + from openmed.clinical.summarize_backends import resolve_summarizer_backend + from openmed.core.capabilities import MissingOptionalDependencyError + from openmed.ner.infer import _convert_gliner_entity + from openmed.service.brief import brief_response + + installed = distribution("openmed") + root = Path(installed.locate_file("openmed")).resolve() + assert Path(openmed.__file__).resolve() == root / "__init__.py" + assert root != source_root.resolve() / "openmed" + assert source_root.resolve() not in {Path(p).resolve() for p in sys.path} + direct = json.loads(installed.read_text("direct_url.json") or "{}") + assert not direct.get("dir_info", {}).get("editable", False) + checks.append(_passed(active)) + + active = "packaged_resources" + for resource in (BRIEF_RESOURCE, "core/thresholds.json"): + assert isinstance( + json.loads( + files("openmed").joinpath(resource).read_text(encoding="utf-8") + ), + dict, + ) + vocabulary = files("openmed").joinpath( + "eval/golden/fixtures/grounding_vocab_synthetic.jsonl" + ) + assert all( + isinstance(json.loads(row), dict) + for row in vocabulary.read_text(encoding="utf-8").splitlines() + ) + checks.append(_passed(active)) + + active = "brief_python" + value, context = _brief_fixture() + provider = lambda text, review_id: (value, context) + brief = build_clinical_brief(value, model="extractive", context=context) + assert ( + brief.summary == value.deidentified_text and len(brief.citations) == 3 + ) + assert brief.refusal_reason is None + assert ( + brief.envelope["requires_human_review"] + and not brief.envelope["is_diagnostic"] + ) + expected = brief_response( + value.original_text, + model="extractive", + review_id="a" * 64, + context_provider=provider, + ) + assert expected == brief.to_response() + assert ( + build_clinical_brief(value, model="extractive").refusal_reason + is BriefRefusal.REVIEW_REQUIRED + ) + checks.append(_passed(active)) + + active = "missing_runtime" + assert importlib.util.find_spec("mlx") is None + try: + resolve_summarizer_backend("mlx").summarize(value.deidentified_text) + except MissingOptionalDependencyError as error: + assert error.package == error.extra == "mlx" + else: + raise AssertionError("missing runtime accepted") + refused = build_clinical_brief(value, model="mlx", context=context) + assert ( + refused.refusal_reason is BriefRefusal.STAGE_FAILED + and refused.summary == "" + ) + for module in ("torch", "transformers", "mlx"): + assert importlib.util.find_spec(module) is None + for module in ("fastapi", "mcp"): + assert (importlib.util.find_spec(module) is not None) == ( + profile == "adapters" + ) + checks.append(_passed(active)) + + active = "privacy_sentinel" + marker = "synthetic-private-5550199" + entity = dict(text=marker, label="Drug", score=0.9) + try: + _convert_gliner_entity(entity) + except KeyError as error: + # Keep the full chain, including Python 3.13's source excerpts. + assert marker not in "".join(traceback.format_exception(error)) + else: + raise AssertionError("malformed entity accepted") + + def faulty_provider(*args): + raise ValueError(marker) + + refused = build_clinical_brief( + value, + model="extractive", + context=replace(context, nli_predict=faulty_provider), + ) + assert refused.summary == "" and refused.refusal_reason is not None + assert marker not in json.dumps(refused.to_dict()) + assert "dehydration" not in json.dumps(brief.to_dict()) + checks.append(_passed(active)) + + active = "brief_cli" + entry = next( + item for item in installed.entry_points if item.name == "openmed" + ) + _probe_cli(entry.load(), value, provider, expected) + checks.append(_passed(active)) + if profile == "adapters": + active = "brief_rest_client" + _probe_rest(value, provider, expected, loop) + checks.append(_passed(active)) + active = "brief_mcp" + from openmed.mcp.server import openmed_brief + from openmed.mcp.tool_registry import TOOL_REGISTRY + + assert ( + openmed_brief( + value.original_text, + model="extractive", + review_id="a" * 64, + runtime_provider=lambda: SimpleNamespace( + brief_context_provider=provider + ), + ) + == expected + ) + assert ( + TOOL_REGISTRY.get("openmed_brief").annotations()["readOnlyHint"] + is True + ) + checks.append(_passed(active)) + active = "no_network" + assert not network_attempts + assert socket.socket.connect is blocked + assert ( + marker not in captured.getvalue() + and "dehydration" not in captured.getvalue() + ) + # Imported package modules must also resolve to the installed tree. + assert all( + Path(module.__file__).resolve().is_relative_to(root) + for name, module in tuple(sys.modules.items()) + if name.startswith("openmed.") and getattr(module, "__file__", None) + ) + checks.append(_passed(active)) + except Exception: + checks.append(_failed(active, "contract_failed")) + finally: + logging.getLogger().removeHandler(handler) + loop.close() + return SmokeReport( + "failed" if checks[-1].status == "failed" else "passed", tuple(checks) + ) + + @dataclass(frozen=True) class CheckResult: """One privacy-safe smoke-check result.""" @@ -322,6 +732,7 @@ def run_smoke_check( *, python_executable: str = sys.executable, runner: CommandRunner = subprocess.run, + brief_profile: str | None = None, ) -> SmokeReport: """Run the installed entry point and deterministic offline runtime probe. @@ -330,6 +741,8 @@ def run_smoke_check( temporary home only prevents host configuration, caches, and credentials from affecting the evidence. """ + if brief_profile is not None and brief_profile not in BRIEF_PROFILES: + raise ValueError("invalid smoke profile") with tempfile.TemporaryDirectory(prefix="openmed-install-smoke-") as raw_home: home = Path(raw_home) environment = _clean_environment(home) @@ -399,9 +812,187 @@ def run_smoke_check( status = "passed" if synthetic_check.status == "passed" else "failed" if status == "passed": checks[0] = _passed("entry_point", version=version) + if status == "passed" and brief_profile is not None: + script = workdir / "probe.py" + shutil.copyfile(Path(__file__), script) + result = _run_captured( + [ + str(python_path), + "-I", + str(script), + "--_brief-probe", + brief_profile, + "--source-root", + str(Path(__file__).resolve().parents[2]), + ], + cwd=workdir, + environment=environment, + runner=runner, + ) + checks.extend(_brief_probe_results(result, brief_profile)) + status = "passed" if all(c.status == "passed" for c in checks) else "failed" return SmokeReport(status=status, checks=tuple(checks)) +def _brief_probe_results(result, profile: str) -> tuple[CheckResult, ...]: + """Allow-list child fields; never relay arbitrary output or exceptions.""" + if result is None: + return (_failed("brief_probe", "command_failed"),) + try: + payload = json.loads(result.stdout) + expected = list(BRIEF_CHECKS) + if profile == "core": + expected.remove("brief_rest_client") + expected.remove("brief_mcp") + rows = payload["checks"] + if not isinstance(rows, list) or not rows or len(rows) > len(expected): + raise ValueError + checks = [] + for row, name in zip(rows, expected): + if row == {"name": name, "status": "passed"}: + checks.append(_passed(name)) + elif row == {"name": name, "status": "failed", "reason": "contract_failed"}: + checks.append(_failed(name, "contract_failed")) + else: + raise ValueError + passed = len(checks) == len(expected) and all( + c.status == "passed" for c in checks + ) + if (result.returncode == 0) != passed or payload["status"] != ( + "passed" if passed else "failed" + ): + raise ValueError + if not passed and checks[-1].status != "failed": + raise ValueError + return tuple(checks) + except (ValueError, KeyError, TypeError): + return (_failed("brief_probe", "invalid_result"),) + + +def _omit_brief_resource(artifact: Path, destination: Path) -> Path: + """Create a damaged artifact in a disposable directory for a negative control.""" + import tarfile + import zipfile + + target = destination / artifact.name + member_suffix = "openmed/" + BRIEF_RESOURCE + omitted = 0 + if artifact.suffix == ".whl": + with ( + zipfile.ZipFile(artifact) as source, + zipfile.ZipFile(target, "w") as output, + ): + for member in source.infolist(): + if member.filename == member_suffix: + omitted += 1 + else: + output.writestr(member, source.read(member)) + else: + with ( + tarfile.open(artifact, "r:gz") as source, + tarfile.open(target, "w:gz") as output, + ): + for member in source.getmembers(): + if member.name.endswith("/" + member_suffix): + omitted += 1 + else: + output.addfile( + member, source.extractfile(member) if member.isfile() else None + ) + if omitted != 1: + raise ValueError("invalid resource inventory") + return target + + +def run_artifact_checks( + directory: Path, *, python_executable: str, constraints: Path | None = None +) -> dict[str, Any]: + """Install both artifacts in disposable core/extras environments, then probe. + + Only this explicit mode invokes uv and may download build/package dependencies. + Each runtime probe still runs offline, outside the source and without weights. + """ + artifacts = [list(directory.glob(pattern)) for pattern in ("*.whl", "*.tar.gz")] + if any(len(matches) != 1 for matches in artifacts): + raise ValueError("expected one wheel and one sdist") + uv = shutil.which("uv") + if uv is None: + raise ValueError("uv required") + rows = [] + for artifact_type, matches in zip(("wheel", "sdist"), artifacts): + artifact = matches[0].resolve() + for profile in (*BRIEF_PROFILES, "missing_resource"): + row = dict(artifact=artifact_type, profile=profile, status="failed") + with tempfile.TemporaryDirectory(prefix="openmed-artifact-") as temporary: + root = Path(temporary) + env = root / "venv" + python = env / ( + "Scripts/python.exe" if os.name == "nt" else "bin/python" + ) + selected = artifact + if profile == "missing_resource": + selected = _omit_brief_resource(artifact, root) + requirement = str(selected) + ( + "[cli,service,mcp]" if profile == "adapters" else "" + ) + install = [uv, "pip", "install", "--python", str(python), requirement] + if constraints is not None: + install.extend(["--constraint", str(constraints.resolve())]) + prepared = True + for command in ( + [uv, "venv", "--python", python_executable, str(env)], + install, + ): + try: + result = subprocess.run( + command, + cwd=root, + capture_output=True, + text=True, + timeout=300, + check=False, + ) + if result.returncode != 0: + prepared = False + break + except (OSError, subprocess.TimeoutExpired): + prepared = False + break + if not prepared: + row["reason"] = "installation_failed" + else: + report = run_smoke_check( + python_executable=str(python), + brief_profile="core" + if profile == "missing_resource" + else profile, + ) + if profile == "missing_resource": + failed = [c for c in report.checks if c.status == "failed"] + passed = ( + report.status == "failed" + and len(failed) == 1 + and failed[0].name == "packaged_resources" + ) + row["status"] = "passed" if passed else "failed" + row["reason"] = ( + "missing_resource_rejected" + if passed + else "negative_control_failed" + ) + else: + row["status"] = report.status + row["checks"] = report.to_dict()["checks"] + rows.append(row) + # Controlled progress only; never print installer output or paths. + print(json.dumps(row, sort_keys=True), flush=True) + return dict( + schema_version=1, + status="passed" if all(r["status"] == "passed" for r in rows) else "failed", + checks=rows, + ) + + def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -410,6 +1001,22 @@ def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: default=sys.executable, help="Python executable for the installed OpenMed environment.", ) + parser.add_argument("--brief-profile", choices=BRIEF_PROFILES) + parser.add_argument( + "--artifacts", + type=Path, + help="Explicitly install and check a wheel/sdist directory using uv.", + ) + parser.add_argument( + "--constraints", + type=Path, + help="Frozen constraints for artifact dependency installation.", + ) + parser.add_argument("--report", type=Path, help="Write the value-free JSON report.") + parser.add_argument( + "--_brief-probe", choices=BRIEF_PROFILES, help=argparse.SUPPRESS + ) + parser.add_argument("--source-root", type=Path, help=argparse.SUPPRESS) return parser.parse_args(argv) @@ -417,13 +1024,32 @@ def main(argv: Sequence[str] | None = None) -> int: """Print one smoke report and return its release-gate status.""" args = _parse_args(argv) try: - report = run_smoke_check(python_executable=args.python_executable) + if args._brief_probe: + report = _run_brief_probe(args._brief_probe, args.source_root) + elif args.artifacts: + payload = run_artifact_checks( + args.artifacts, + python_executable=args.python_executable, + constraints=args.constraints, + ) + if args.report: + args.report.write_text( + json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 if payload["status"] == "passed" else 1 + else: + report = run_smoke_check( + python_executable=args.python_executable, + brief_profile=args.brief_profile, + ) except Exception: # pragma: no cover - defensive privacy boundary. report = SmokeReport( status="failed", checks=(_failed("smoke_check", "internal_error"),), ) print(report.to_json()) + if args.report: + args.report.write_text(report.to_json() + "\n", encoding="utf-8") return 0 if report.status == "passed" else 1 diff --git a/scripts/release/README.md b/scripts/release/README.md index 64b212026..45619cf08 100644 --- a/scripts/release/README.md +++ b/scripts/release/README.md @@ -33,19 +33,30 @@ OpenMed keeps package-size and core-import budgets in python scripts/release/check_import_budget.py ``` -The committed wheel baseline is 5,709,568 bytes. Its maximum is 6,280,525 +The committed wheel baseline is 7,036,059 bytes. Its maximum is 7,739,665 bytes, which provides 10% headroom. A fresh `import openmed` must remain at or below 300,000 cumulative microseconds on `ubuntu-latest`, and it must not load `jieba`, `opencc`, `pypinyin`, or `indicnlp`. -The baseline was measured from the clinical-fact integration candidate on -`ubuntu-latest` in [CI run 35787695425](https://github.com/maziyarpanahi/openmed/actions/runs/35787695425). -The previous 5,185,410-byte baseline, measured from `master` commit -`dbfe2415e58325f1b21c6ec013672f08fa46d32b`, no longer covers the reviewed -Journey contracts, model packs, fact normalization, ingestion, terminology, -reconciliation, and timeline implementation. The candidate exceeded the prior -5,703,951-byte maximum by 5,617 bytes. The baseline now records that measured -candidate while retaining the repository's 10% future headroom rule. +The baseline records `master` commit +`a88fe2feb699fd1945d053859998e54e00237825`. An Ubuntu 24.04 x86-64 rebuild +with uv 0.11.28, Python 3.11.15 and Hatchling 1.32.4 produced a 7,036,059-byte +wheel with SHA-256 +`670169af4a50f40db81b05eab20272fe3f1946dbdf95dbe25fb80c1ad09bd5e4`, +identical to the macOS rebuild. The PR build job also rebuilds the frozen base +on `ubuntu-latest` and requires its measured size to equal the proposed baseline +before accepting a budget refresh; the local reproduction is not a substitute +for that hosted check. + +The previous 6,319,377-byte baseline predates already-merged clinical brief, +SDOH, local NLI, language, evaluation and agent capabilities. Unchanged master +exceeded the old 6,951,315-byte maximum by 84,744 bytes. The installed-SDK +candidate at `88b12440f2a05a2f982e1ff98f364db88dc117e2` added only 238 bytes, +measured at 7,036,297 bytes in +[CI run 37135994309](https://github.com/maziyarpanahi/openmed/actions/runs/37135994309). +Both wheels have the same 1,495 members, with every source/resource payload +matching its Git revision. This refresh records accepted source growth; it +does not change package contents, compression, 10% headroom or import limits. The JSON size report records the wheel size plus total site-packages bytes for `openmed`, `openmed[zh]`, and `openmed[indic]`. Each language profile includes @@ -66,6 +77,12 @@ not accept environment-variable overrides. startup work in the pull request. 5. Run both checks above and include the resulting measurements in the review. +For a pull request that changes the budget file, CI independently archives and +builds the event's frozen base commit. A different measured wheel size fails +the build, and CI retains the baseline artifacts. Re-measure and update the +baseline if `master` has changed; do not use an environment override or disable +the comparison. + Do not raise a budget merely to make an unexplained regression pass. ## V3 Journey release packet diff --git a/scripts/release/check_license_policy.py b/scripts/release/check_license_policy.py index 325e5b08c..cd95fe734 100644 --- a/scripts/release/check_license_policy.py +++ b/scripts/release/check_license_policy.py @@ -153,6 +153,7 @@ "pymdown-extensions": "MIT", "pysbd": "MIT", "pyspark": "Apache-2.0", + "tomli": "MIT", "python-doctr": "Apache-2.0", "pytesseract": "Apache-2.0", "python-docx": "MIT", diff --git a/scripts/skills/validate.py b/scripts/skills/validate.py index bfd02fd23..952ea1a42 100755 --- a/scripts/skills/validate.py +++ b/scripts/skills/validate.py @@ -702,7 +702,9 @@ def validate_repository( report = ValidationReport() requested_root = repo_root or REPO_ROOT try: - root = requested_root.resolve() + # Python 3.13 leaves symlink loops unresolved in non-strict mode. + # Require a real root on every supported interpreter before inspection. + root = requested_root.resolve(strict=True) except (OSError, RuntimeError): root = Path(os.path.abspath(os.fspath(requested_root))) _add_error(report, root, root, "repository root cannot be resolved") diff --git a/tests/unit/cli/test_benchmark_meta_cli.py b/tests/unit/cli/test_benchmark_meta_cli.py index c7f6d93c5..a9b6fcadf 100644 --- a/tests/unit/cli/test_benchmark_meta_cli.py +++ b/tests/unit/cli/test_benchmark_meta_cli.py @@ -134,9 +134,14 @@ def test_mismatched_or_empty_reports_rejected(suite, count, tmp_path, capsys): @pytest.mark.parametrize( - "data", ["{CaseySecret", "[]", "{}", '{"metrics": 1}', "x" * (8 * 1024**2 + 1)] + "data", + ["{CaseySecret", "[]", "{}", '{"metrics": 1}', "x" * (8 * 1024**2 + 1)], + ids=["invalid-json", "wrong-root", "empty-object", "invalid-metrics", "oversized"], ) -def test_malformed_or_oversized_file_rejected(data, tmp_path, capsys): +def test_malformed_or_oversized_file_rejected(data, tmp_path, capsys, request): + # Pytest exports node IDs in PYTEST_CURRENT_TEST. Keep payloads out of + # identifiers so the 8 MiB case fits Windows' environment-variable limit. + assert len(request.node.nodeid) < 256 path = tmp_path / "CaseySecret.json" path.write_text(data) assert ( diff --git a/tests/unit/eval/test_grounding_export_roundtrip.py b/tests/unit/eval/test_grounding_export_roundtrip.py index 7773a5448..59c85a4d2 100644 --- a/tests/unit/eval/test_grounding_export_roundtrip.py +++ b/tests/unit/eval/test_grounding_export_roundtrip.py @@ -8,7 +8,7 @@ import pytest -from openmed.clinical.grounding import Candidate +from openmed.clinical.grounding import Candidate, GroundedSpan from openmed.eval.golden.loader import list_fixture_paths from openmed.eval.medmentions_linking import ( MEDMENTIONS_TOP1_FLOOR, @@ -23,6 +23,105 @@ ) +def _synthetic_linking_projection(tmp_path: Path) -> Path: + path = tmp_path / "synthetic-linking.jsonl" + path.write_text( + json.dumps({"mention": "synthetic mention", "cui": "SYNTHETIC-GOLD"}) + "\n", + encoding="utf-8", + ) + return path + + +def _synthetic_linking_candidate(code: str, system: str = "umls") -> Candidate: + return Candidate( + system=system, + code=code, + display="synthetic", + score=0.5, + source="synthetic-test", + ) + + +@pytest.mark.parametrize("top_k, expected", [(1, 0.0), (2, 0.0), (3, 1.0), (5, 1.0)]) +def test_medmentions_grounded_span_scores_ranked_alternatives_at_requested_depth( + tmp_path: Path, top_k: int, expected: float +) -> None: + selected = _synthetic_linking_candidate("SYNTHETIC-SELECTED") + alternatives = ( + _synthetic_linking_candidate("SYNTHETIC-SECOND"), + _synthetic_linking_candidate("SYNTHETIC-GOLD"), + ) + span = GroundedSpan( + "synthetic mention", 0, 17, (selected,), alternatives=alternatives + ) + calls: list[tuple[str, int]] = [] + + def provider(mention: str, depth: int) -> GroundedSpan: + calls.append((mention, depth)) + return span + + report = evaluate_medmentions_st21pv( + _synthetic_linking_projection(tmp_path), provider=provider, top_k=top_k + ) + assert report.metrics["top1_accuracy"] == 0.0 + assert report.metrics[f"top{top_k}_accuracy"] == expected + assert report.metrics["abstention_rate"] == 0.0 + assert calls == [("synthetic mention", top_k)] + assert span.candidates == (selected,) + assert span.ranked_alternatives == alternatives + assert "synthetic mention" not in report.to_json() + assert "SYNTHETIC-GOLD" not in report.to_json() + + +@pytest.mark.parametrize( + "abstained, selected", [(True, False), (True, True), (False, False)] +) +def test_medmentions_never_credits_withheld_or_unselected_alternatives( + tmp_path: Path, abstained: bool, selected: bool +) -> None: + gold = _synthetic_linking_candidate("SYNTHETIC-GOLD") + span = GroundedSpan( + "synthetic mention", + 0, + 17, + (gold,) if selected else (), + alternatives=(gold,), + abstained=abstained, + ) + report = evaluate_medmentions_st21pv( + _synthetic_linking_projection(tmp_path), + provider=lambda *_: span, + top_k=2, + ) + assert report.metrics["top1_accuracy"] == 0.0 + assert report.metrics["top2_accuracy"] == 0.0 + assert report.metrics["abstention_rate"] == 1.0 + + +@pytest.mark.parametrize("as_span", [True, False]) +def test_medmentions_filters_non_umls_before_top_k_for_both_provider_shapes( + tmp_path: Path, as_span: bool +) -> None: + selected = _synthetic_linking_candidate("SYNTHETIC-WRONG", "UMLS") + alternatives = ( + _synthetic_linking_candidate("SYNTHETIC-OTHER", "icd10cm"), + _synthetic_linking_candidate("SYNTHETIC-GOLD", "UmLs"), + ) + output = ( + GroundedSpan("synthetic mention", 0, 17, (selected,), alternatives=alternatives) + if as_span + else (selected, *alternatives) + ) + report = evaluate_medmentions_st21pv( + _synthetic_linking_projection(tmp_path), + provider=lambda *_: output, + top_k=2, + ) + assert report.metrics["top1_accuracy"] == 0.0 + assert report.metrics["top2_accuracy"] == 1.0 + assert report.metrics["abstention_rate"] == 0.0 + + def test_grounding_export_fixture_is_not_generic_deidentification_gold() -> None: assert all(path.name != "grounding_export.jsonl" for path in list_fixture_paths()) diff --git a/tests/unit/install/test_smoke_check.py b/tests/unit/install/test_smoke_check.py index 35dc87781..a24ea386a 100644 --- a/tests/unit/install/test_smoke_check.py +++ b/tests/unit/install/test_smoke_check.py @@ -8,6 +8,8 @@ from pathlib import Path from typing import Any +import pytest + from scripts.install import smoke_check @@ -319,3 +321,159 @@ def runner(command: list[str], **_: Any) -> subprocess.CompletedProcess[str]: "reason": "invalid_version_output", "status": "failed", } + + +@pytest.mark.parametrize("profile", smoke_check.BRIEF_PROFILES) +def test_brief_probe_report_requires_the_complete_ordered_contract(profile): + names = list(smoke_check.BRIEF_CHECKS) + if profile == "core": + names.remove("brief_rest_client") + names.remove("brief_mcp") + rows = [{"name": name, "status": "passed"} for name in names] + result = _completed([], stdout=json.dumps({"status": "passed", "checks": rows})) + assert [c.name for c in smoke_check._brief_probe_results(result, profile)] == names + rows.pop() + result = _completed([], stdout=json.dumps({"status": "passed", "checks": rows})) + assert smoke_check._brief_probe_results(result, profile)[-1].status == "failed" + + +@pytest.mark.parametrize("mutation", ["extra", "name", "reason", "status", "exit"]) +def test_brief_probe_rejects_unsafe_child_output(mutation): + secret = "synthetic-install-sensitive-value" + rows = [dict(name="installed_origin", status="failed", reason="contract_failed")] + payload = dict(status="failed", checks=rows) + code = 1 + if mutation == "extra": + rows[0]["text"] = secret + elif mutation == "name": + rows[0]["name"] = secret + elif mutation == "reason": + rows[0]["reason"] = secret + elif mutation == "status": + payload["status"] = secret + else: + code = 0 + checks = smoke_check._brief_probe_results( + _completed([], returncode=code, stdout=json.dumps(payload)), "core" + ) + assert checks == (smoke_check._failed("brief_probe", "invalid_result"),) + assert secret not in json.dumps([check.to_dict() for check in checks]) + + +def test_brief_probe_preserves_a_specific_controlled_failure(): + rows = [ + smoke_check._passed("installed_origin"), + smoke_check._failed("packaged_resources", "contract_failed"), + ] + report = smoke_check.SmokeReport("failed", tuple(rows)) + result = _completed([], returncode=1, stdout=report.to_json()) + assert smoke_check._brief_probe_results(result, "core") == tuple(rows) + + +@pytest.mark.parametrize("kind", ["wheel", "sdist"]) +def test_missing_resource_control_changes_only_disposable_artifact(tmp_path, kind): + import hashlib + import io + import tarfile + import zipfile + + name = "openmed/" + smoke_check.BRIEF_RESOURCE + data = b'{"synthetic": true}' + output = tmp_path / "control" + output.mkdir() + if kind == "wheel": + artifact = tmp_path / "openmed-0.0.0-py3-none-any.whl" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr(name, data) + archive.writestr("openmed/__init__.py", b"") + else: + artifact = tmp_path / "openmed-0.0.0.tar.gz" + with tarfile.open(artifact, "w:gz") as archive: + for path, content in [(name, data), ("openmed/__init__.py", b"")]: + member = tarfile.TarInfo("openmed-0.0.0/" + path) + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + before = hashlib.sha256(artifact.read_bytes()).hexdigest() + damaged = smoke_check._omit_brief_resource(artifact, output) + assert hashlib.sha256(artifact.read_bytes()).hexdigest() == before + assert damaged.parent == output + if kind == "wheel": + with zipfile.ZipFile(damaged) as archive: + assert archive.namelist() == ["openmed/__init__.py"] + else: + with tarfile.open(damaged, "r:gz") as archive: + assert archive.getnames() == ["openmed-0.0.0/openmed/__init__.py"] + + +def test_resource_control_cannot_succeed_when_resource_was_already_missing(tmp_path): + import zipfile + + artifact = tmp_path / "openmed-0.0.0-py3-none-any.whl" + with zipfile.ZipFile(artifact, "w") as archive: + archive.writestr("openmed/__init__.py", b"") + output = tmp_path / "control" + output.mkdir() + with pytest.raises(ValueError, match="invalid resource inventory"): + smoke_check._omit_brief_resource(artifact, output) + + +def test_brief_fixture_is_synthetic_reviewed_and_not_diagnostic(): + from openmed.clinical.brief import build_clinical_brief + + value, context = smoke_check._brief_fixture() + result = build_clinical_brief(value, model="extractive", context=context) + assert result.refusal_reason is None + assert result.summary == value.deidentified_text + assert len(result.citations) == 3 + assert result.envelope["requires_human_review"] + assert not result.envelope["is_diagnostic"] + assert "dehydration" not in json.dumps(result.to_dict()) + + +def test_artifact_checks_reject_missing_or_ambiguous_inputs(tmp_path): + with pytest.raises(ValueError, match="expected one wheel and one sdist"): + smoke_check.run_artifact_checks(tmp_path, python_executable="python") + + +def test_artifact_install_failures_are_redacted_and_all_six_cases_run( + tmp_path, monkeypatch, capsys +): + for name in ("openmed-0.0.0-py3-none-any.whl", "openmed-0.0.0.tar.gz"): + (tmp_path / name).touch() + monkeypatch.setattr(smoke_check.shutil, "which", lambda _: "/synthetic/uv") + monkeypatch.setattr( + smoke_check, "_omit_brief_resource", lambda artifact, destination: artifact + ) + secret = "synthetic-sensitive-installer-error" + monkeypatch.setattr( + smoke_check.subprocess, + "run", + lambda *args, **kwargs: _completed([], returncode=1, stdout=secret), + ) + payload = smoke_check.run_artifact_checks(tmp_path, python_executable="python") + assert payload["status"] == "failed" and len(payload["checks"]) == 6 + assert all(row["reason"] == "installation_failed" for row in payload["checks"]) + assert secret not in json.dumps(payload) + capsys.readouterr().out + + +def test_resource_control_must_fail_at_the_resource_check(tmp_path, monkeypatch): + for name in ("openmed-0.0.0-py3-none-any.whl", "openmed-0.0.0.tar.gz"): + (tmp_path / name).touch() + monkeypatch.setattr(smoke_check.shutil, "which", lambda _: "/synthetic/uv") + monkeypatch.setattr( + smoke_check, "_omit_brief_resource", lambda artifact, destination: artifact + ) + monkeypatch.setattr( + smoke_check.subprocess, "run", lambda *args, **kwargs: _completed([]) + ) + monkeypatch.setattr( + smoke_check, + "run_smoke_check", + lambda **kwargs: smoke_check.SmokeReport( + "failed", (smoke_check._failed("entry_point", "command_failed"),) + ), + ) + report = smoke_check.run_artifact_checks(tmp_path, python_executable="python") + controls = [row for row in report["checks"] if row["profile"] == "missing_resource"] + assert len(controls) == 2 + assert all(row["reason"] == "negative_control_failed" for row in controls) diff --git a/tests/unit/licenses/test_inventory.py b/tests/unit/licenses/test_inventory.py index 2552e77fc..5ab9f58e5 100644 --- a/tests/unit/licenses/test_inventory.py +++ b/tests/unit/licenses/test_inventory.py @@ -48,7 +48,7 @@ def test_checked_in_inventory_covers_non_dev_project_dependencies() -> None: entries = inventory.parse_inventory(inventory.DEFAULT_INVENTORY) records = inventory.audit_project() - assert len(entries) == 103 + assert len(entries) == 104 assert len(records) == len(entries) assert { record.name @@ -63,6 +63,7 @@ def test_checked_in_inventory_covers_non_dev_project_dependencies() -> None: "pysbd", "pyyaml", "snowflake-snowpark-python", + "tomli", } diff --git a/tests/unit/licenses/test_sbom.py b/tests/unit/licenses/test_sbom.py index 1be3b15d3..a663f5457 100644 --- a/tests/unit/licenses/test_sbom.py +++ b/tests/unit/licenses/test_sbom.py @@ -147,7 +147,9 @@ def test_current_manifests_generate_the_base_runtime_closure() -> None: ) component_names = {component["name"] for component in document["components"]} - assert component_names == {"faker", "jieba", "pysbd", "pyyaml", "tzdata"} + # The SBOM is a universal closure, including the conditional Python 3.10 + # TOML backport even when this test runs on a newer interpreter. + assert component_names == {"faker", "jieba", "pysbd", "pyyaml", "tomli", "tzdata"} root_ref = document["metadata"]["component"]["bom-ref"] root_dependencies = next( entry for entry in document["dependencies"] if entry["ref"] == root_ref @@ -159,6 +161,7 @@ def test_current_manifests_generate_the_base_runtime_closure() -> None: "jieba", "pysbd", "pyyaml", + "tomli", ] properties = { item["name"]: item["value"] for item in document["metadata"]["properties"] diff --git a/tests/unit/ner/test_infer.py b/tests/unit/ner/test_infer.py index 6f698b98d..884f6674e 100644 --- a/tests/unit/ner/test_infer.py +++ b/tests/unit/ner/test_infer.py @@ -214,13 +214,17 @@ def predict_entities(self, text, labels=None, threshold=0.5, flat_ner=True): def test_malformed_gliner_offset_error_does_not_echo_entity_text() -> None: import traceback + marker = "synthetic-private-5550199" + entity = {"text": marker, "label": "Drug", "score": 0.9} with pytest.raises(KeyError) as caught: - infer_module._convert_gliner_entity( - {"text": "synthetic-private-5550199", "label": "Drug", "score": 0.9} - ) - assert "synthetic-private-5550199" not in "".join( - traceback.format_exception(caught.value) + infer_module._convert_gliner_entity(entity) + # Keep literals out of the raising call's source excerpt on Python 3.13+. + # Assert the message AND the full exception chain; neither may leak values. + rendered = "".join( + traceback.format_exception_only(type(caught.value), caught.value) ) + assert marker not in rendered + assert marker not in "".join(traceback.format_exception(caught.value)) @pytest.mark.parametrize("offset", [True, 1.5, "private-offset", -1]) diff --git a/tests/unit/release/test_release_budget_workflow.py b/tests/unit/release/test_release_budget_workflow.py index 302536545..c25c6bd88 100644 --- a/tests/unit/release/test_release_budget_workflow.py +++ b/tests/unit/release/test_release_budget_workflow.py @@ -1,12 +1,31 @@ """CI build and release-budget wiring tests.""" +import json +import shlex +import subprocess +import sys +import zipfile from pathlib import Path +from types import SimpleNamespace +import pytest import yaml +try: + import tomllib +except ImportError: + import tomli as tomllib + ROOT = Path(__file__).resolve().parents[3] CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml" MAKEFILE = ROOT / "Makefile" +PYTEST_DIAGNOSTIC_WRAPPER = ( + "import faulthandler, pytest; " + "diagnostics = type('CiSummaryDiagnostics', (), " + "{'pytest_sessionfinish': lambda self, session, exitstatus: " + "faulthandler.dump_traceback_later(120, repeat=True)})(); " + "raise SystemExit(pytest.main(plugins=[diagnostics]))" +) def _load_ci() -> dict[str, object]: @@ -52,3 +71,307 @@ def test_build_job_enforces_and_uploads_release_budgets(): assert "python scripts/release/check_import_budget.py" in workflow assert "size-budget-report.json" in workflow assert "--gate-file" not in workflow + + +def test_budget_refresh_is_verified_against_the_frozen_pr_base(): + job = _load_ci()["jobs"]["build"] + steps = job["steps"] + step = next( + step + for step in steps + if step.get("name") == "Verify refreshed wheel-size baseline" + ) + assert step["if"] == "github.event_name == 'pull_request'" + assert step["shell"] == "bash" + assert step["env"] == {"BASE_SHA": "${{ github.event.pull_request.base.sha }}"} + assert step.get("continue-on-error", "false") == "false" + assert 'git fetch --no-tags --depth=1 origin "$BASE_SHA"' in step["run"] + assert ( + 'git diff --quiet "$BASE_SHA" HEAD -- gates/release_budgets.json' in step["run"] + ) + assert 'if [ "$diff_status" -ne 1 ]; then\n exit "$diff_status"' in step["run"] + assert 'git archive "$BASE_SHA" | tar -x -C "$baseline_source"' in step["run"] + assert ( + 'uv build --out-dir "$RUNNER_TEMP/openmed-baseline-dist" "$baseline_source"' + in step["run"] + ) + assert "|| true" not in step["run"] + assert step["id"] == "wheel_baseline" + assert 'echo "measured=true" >> "$GITHUB_OUTPUT"' in step["run"] + upload = next( + step for step in steps if step.get("name") == "Upload wheel baseline evidence" + ) + assert upload["with"]["path"] == "${{ runner.temp }}/openmed-baseline-dist/" + assert upload["if"] == "always() && steps.wheel_baseline.outputs.measured == 'true'" + artifacts = next( + step for step in steps if step.get("name") == "Upload build artifacts" + ) + assert artifacts["with"]["path"].splitlines() == [ + "dist/", + "size-budget-report.json", + ] + size_step = next( + step + for step in steps + if step.get("name") + == "Enforce wheel size budget and record language-extra footprints" + ) + assert steps.index(step) < steps.index(size_step) + + +@pytest.mark.parametrize("delta", [0, -1, 1]) +def test_budget_refresh_comparison_requires_the_exact_measured_size( + tmp_path, monkeypatch, delta +): + steps = _load_ci()["jobs"]["build"]["steps"] + step = next( + step + for step in steps + if step.get("name") == "Verify refreshed wheel-size baseline" + ) + code = step["run"].split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + wheel_dir = tmp_path / "dist" + wheel_dir.mkdir() + wheel = wheel_dir / "openmed-test-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("openmed-test.dist-info/WHEEL", "Generator: hatchling test\n") + measured = wheel.stat().st_size + expected = measured + delta + gates = tmp_path / "gates" + gates.mkdir() + (gates / "release_budgets.json").write_text( + json.dumps( + { + "entries": { + "package::openmed::wheel": { + "metrics": { + "baseline_bytes": expected, + } + } + } + } + ) + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["-", str(wheel_dir)]) + monkeypatch.setenv("BASE_SHA", "a" * 40) + monkeypatch.setattr(subprocess, "check_output", lambda *args, **kwargs: "uv test\n") + if measured == expected: + exec(compile(code, "", "exec"), {}) + else: + with pytest.raises(SystemExit, match="Wheel baseline mismatch"): + exec(compile(code, "", "exec"), {}) + receipt = json.loads((wheel_dir / "baseline-receipt.json").read_text()) + assert receipt["base_sha"] == "a" * 40 + assert receipt["measured_bytes"] == measured + assert receipt["proposed_baseline_bytes"] == expected + assert receipt["uv"] == "uv test" + assert receipt["wheel_metadata"] == "Generator: hatchling test\n" + assert receipt["matches"] is (delta == 0) + + +@pytest.mark.parametrize("wheel_count", [0, 2]) +def test_budget_refresh_rejects_missing_or_ambiguous_wheels( + tmp_path, monkeypatch, wheel_count +): + steps = _load_ci()["jobs"]["build"]["steps"] + step = next( + step + for step in steps + if step.get("name") == "Verify refreshed wheel-size baseline" + ) + code = step["run"].split("<<'PY'\n", 1)[1].rsplit("\nPY", 1)[0] + for index in range(wheel_count): + (tmp_path / f"openmed-{index}-py3-none-any.whl").write_bytes(b"x") + monkeypatch.setattr(sys, "argv", ["-", str(tmp_path)]) + with pytest.raises(SystemExit, match="Expected exactly one frozen-base wheel"): + exec(compile(code, "", "exec"), {}) + + +def test_sdk_compatibility_matches_advertised_python_and_os_support(): + jobs = _load_ci()["jobs"] + lane = jobs["sdk-compatibility"] + matrix = lane["strategy"]["matrix"] + versions = ["3.10", "3.11", "3.12", "3.13"] + assert matrix == { + "os": ["ubuntu-latest", "windows-latest", "macos-latest"], + "python-version": versions, + } + assert lane["strategy"]["fail-fast"] == "false" + assert int(lane["timeout-minutes"]) <= 30 + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + prefix = "Programming Language :: Python :: " + assert ( + sorted( + c.removeprefix(prefix) + for c in project["classifiers"] + if c.startswith(prefix) and c.removeprefix(prefix).startswith("3.") + ) + == versions + ) + assert project["requires-python"] == ">=3.10" + # CLI imports dependency-report parsing even in a minimal installation. + # Development extras used to hide this missing Python 3.10 backport. + assert "tomli>=2.0; python_version < '3.11'" in project["dependencies"] + assert { + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + } <= set(project["classifiers"]) + docs = (ROOT / "docs/testing.md").read_text(encoding="utf-8") + assert all(version in docs for version in versions) + assert "12 jobs" in docs and "sdk-compatibility-gate" in docs + + +def test_installed_lane_is_mandatory_offline_and_covers_privacy_sentinels(): + jobs = _load_ci()["jobs"] + lane = jobs["sdk-compatibility"] + assert all(value == "1" for value in lane["env"].values()) + commands = "\n".join(step.get("run", "") for step in lane["steps"]) + for fragment in ( + "--frozen", + "--no-emit-project", + "--extra cli --extra service --extra mcp", + "tests/unit/service/test_brief_surfaces.py", + "tests/unit/ner/test_infer.py", + "tests/unit/test_no_raw_text_logging.py", + "--artifacts", + "--constraints", + "--report sdk-compatibility.json", + ): + assert fragment in commands + assert "sdk-compatibility-gate" in jobs["build"]["needs"] + assert "needs.sdk-compatibility-gate.result == 'success'" in jobs["build"]["if"] + gate = jobs["sdk-compatibility-gate"] + assert gate["needs"] == "sdk-compatibility" and gate["if"] == "always()" + assert 'test "$MATRIX_RESULT" = success' in gate["steps"][0]["run"] + + +def _assert_full_suite_diagnostics(lane): + assert lane.get("continue-on-error", "false") == "false" + steps = lane["steps"] + test_step = next(step for step in steps if step.get("name") == "Test with pytest") + assert test_step["timeout-minutes"] == "45" + assert test_step.get("continue-on-error", "false") == "false" + assert "if" not in test_step + # Exact argv retains default full-suite discovery and rejects selection, + # early-stop flags, shell success overrides, and changes to coverage. + assert shlex.split(test_step["run"]) == [ + "uv", + "run", + "--frozen", + "python", + "-c", + PYTEST_DIAGNOSTIC_WRAPPER, + "--cov=openmed", + "--cov-report=xml", + "--cov-report=term-missing", + "--tb=line", + "--show-capture=no", + "--color=no", + "--code-highlight=no", + "-ra", + "--junitxml=pytest-results.xml", + ] + artifact = next( + step for step in steps if step.get("name") == "Upload test diagnostics" + ) + assert artifact["if"] == "always()" + assert artifact["uses"] == ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + ) + assert artifact["with"]["name"] == ( + "pytest-${{ matrix.os }}-py${{ matrix.python-version }}-${{ github.run_attempt }}" + ) + assert artifact["with"]["path"].splitlines() == [ + "pytest-results.xml", + "coverage.xml", + ] + assert artifact["with"]["if-no-files-found"] == "warn" + assert steps.index(test_step) < steps.index(artifact) + + +def test_full_suite_diagnostics_preserve_discovery_and_failure_semantics(): + _assert_full_suite_diagnostics(_load_ci()["jobs"]["test"]) + project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + options = project["tool"]["pytest"]["ini_options"] + assert options["testpaths"] == ["tests"] + assert not options.get("addopts") + + +@pytest.mark.parametrize("exit_code", [0, 1, 2, 3, 4, 5]) +def test_ci_pytest_diagnostic_wrapper_preserves_arguments_and_exit_status( + monkeypatch, exit_code +): + steps = _load_ci()["jobs"]["test"]["steps"] + step = next(step for step in steps if step.get("name") == "Test with pytest") + argv = shlex.split(step["run"]) + wrapper = argv[argv.index("-c") + 1] + pytest_args = argv[argv.index("-c") + 2 :] + observed = [] + + def dump_traceback_later(timeout, *, repeat): + observed.append(("diagnostics", timeout, repeat)) + + def main(*, plugins): + observed.append(("pytest", sys.argv[1:])) + assert len(plugins) == 1 + # Pytest cancels ordinary watchdogs on test errors. Arm this one at + # session finish so it survives into error-summary rendering. + plugins[0].pytest_sessionfinish(session=None, exitstatus=exit_code) + return exit_code + + monkeypatch.setitem( + sys.modules, + "faulthandler", + SimpleNamespace(dump_traceback_later=dump_traceback_later), + ) + monkeypatch.setitem(sys.modules, "pytest", SimpleNamespace(main=main)) + monkeypatch.setattr(sys, "argv", ["-c", *pytest_args]) + with pytest.raises(SystemExit) as result: + exec(compile(wrapper, "", "exec"), {}) + assert result.value.code == exit_code + assert observed == [("pytest", pytest_args), ("diagnostics", 120, True)] + + +@pytest.mark.parametrize( + "suffix", + ["-k smoke", "-m 'not slow'", "-x", "--maxfail=1", "--ignore=tests", "|| true"], +) +def test_full_suite_diagnostics_contract_rejects_reduced_or_masked_runs(suffix): + lane = _load_ci()["jobs"]["test"] + step = next( + step for step in lane["steps"] if step.get("name") == "Test with pytest" + ) + step["run"] += " " + suffix + with pytest.raises(AssertionError): + _assert_full_suite_diagnostics(lane) + + +@pytest.mark.parametrize( + ("step_name", "field", "value"), + [ + ("Test with pytest", "timeout-minutes", "360"), + ("Test with pytest", "continue-on-error", "true"), + ("Test with pytest", "if", "false"), + ("Upload test diagnostics", "if", "success()"), + ("Upload test diagnostics", "uses", "actions/upload-artifact@v7"), + ], +) +def test_full_suite_diagnostics_contract_rejects_unbounded_or_optional_steps( + step_name, field, value +): + lane = _load_ci()["jobs"]["test"] + step = next(step for step in lane["steps"] if step.get("name") == step_name) + step[field] = value + with pytest.raises(AssertionError): + _assert_full_suite_diagnostics(lane) + + +def test_full_suite_diagnostics_contract_rejects_job_failure_override(): + lane = _load_ci()["jobs"]["test"] + lane["continue-on-error"] = "true" + with pytest.raises(AssertionError): + _assert_full_suite_diagnostics(lane) diff --git a/tests/unit/security/test_vulnerability_scan_gate.py b/tests/unit/security/test_vulnerability_scan_gate.py index 35fccc0c8..8c1a7bc58 100644 --- a/tests/unit/security/test_vulnerability_scan_gate.py +++ b/tests/unit/security/test_vulnerability_scan_gate.py @@ -187,6 +187,36 @@ def test_allowlist_cannot_suppress_finding_with_fixed_version(tmp_path: Path) -> assert summary["blocking_findings"][0]["fixed_version"] == "1.0.1" +@pytest.mark.parametrize( + ("vuln_id", "package"), + [("CVE-2026-99998", "openssl"), ("CVE-2026-99999", "other-package")], +) +def test_allowlist_cannot_suppress_another_advisory_or_package( + tmp_path: Path, vuln_id: str, package: str +) -> None: + report = _write_trivy_report( + tmp_path / "trivy.json", + vuln_id=vuln_id, + package=package, + fixed_version="", + ) + allowlist, threshold = vulnerability_scan_gate.load_allowlist( + _write_allowlist(tmp_path / "allowlist.yaml"), dt.date(2026, 7, 1) + ) + + result = vulnerability_scan_gate.evaluate_reports([report], allowlist, threshold) + + assert result.allowed_findings == [] + assert len(result.blocking_findings) == 1 + + +def test_waiver_expires_immediately_after_the_last_approved_day(tmp_path: Path) -> None: + path = _write_allowlist(tmp_path / "allowlist.yaml", expires="2026-07-15") + assert vulnerability_scan_gate.load_allowlist(path, dt.date(2026, 7, 15))[0] + with pytest.raises(ValueError, match="expired"): + vulnerability_scan_gate.load_allowlist(path, dt.date(2026, 7, 16)) + + def test_expired_allowlist_entry_fails_closed(tmp_path: Path) -> None: report = _write_trivy_report(tmp_path / "trivy.json") allowlist = _write_allowlist(tmp_path / "allowlist.yaml", expires="2026-06-30") diff --git a/tests/unit/service/test_brief_surfaces.py b/tests/unit/service/test_brief_surfaces.py index 5991d22c5..31f1cbf3f 100644 --- a/tests/unit/service/test_brief_surfaces.py +++ b/tests/unit/service/test_brief_surfaces.py @@ -3,6 +3,7 @@ import argparse import json import logging +import os import stat from types import SimpleNamespace @@ -138,7 +139,14 @@ def test_cli_separates_protected_content_and_private_audit(tmp_path, capsys): assert "dehydration" not in args.review_output.read_text() assert "dehydration" not in capsys.readouterr().out for path in (args.summary_output, args.review_output): - assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert path.is_file() and not path.is_symlink() + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + else: + # Windows stat exposes read/write attributes, not Unix ACLs. + mode = stat.S_IMODE(path.stat().st_mode) + assert mode & stat.S_IREAD + assert mode & stat.S_IWRITE @pytest.mark.parametrize("collision", ["existing", "same", "symlink"]) diff --git a/tests/unit/skills/test_agent_skill_validation.py b/tests/unit/skills/test_agent_skill_validation.py index b3e593cdc..e5d58c254 100644 --- a/tests/unit/skills/test_agent_skill_validation.py +++ b/tests/unit/skills/test_agent_skill_validation.py @@ -333,6 +333,18 @@ def test_validator_help_command_is_successful_and_local() -> None: assert "without network access" in result.stdout +def test_missing_repository_root_returns_a_fixed_error(tmp_path: Path) -> None: + validator = _load_validator() + report = validator.validate_repository( + tmp_path / "synthetic-private-missing-root", run_helper_help=False + ) + output = validator.format_report(report) + assert not report.ok + assert "repository root cannot be resolved" in output + assert "synthetic-private-missing-root" not in output + assert str(tmp_path) not in output + + def test_helper_environment_drops_ambient_credentials( tmp_path: Path, monkeypatch, diff --git a/tests/unit/test_pages_manifest.py b/tests/unit/test_pages_manifest.py index b2f6660ba..1c19a15f7 100644 --- a/tests/unit/test_pages_manifest.py +++ b/tests/unit/test_pages_manifest.py @@ -16,6 +16,7 @@ artifact_path_for_url, build_manifest, changed_snapshot_paths, + compact_search_index, copy_locale_sitemaps, ensure_safe_output_dir, find_overlay_collisions, @@ -701,6 +702,62 @@ def test_source_maps_are_pruned_from_the_staged_artifact(tmp_path: Path) -> None assert runtime.is_file() +def test_search_compaction_preserves_multilingual_data_and_is_idempotent( + tmp_path: Path, +) -> None: + index = tmp_path / "docs" / "search" / "search_index.json" + index.parent.mkdir(parents=True) + payload = { + "config": {"lang": ["en", "zh", "hi"], "separator": r"[\s\-]+"}, + "docs": [ + { + "location": "zh/", + "title": "中文 दस्तावेज़ 🩺", + "text": '

中文\nclinical "evidence"

', + } + ], + } + index.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8") + before = index.stat().st_size + + saved = compact_search_index(tmp_path) + + assert saved > 0 + assert index.stat().st_size == before - saved + assert json.loads(index.read_bytes()) == payload + assert "中文" in index.read_text(encoding="utf-8") + assert compact_search_index(tmp_path) == 0 + + +@pytest.mark.parametrize("contents", [None, b"{invalid", b"\xff"]) +def test_search_compaction_fails_closed_on_missing_or_invalid_index( + tmp_path: Path, contents: bytes | None +) -> None: + index = tmp_path / "docs" / "search" / "search_index.json" + index.parent.mkdir(parents=True) + if contents is not None: + index.write_bytes(contents) + + with pytest.raises(PageStagingError, match="compact staged search index"): + compact_search_index(tmp_path) + + +def test_search_compaction_rejects_an_index_symlink(tmp_path: Path) -> None: + artifact = tmp_path / "artifact" + index = artifact / "docs" / "search" / "search_index.json" + index.parent.mkdir(parents=True) + outside = tmp_path / "outside.json" + outside.write_text('{"text": "untouched"}', encoding="utf-8") + try: + index.symlink_to(outside) + except OSError: + pytest.skip("symlink creation is unavailable on this platform") + + with pytest.raises(PageStagingError, match="inside the artifact"): + compact_search_index(artifact) + assert outside.read_text(encoding="utf-8") == '{"text": "untouched"}' + + def test_release_tag_is_normalized_and_rejects_non_versions() -> None: assert resolve_release_tag("2.0.0") == "v2.0.0" assert resolve_release_tag("v2.0.0-rc.1") == "v2.0.0-rc.1" diff --git a/uv.lock b/uv.lock index c4a8b863b..2836f5888 100644 --- a/uv.lock +++ b/uv.lock @@ -672,7 +672,13 @@ conflicts = [[ ]] [manifest] -constraints = [{ name = "betterproto", marker = "python_full_version >= '3.11'", specifier = "==2.0.0b6" }] +constraints = [ + { name = "betterproto", marker = "python_full_version >= '3.11'", specifier = "==2.0.0b6" }, + { name = "pyjwt", specifier = ">=2.14.0" }, + { name = "tornado", specifier = ">=6.5.9" }, + { name = "urllib3", specifier = ">=2.8.0" }, + { name = "virtualenv", specifier = ">=21.7.13" }, +] overrides = [ { name = "cryptography", specifier = ">=50,<51" }, { name = "httplib2", specifier = ">=0.32" }, @@ -11475,6 +11481,7 @@ dependencies = [ { name = "jieba" }, { name = "pysbd" }, { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-7-openmed-airflow' and extra == 'extra-7-openmed-ocr-paddle') or (extra == 'extra-7-openmed-awq' and extra == 'extra-7-openmed-gliner') or (extra == 'extra-7-openmed-multimodal' and extra == 'extra-7-openmed-scispacy') or (extra == 'extra-7-openmed-onnx' and extra == 'extra-7-openmed-scispacy')" }, ] [package.optional-dependencies] @@ -11952,6 +11959,7 @@ requires-dist = [ { name = "tokenizers", marker = "extra == 'hf'", specifier = ">=0.15" }, { name = "tokenizers", marker = "extra == 'mlx'", specifier = ">=0.15" }, { name = "tokenizers", marker = "extra == 'onnx-runtime'", specifier = ">=0.15" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "tomli", marker = "python_full_version < '3.11' and extra == 'dev'", specifier = ">=2.0" }, { name = "torch", marker = "extra == 'coreml'", specifier = ">=2.0" }, { name = "torch", marker = "extra == 'gliner'", specifier = ">=2.0" }, @@ -13710,14 +13718,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-7-openmed-airflow' and extra == 'extra-7-openmed-ocr-paddle') or (extra == 'extra-7-openmed-awq' and extra == 'extra-7-openmed-gliner') or (extra == 'extra-7-openmed-multimodal' and extra == 'extra-7-openmed-scispacy') or (extra == 'extra-7-openmed-onnx' and extra == 'extra-7-openmed-scispacy')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] @@ -14191,15 +14199,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.2" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/57/250bd238b966cece44328235eb85290045d059265fdaf7527a3a958123db/python_discovery-1.6.1.tar.gz", hash = "sha256:cf87d3627dfb4412437fdd5b13eae402607722998d21567993aedbc59b23c15e", size = 84338, upload-time = "2026-09-18T01:31:53.971Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/16/7d/e9ffbadfbf89c93848412d04594135c4ae8c1d37d9e053b9c3ed718fabc4/python_discovery-1.6.1-py3-none-any.whl", hash = "sha256:d43fcdef879fe795352bd13ccf8d185ba5a9f86f36cfcd00529f596e737442b3", size = 38664, upload-time = "2026-09-18T01:31:52.448Z" }, ] [[package]] @@ -16946,19 +16953,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.8" +version = "6.5.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/d3/343e5bb989d6515b1646cf3d40135d73f3d5e45339bded401b56cdac24dd/tornado-6.5.8.tar.gz", hash = "sha256:9452e1b208a8bd771e2cb1f2ff564985b9b214bdebbe622793e1799e0a6bd23f", size = 520493, upload-time = "2026-08-07T02:12:42.971Z" } +sdist = { url = "https://files.pythonhosted.org/packages/06/61/53d562a57b28c08eda40b258c0f975e360541943ad7c7bef897a40caafda/tornado-6.5.10.tar.gz", hash = "sha256:a6b1ccd08c04b4a06fb5aeb381be99de5ad1e5375c1785e31d78c880feb57687", size = 537910, upload-time = "2026-09-15T13:47:48.73Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f2/d5/007086fd8df5489338e204f65adce33fd4f21a4999dbb2b9cff2f897b5f4/tornado-6.5.8-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:cc6aa787d7cfab7c3d35189dc7a56fbd2399a569624c730c6b55b3d6531d0403", size = 449487, upload-time = "2026-08-07T02:12:28.682Z" }, - { url = "https://files.pythonhosted.org/packages/70/c8/5a24a99495903f594f6a199dd7beead1cbc0a13e2cb9102727bcaaf2a997/tornado-6.5.8-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9715b5eb79735b2bcd454ce216a9275b7c0470e64ea1bf5742f78b2f72b26eeb", size = 447649, upload-time = "2026-08-07T02:12:30.306Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/f2e733f386b85962d1b1dc82cd63d169b5b4580062b35397eac9244a41fe/tornado-6.5.8-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:547d63f450d570c14fe0e8db2cfb14c9bbd1c2503b4a6612586267955aa47b58", size = 450707, upload-time = "2026-08-07T02:12:31.95Z" }, - { url = "https://files.pythonhosted.org/packages/0b/94/20efeee9a01c141e9ac47c397f81679dfda24b32768fc4fff24e76d36c2c/tornado-6.5.8-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e2360a0ffbe145eca8af0b19cb7203d79b1a98dd4cccdd6b368f6f49c2e3808", size = 451677, upload-time = "2026-08-07T02:12:33.512Z" }, - { url = "https://files.pythonhosted.org/packages/42/ec/a96ccb8ccf0de2b7bc2c5fa1608a4803735018242e90c4882365a9fd418f/tornado-6.5.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5d242290bdf7ab3151bc1065fdd75c0dcc21cbc7b49f22a4c56329c2d6566d22", size = 451510, upload-time = "2026-08-07T02:12:35.346Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/93185859245ad3f00e62175f29607346788b696369347f0146e0421286bb/tornado-6.5.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7b94ff0e128fe0542f3bd331fb44d06260fc4ac16881545159f34ef08aad4195", size = 450917, upload-time = "2026-08-07T02:12:36.963Z" }, - { url = "https://files.pythonhosted.org/packages/97/cf/fe33cf062834487d34d1559746a4a12521033c22645b6d74d4bca702e018/tornado-6.5.8-cp39-abi3-win32.whl", hash = "sha256:67832909c4779c64942380cb5f044a5c6163d00831472d80e25e115de9917836", size = 451952, upload-time = "2026-08-07T02:12:38.512Z" }, - { url = "https://files.pythonhosted.org/packages/cb/e1/468ad54333e92ccb62627e62cb88e5fc14a2171daa67ed47b1b8542d5b86/tornado-6.5.8-cp39-abi3-win_amd64.whl", hash = "sha256:11881db6b7c168494be2c2d12e65931451bdf7ee718535418ae1d8855dd5a0ee", size = 452391, upload-time = "2026-08-07T02:12:39.971Z" }, - { url = "https://files.pythonhosted.org/packages/ad/3e/cd5e4f06e34cde33b8ef66cf36aa2b5ad46354cc1af7d2136bbe365fee1d/tornado-6.5.8-cp39-abi3-win_arm64.whl", hash = "sha256:68a7468c7e289f8514d7d664101753903217eff1bb6822c6b5994a0b5f5bcb26", size = 451411, upload-time = "2026-08-07T02:12:41.469Z" }, + { url = "https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9261783640e23258694a9ff0795df430a5a7b0a651d3dd53dd0969ad6be16da7", size = 465883, upload-time = "2026-09-15T13:47:35.463Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f5/cd7be26c34a3315532f3aef5f092465da8f59c334dd439d3c14aaef16461/tornado-6.5.10-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:83e6cf438b106c6b3852d70960967bb1b70c87438050dca0981e4b9aa751a4c1", size = 464046, upload-time = "2026-09-15T13:47:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/60/33/df6d7d04854a58619f8349a51e3edb138324130a7562b0bb21f115bb940f/tornado-6.5.10-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bdf942448169e5336451d0494d7e3d81cfa726d5aa312affdc4682dd62a62f6d", size = 467096, upload-time = "2026-09-15T13:47:38.559Z" }, + { url = "https://files.pythonhosted.org/packages/29/17/cc35dff68272d685cffd8600ffafbd8067e7d05e7348d9f80caddffbbd5f/tornado-6.5.10-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69acca6501eed74582b76dbbceee2a91613f54728e3e418346000d7103101676", size = 468067, upload-time = "2026-09-15T13:47:40.085Z" }, + { url = "https://files.pythonhosted.org/packages/c3/01/6e5349b4e1a53a4b4972a6716785e1fe7407f312063c3972690af8ff301b/tornado-6.5.10-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:66aaa3f57d30c6e6becee83ff28055d5930ac724214bde99393eefda83d5e015", size = 467901, upload-time = "2026-09-15T13:47:41.576Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/b4facf94370dba006819c8d304376f8b9fbec6b935b5e51bf45823a9790b/tornado-6.5.10-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4bd192b959f9128fb99b8898148070ba4574c9589b78bce42d1851131fe85828", size = 467308, upload-time = "2026-09-15T13:47:43.145Z" }, + { url = "https://files.pythonhosted.org/packages/56/ae/047938e828cafc8eca4c908fafb6588fee944e3af39a0af9d7b602499ae5/tornado-6.5.10-cp39-abi3-win32.whl", hash = "sha256:302eb1e0e3e159314eb591920529fdea80acca92df5510a2cec5bbd4f099ec72", size = 468387, upload-time = "2026-09-15T13:47:44.556Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d4/5901517f05affd752490f6a654ba31b7474664e8dd80bd045a00c220bd88/tornado-6.5.10-cp39-abi3-win_amd64.whl", hash = "sha256:37ae8f150cecfdbf747fc4e12f5e9a97ecd8cf1d4cdb3f119e2de84b11196918", size = 468828, upload-time = "2026-09-15T13:47:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/fd497f3a7f7b74bb04f4b94536b5c9f80742b5d50501fd27977652ddec16/tornado-6.5.10-cp39-abi3-win_arm64.whl", hash = "sha256:ce045d3c298fddd30e89a2777f97039d1b641eb9518ac7b26a4721903539c694", size = 467847, upload-time = "2026-09-15T13:47:47.283Z" }, ] [[package]] @@ -17174,11 +17181,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] @@ -17370,18 +17377,19 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.5.1" +version = "21.14.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, { name = "filelock" }, + { name = "packaging" }, { name = "platformdirs" }, { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-7-openmed-airflow' and extra == 'extra-7-openmed-ocr-paddle') or (extra == 'extra-7-openmed-awq' and extra == 'extra-7-openmed-gliner') or (extra == 'extra-7-openmed-multimodal' and extra == 'extra-7-openmed-scispacy') or (extra == 'extra-7-openmed-onnx' and extra == 'extra-7-openmed-scispacy')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/f9/f323b3b6058cff3853b31cf6a49c0425ed797bf9611572ec10d065e08bac/virtualenv-21.14.5.tar.gz", hash = "sha256:c4cb6c13e46b57225a999c7e22a09b163393878facc7ac4c059a57f46faa1647", size = 5467127, upload-time = "2026-10-02T17:42:25.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/df/93/084f10ebbecdedf7902ed68fbecc903f0e9c5742155bfffb05eccbc071da/virtualenv-21.14.5-py3-none-any.whl", hash = "sha256:b0651e0174982bba17cc6f0aaa2ef496730cb85a3f35eb5d1119d1c904f7d1da", size = 5487482, upload-time = "2026-10-02T17:42:23.38Z" }, ] [[package]]