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
@@ -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]]