From e6a297866ceb35d01d5ccfca064cfca7abaa2b2f Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 5 Sep 2026 14:43:50 -0400 Subject: [PATCH 01/27] feat: py-gemara, Gemara v1 schemas as Pydantic v2 models Replaces complytime-labs/gemara-py as a new distribution: `py-gemara` on the index, imported as `gemara.v1`. `gemara` is a PEP 420 namespace package, so a future `gemara.v2` can ship separately and install side by side. Pipeline. `poe sync-schema` exports every #Definition as JSON Schema and vendors the merged result, its provenance, and the upstream good-*/bad-* corpus. `poe generate` is hermetic -- no cue, no network -- reads that schema, and emits _models.py and _registry.py, both committed behind a `git diff --exit-code` drift gate that CI runs on PRs and now on the release path too. JSON Schema rather than upstream's OpenAPI 3.0.3 projection, which loses integer types, flattens date-time to date, and leaks a hidden _uniqueTermIds field as required. Three repair passes from the old generator are gone. Two new ones exist: recovering an array `allOf`'s element type (EnforcementLog.actions was landing as list[Any], hiding two structurally-detectable bad fixtures), and reopening CUE's closed structs so unknown properties are ignored rather than rejected -- without which every additive minor upstream breaks every installed reader until it re-syncs. Unknown properties are dropped, not retained, matching go-gemara and keeping the models honest as a typed surface. API. `load`/`loads` dispatch on metadata.type through the generated registry and return a GemaraDocument union, so consumers narrow with `match` and no isinstance ladder. Every failure raises from one hierarchy: GemaraError, or UnknownDocumentTypeError naming the offending value and the 13 valid ones. pyyaml sits behind a `yaml` extra; the core needs only pydantic. Known limitations, stated in the README rather than discovered later: these are structural validators, not full Gemara validators -- 12 of 17 bad-* fixtures parse, because CUE enforces cross-field semantics that cannot survive projection into JSON Schema, and `cue vet` remains the source of truth. Reading a document written against a newer minor and writing it back does not preserve its unknown fields. Assisted-by: Claude Opus 5 Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 69 + .github/workflows/release.yml | 76 + .gitignore | 9 + README.md | 87 +- pyproject.toml | 80 + .../bad-audit-log-invalid-digest.yaml | 54 + .../bad-audit-log-undeclared-criteria.yaml | 48 + schemas/fixtures/bad-audit-log.yaml | 15 + .../bad-capability-invalid-group.yaml | 23 + .../fixtures/bad-control-invalid-group.yaml | 34 + .../bad-enforcement-clear-failed.yaml | 46 + schemas/fixtures/bad-enforcement-log.yaml | 43 + .../fixtures/bad-enforcement-missing-log.yaml | 43 + .../bad-evaluation-log-missing-start.yaml | 35 + .../bad-lexicon-duplicate-term-id.yaml | 17 + schemas/fixtures/bad-lifecycle.yaml | 26 + schemas/fixtures/bad-mapping-document.yaml | 17 + schemas/fixtures/bad-mapping-no-target.yaml | 29 + schemas/fixtures/bad-no-groups.yaml | 26 + .../fixtures/bad-principle-invalid-group.yaml | 22 + .../bad-risk-catalog-duplicate-rank.yaml | 37 + .../fixtures/bad-threat-invalid-group.yaml | 31 + schemas/fixtures/good-aigf-nist-mapping.yaml | 91 + schemas/fixtures/good-aigf-principles.yaml | 78 + schemas/fixtures/good-aigf-vectors.yaml | 202 ++ schemas/fixtures/good-aigf.yaml | 278 ++ schemas/fixtures/good-audit-log.yaml | 123 + schemas/fixtures/good-capability-catalog.yaml | 29 + schemas/fixtures/good-ccc.json | 423 +++ schemas/fixtures/good-ccc.yaml | 303 ++ schemas/fixtures/good-enforcement-log.yaml | 145 + .../good-evaluation-log-unstarted.yaml | 67 + schemas/fixtures/good-lexicon.yaml | 27 + schemas/fixtures/good-lifecycle.yaml | 44 + schemas/fixtures/good-mapping-document.yaml | 147 + schemas/fixtures/good-osps.yml | 2171 ++++++++++++ schemas/fixtures/good-policy.yaml | 97 + schemas/fixtures/good-risk-catalog.yaml | 92 + schemas/fixtures/good-security-policy.yml | 80 + schemas/fixtures/good-threat-catalog.yaml | 68 + .../fixtures/good-vector-owasp-mapping.yaml | 219 ++ schemas/gemara-v1.schema.json | 2988 +++++++++++++++++ schemas/provenance.json | 25 + src/gemara/v1/__init__.py | 26 + src/gemara/v1/_loader.py | 118 + src/gemara/v1/_models.py | 2028 +++++++++++ src/gemara/v1/_registry.py | 49 + src/gemara/v1/py.typed | 0 tests/conftest.py | 6 + tests/support.py | 14 + tests/test_fixtures.py | 112 + tests/test_generate.py | 193 ++ tests/test_loader.py | 198 ++ tests/test_packaging.py | 52 + tests/test_readme.py | 36 + tests/test_registry.py | 43 + tests/test_schema.py | 70 + tests/test_sync_schema.py | 101 + tools/generate.py | 339 ++ tools/sync_schema.py | 224 ++ uv.lock | 882 +++++ 61 files changed, 13054 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 pyproject.toml create mode 100644 schemas/fixtures/bad-audit-log-invalid-digest.yaml create mode 100644 schemas/fixtures/bad-audit-log-undeclared-criteria.yaml create mode 100644 schemas/fixtures/bad-audit-log.yaml create mode 100644 schemas/fixtures/bad-capability-invalid-group.yaml create mode 100644 schemas/fixtures/bad-control-invalid-group.yaml create mode 100644 schemas/fixtures/bad-enforcement-clear-failed.yaml create mode 100644 schemas/fixtures/bad-enforcement-log.yaml create mode 100644 schemas/fixtures/bad-enforcement-missing-log.yaml create mode 100644 schemas/fixtures/bad-evaluation-log-missing-start.yaml create mode 100644 schemas/fixtures/bad-lexicon-duplicate-term-id.yaml create mode 100644 schemas/fixtures/bad-lifecycle.yaml create mode 100644 schemas/fixtures/bad-mapping-document.yaml create mode 100644 schemas/fixtures/bad-mapping-no-target.yaml create mode 100644 schemas/fixtures/bad-no-groups.yaml create mode 100644 schemas/fixtures/bad-principle-invalid-group.yaml create mode 100644 schemas/fixtures/bad-risk-catalog-duplicate-rank.yaml create mode 100644 schemas/fixtures/bad-threat-invalid-group.yaml create mode 100644 schemas/fixtures/good-aigf-nist-mapping.yaml create mode 100644 schemas/fixtures/good-aigf-principles.yaml create mode 100644 schemas/fixtures/good-aigf-vectors.yaml create mode 100644 schemas/fixtures/good-aigf.yaml create mode 100644 schemas/fixtures/good-audit-log.yaml create mode 100644 schemas/fixtures/good-capability-catalog.yaml create mode 100644 schemas/fixtures/good-ccc.json create mode 100644 schemas/fixtures/good-ccc.yaml create mode 100644 schemas/fixtures/good-enforcement-log.yaml create mode 100644 schemas/fixtures/good-evaluation-log-unstarted.yaml create mode 100644 schemas/fixtures/good-lexicon.yaml create mode 100644 schemas/fixtures/good-lifecycle.yaml create mode 100644 schemas/fixtures/good-mapping-document.yaml create mode 100644 schemas/fixtures/good-osps.yml create mode 100644 schemas/fixtures/good-policy.yaml create mode 100644 schemas/fixtures/good-risk-catalog.yaml create mode 100644 schemas/fixtures/good-security-policy.yml create mode 100644 schemas/fixtures/good-threat-catalog.yaml create mode 100644 schemas/fixtures/good-vector-owasp-mapping.yaml create mode 100644 schemas/gemara-v1.schema.json create mode 100644 schemas/provenance.json create mode 100644 src/gemara/v1/__init__.py create mode 100644 src/gemara/v1/_loader.py create mode 100644 src/gemara/v1/_models.py create mode 100644 src/gemara/v1/_registry.py create mode 100644 src/gemara/v1/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/support.py create mode 100644 tests/test_fixtures.py create mode 100644 tests/test_generate.py create mode 100644 tests/test_loader.py create mode 100644 tests/test_packaging.py create mode 100644 tests/test_readme.py create mode 100644 tests/test_registry.py create mode 100644 tests/test_schema.py create mode 100644 tests/test_sync_schema.py create mode 100644 tools/generate.py create mode 100644 tools/sync_schema.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c1765cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen --python ${{ matrix.python-version }} + - name: Run tests, failing if any is skipped + run: | + # Defect 1 was a suite that skipped 39 of 40 tests and stayed green. + set -o pipefail + uv run pytest -q | tee summary.txt + if grep -qE '[0-9]+ skipped' summary.txt; then + echo "::error::tests were skipped; the fixture corpus must always run" + exit 1 + fi + + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen + - run: uv run poe lint + - run: uv run ruff format --check . + - run: uv run poe typecheck + + drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + - run: uv sync --frozen + - name: Regenerate models from the vendored schema + run: uv run poe generate + - name: Fail if the committed output drifted + run: | + git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ + || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } + + lower-bounds: + runs-on: ubuntu-latest + env: + UV_RESOLUTION: lowest-direct + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - name: Resolve and install the lowest declared direct dependencies + run: uv sync + - name: Show what actually got installed + run: uv run python -c "import pydantic, pytest; print('pydantic', pydantic.VERSION, '| pytest', pytest.__version__)" + - run: uv run pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f0400ea --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: astral-sh/setup-uv@v7 + - run: uv sync --frozen + - name: Verify the tag matches the static version + run: | + # Defect 3: artifacts must never ship as 0.0.0, and the tag is the + # only thing that should ever disagree with pyproject.toml. + declared="$(uv run python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + tagged="${GITHUB_REF_NAME#v}" + test "$declared" != "0.0.0" || { echo "::error::version is 0.0.0"; exit 1; } + test "$declared" = "$tagged" || { + echo "::error::tag $tagged does not match pyproject version $declared"; exit 1; } + - name: Run tests, failing if any is skipped + run: | + # Defect 1 was a suite that skipped 39 of 40 tests and stayed green; + # the release path must not be the one place that regresses on it. + set -o pipefail + uv run pytest -q | tee summary.txt + if grep -qE '[0-9]+ skipped' summary.txt; then + echo "::error::tests were skipped; the fixture corpus must always run" + exit 1 + fi + - name: Regenerate models from the vendored schema + run: uv run poe generate + - name: Fail if the committed output drifted + run: | + git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ + || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } + - run: uv run poe lint + - run: uv run poe typecheck + - run: uv build + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + publish-testpypi: + needs: build + runs-on: ubuntu-latest + environment: testpypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + needs: publish-testpypi + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index 83972fa..1bd4306 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,12 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# Superpowers spec artifacts (local design docs, not tracked) +docs/superpowers/ + +# Superpowers SDD workspace (scratch, not tracked) +.superpowers/ + +# Written to the repo root by CI's no-skipped-tests step (see ci.yml/release.yml) +summary.txt diff --git a/README.md b/README.md index 923b186..a7a191d 100644 --- a/README.md +++ b/README.md @@ -1 +1,86 @@ -# py-gemara \ No newline at end of file +# py-gemara + +[Gemara](https://github.com/gemaraproj/gemara) v1 schema types as Pydantic v2 +models, generated from the upstream CUE schemas. + +```bash +pip install py-gemara # core: pydantic only +pip install py-gemara[yaml] # adds YAML support +``` + +```python +from gemara.v1 import load, DOCUMENT_TYPES, SCHEMA_VERSION, ControlCatalog + +doc = load("catalog.yaml") # dispatches on metadata.type +assert isinstance(doc, ControlCatalog) +``` + +`DOCUMENT_TYPES` maps each of the 13 `metadata.type` values to its model, and is +generated from the schema's discriminators — you never need to hand-maintain a +dispatch table. `load` and `loads` raise `UnknownDocumentTypeError` (naming the +offending value and the 13 valid ones) or `pydantic.ValidationError`. + +Models are fully typed and the package ships `py.typed`. + +## Versioning + +`SCHEMA_VERSION` reports the Gemara schema release these models were generated +from: currently **v1.5.0**. Changes within Gemara v1 are additive by +construction — upstream CI enforces this with `oasdiff` — so one model set reads +every v1.x document. + +**Documents from a newer v1.x are read, not rejected.** Properties these models +do not know about are ignored: accepted on the way in, then dropped rather than +carried onto the model or written back out — the same behaviour as +[go-gemara](https://github.com/gemaraproj/go-gemara), where a property with no +corresponding struct field is neither stored nor re-marshalled. Without this, +every additive release upstream would break every already-installed reader until +it re-synced, and a schema library that rejects valid documents of its own major +version is worse than no library. + +The consequence worth knowing: `load` followed by `model_dump` is **not** a +faithful copy of a document written against a newer minor — unknown fields are +absent from the output. Treat these models as a reader, not a round-tripping +editor, and keep the source document if you need to preserve it byte for byte. + +Strictness is relocated, not lost: required fields, enums, patterns and length +bounds still apply, and `cue vet` remains the source of truth for the rest. + +There is also deliberately no per-minor namespace. Pinning to an older minor +would buy breakage, not safety. + +A future `gemara.v2` will ship as a separate distribution, installable +side by side, because `gemara` is a PEP 420 namespace package. + +## Known limitations + +**These models are a structural validator, not a full Gemara validator.** CUE +enforces cross-field semantics — uniqueness via hidden `_unique*` fields, +referential integrity via comprehensions — that cannot survive projection into +JSON Schema. Measured against the upstream corpus at v1.5.0, 12 of 17 `bad-*` +fixtures parse successfully, including `bad-lexicon-duplicate-term-id`, +`bad-risk-catalog-duplicate-rank`, `bad-evaluation-log-missing-start`, and the +`bad-*-invalid-group` family. + +If you need full validation, run `cue vet` against the Gemara schemas. The gaps +are pinned in `SEMANTIC_GAPS` in `tests/test_fixtures.py`, so any newly-gained +strictness fails the test suite instead of passing unnoticed. + +## Development + +```bash +uv sync +uv run poe test # pytest +uv run poe lint # ruff check +uv run poe typecheck # mypy --strict +uv run poe generate # regenerate _models.py and _registry.py (hermetic) +uv run poe sync-schema # re-vendor from upstream (maintainer only; needs cue) +``` + +`src/gemara/v1/_models.py` and `_registry.py` are generated and committed. CI +regenerates them and fails on any diff, so edit `tools/generate.py`, never the +output. + +## License + +Apache-2.0. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d6b2ccf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,80 @@ +[build-system] +requires = ["uv_build>=0.9.0,<0.15.0"] +build-backend = "uv_build" + +[project] +name = "py-gemara" +version = "0.1.0" +description = "Gemara v1 schema types as Pydantic v2 models" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.11" +dependencies = ["pydantic>=2.9"] +classifiers = [ + "Development Status :: 4 - Beta", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] + +[project.optional-dependencies] +yaml = ["pyyaml>=6.0"] + +[project.urls] +Homepage = "https://github.com/jpower432/py-gemara" +Repository = "https://github.com/jpower432/py-gemara" +Issues = "https://github.com/jpower432/py-gemara/issues" + +[dependency-groups] +dev = [ + "datamodel-code-generator==0.76.2", + "ruff==0.16.6", + "mypy>=2.3", + "poethepoet>=0.30", + "pytest>=8.0", + "pyyaml>=6.0", + "types-PyYAML>=6.0", +] + +[tool.uv.build-backend] +module-name = "gemara.v1" +namespace = true + +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +# _models.py is generated; lint rules would only create churn against the drift gate. +exclude = ["src/gemara/v1/_models.py"] + +[tool.ruff.lint.per-file-ignores] +# The public API deliberately re-exports every model via a star import. +"src/gemara/v1/__init__.py" = ["F403", "F405", "F822"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["src", "tools", "tests"] +mypy_path = "src:tools:tests" +explicit_package_bases = true + +[[tool.mypy.overrides]] +module = ["gemara.v1._models"] +ignore_errors = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-ra" +pythonpath = ["tests"] + +[tool.poe.tasks] +test = "pytest" +lint = "ruff check ." +format = "ruff format ." +typecheck = "mypy" +generate = "python tools/generate.py" +sync-schema = "python tools/sync_schema.py" diff --git a/schemas/fixtures/bad-audit-log-invalid-digest.yaml b/schemas/fixtures/bad-audit-log-invalid-digest.yaml new file mode 100644 index 0000000..c254c13 --- /dev/null +++ b/schemas/fixtures/bad-audit-log-invalid-digest.yaml @@ -0,0 +1,54 @@ +metadata: + id: audit-log-bad-digest + type: AuditLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid audit log: evidence source digest uses uppercase algorithm" + author: + id: lead-auditor + name: "Auditor" + type: Human + mapping-references: + - id: github-api + title: "GitHub Dependency Graph API" + version: "2026" + url: "https://docs.github.com/en/rest/dependency-graph" + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + environment: production + +owner: + responsible: + - name: "Auditor" + affiliation: "External Audit Firm" + accountable: + - name: "Project Lead" + affiliation: "OpenSSF" + +summary: "Digest format validation test." + +criteria: + - reference-id: github-api + +results: + - id: AR-QA-01 + title: "Dependency manifests present" + type: Observation + description: "Repository includes dependency manifests." + criteria-reference: + reference-id: github-api + entries: + - reference-id: OSPS-QA-02 + evidence: + - id: EV-QA-01 + type: api-response + description: "Dependency manifests from the GitHub dependency graph SBOM endpoint" + collected-at: "2026-02-10T15:05:00Z" + source: + reference-id: github-api + coordinate: "/repos/gemaraproj/gemara/dependency-graph/sbom" + digest: "SHA256:invalidbecauseuppercasealgorithm" diff --git a/schemas/fixtures/bad-audit-log-undeclared-criteria.yaml b/schemas/fixtures/bad-audit-log-undeclared-criteria.yaml new file mode 100644 index 0000000..df0b85e --- /dev/null +++ b/schemas/fixtures/bad-audit-log-undeclared-criteria.yaml @@ -0,0 +1,48 @@ +metadata: + id: audit-log-undeclared-criteria + type: AuditLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid audit log: result scored against criteria the audit never declared" + author: + id: lead-auditor + name: "Auditor" + type: Human + mapping-references: + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + - id: OSPS + title: "Open Source Project Security Baseline" + version: "2025.1" + url: "https://baseline.openssf.org" + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + environment: production + +owner: + responsible: + - name: "Auditor" + affiliation: "External Audit Firm" + accountable: + - name: "Project Lead" + affiliation: "OpenSSF" + +summary: "Criteria declaration test." + +criteria: + - reference-id: security-policy + +results: + - id: AR-AC-01 + title: "MFA enforcement verified" + type: Strength + description: "Scored against OSPS, which is not declared in criteria." + criteria-reference: + reference-id: OSPS + entries: + - reference-id: OSPS-AC-01 diff --git a/schemas/fixtures/bad-audit-log.yaml b/schemas/fixtures/bad-audit-log.yaml new file mode 100644 index 0000000..c67dfac --- /dev/null +++ b/schemas/fixtures/bad-audit-log.yaml @@ -0,0 +1,15 @@ +metadata: + id: audit-log-bad + type: AuditLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid audit log: missing summary, criteria, and results" + author: + id: lead-auditor + name: "Jane Auditor" + type: Human + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software diff --git a/schemas/fixtures/bad-capability-invalid-group.yaml b/schemas/fixtures/bad-capability-invalid-group.yaml new file mode 100644 index 0000000..f55a7a2 --- /dev/null +++ b/schemas/fixtures/bad-capability-invalid-group.yaml @@ -0,0 +1,23 @@ +metadata: + id: EXAMPLE-CAPABILITY-CATALOG + type: CapabilityCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Example Capability Catalog + author: + id: security-team + name: Security Team + type: Human + +title: Example Capability Catalog + +groups: + - id: container-infrastructure + title: Container Infrastructure + description: Capabilities related to the core container infrastructure + +capabilities: + - id: CAP-001 + title: Container Runtime + description: System's ability to run containerized applications. + group: nonexistent-group diff --git a/schemas/fixtures/bad-control-invalid-group.yaml b/schemas/fixtures/bad-control-invalid-group.yaml new file mode 100644 index 0000000..93981ff --- /dev/null +++ b/schemas/fixtures/bad-control-invalid-group.yaml @@ -0,0 +1,34 @@ +metadata: + id: TEST-INVALID-GROUP + type: ControlCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Control catalog with a control referencing an invalid group. + author: + id: test + name: Test Author + type: Human + applicability-groups: + - id: production + title: Production + description: Production environments. + +title: Invalid Group Test + +groups: + - id: access-control + title: Access Control + description: Controls related to access. + +controls: + - id: TC-001 + group: nonexistent-group + title: Test Control + objective: Test objective. + state: Active + assessment-requirements: + - id: TC-001.AR01 + text: Test requirement. + state: Active + applicability: + - production diff --git a/schemas/fixtures/bad-enforcement-clear-failed.yaml b/schemas/fixtures/bad-enforcement-clear-failed.yaml new file mode 100644 index 0000000..cb9eb2d --- /dev/null +++ b/schemas/fixtures/bad-enforcement-clear-failed.yaml @@ -0,0 +1,46 @@ +metadata: + id: "enforcement-log-bad-clear" + type: EnforcementLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid enforcement log: Clear disposition with Failed assessment result" + author: + id: enforcement-engine + name: "Example Enforcement Engine" + type: Software + version: "1.2.0" + uri: "https://github.com/gemaraproj/gemara" + mapping-references: + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + - id: eval-log + title: "pvtr Evaluation Log" + version: "2025-08-22" + +disposition: Clear + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + +actions: + - disposition: Clear + method: + reference-id: security-policy + entry-id: EM-PASS-01 + message: "Falsely cleared despite failed assessment" + start: "2025-08-22T16:07:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/allow.PassThrough + justification: + assessments: + - result: Failed + plan: + reference-id: security-policy + entry-id: AP-AC-01 + log: + reference-id: eval-log + entry-id: OSPS-AC-01 diff --git a/schemas/fixtures/bad-enforcement-log.yaml b/schemas/fixtures/bad-enforcement-log.yaml new file mode 100644 index 0000000..24b1b27 --- /dev/null +++ b/schemas/fixtures/bad-enforcement-log.yaml @@ -0,0 +1,43 @@ +metadata: + id: "enforcement-log-bad-001" + type: EnforcementLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid enforcement log: action justification missing required log reference" + author: + id: enforcement-engine + name: "Example Enforcement Engine" + type: Software + version: "1.2.0" + uri: "https://github.com/gemaraproj/gemara" + mapping-references: + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + +disposition: Enforced + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + +actions: + - disposition: Enforcedd + method: + reference-id: security-policy + entry-id: EM-GATE-01 + message: "Blocked merge: missing user documentation" + start: "2025-08-22T16:05:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/gate.BlockMerge + justification: + assessments: + - result: Failed + requirement: + reference-id: security-policy + entry-id: OSPS-DO-01.01 + plan: + reference-id: security-policy + entry-id: AP-DO-01 diff --git a/schemas/fixtures/bad-enforcement-missing-log.yaml b/schemas/fixtures/bad-enforcement-missing-log.yaml new file mode 100644 index 0000000..815eab1 --- /dev/null +++ b/schemas/fixtures/bad-enforcement-missing-log.yaml @@ -0,0 +1,43 @@ +metadata: + id: "enforcement-log-bad-002" + type: EnforcementLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Invalid enforcement log: action justification missing required log reference" + author: + id: enforcement-engine + name: "Example Enforcement Engine" + type: Software + version: "1.2.0" + uri: "https://github.com/gemaraproj/gemara" + mapping-references: + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + +disposition: Enforced + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + +actions: + - disposition: Enforced + method: + reference-id: security-policy + entry-id: EM-GATE-01 + message: "Blocked merge: missing user documentation" + start: "2025-08-22T16:05:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/gate.BlockMerge + justification: + assessments: + - result: Failed + requirement: + reference-id: security-policy + entry-id: OSPS-DO-01.01 + plan: + reference-id: security-policy + entry-id: AP-DO-01 diff --git a/schemas/fixtures/bad-evaluation-log-missing-start.yaml b/schemas/fixtures/bad-evaluation-log-missing-start.yaml new file mode 100644 index 0000000..bf78479 --- /dev/null +++ b/schemas/fixtures/bad-evaluation-log-missing-start.yaml @@ -0,0 +1,35 @@ +metadata: + id: EVAL-MISSING-START + type: EvaluationLog + gemara-version: "1.1.0" + version: 1.0.0 + description: Evaluation log whose executed assessment omits its start time + author: + id: pvtr + name: PVTR + type: Software +result: Passed +target: + id: github-repo + name: GitHub Repository + type: Software +evaluations: +- name: access control + control: + reference-id: OSPS-B + entry-id: OSPS-AC-01 + result: Passed + message: Multi-factor authentication is required + assessment-logs: + # Passed assessments executed, so start is still required + - requirement: + entry-id: OSPS-AC-01.01 + description: Verify that multi-factor authentication is required. + result: Passed + message: Two-factor authentication is configured as required by the parent organization + applicability: + - Maturity Level 1 + steps: + - github.com/revanite-io/pvtr-github-repo/evaluation_plans/osps/access_control.orgRequiresMFA + steps-executed: 1 + end: 2025-08-22T16:02:00.000003708Z diff --git a/schemas/fixtures/bad-lexicon-duplicate-term-id.yaml b/schemas/fixtures/bad-lexicon-duplicate-term-id.yaml new file mode 100644 index 0000000..7409d6a --- /dev/null +++ b/schemas/fixtures/bad-lexicon-duplicate-term-id.yaml @@ -0,0 +1,17 @@ +title: Invalid lexicon with duplicate term ids +metadata: + id: bad-lexicon-dup + type: Lexicon + gemara-version: "1.1.0" + description: Term ids must be unique within the lexicon. + author: + id: gemara-example + name: Gemara Example Author + type: Human +terms: + - id: same-id + title: First + definition: First definition. + - id: same-id + title: Second + definition: Second definition. diff --git a/schemas/fixtures/bad-lifecycle.yaml b/schemas/fixtures/bad-lifecycle.yaml new file mode 100644 index 0000000..4bdfcfe --- /dev/null +++ b/schemas/fixtures/bad-lifecycle.yaml @@ -0,0 +1,26 @@ +metadata: + id: TEST-BAD-LIFECYCLE + type: GuidanceCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Retired guideline with recommendations should fail validation. + author: + id: test + name: Test Author + type: Human + +title: Bad Lifecycle Test +type: Standard +groups: + - id: fam-1 + title: Family One + description: Test family. + +guidelines: + - id: GL-001 + group: fam-1 + title: Retired Guideline With Recommendations + objective: This should fail because retired guidelines cannot have recommendations. + state: Retired + recommendations: + - This recommendation should not exist on a retired guideline. diff --git a/schemas/fixtures/bad-mapping-document.yaml b/schemas/fixtures/bad-mapping-document.yaml new file mode 100644 index 0000000..beb3fae --- /dev/null +++ b/schemas/fixtures/bad-mapping-document.yaml @@ -0,0 +1,17 @@ +title: Invalid Mapping Document Without Mapping References +metadata: + id: INVALID-MAP-001 + type: MappingDocument + gemara-version: "1.1.0" + version: "1.0.0" + description: This mapping document is missing mapping-references + author: + id: test-author + name: Test Author + type: Human +# Note: mapping-references is intentionally missing to test validation +source-reference: + reference-id: SOURCE +target-reference: + reference-id: TARGET +mappings: [] diff --git a/schemas/fixtures/bad-mapping-no-target.yaml b/schemas/fixtures/bad-mapping-no-target.yaml new file mode 100644 index 0000000..f5e99c1 --- /dev/null +++ b/schemas/fixtures/bad-mapping-no-target.yaml @@ -0,0 +1,29 @@ +title: Test Missing Target +metadata: + id: TEST-MAP-001 + version: "1.0.0" + type: MappingDocument + gemara-version: "1.1.0" + description: Test + author: + id: test + name: Test + type: Human + mapping-references: + - id: SRC + title: Source + version: "1.0" + - id: TGT + title: Target + version: "1.0" +source-reference: + reference-id: SRC + entry-type: Control +target-reference: + reference-id: TGT + entry-type: Guideline +mappings: + - id: m1 + source: S1 + relationship: implements + remarks: This should fail because targets is missing diff --git a/schemas/fixtures/bad-no-groups.yaml b/schemas/fixtures/bad-no-groups.yaml new file mode 100644 index 0000000..1096e1d --- /dev/null +++ b/schemas/fixtures/bad-no-groups.yaml @@ -0,0 +1,26 @@ +metadata: + id: TEST-NO-FAMILIES + type: ControlCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Control catalog with controls but no groups declared. + author: + id: test + name: Test Author + type: Human + applicability-groups: + - id: production + title: Production + description: Production environments. + +title: No Groups Test +controls: + - id: TC-001 + group: missing-group + title: Test Control + objective: Test objective. + assessment-requirements: + - id: TC-001.AR01 + text: Test requirement. + applicability: + - production diff --git a/schemas/fixtures/bad-principle-invalid-group.yaml b/schemas/fixtures/bad-principle-invalid-group.yaml new file mode 100644 index 0000000..97d797d --- /dev/null +++ b/schemas/fixtures/bad-principle-invalid-group.yaml @@ -0,0 +1,22 @@ +title: AI Governance Framework Principles +metadata: + id: AIR-PRIN + type: PrincipleCatalog + gemara-version: "1.1.0" + description: Core principles underpinning the FINOS AI Governance Framework. + version: 0.1.0 + author: + id: finos + name: FINOS + type: Human + +groups: + - id: data-protection + title: Data Protection + description: Principles governing the handling of sensitive data within AI systems. + +principles: + - id: AIR-PRIN-001 + title: Proactive Data Sanitization + group: nonexistent-group + description: Apply filtering and anonymization techniques before data enters the AI pipeline. diff --git a/schemas/fixtures/bad-risk-catalog-duplicate-rank.yaml b/schemas/fixtures/bad-risk-catalog-duplicate-rank.yaml new file mode 100644 index 0000000..d4af8b8 --- /dev/null +++ b/schemas/fixtures/bad-risk-catalog-duplicate-rank.yaml @@ -0,0 +1,37 @@ +metadata: + id: BAD-RISK-CATALOG-DUP-RANK + type: RiskCatalog + gemara-version: "0.20.0" + version: "1.0.0" + description: Invalid — two risks use the same rank + author: + id: test + name: Test + type: Human + mapping-references: + - id: EXAMPLE-THREAT-CATALOG + title: Example + version: "1.0.0" + description: x + +title: Invalid duplicate rank + +groups: + - id: CAT-SECURITY + title: Security + description: x + appetite: Low + +risks: + - id: RISK-A + title: A + description: x + group: CAT-SECURITY + severity: High + rank: 1 + - id: RISK-B + title: B + description: x + group: CAT-SECURITY + severity: High + rank: 1 diff --git a/schemas/fixtures/bad-threat-invalid-group.yaml b/schemas/fixtures/bad-threat-invalid-group.yaml new file mode 100644 index 0000000..c67e833 --- /dev/null +++ b/schemas/fixtures/bad-threat-invalid-group.yaml @@ -0,0 +1,31 @@ +metadata: + id: EXAMPLE-THREAT-CATALOG + type: ThreatCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Example Threat Catalog + author: + id: security-team + name: Security Team + type: Human + mapping-references: + - id: EXAMPLE-CAPABILITY-CATALOG + title: Example Capability Catalog + version: "1.0.0" + +title: Example Threat Catalog + +groups: + - id: stride-s + title: Spoofing + description: Impersonating something or someone to gain unauthorized access + +threats: + - id: THREAT-001 + title: Exploitation of Vulnerable Container Images + description: Attackers exploit known vulnerabilities in container images. + group: nonexistent-group + capabilities: + - reference-id: EXAMPLE-CAPABILITY-CATALOG + entries: + - reference-id: CAP-001 diff --git a/schemas/fixtures/good-aigf-nist-mapping.yaml b/schemas/fixtures/good-aigf-nist-mapping.yaml new file mode 100644 index 0000000..2044bce --- /dev/null +++ b/schemas/fixtures/good-aigf-nist-mapping.yaml @@ -0,0 +1,91 @@ +# AIGF to NIST SP 800-53r5 Mapping Document +title: AI Governance Framework to NIST SP 800-53r5 +metadata: + id: AIR-NIST-MAP-001 + version: "0.1.0" + type: MappingDocument + gemara-version: "1.1.0" + description: > + Maps FINOS AI Governance Framework mitigations (guidelines) to + NIST SP 800-53 Revision 5 security and privacy controls. + References derived from AIGF mitigation frontmatter. + author: + id: finos + name: FINOS + type: Human + mapping-references: + - id: FINOS-AIR + title: AI Governance Framework + version: "0.1.0" + url: "https://aigf.finos.org" + description: FINOS AI Governance Framework mitigations and risks + - id: NIST-800-53 + title: NIST SP 800-53 Revision 5 + version: "rev5" + url: "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-53r5.pdf" + description: Security and privacy guidelines for information systems and organizations + +source-reference: + reference-id: FINOS-AIR + entry-type: Guideline +target-reference: + reference-id: NIST-800-53 + entry-type: Guideline +remarks: > + AIGF guidelines mapped to NIST 800-53r5 guidelines based on mitigation + frontmatter references from the original AIGF content. + +mappings: + # AIR-PREV-002: Data Filtering From External Knowledge Bases + - id: MAP-PREV002-data-filtering + source: AIR-PREV-002 + relationship: supports + targets: + - entry-id: AC-4 + rationale: > + Data filtering enforces information flow policies across AI data pipelines. + - entry-id: AC-22 + rationale: > + Data filtering prevents sensitive data exposure in publicly accessible content. + - entry-id: MP-6 + rationale: > + Data filtering implements sanitization of media and data stores in AI pipelines. + - entry-id: PT-2 + rationale: > + Data filtering enforces authority and purpose constraints on data processing. + - entry-id: SI-4 + rationale: > + Data filtering implements monitoring across AI data pipelines. + - entry-id: SI-12 + rationale: > + Data filtering supports information management and retention policies. + - entry-id: SI-15 + rationale: > + Data filtering implements output masking and sanitization. + - entry-id: SI-19 + rationale: > + Data filtering supports de-identification of personal information. + + # AIR-PREV-003: User/App/Model Firewalling + - id: MAP-PREV003-firewalling + source: AIR-PREV-003 + relationship: supports + targets: + - entry-id: AC-4 + rationale: > + Layered firewalling enforces information flow policies at AI boundaries. + - entry-id: SC-5 + rationale: > + Firewalling protects against denial of service at model interaction points. + - entry-id: SC-7 + rationale: > + Firewalling at user, application, and model layers provides boundary protection. + - entry-id: SI-4 + rationale: > + Firewalling enables monitoring of AI interactions for anomalous activity. + - entry-id: SI-10 + rationale: > + Firewalling implements input validation for user and application prompts. + - entry-id: SI-15 + rationale: > + Firewalling implements output filtering for model responses. diff --git a/schemas/fixtures/good-aigf-principles.yaml b/schemas/fixtures/good-aigf-principles.yaml new file mode 100644 index 0000000..be65732 --- /dev/null +++ b/schemas/fixtures/good-aigf-principles.yaml @@ -0,0 +1,78 @@ +# AIGF Principles Catalog +title: AI Governance Framework Principles +metadata: + id: AIR-PRIN + type: PrincipleCatalog + gemara-version: "1.1.0" + description: > + Core principles underpinning the FINOS AI Governance Framework. + Each principle represents a foundational value that one or more + AIGF mitigations (guidelines) are designed to uphold. + version: 0.1.0 + author: + id: finos + name: FINOS + type: Human + mapping-references: + - id: FINOS-AIR + title: AI Governance Framework + version: 0.1.0 + url: "https://aigf.finos.org" + description: FINOS AI Governance Framework mitigations and risks + +groups: + - id: data-protection + title: Data Protection + description: > + Principles governing the handling, classification, and minimization + of sensitive data within AI systems. + - id: security-architecture + title: Security Architecture + description: > + Principles addressing layered defenses and resilience in AI + system design. + - id: governance + title: Governance + description: > + Principles ensuring transparency, accountability, and auditability + of AI data processing activities. + +principles: + - id: AIR-PRIN-001 + title: Proactive Data Sanitization + group: data-protection + description: > + Apply filtering and anonymization techniques before data enters the + AI processing pipeline, vector databases, or any external service + endpoints. + + - id: AIR-PRIN-002 + title: Data Classification Awareness + group: data-protection + description: > + Understand and respect the sensitivity levels and access controls + associated with source data when determining appropriate filtering + strategies. + + - id: AIR-PRIN-003 + title: Principle of Least Exposure + group: data-protection + description: > + Only include data in AI systems that is necessary for the intended + business function, and ensure that even this data is appropriately + de-identified or masked when possible. + + - id: AIR-PRIN-004 + title: Defense in Depth + group: security-architecture + description: > + Implement multiple layers of filtering at data ingestion, during + processing, and at output generation to create robust protection + against data leakage. + + - id: AIR-PRIN-005 + title: Auditability and Transparency + group: governance + description: > + Maintain clear documentation and audit trails of what data filtering + processes have been applied and why. diff --git a/schemas/fixtures/good-aigf-vectors.yaml b/schemas/fixtures/good-aigf-vectors.yaml new file mode 100644 index 0000000..e62cee8 --- /dev/null +++ b/schemas/fixtures/good-aigf-vectors.yaml @@ -0,0 +1,202 @@ +# AIGF Vector Catalog +title: AI Governance Framework Risk Vectors +metadata: + id: AIR-VEC + type: VectorCatalog + gemara-version: "1.1.0" + version: 0.1.0 + description: > + AIGF risks expressed as Gemara vectors. Each vector describes a + pathway through which AI system failures or negative outcomes + may be realized in financial services deployments. + author: + id: finos + name: FINOS + type: Human + +groups: + - id: model-availability + title: Model Availability + description: > + Foundation models often rely on GPU-heavy infrastructure hosted by third-party providers, introducing risks + related to service availability and performance. Key threats include Denial of Wallet (excessive usage leading + to cost spikes or throttling), outages from immature Technology Service Providers, and VRAM exhaustion due to + memory leaks or configuration changes. These issues can disrupt operations, limit failover options, and + undermine the reliability of LLM-based applications. + - id: operational + title: Operational + description: > + Risks arising from AI system behaviour, reliability, and + operational characteristics that may impact business processes. + - id: prompt-injection + title: Prompt Injection + description: > + Prompt injection occurs when attackers craft inputs that manipulate a language model into producing + unintended, harmful, or unauthorized outputs. These attacks can be direct—overriding the model’s + intended behaviour—or indirect, where malicious instructions are hidden in third-party content and + later processed by the model. This threat can lead to misinformation, data leakage, reputational damage, + or unsafe automated actions, especially in systems without strong safeguards or human oversight. + - id: data-poisoning + title: Data Poisoning + description: > + Data poisoning occurs when adversaries tamper with training or fine-tuning data to manipulate an + AI model’s behaviour, often by injecting misleading or malicious patterns. This can lead to biased + decision-making, such as incorrectly approving fraudulent transactions or degrading model performance + in subtle ways. The risk is heightened in systems that continuously learn from unvalidated or + third-party data, with impacts that may remain hidden until a major failure occurs. + - id: information-leakage + title: Information Leakage + description: > + Using third-party hosted LLMs creates a two-way trust boundary where neither inputs nor outputs can be fully trusted. + Sensitive financial data sent for inference may be memorized by models, leaked through prompt attacks, or exposed via + inadequate provider controls. This risks exposing customer PII, proprietary algorithms, and confidential business + information, particularly with free or poorly-governed LLM services. + +vectors: + - id: AIR-RC-001-01 + title: Model Memorization + group: information-leakage + description: > + LLMs can memorize sensitive data from training or user interactions, + later disclosing customer details, loan terms, or trading strategies + in unrelated sessions. This includes cross-user leakage, where one + user's sensitive data is disclosed to another. + - id: AIR-RC-001-02 + title: Prompt-Based Data Extraction + group: information-leakage + description: > + Adversaries craft prompts to extract memorized sensitive information + from hosted models. Targeted prompt sequences can cause the model to + reproduce confidential training data, PII, or proprietary algorithms + that were not intended to be accessible. + - id: AIR-RC-001-03 + title: Inadequate Provider Data Controls + group: information-leakage + description: > + Insufficient sanitization, encryption, or access controls by hosted + model providers increases disclosure risk. Providers may lack + transparent mechanisms for how input data is processed, retained, + or sanitized, leading to persistent exposure of proprietary data. + - id: AIR-RC-001-04 + title: Provider Data Handling Deficiency + group: information-leakage + description: > + Without clear contracts ensuring encryption, retention limits, and + secure deletion, institutions lose control over sensitive data sent + to hosted models. Providers may lack transparency about data + processing and retention practices. + - id: AIR-RC-001-05 + title: Fine-Tuning Data Exposure + group: information-leakage + description: > + Using proprietary data for fine-tuning embeds sensitive information + directly into model weights, potentially making it accessible to + unauthorized users if access controls are inadequate. + - id: AIR-SEC-009-01 + title: Training Data Manipulation + group: data-poisoning + description: > + Adversaries alter training datasets by changing labels or injecting + crafted data points with hidden patterns. In financial services, + this includes marking fraudulent transactions as legitimate to + corrupt fraud detection models, or embedding backdoor triggers + exploitable after deployment. + - id: AIR-SEC-009-02 + title: Continuous Learning Exploitation + group: data-poisoning + description: > + Systems that continuously learn from new data are vulnerable when + validation mechanisms are inadequate. Adversaries systematically + feed misleading information over time to gradually skew + decision-making in credit scoring, trading, or risk models. + - id: AIR-SEC-009-03 + title: Third-Party Data Compromise + group: data-poisoning + description: > + Financial institutions rely on external data feeds such as market + data, credit references, and KYC/AML watchlists. Compromise of + these sources introduces poisoned data that can unknowingly embed + biases or vulnerabilities into downstream models. + - id: AIR-SEC-009-04 + title: Bias Introduction + group: data-poisoning + description: > + Deliberate data poisoning amplifies biases in credit scoring or + loan approval models, leading to discriminatory outcomes and + regulatory non-compliance. Effects are subtle and may remain + hidden until major failures or regulatory interventions occur. + - id: AIR-OP-007-01 + title: Denial of Wallet + group: model-availability + description: > + Usage patterns inadvertently lead to excessive costs, throttling, + or service disruptions. Overly long prompts from large document + chunking, multimedia content, or token-expensive adversarial queries + can exhaust token limits or drive up charges. Poorly throttled + scripts or agentic systems may generate excessive API calls, + overwhelming resources and bypassing capacity planning. + - id: AIR-OP-007-02 + title: TSP Outage or Degradation + group: model-availability + description: > + External technology service providers may lack operational maturity + to maintain stable service levels, leading to unexpected outages or + performance degradation under load. Tight coupling to a specific + proprietary provider limits failover capability, violating business + continuity expectations. + - id: AIR-OP-007-03 + title: VRAM Exhaustion + group: model-availability + description: > + Video RAM exhaustion on serving infrastructure compromises model + responsiveness or triggers crashes. Causes include configuration + changes that exceed available resources, caching strategies that + trade VRAM for throughput, and memory leaks in model-serving + libraries that prevent proper resource release. + - id: AIR-SEC-010-01 + title: Direct Prompt Injection + group: prompt-injection + description: > + Attackers interact directly with the LLM to override its intended + behaviour. Crafted inputs attempt to bypass system prompts, ignore + safety guardrails, or coerce the model into disclosing sensitive + information. Requires no special privileges and can be executed + through simple input manipulation. + - id: AIR-SEC-010-02 + title: Indirect Prompt Injection + group: prompt-injection + description: > + Malicious instructions are embedded in third-party content such as + websites, emails, or uploaded documents. When the LLM processes + this contaminated data, the injected prompts can hijack decision-making, + escalate privileges, trigger unauthorized actions, or exfiltrate + data being processed. Especially dangerous in automated workflows + or multi-agent architectures. + - id: AIR-SEC-010-03 + title: Model Profiling and Inversion + group: prompt-injection + description: > + Sophisticated prompt injection techniques probe the internal + structure of an LLM to extract model biases, proprietary system + prompts, configurations, or training data used in fine-tuning or + RAG corpora. Enables intellectual property theft, facilitates + future attacks, or supports creation of clone models. + - id: AIR-OP-018 + title: Model Overreach / Expanded Use + group: operational + description: > + AI systems may be used beyond their originally intended and + validated scope, leading to unreliable outputs in contexts the + model was not designed or tested for. Scope creep can occur + gradually as users discover new applications, or suddenly when + systems are repurposed without adequate re-evaluation of risks + and performance characteristics. + - id: AIR-OP-020 + title: Reputational Risk + group: operational + description: > + AI systems may generate outputs that are offensive, inappropriate, + misleading, or otherwise damaging to the organization's + reputation. This risk is amplified when attackers deliberately + manipulate models into producing harmful content that is then + attributed to the organization. diff --git a/schemas/fixtures/good-aigf.yaml b/schemas/fixtures/good-aigf.yaml new file mode 100644 index 0000000..9d4ccd8 --- /dev/null +++ b/schemas/fixtures/good-aigf.yaml @@ -0,0 +1,278 @@ +metadata: + id: FINOS-AIR + type: GuidanceCatalog + gemara-version: "1.1.0" + description: > + A comprehensive collection of risks and mitigations that support + on-boarding, development of, and running Generative AI solutions. + author: + id: finos + name: FINOS + type: Human + version: 0.1.0 + mapping-references: + - id: AIR-PRIN + title: AI Governance Framework Principles + version: 0.1.0 + url: "https://aigf.finos.org/principles" + description: Core principles underpinning the FINOS AI Governance Framework + - id: AIR-VEC + title: AI Governance Framework Risk Vectors + version: 0.1.0 + url: "https://aigf.finos.org/risks" + description: AIGF risks expressed as Gemara vectors +title: AI Governance Framework +type: Framework +front-matter: | + AI, especially Generative AI, is reshaping financial services, enhancing products, client interactions, and productivity. However, challenges like hallucinations and model unpredictability make safe deployment complex. Rapid advancements require flexible governance. + Financial institutions are eager to adopt AI but face regulatory hurdles. Existing frameworks may not address AI's unique risks, necessitating an adaptive governance model for safe and compliant integration. + The following framework has been developed by FINOS (Fintech Open Source Foundation) members, providing a comprehensive catalogue of risks and associated mitigations. We suggest using our heuristic risk identification framework to determine which risks are most relevant for a given use case. +groups: + - id: DET + title: Detective + description: Detection and Continuous Improvement + - id: PREV + title: Preventive + description: Prevention and Risk Mitigation +guidelines: + - id: AIR-PREV-002 + group: PREV + title: Data Filtering From External Knowledge Bases + objective: > + This control addresses the critical need to sanitize, filter, and appropriately + manage sensitive information when AI systems ingest data from internal knowledge + sources such as wikis, document management systems, databases, or collaboration + platforms (e.g., Confluence, SharePoint, internal websites). The primary objective + is to prevent the inadvertent exposure, leakage, or manipulation of confidential + organizational knowledge when this data is processed by AI models, converted into + embeddings for vector databases, or used in Retrieval Augmented Generation (RAG) systems. + Given that many AI applications, particularly RAG systems, rely on internal knowledge bases to + provide contextually relevant and organization-specific responses, ensuring that sensitive + information within these sources is appropriately handled is paramount for maintaining + data confidentiality and preventing unauthorized access. + rationale: + importance: > + This control is particularly important given the evolving nature of AI technologies + and the sophisticated ways they interact with and process large volumes of organizational + information. A proactive approach to data sanitization helps maintain confidentiality, + integrity, and compliance while enabling the organization to benefit from AI capabilities. + goals: + - "Prevention of Data Leakage: Significantly reduces the risk of sensitive organizational information being inadvertently exposed through AI system outputs or stored in less secure external services." + - "Regulatory Compliance: Helps meet requirements under data protection regulations (e.g., GDPR, CCPA, GLBA) that mandate the protection of personal and sensitive business information." + - "Intellectual Property Protection: Safeguards valuable trade secrets, strategic information, and proprietary data from unauthorized disclosure or competitive exposure." + - "Reduced Attack Surface: By controlling the information that enters AI operational environments, organizations minimize the potential impact of AI-specific attacks like prompt injection or data extraction attempts." + - "Enhanced Trust and Confidence: Builds stakeholder confidence in AI systems by demonstrating rigorous data protection practices." + - "Compliance with Internal Data Governance: Supports adherence to internal data classification and handling policies within AI contexts." + - "Mitigation of Insider Risk: Reduces the risk of sensitive information being accessed by unauthorized internal users through AI interfaces." + see-also: + - AIR-DET-001 + - AIR-PREV-006 + - AIR-DET-016 + statements: + - id: AIR-PREV-002.1 + title: Rigorous Data Cleansing and Anonymization at Ingestion + text: "Identify and remove or appropriately anonymize sensitive details to ensure that data fed into the AI system is free from information that could pose a security or privacy risk if inadvertently exposed." + recommendations: + - > + Pre-Processing Review and Cleansing: Before any information from + internal knowledge sources is ingested by an AI system, it must + undergo a thorough review and cleansing process to identify and + remove or appropriately anonymize sensitive details. + - > + Categories of Data to Target for Filtering: Personally + Identifiable Information (PII), Proprietary Business Information, + Sensitive Internal Operational Data, Confidential Customer Data, + and Regulatory or Compliance-Sensitive Information. + - > + Filtering and Anonymization Methods: Data Masking, Redaction, + Generalization, Tokenization, and Synthetic Data Generation. + - id: AIR-PREV-002.2 + title: Segregation for Highly Sensitive Data + text: "" + recommendations: + - > + Isolated AI Systems for Critical Data: For datasets containing + exceptionally sensitive information that cannot be adequately + protected through standard cleansing, implement separate, isolated + AI systems with stricter access controls, enhanced encryption, + and limited network connectivity. + - > + Access Domain-Based Segregation: Segment data and AI system access + based on clearly defined access domains that mirror the + organization's existing data classification and access control + structures. + - id: AIR-PREV-002.3 + title: Filtering AI System Outputs (Secondary Defense) + text: "" + recommendations: + - > + Response Filtering and Validation: Responses generated by the AI + system should be monitored and filtered before being presented to + users, acting as a safety net to detect sensitive data that might + have bypassed initial input cleansing. + - > + Contextual Output Analysis: Implement intelligent filtering that + considers the context of the user's query and their authorization + level to determine what information should be included in the + response. + - id: AIR-PREV-002.4 + title: Integration with Source System Access Controls + text: "" + recommendations: + - "Respect Original Permissions: Design the AI system to respect and replicate the original access control permissions from source systems." + - "Dynamic Source Querying: For real-time RAG systems, consider querying source systems dynamically while respecting user permissions, rather than pre-processing all data indiscriminately." + - id: AIR-PREV-002.5 + title: Monitoring and Continuous Improvement + text: > + Periodically audit the effectiveness of data filtering processes + by sampling processed data and checking for any sensitive + information that may have been missed. + recommendations: + - "Regular Review of Filtering Effectiveness: Periodically audit the effectiveness of data filtering processes by sampling processed data." + - "Feedback Loop Integration: Establish mechanisms for users and reviewers to report instances where sensitive information may have been inappropriately exposed." + - "Threat Intelligence Integration: Stay informed about new types of data leakage vectors and attack techniques that might affect AI systems." + principles: + - reference-id: AIR-PRIN + entries: + - reference-id: AIR-PRIN-001 + remarks: Filtering and anonymization applied before data enters AI pipelines + - reference-id: AIR-PRIN-002 + remarks: Filtering strategies respect source data sensitivity and access controls + - reference-id: AIR-PRIN-003 + remarks: Only necessary data included in AI systems, de-identified where possible + - reference-id: AIR-PRIN-004 + remarks: Multiple filtering layers at ingestion, processing, and output + - reference-id: AIR-PRIN-005 + remarks: Audit trails document what filtering has been applied and why + vectors: + - reference-id: AIR-VEC + entries: + - reference-id: AIR-RC-001-01 + - reference-id: AIR-RC-001-02 + - reference-id: AIR-RC-001-03 + - reference-id: AIR-RC-001-04 + - reference-id: AIR-RC-001-05 + - reference-id: AIR-SEC-009-01 + - reference-id: AIR-SEC-009-02 + - reference-id: AIR-SEC-009-03 + - reference-id: AIR-SEC-009-04 + state: Active + + - id: AIR-PREV-003 + group: PREV + title: User/App/Model Firewalling + objective: > + User/App/Model Firewalling encompasses the set of security controls + applied at the boundaries between users, applications, AI models, + and supporting data stores such as RAG databases. When internal + company information is used to enrich a RAG database, especially if + this involves processing by external services, this data and the + external communication pathways must be carefully managed and + secured. Any proprietary or sensitive information sent to an external + service for such processing requires rigorous filtering before + transmission to prevent data leakage. + rationale: + importance: > + Implementing comprehensive user/app/model firewalling provides + critical security benefits including attack prevention, data + protection, service availability, reputation protection, and + compliance support. Firewalling blocks prompt injection attacks + and malicious inputs before they reach AI models, prevents + sensitive information leakage through AI outputs or RAG processing, + and helps meet regulatory requirements for data handling and + system security. + goals: + - "RAG Data Ingestion: Filter sensitive or private data before transmitting internal information to external services for embedding creation" + - "User Input to AI Model: Detect and block malicious or abusive user inputs such as Prompt Injection attacks" + - "AI Model Output: Detect excessively long responses, format deviations, evasion patterns, data leakage, and inappropriate content" + see-also: + - AIR-PREV-017 + - AIR-PREV-008 + - AIR-DET-015 + statements: + - id: AIR-PREV-003.1 + title: RAG Data Ingestion Filtering + text: "" + recommendations: + - > + RAG Database Security: While it's often more practical to + pre-process and filter data for RAG systems before sending it + for external embedding creation, organizations might also + consider in-line filters for real-time checks. Once internal + information is converted into embeddings and stored in vector + databases, the data becomes largely opaque to traditional + security tools. + - > + Filtering Efficacy: Static filters based on regular expressions + or keyword blocklists are effective for well-defined patterns + but less effective at identifying nuanced issues such as + generic private information or subtle Prompt Injection attacks. + - > + Streaming Outputs: Streaming responses improve user experience + but implementing output filtering can be challenging. An + approach is to stream while performing on-the-fly detection, + cancelling output if an issue is found. + - id: AIR-PREV-003.2 + title: Remediation Techniques + text: "" + recommendations: + - > + Basic Filters: Simple static checks using blocklists and + regular expressions can detect rudimentary attacks or policy + violations. + - > + System Prompts (Caution Advised): While system prompts can + instruct an LLM on what to avoid, they are generally not a + robust security control. Attackers can often bypass these + instructions. + - > + LLM as a Judge: A secondary, specialized LLM analyzes user + queries and the primary LLM's responses, categorizing + inputs/outputs for various risks such as prompt injection, + abuse, hate speech, and data leakage. + - > + Human Feedback Loop: Implementing a system where users can + easily report problematic AI responses provides a valuable + complementary control. + - id: AIR-PREV-003.3 + title: Additional Considerations + text: "" + recommendations: + - > + API Security and Observability: Implementing a comprehensive + API monitoring and security solution offers benefits beyond + AI-specific threats. A security proxy can enforce encrypted + communication between all AI system components. + - > + Logging and Analysis: Detailed logging of interactions is + essential for understanding user behavior, system performance, + and detection of sophisticated attacks or anomalies. + - id: AIR-PREV-003.4 + title: Challenges and Considerations + text: > + RAG Database Security: Vector databases make traditional security + filtering difficult once data is embedded. Filtering Efficacy: + Static filters may miss nuanced attacks or sophisticated content. + Streaming Outputs: Real-time filtering creates trade-offs between + security and user experience. + principles: + - reference-id: AIR-PRIN + entries: + - reference-id: AIR-PRIN-001 + remarks: RAG data filtered before transmission to external services + - reference-id: AIR-PRIN-004 + remarks: Layered filtering at user input, model output, and RAG ingestion boundaries + - reference-id: AIR-PRIN-005 + remarks: Logging and analysis of all interactions for audit and anomaly detection + vectors: + - reference-id: AIR-VEC + entries: + - reference-id: AIR-OP-007-01 + - reference-id: AIR-OP-007-02 + - reference-id: AIR-OP-007-03 + - reference-id: AIR-SEC-010-01 + - reference-id: AIR-SEC-010-02 + - reference-id: AIR-SEC-010-03 + - reference-id: AIR-OP-018 + - reference-id: AIR-OP-020 + state: Active diff --git a/schemas/fixtures/good-audit-log.yaml b/schemas/fixtures/good-audit-log.yaml new file mode 100644 index 0000000..4d3c3df --- /dev/null +++ b/schemas/fixtures/good-audit-log.yaml @@ -0,0 +1,123 @@ +metadata: + id: audit-log-001 + type: AuditLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Q1 2026 Gemara Audit" + author: + id: lead-auditor + name: "Jane Auditor" + type: Human + mapping-references: + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + - id: OSPS + title: "Open Source Project Security Baseline" + version: "2025.1" + url: "https://baseline.openssf.org" + - id: eval-log + title: "PVTR Evaluation Log" + version: "2025-08-22" + url: "https://artifacts.example.com/eval-logs/pvtr-baseline-scan.yaml" + - id: enforcement-log + title: "Example Enforcement Log" + version: "2025-08-22" + url: "https://artifacts.example.com/enforcement-logs/enforcement-log-001.yaml" + - id: github-api + title: "GitHub Dependency Graph API" + version: "2026" + url: "https://docs.github.com/en/rest/dependency-graph" + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + environment: production + owner: + name: "Gemara Maintainers" + affiliation: "OpenSSF" + +owner: + responsible: + - name: "Jane Auditor" + affiliation: "External Audit Firm" + accountable: + - name: "Project Lead" + affiliation: "OpenSSF" + +summary: "Access control and quality controls are strong. Documentation controls have gaps requiring remediation." + +criteria: + - reference-id: security-policy + remarks: "Organizational policy establishing the audit's scope." + - reference-id: OSPS + remarks: "Baseline whose controls each result is scored against." + +results: + - id: AR-AC-01 + title: "MFA enforcement verified" + type: Strength + description: "Multi-factor authentication is enforced at the organization level for all contributors." + criteria-reference: + reference-id: OSPS + entries: + - reference-id: OSPS-AC-01 + + - id: AR-DO-01 + title: "User documentation missing" + type: Gap + description: "No user guide is published or referenced in the Security Insights data." + criteria-reference: + reference-id: OSPS + entries: + - reference-id: OSPS-DO-01 + evidence: + - id: EV-DO-01 + type: EvaluationLog + description: "PVTR evaluation results for documentation controls" + collected-at: "2025-08-22T16:02:00Z" + source: + reference-id: eval-log + entry-id: "assessment-do-01" + digest: "sha256:c4d5e6f7a8b9012cdef34567890abcdef1234567890abcdef1234567890abc123" + recommendations: + - id: REC-01 + text: "Add user guide references to the Security Insights file and publish basic user documentation." + required: true + + - id: AR-DO-02 + title: "Vulnerability reporting channel not formalized" + type: Finding + description: "Private vulnerability reporting was not enabled prior to enforcement remediation." + criteria-reference: + reference-id: OSPS + entries: + - reference-id: OSPS-DO-02 + evidence: + - id: EV-DO-02 + type: EnforcementLog + description: "Enforcement actions taken for documentation failures" + collected-at: "2025-08-22T16:05:00Z" + recommendations: + - id: REC-02 + text: "Formalize the private vulnerability reporting process and document it in SECURITY.md." + + - id: AR-QA-01 + title: "Dependency manifests present" + type: Observation + description: "Repository includes dependency manifests and the dependency graph is accessible via GitHub API." + criteria-reference: + reference-id: OSPS + entries: + - reference-id: OSPS-QA-02 + evidence: + - id: EV-QA-01 + type: api-response + description: "Dependency manifests from the GitHub dependency graph SBOM endpoint" + collected-at: "2026-02-10T15:05:00Z" + source: + reference-id: github-api + coordinate: "/repos/gemaraproj/gemara/dependency-graph/sbom" + digest: "sha256:a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890" diff --git a/schemas/fixtures/good-capability-catalog.yaml b/schemas/fixtures/good-capability-catalog.yaml new file mode 100644 index 0000000..dc13913 --- /dev/null +++ b/schemas/fixtures/good-capability-catalog.yaml @@ -0,0 +1,29 @@ +metadata: + id: EXAMPLE-CAPABILITY-CATALOG + type: CapabilityCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Example Capability Catalog + author: + id: security-team + name: Security Team + type: Human + +title: Example Capability Catalog + +groups: + - id: container-infrastructure + title: Container Infrastructure + description: Capabilities related to the core container infrastructure, including runtimes and registries + +capabilities: + - id: CAP-001 + title: Container Runtime + description: System's ability to run containerized applications, including image management and container lifecycle. + group: container-infrastructure + + - id: CAP-002 + title: Container Registry + description: System's ability to store and retrieve container images from registries. + group: container-infrastructure + diff --git a/schemas/fixtures/good-ccc.json b/schemas/fixtures/good-ccc.json new file mode 100644 index 0000000..e351962 --- /dev/null +++ b/schemas/fixtures/good-ccc.json @@ -0,0 +1,423 @@ +{ + "title": "FINOS Cloud Control Catalog", + "metadata": { + "id": "FINOS-CCC", + "type": "ControlCatalog", + "gemara-version": "1.0.0", + "description": "FINOS CCC is an open standard project that describes consistent controls for\ncompliant public cloud deployments in the financial services sector.\n", + "author": { + "id": "finos", + "name": "FINOS", + "type": "Human" + }, + "applicability-groups": [ + { + "id": "tlp_clear", + "title": "TLP:Clear", + "description": "Information may be shared without restriction.\n" + }, + { + "id": "tlp_green", + "title": "TLP:Green", + "description": "Information may be shared with partners and restricted to the\norganization.\n" + }, + { + "id": "tlp_amber", + "title": "TLP:Amber", + "description": "Information may be shared with partners and restricted to the\norganization.\n" + }, + { + "id": "tlp_red", + "title": "TLP:Red", + "description": "Information is restricted to the organization.\n" + } + ] + }, + "groups": [ + { + "id": "data-protection", + "title": "Data Protection", + "description": "Data protection controls ensure that data is protected from unauthorized\naccess, disclosure, and tampering. This includes encryption of data at\nrest and in transit, access controls, and data retention policies.\n" + } + ], + "controls": [ + { + "id": "CCC.C01", + "title": "Prevent Unencrypted Requests", + "objective": "Ensure that all communications are encrypted in transit to protect data\nintegrity and confidentiality.\n", + "threats": [ + { + "reference-id": "CCC", + "entries": [ + { + "reference-id": "CCC.TH02", + "remarks": "Data is Intercepted in Transit" + } + ] + } + ], + "guidelines": [ + { + "reference-id": "CSF", + "entries": [ + { + "reference-id": "PR.DS-02", + "remarks": "Data-in-transit is protected" + } + ] + }, + { + "reference-id": "CCM", + "entries": [ + { + "reference-id": "IVS-03" + }, + { + "reference-id": "IVS-07" + } + ] + }, + { + "reference-id": "ISO-27001", + "entries": [ + { + "reference-id": "2013 A.13.1.1", + "remarks": "This control is closely related to 2013 A.13.1.1." + } + ] + }, + { + "reference-id": "NIST-800-53", + "entries": [ + { + "reference-id": "SC-8" + }, + { + "reference-id": "SC-13" + } + ] + } + ], + "assessment-requirements": [ + { + "id": "CCC.C01.TR01", + "text": "When a port is exposed for non-SSH network traffic, all traffic MUST\ninclude a TLS handshake AND be encrypted using TLS 1.2 or higher.\n", + "applicability": [ + "tlp_clear", + "tlp_green", + "tlp_amber", + "tlp_red" + ] + }, + { + "id": "CCC.C01.TR02", + "text": "When a port is exposed for SSH network traffic, all traffic MUST\ninclude a SSH handshake AND be encrypted using SSHv2 or higher.\n", + "applicability": [ + "tlp_clear", + "tlp_green", + "tlp_amber", + "tlp_red" + ] + } + ], + "group": "data-protection" + }, + { + "id": "CCC.C06", + "title": "Prevent Deployment in Restricted Regions", + "objective": "Ensure that resources are not provisioned or deployed in\ngeographic regions or cloud availability zones that have been\ndesignated as restricted or prohibited, to comply with\nregulatory requirements and reduce exposure to geopolitical\nrisks.\n", + "threats": [ + { + "reference-id": "CCC", + "entries": [ + { + "reference-id": "CCC.TH03", + "remarks": "Deployment Region Network is Untrusted" + } + ] + } + ], + "guidelines": [ + { + "reference-id": "CCM", + "entries": [ + { + "reference-id": "DSI-06", + "remarks": "This control is closely related to DSI-06." + }, + { + "reference-id": "DSI-08", + "remarks": "This control is closely related to DSI-08." + } + ] + }, + { + "reference-id": "ISO-27001", + "entries": [ + { + "reference-id": "2013 A.11.1.1", + "remarks": "This control is closely related to 2013 A.11.1.1." + } + ] + }, + { + "reference-id": "NIST-800-53", + "entries": [ + { + "reference-id": "AC-6", + "remarks": "This control is closely related to AC-6." + } + ] + }, + { + "reference-id": "CSF", + "entries": [ + { + "reference-id": "PR.DS-1", + "remarks": "Data-at-rest is protected" + } + ] + } + ], + "assessment-requirements": [ + { + "id": "CCC.C06.TR01", + "text": "When a deployment request is made, the service MUST validate\nthat the deployment region is not to a restricted or regions\nor availability zones.\n", + "applicability": [ + "tlp_clear", + "tlp_green", + "tlp_amber", + "tlp_red" + ] + }, + { + "id": "CCC.C06.TR02", + "text": "When a deployment request is made, the service MUST validate that\nreplication of data, backups, and disaster recovery operations\nwill not occur in restricted regions or availability zones.\n", + "applicability": [ + "tlp_clear", + "tlp_green", + "tlp_amber", + "tlp_red" + ] + } + ], + "group": "data-protection" + }, + { + "id": "CCC.C08", + "title": "Enable Multi-zone or Multi-region Data Replication", + "objective": "Ensure that data is replicated across multiple\nzones or regions to protect against data loss due to hardware\nfailures, natural disasters, or other catastrophic events.\n", + "threats": [ + { + "reference-id": "CCC", + "entries": [ + { + "reference-id": "CCC.TH06", + "remarks": "Data is Lost or Corrupted" + } + ] + } + ], + "guidelines": [ + { + "reference-id": "CSF", + "entries": [ + { + "reference-id": "PR.DS-5", + "remarks": "Protections against data leaks are implemented" + } + ] + }, + { + "reference-id": "CCM", + "entries": [ + { + "reference-id": "BCR-08", + "remarks": "Backup" + } + ] + }, + { + "reference-id": "NIST-800-53", + "entries": [ + { + "reference-id": "CP-2", + "remarks": "Contingency plan" + }, + { + "reference-id": "CP-10", + "remarks": "Information system recovery and reconstitution" + } + ] + } + ], + "assessment-requirements": [ + { + "id": "CCC.C08.TR01", + "text": "When data is stored, the service MUST ensure that data is\nreplicated across multiple availability zones or regions.\n", + "applicability": [ + "tlp_green", + "tlp_amber", + "tlp_red" + ] + }, + { + "id": "CCC.C08.TR02", + "text": "When data is replicated across multiple zones or regions,\nthe service MUST be able to verify the replication state,\nincluding the replication locations and data synchronization\nstatus.\n", + "applicability": [ + "tlp_green", + "tlp_amber", + "tlp_red" + ] + } + ], + "group": "data-protection" + }, + { + "id": "CCC.C09", + "title": "Prevent Tampering, Deletion, or Unauthorized Access to Access Logs", + "objective": "Access logs should always be considered sensitive.\nEnsure that access logs are protected against unauthorized\naccess, tampering, or deletion.\n", + "threats": [ + { + "reference-id": "CCC", + "entries": [ + { + "reference-id": "CCC.TH07", + "remarks": "Logs are Tampered with or Deleted" + }, + { + "reference-id": "CCC.TH09", + "remarks": "Logs or Monitoring Data are Read by Unauthorized Users" + }, + { + "reference-id": "CCC.TH04", + "remarks": "Data is Replicated to Untrusted or External Locations" + } + ] + } + ], + "guidelines": [ + { + "reference-id": "CCM", + "entries": [ + { + "reference-id": "LOG-02", + "remarks": "Audit log protection" + }, + { + "reference-id": "LOG-04", + "remarks": "Audit log access and accountability" + }, + { + "reference-id": "LOG-09", + "remarks": "Log protection" + } + ] + }, + { + "reference-id": "NIST-800-53", + "entries": [ + { + "reference-id": "AU-9", + "remarks": "Protection of audit information" + } + ] + } + ], + "assessment-requirements": [ + { + "id": "CCC.C09.TR01", + "text": "When access logs are stored, the service MUST ensure that\naccess logs cannot be accessed without proper authorization.\n", + "applicability": [ + "tlp_amber", + "tlp_red", + "tlp_green", + "tlp_clear" + ] + }, + { + "id": "CCC.C09.TR02", + "text": "When access logs are stored, the service MUST ensure that\naccess logs cannot be modified without proper authorization.\n", + "applicability": [ + "tlp_amber", + "tlp_red", + "tlp_green", + "tlp_clear" + ] + }, + { + "id": "CCC.C09.TR03", + "text": "When access logs are stored, the service MUST ensure that\naccess logs cannot be deleted without proper authorization.\n", + "applicability": [ + "tlp_amber", + "tlp_red", + "tlp_green", + "tlp_clear" + ] + } + ], + "group": "data-protection" + }, + { + "id": "CCC.C10", + "title": "Prevent Data Replication to Destinations Outside of Defined\nTrust Perimeter\n", + "objective": "Prevent replication of data to untrusted destinations outside\nof defined trust perimeter. An untrusted destination is defined\nas a resource that exists outside of a specified trusted\nidentity or network or data perimeter.\n", + "threats": [ + { + "reference-id": "CCC", + "entries": [ + { + "reference-id": "CCC.TH04", + "remarks": "Data is Replicated to Untrusted or External Locations" + } + ] + } + ], + "guidelines": [ + { + "reference-id": "CSF", + "entries": [ + { + "reference-id": "PR.DS-5", + "remarks": "Protections against data leaks are implemented" + } + ] + }, + { + "reference-id": "CCM", + "entries": [ + { + "reference-id": "DSP-10", + "remarks": "Sensitive data transfer" + }, + { + "reference-id": "DSP-19", + "remarks": "Data location" + } + ] + }, + { + "reference-id": "NIST-800-53", + "entries": [ + { + "reference-id": "AC-4", + "remarks": "Information flow enforcement" + } + ] + } + ], + "assessment-requirements": [ + { + "id": "CCC.C10.TR01", + "text": "When data is replicated, the service MUST ensure that\nreplication is restricted to explicitly trusted destinations.\n", + "applicability": [ + "tlp_green", + "tlp_amber", + "tlp_red" + ] + } + ], + "group": "data-protection" + } + ] +} \ No newline at end of file diff --git a/schemas/fixtures/good-ccc.yaml b/schemas/fixtures/good-ccc.yaml new file mode 100644 index 0000000..720460c --- /dev/null +++ b/schemas/fixtures/good-ccc.yaml @@ -0,0 +1,303 @@ +metadata: + id: FINOS-CCC + type: ControlCatalog + gemara-version: "1.1.0" + version: "2024.1" + description: | + FINOS CCC is an open standard project that describes consistent controls for + compliant public cloud deployments in the financial services sector. + author: + id: finos + name: FINOS + type: Human + mapping-references: + - id: CCC + title: FINOS Common Cloud Controls Threats + version: "2024.1" + - id: CSF + title: NIST Cybersecurity Framework + version: "2.0" + - id: CCM + title: Cloud Security Alliance Cloud Controls Matrix + version: "4.0" + - id: ISO-27001 + title: ISO/IEC 27001 + version: "2013" + - id: NIST-800-53 + title: NIST Special Publication 800-53 + version: "Rev. 5" + applicability-groups: + - id: tlp_clear + title: TLP:Clear + description: | + Information may be shared without restriction. + - id: tlp_green + title: TLP:Green + description: | + Information may be shared with partners and restricted to the + organization. + - id: tlp_amber + title: TLP:Amber + description: | + Information may be shared with partners and restricted to the + organization. + - id: tlp_red + title: TLP:Red + description: | + Information is restricted to the organization. +title: FINOS Cloud Control Catalog +groups: + - id: data-protection + title: Data Protection + description: | + Data protection controls ensure that data is protected from unauthorized + access, disclosure, and tampering. This includes encryption of data at + rest and in transit, access controls, and data retention policies. +controls: + - id: CCC.C01 + group: data-protection + title: Prevent Unencrypted Requests + objective: | + Ensure that all communications are encrypted in transit to protect data + integrity and confidentiality. + threats: + - reference-id: CCC + entries: + - reference-id: CCC.TH02 + remarks: Data is Intercepted in Transit + guidelines: + - reference-id: CSF + entries: + - reference-id: PR.DS-02 + remarks: Data-in-transit is protected + - reference-id: CCM + entries: + - reference-id: IVS-03 + - reference-id: IVS-07 + - reference-id: ISO-27001 + entries: + - reference-id: 2013 A.13.1.1 + remarks: This control is closely related to 2013 A.13.1.1. + - reference-id: NIST-800-53 + entries: + - reference-id: SC-8 + - reference-id: SC-13 + assessment-requirements: + - id: CCC.C01.TR01 + text: | + When a port is exposed for non-SSH network traffic, all traffic MUST + include a TLS handshake AND be encrypted using TLS 1.2 or higher. + applicability: + - tlp_clear + - tlp_green + - tlp_amber + - tlp_red + - id: CCC.C01.TR02 + text: | + When a port is exposed for SSH network traffic, all traffic MUST + include a SSH handshake AND be encrypted using SSHv2 or higher. + applicability: + - tlp_clear + - tlp_green + - tlp_amber + - tlp_red + + - id: CCC.C06 + group: data-protection + title: Prevent Deployment in Restricted Regions + objective: | + Ensure that resources are not provisioned or deployed in + geographic regions or cloud availability zones that have been + designated as restricted or prohibited, to comply with + regulatory requirements and reduce exposure to geopolitical + risks. + threats: + - reference-id: CCC + entries: + - reference-id: CCC.TH03 + remarks: Deployment Region Network is Untrusted + guidelines: + - reference-id: CCM + entries: + - reference-id: DSI-06 + remarks: This control is closely related to DSI-06. + - reference-id: DSI-08 + remarks: This control is closely related to DSI-08. + - reference-id: ISO-27001 + entries: + - reference-id: 2013 A.11.1.1 + remarks: This control is closely related to 2013 A.11.1.1. + - reference-id: NIST-800-53 + entries: + - reference-id: AC-6 + remarks: This control is closely related to AC-6. + - reference-id: CSF + entries: + - reference-id: PR.DS-1 + remarks: Data-at-rest is protected + assessment-requirements: + - id: CCC.C06.TR01 + text: | + When a deployment request is made, the service MUST validate + that the deployment region is not to a restricted or regions + or availability zones. + applicability: + - tlp_clear + - tlp_green + - tlp_amber + - tlp_red + - id: CCC.C06.TR02 + text: | + When a deployment request is made, the service MUST validate that + replication of data, backups, and disaster recovery operations + will not occur in restricted regions or availability zones. + applicability: + - tlp_clear + - tlp_green + - tlp_amber + - tlp_red + + - id: CCC.C08 + group: data-protection + title: Enable Multi-zone or Multi-region Data Replication + objective: | + Ensure that data is replicated across multiple + zones or regions to protect against data loss due to hardware + failures, natural disasters, or other catastrophic events. + threats: + - reference-id: CCC + entries: + - reference-id: CCC.TH06 + remarks: Data is Lost or Corrupted + guidelines: + - reference-id: CSF + entries: + - reference-id: PR.DS-5 + remarks: Protections against data leaks are implemented + - reference-id: CCM + entries: + - reference-id: BCR-08 + remarks: Backup + - reference-id: NIST-800-53 + entries: + - reference-id: CP-2 + remarks: Contingency plan + - reference-id: CP-10 + remarks: Information system recovery and reconstitution + assessment-requirements: + - id: CCC.C08.TR01 + text: | + When data is stored, the service MUST ensure that data is + replicated across multiple availability zones or regions. + applicability: + - tlp_green + - tlp_amber + - tlp_red + - id: CCC.C08.TR02 + text: | + When data is replicated across multiple zones or regions, + the service MUST be able to verify the replication state, + including the replication locations and data synchronization + status. + applicability: + - tlp_green + - tlp_amber + - tlp_red + + - id: CCC.C09 + group: data-protection + title: Prevent Tampering, Deletion, or Unauthorized Access to Access Logs + objective: | + Access logs should always be considered sensitive. + Ensure that access logs are protected against unauthorized + access, tampering, or deletion. + threats: + - reference-id: CCC + entries: + - reference-id: CCC.TH07 + remarks: Logs are Tampered with or Deleted + - reference-id: CCC.TH09 + remarks: Logs or Monitoring Data are Read by Unauthorized Users + - reference-id: CCC.TH04 + remarks: Data is Replicated to Untrusted or External Locations + guidelines: + - reference-id: CCM + entries: + - reference-id: LOG-02 + remarks: Audit log protection + - reference-id: LOG-04 + remarks: Audit log access and accountability + - reference-id: LOG-09 + remarks: Log protection + - reference-id: NIST-800-53 + entries: + - reference-id: AU-9 + remarks: Protection of audit information + assessment-requirements: + - id: CCC.C09.TR01 + text: | + When access logs are stored, the service MUST ensure that + access logs cannot be accessed without proper authorization. + applicability: + - tlp_amber + - tlp_red + - tlp_green + - tlp_clear + - id: CCC.C09.TR02 + text: | + When access logs are stored, the service MUST ensure that + access logs cannot be modified without proper authorization. + applicability: + - tlp_amber + - tlp_red + - tlp_green + - tlp_clear + - id: CCC.C09.TR03 + text: | + When access logs are stored, the service MUST ensure that + access logs cannot be deleted without proper authorization. + applicability: + - tlp_amber + - tlp_red + - tlp_green + - tlp_clear + + - id: CCC.C10 + group: data-protection + title: | + Prevent Data Replication to Destinations Outside of Defined + Trust Perimeter + objective: | + Prevent replication of data to untrusted destinations outside + of defined trust perimeter. An untrusted destination is defined + as a resource that exists outside of a specified trusted + identity or network or data perimeter. + threats: + - reference-id: CCC + entries: + - reference-id: CCC.TH04 + remarks: Data is Replicated to Untrusted or External Locations + guidelines: + - reference-id: CSF + entries: + - reference-id: PR.DS-5 + remarks: Protections against data leaks are implemented + - reference-id: CCM + entries: + - reference-id: DSP-10 + remarks: Sensitive data transfer + - reference-id: DSP-19 + remarks: Data location + - reference-id: NIST-800-53 + entries: + - reference-id: AC-4 + remarks: Information flow enforcement + assessment-requirements: + - id: CCC.C10.TR01 + text: | + When data is replicated, the service MUST ensure that + replication is restricted to explicitly trusted destinations. + applicability: + - tlp_green + - tlp_amber + - tlp_red diff --git a/schemas/fixtures/good-enforcement-log.yaml b/schemas/fixtures/good-enforcement-log.yaml new file mode 100644 index 0000000..4c54d5b --- /dev/null +++ b/schemas/fixtures/good-enforcement-log.yaml @@ -0,0 +1,145 @@ +metadata: + id: "enforcement-log-001" + type: EnforcementLog + gemara-version: "1.1.0" + version: "1.0.0" + description: "Enforcement actions taken against pvtr evaluation findings for the Gemara repository" + author: + id: enforcement-engine + name: "Example Enforcement Engine" + type: Software + version: "1.2.0" + uri: "https://github.com/gemaraproj/gemara" + mapping-references: + - id: OSPS + title: "Open Source Project Security Baseline" + version: "2025.1" + url: "https://baseline.openssf.org" + - id: security-policy + title: "Information Security Policy" + version: "2.1.0" + - id: eval-log + title: "pvtr Evaluation Log" + version: "2025-08-22" + - id: exception-register + title: "Approved Exception Register" + version: "2025-Q3" + +disposition: Enforced + +target: + id: gemara-repo + name: "gemaraproj/gemara" + type: Software + uri: "https://github.com/gemaraproj/gemara" + environment: production + owner: + name: "Gemara Maintainers" + affiliation: "OpenSSF" + +actions: + - disposition: Enforced + method: + reference-id: security-policy + entry-id: EM-GATE-01 + message: "Blocked merge: missing user documentation" + start: "2025-08-22T16:05:00Z" + end: "2025-08-22T16:05:01Z" + steps: + - github.com/gemaraproj/gemara/enforcement/gate.BlockMerge + justification: + assessments: + - result: Failed + requirement: + reference-id: OSPS + entry-id: OSPS-DO-01.01 + plan: + reference-id: security-policy + entry-id: AP-DO-01 + log: + reference-id: eval-log + entry-id: OSPS-DO-01 + + - disposition: Enforced + method: + reference-id: security-policy + entry-id: EM-REMEDIATE-01 + message: "Auto-remediation: enabled private vulnerability reporting" + start: "2025-08-22T16:06:00Z" + end: "2025-08-22T16:06:03Z" + steps: + - github.com/gemaraproj/gemara/enforcement/remediate.EnablePrivateVulnReporting + - github.com/gemaraproj/gemara/enforcement/remediate.VerifyVulnReportingActive + justification: + assessments: + - result: Failed + requirement: + reference-id: OSPS + entry-id: OSPS-DO-02.01 + plan: + reference-id: security-policy + entry-id: AP-DO-02 + log: + reference-id: eval-log + entry-id: OSPS-DO-02 + + - disposition: Clear + method: + reference-id: security-policy + entry-id: EM-PASS-01 + message: "All access control assessments passed; no enforcement action required" + start: "2025-08-22T16:07:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/allow.PassThrough + justification: + assessments: + - result: Passed + plan: + reference-id: security-policy + entry-id: AP-AC-01 + log: + reference-id: eval-log + entry-id: OSPS-AC-01 + + - disposition: Tolerated + method: + reference-id: security-policy + entry-id: EM-WAIVE-01 + message: "Waived: subproject listing requirement deferred per approved exception EXC-2025-042" + start: "2025-08-22T16:08:00Z" + end: "2025-08-22T16:08:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/waive.RecordException + justification: + assessments: + - result: Failed + requirement: + reference-id: OSPS + entry-id: OSPS-QA-04.01 + plan: + reference-id: security-policy + entry-id: AP-QA-04 + log: + reference-id: eval-log + entry-id: OSPS-QA-04 + exceptions: + - reference-id: exception-register + remarks: "EXC-2025-042: Single-repository projects are exempt from the subproject listing requirement" + + - disposition: Clear + method: + reference-id: security-policy + entry-id: EM-REMEDIATE-01 + message: "Autoremediation enforcement active; no noncompliance findings to act on" + start: "2025-08-22T16:09:00Z" + steps: + - github.com/gemaraproj/gemara/enforcement/remediate.EnablePrivateVulnReporting + justification: + assessments: + - result: Passed + plan: + reference-id: security-policy + entry-id: AP-DO-02 + log: + reference-id: eval-log + entry-id: OSPS-DO-02 diff --git a/schemas/fixtures/good-evaluation-log-unstarted.yaml b/schemas/fixtures/good-evaluation-log-unstarted.yaml new file mode 100644 index 0000000..227d835 --- /dev/null +++ b/schemas/fixtures/good-evaluation-log-unstarted.yaml @@ -0,0 +1,67 @@ +metadata: + id: EVAL-UNSTARTED + type: EvaluationLog + gemara-version: "1.1.0" + version: 1.0.0 + description: Evaluation log containing assessments that never executed + author: + id: pvtr + name: PVTR + type: Software +result: Needs Review +target: + id: github-repo + name: GitHub Repository + type: Software +evaluations: +- name: access control + control: + reference-id: OSPS-B + entry-id: OSPS-AC-01 + result: Needs Review + message: Assessments did not execute + assessment-logs: + # Not Run: no start time is recorded because the procedure never began + - requirement: + entry-id: OSPS-AC-01.01 + description: Verify that multi-factor authentication is required. + result: Not Run + message: Halted before execution because a prior assessment failed + applicability: + - Maturity Level 1 + steps: + - github.com/revanite-io/pvtr-github-repo/evaluation_plans/osps/access_control.orgRequiresMFA + steps-executed: 0 + # Unknown: the outcome could not be determined, so no timing is asserted + - requirement: + entry-id: OSPS-AC-01.02 + description: Verify that administrative access is restricted. + result: Unknown + message: Evaluator could not reach the target + applicability: + - Maturity Level 1 + steps: + - github.com/revanite-io/pvtr-github-repo/evaluation_plans/osps/access_control.adminAccess + # Not Applicable: the procedure was skipped as out of scope + - requirement: + entry-id: OSPS-AC-01.03 + description: Verify that branch protection is enabled on release branches. + result: Not Applicable + message: Project has no release branches + applicability: + - Maturity Level 2 + steps: + - github.com/revanite-io/pvtr-github-repo/evaluation_plans/osps/access_control.branchProtection + # Passed: an executed assessment still records its start time + - requirement: + entry-id: OSPS-AC-01.04 + description: Verify that the default branch requires review. + result: Passed + message: Reviews are required on the default branch + applicability: + - Maturity Level 1 + steps: + - github.com/revanite-io/pvtr-github-repo/evaluation_plans/osps/access_control.reviewRequired + steps-executed: 1 + start: 2025-08-22T16:02:00.000000000Z + end: 2025-08-22T16:02:00.000003708Z diff --git a/schemas/fixtures/good-lexicon.yaml b/schemas/fixtures/good-lexicon.yaml new file mode 100644 index 0000000..3cd3102 --- /dev/null +++ b/schemas/fixtures/good-lexicon.yaml @@ -0,0 +1,27 @@ +title: Example security lexicon +metadata: + id: example-lexicon-001 + type: Lexicon + gemara-version: "1.1.0" + version: "1.0.0" + description: > + Minimal lexicon illustrating the Gemara Lexicon artifact schema. + author: + id: gemara-example + name: Gemara Example Author + type: Human +terms: + - id: sbom + title: Software Bill of Materials + definition: > + A formal, machine-readable inventory of software components and dependencies. + synonyms: + - SBOM + - software bill of materials + references: + - citation: NIST SP 800-218 SSDF + url: https://csrc.nist.gov/publications/detail/sp/800-218/final + - id: vulnerability-disclosure + title: Coordinated vulnerability disclosure + definition: > + A process for reporting and remediating security flaws before public release. diff --git a/schemas/fixtures/good-lifecycle.yaml b/schemas/fixtures/good-lifecycle.yaml new file mode 100644 index 0000000..248340c --- /dev/null +++ b/schemas/fixtures/good-lifecycle.yaml @@ -0,0 +1,44 @@ +metadata: + id: TEST-LIFECYCLE + type: ControlCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Validates lifecycle states on controls and assessment requirements. + author: + id: test + name: Test Author + type: Human + applicability-groups: + - id: production + title: Production + description: Production environments. + +title: Lifecycle Test Catalog +groups: + - id: dp + title: Data Protection + description: Data protection controls. + +controls: + - id: TC-001 + group: dp + title: Encrypt Data at Rest + objective: Ensure all stored data is encrypted. + assessment-requirements: + - id: TC-001.AR01 + text: The system MUST encrypt all data at rest. + applicability: + - production + recommendation: Use AES-256 or equivalent. + + - id: TC-002 + group: dp + title: Encrypt Data at Rest Using DES + objective: Ensure all stored data is encrypted using DES. + state: Retired + assessment-requirements: + - id: TC-002.AR01 + text: The system MUST encrypt all data at rest using DES. + applicability: + - production + state: Retired diff --git a/schemas/fixtures/good-mapping-document.yaml b/schemas/fixtures/good-mapping-document.yaml new file mode 100644 index 0000000..c513ddf --- /dev/null +++ b/schemas/fixtures/good-mapping-document.yaml @@ -0,0 +1,147 @@ +title: OSPS Baseline to EU Cyber Resilience Act (CRA) Annex I +metadata: + id: OSPS-CRA-MAP-001 + version: "1.0.0" + type: MappingDocument + gemara-version: "1.1.0" + description: > + Maps OSPS Baseline controls to essential cybersecurity requirements + in Annex I of the EU Cyber Resilience Act (Regulation 2024/2847). + OSPS classifies all external guideline mappings as relates-to. + Downstream consumers may refine the relationship type based on + their applicability context. + author: + id: gemara-example + name: Gemara Example Author + type: Human + applicability-groups: + - id: manufacturer + title: Manufacturer + description: > + Entity placing a product with digital elements on the EU market. + Subject to full CRA Annex I obligations. + - id: open-source-steward + title: Open Source Software Steward + description: > + Entity systematically providing support for open source products + intended for commercial use. Subject to reduced obligations + under CRA Article 24. + - id: ML1 + title: Maturity Level 1 + description: > + OSPS Baseline entry-level maturity. Projects at this level + satisfy foundational security requirements. + - id: ML2 + title: Maturity Level 2 + description: > + OSPS Baseline intermediate maturity. Projects at this level + satisfy enhanced security requirements including vulnerability + disclosure and release practices. + - id: ML3 + title: Maturity Level 3 + description: > + OSPS Baseline advanced maturity. Projects at this level satisfy + the most rigorous security requirements including SBOM, VEX, + and automated enforcement. + mapping-references: + - id: OSPS + title: Open Source Project Security Baseline + version: "2025.03.03" + url: "https://baseline.openssf.org/" + - id: CRA + title: EU Cyber Resilience Act - Annex I + version: "2024/2847" + url: "https://eur-lex.europa.eu/eli/reg/2024/2847/oj" + +source-reference: + reference-id: OSPS + entry-type: Control +target-reference: + reference-id: CRA + entry-type: Guideline +remarks: > + CRA Annex I Part I (1.x) covers security requirements for products + with digital elements; Part II (2.x) covers vulnerability handling. + +mappings: + - id: QA02-2.1-mfr + source: OSPS-QA-02 + relationship: relates-to + targets: + - entry-id: "2.1" + strength: 6 + confidence-level: Medium + applicability: + - "manufacturer" + rationale: > + OSPS-QA-02 requires dependency lists and SBOMs. CRA 2.1 requires + identifying and documenting vulnerabilities and components. SBOMs + address component identification but not the full manufacturer + obligation. + + - id: QA02-2.1-steward + source: OSPS-QA-02 + relationship: implements + targets: + - entry-id: "2.1" + strength: 9 + confidence-level: High + applicability: + - "open-source-steward" + rationale: > + For open-source stewards, CRA Article 24 narrows 2.1 to a + best-effort duty around vulnerability facilitation. OSPS-QA-02 + dependency tracking and SBOM generation fulfills this + scoped-down obligation. + + - id: VM01-vuln-handling + source: OSPS-VM-01 + relationship: relates-to + targets: + - entry-id: "2.5" + confidence-level: High + applicability: + - "ML2" + - "ML3" + rationale: > + OSPS-VM-01 requires a CVD policy with a clear timeframe for + response. CRA 2.5 requires timely remediation of + vulnerabilities. + - entry-id: "2.6" + confidence-level: High + applicability: + - "ML2" + - "ML3" + rationale: > + CRA 2.6 requires public disclosure of fixed vulnerabilities + with advisories. CVD policy scope overlaps. + - entry-id: "2.7" + confidence-level: High + applicability: + - "ML2" + - "ML3" + rationale: > + CRA 2.7 requires mechanisms for sharing information about + vulnerabilities. CVD policy facilitates this. + + - id: VM04-2.4 + source: OSPS-VM-04 + relationship: relates-to + targets: + - entry-id: "2.4" + confidence-level: High + applicability: + - "ML3" + rationale: > + OSPS-VM-04 requires a VEX document for vulnerabilities in + components that do not affect the project. This Maturity Level 3 + requirement strengthens the CRA 2.4 relationship by providing + machine-readable exploitability data beyond basic disclosure. + + - id: GV01-no-match + source: OSPS-GV-01 + relationship: no-match + remarks: > + OSPS-GV-01 requires publishing project roles and responsibilities. + CRA Annex I has no corresponding requirement for governance + documentation. diff --git a/schemas/fixtures/good-osps.yml b/schemas/fixtures/good-osps.yml new file mode 100644 index 0000000..8118559 --- /dev/null +++ b/schemas/fixtures/good-osps.yml @@ -0,0 +1,2171 @@ +metadata: + id: OSPS-B + type: ControlCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: | + The Open Source Project Security (OSPS) Baseline is a set of security + criteria that projects should meet to demonstrate a strong security posture. + author: + id: ossf + name: OpenSSF + type: Human + applicability-groups: + - id: Maturity1 + title: Maturity Level 1 + description: | + Any code or non-code project with any number of maintainers or users + - id: Maturity2 + title: Maturity Level 2 + description: | + Any code project that has at least 2 maintainers and a small number of + consistent users + - id: Maturity3 + title: Maturity Level 3 + description: | + Any code project that has a large number of consistent users + mapping-references: + - id: BPB + title: OpenSSF Best Practices Badge + version: "2024" + url: https://github.com/coreinfrastructure/best-practices-badge/blob/main/controls/controls.yml + - id: CSF + title: NIST Cybersecurity Framework + version: "2.0" + url: https://nvlpubs.nist.gov/nistpubs/CSWP/NIST.CSWP.29.pdf + - id: CRA + title: Cyber Resilience Act + version: 20.11.2024 + url: https://eur-lex.europa.eu/legal-content/EN/TXT/HTML/?uri=OJ:L_202402847#tit_1 + - id: SSDF + title: Software Security Development Framework + version: "1.1" + url: https://csrc.nist.gov/pubs/sp/800/218/final + - id: OC + title: ISO/IEC 18974 + version: 1.0 - 2023-12 + url: https://openchainproject.org/security-assurance + - id: OCRE + title: Open Cybersecurity Reference Architecture + version: "2024" + url: https://github.com/OWASP/OpenCRE + - id: SLSA + title: Supply Chain Levels for Software Artifacts + version: "1.0" + url: https://github.com/slsa-framework/slsa + - id: ScCrd + title: OpenSSF Scorecard + version: "5.0" + url: https://github.com/ossf/scorecard + +title: Open Source Project Security Baseline +groups: +- id: access-control + title: Access Control + description: | + Access Control focuses on the mechanisms and + policies that control access to the project's version + control system and CI/CD pipelines. These controls help + ensure that only authorized users can access sensitive + data, modify repository settings, or execute build and + release processes. +- id: build-and-release + title: Build and Release + description: | + Build and Release focuses on the processes and + tools used to compile, package, and distribute the + project's software. These controls help ensure that the + project's build and release pipelines are secure, + consistent, and reliable, reducing the risk of + vulnerabilities or errors in the software distribution + process. +- id: documentation + title: Documentation + description: | + Documentation focuses on the information + provided to users, contributors, and maintainers + of the project. These controls help ensure that + the project's documentation is comprehensive, + accurate, and up-to-date, enabling users to + understand the project's features and functionality. +- id: governance + title: Governance + description: | + Governance focuses on the policies and + procedures that guide the project's decision-making + and community interactions. These controls help ensure + that the project is well positioned to respond to + both threats and opportunities. +- id: legal + title: Legal + description: | + Legal focuses on the policies and + procedures that govern the project's licensing + and intellectual property. These controls help + ensure that the project's source code is + distributed under a recognized and legally + enforceable open source software license, + reducing the risk of intellectual property + disputes or licensing violations. +- id: quality + title: Quality + description: | + Quality focuses on the processes and + practices used to ensure the quality and + reliability of the project's source code and + software assets. These controls help ensure + that the project's source code is well + maintained, secure, and reliable, reducing the + risk of defects or vulnerabilities in the + software. +- id: security-assessment + title: Security Assessment + description: | + Security Assessment encourages practices that + help ensure that the project is well positioned + to identify and address security vulnerabilities + and threats in the software. +- id: vulnerability-management + title: Vulnerability Management + description: | + Vulnerability Management focuses on the + processes and practices used to identify and + address security vulnerabilities in the project's + software dependencies. These controls help ensure + that the project is well positioned to respond to + security threats and vulnerabilities in the software. +controls: +- id: OSPS-AC-01 + title: | + The project's version control system MUST require multi-factor + authentication for collaborators modifying the project repository + settings or accessing sensitive data. + objective: | + Reduce the risk of account compromise or insider threats by requiring + multi-factor authentication for collaborators modifying the project + repository settings or accessing sensitive data. + guidelines: + - reference-id: BPB + entries: + - reference-id: CC-G-1 + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: 1.2e + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: CSF + entries: + - reference-id: PR.A-02 + - reference-id: PR.A-05 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 347-352 + - reference-id: 333-858 + - reference-id: 152-725 + - reference-id: 201-246 + assessment-requirements: + - id: OSPS-AC-01.01 + text: | + When a user attempts to access a sensitive resource in the project's + version control system, the system MUST require the user to complete + a multi-factor authentication process. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Enforce multi-factor authentication for the project's version + control system, requiring collaborators to provide a second form of + authentication when accessing sensitive data or modifying repository + settings. Passkeys are acceptable for this control. + group: access-control +- id: OSPS-AC-02 + title: | + The project's version control system MUST restrict collaborator + permissions to the lowest available privileges by default. + objective: | + Reduce the risk of unauthorized access to the project's repository by + limiting the permissions granted to new collaborators. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO2 + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: CSF + entries: + - reference-id: PR.AA-02 + - reference-id: PR.AA-05 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 802-056 + - reference-id: 368-633 + - reference-id: 152-725 + assessment-requirements: + - id: OSPS-AC-02.01 + text: | + When a new collaborator is added, the version control system MUST + require manual permission assignment, or restrict the collaborator + permissions to the lowest available privileges by default. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Most public version control systems are configured in this manner. + Ensure the project's version control system always assigns the lowest + available permissions to collaborators by default when added, granting + additional permissions only when necessary. + group: access-control +- id: OSPS-AC-03 + title: | + The project's version control system MUST prevent unintentional + modification of the primary branch. + objective: | + Reduce the risk of accidental changes or deletion of the primary branch + of the project's repository by preventing unintentional modification. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: CSF + entries: + - reference-id: PR.A-02 + - reference-id: PR.A-05 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 152-725 + - reference-id: ScCrd + entries: + - reference-id: Branch-Protection + assessment-requirements: + - id: OSPS-AC-03.01 + text: | + When a direct commit is attempted on the project's primary branch, + an enforcement mechanism MUST prevent the change from being applied. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + If the VCS is centralized, set branch protection on the primary branch + in the project's VCS. Alternatively, use a decentralized approach, + like the Linux kernel's, where changes are first proposed in another + repository, and merging changes into the primary repository requires a + specific separate act. + - id: OSPS-AC-03.02 + text: | + When an attempt is made to delete the project's primary branch, + the version control system MUST treat this as a sensitive activity + and require explicit confirmation of intent. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Set branch protection on the primary branch in the project's version + control system to prevent deletion. + group: access-control +- id: OSPS-AC-04 + title: | + The project's permissions in CI/CD pipelines MUST follow the principle + of least privilege. + objective: | + Reduce the risk of unauthorized access to the project's build and release + processes by limiting the permissions granted to steps within the CI/CD + pipelines. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: 1.2e + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO2 + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: CSF + entries: + - reference-id: PR.AA-02 + - reference-id: PR.AA-05 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 347-507 + - reference-id: 263-284 + - reference-id: 123-124 + - reference-id: SLSA + entries: + - reference-id: Producer - Choose an appropriate build platform + - reference-id: Build platform - Isolation strength - Isolated + assessment-requirements: + - id: OSPS-AC-04.01 + text: | + When a CI/CD task is executed with no permissions specified, the + project's version control system MUST default to the lowest available + permissions for all activities in the pipeline. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Configure the project's settings to assign the lowest available + permissions to new pipelines by default, granting additional + permissions only when necessary for specific tasks. + - id: OSPS-AC-04.02 + text: | + When a job is assigned permissions in a CI/CD pipeline, the source + code or configuration MUST only assign the minimum privileges + necessary for the corresponding activity. + applicability: + - Maturity3 + recommendation: | + Configure the project's CI/CD pipelines to assign the lowest available + permissions to users and services by default, elevating permissions + only when necessary for specific tasks. In some version control + systems, this may be possible at the organizational or repository + level. If not, set permissions at the top level of the pipeline. + group: access-control +- id: OSPS-BR-01 + title: | + The project's build and release pipelines MUST NOT permit untrusted + input that allows access to privileged resources. + objective: | + Reduce the risk of code injection or other security vulnerabilities in the + project's build and release pipelines by preventing untrusted input from + accessing privileged resources. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PO5.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: CSF + entries: + - reference-id: PR.AA-02 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 357-352 + - reference-id: SLSA + entries: + - reference-id: Choose an appropriate build platform + assessment-requirements: + - id: OSPS-BR-01.01 + text: | + When a CI/CD pipeline accepts an input parameter, that parameter MUST + be sanitized and validated prior to use in the pipeline. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + - id: OSPS-BR-01.02 + text: | + When a CI/CD pipeline uses a branch name in its functionality, that + name value MUST be sanitized and validated prior to use in the + pipeline. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + group: build-and-release +- id: OSPS-BR-02 + title: | + All releases and released software assets MUST be assigned a unique + version identifier for each release intended to be used by users. + objective: | + Ensure that each software asset produced by the project is uniquely + identified, enabling users to track changes and updates to the project + over time. + guidelines: + - reference-id: BPB + entries: + - reference-id: CC-B-5 + - reference-id: CC-B-6 + - reference-id: CC-B-7 + - reference-id: CRA + entries: + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: PS3 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: SLSA + entries: + - reference-id: Follow a consistent build process + - reference-id: Provenance generation- Exists, Authentic + assessment-requirements: + - id: OSPS-BR-02.01 + text: | + When an official release is created, that release MUST be assigned a + unique version identifier. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Assign a unique version identifier to each release produced by the + project, following a consistent naming convention or numbering scheme. + Examples include SemVer, CalVer, or git commit id. + - id: OSPS-BR-02.02 + text: | + When an official release is created, all assets within that release + MUST be clearly associated with the release identifier or another + unique identifier for the asset. + applicability: + - Maturity3 + recommendation: | + Assign a unique version identifier to each software asset produced by + the project, following a consistent naming convention or numbering + scheme. Examples include SemVer, CalVer, or git commit id. + group: build-and-release +- id: OSPS-BR-03 + title: | + All official project URIs MUST be delivered using encrypted channels. + objective: | + Protect the confidentiality and integrity of project source code during + development, reducing the risk of eavesdropping or data tampering. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-11 + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: 1.2e + - reference-id: 1.2f + - reference-id: 1.2i + - reference-id: 1.2j + - reference-id: 1.2k + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PO5.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: OCRE + entries: + - reference-id: 483-813 + - reference-id: 124-564 + - reference-id: 263-184 + - reference-id: SLSA + entries: + - reference-id: Choose an appropriate build platform + assessment-requirements: + - id: OSPS-BR-03.01 + text: | + When the project lists a URI as an official project channel, that URI + MUST be exclusively delivered using encrypted channels. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Configure the project's websites and version control systems to use + encrypted channels such as SSH or HTTPS for data transmission. + Ensure all tools and domains referenced in project documentation can + only be accessed via encrypted channels. + - id: OSPS-BR-03.02 + text: | + When the project lists a URI as an official distribution channel, + that URI MUST be exclusively delivered using encrypted channels. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Configure the project's release pipeline to only fetch data from + websites, API responses, and other services which use encrypted + channels such as SSH or HTTPS for data transmission. + group: build-and-release +- id: OSPS-BR-04 + title: | + All releases MUST provide a descriptive log of functional and security + modifications. + objective: | + Provide transparency and accountability for changes made to the project's + software releases, enabling users to understand the modifications and + improvements included in each release. + guidelines: + - reference-id: BPB + entries: + - reference-id: CC-B-8 + - reference-id: CC-B-9 + - reference-id: Q-B-7 + - reference-id: A-B-1 + - reference-id: A-S-1 + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: 1.2f + - reference-id: 1.2h + - reference-id: 1.2j + - reference-id: 1.2l + - reference-id: '2.5' + - reference-id: SSDF + entries: + - reference-id: PS1 + - reference-id: PS2 + - reference-id: PS3 + - reference-id: PW1.2 + - reference-id: OCRE + entries: + - reference-id: 483-813 + - reference-id: 068-486 + - reference-id: 124-564 + - reference-id: 757-271 + - reference-id: 347-352 + - reference-id: 263-184 + - reference-id: 208-355 + - reference-id: 745-356 + - reference-id: 732-148 + - reference-id: SLSA + entries: + - reference-id: Choose an appropriate build platform + - reference-id: Follow a consistent build process + - reference-id: Build platform - Isolation strength - isolated + assessment-requirements: + - id: OSPS-BR-04.01 + text: | + When an official release is created, that release MUST contain + a descriptive log of functional and security + modifications. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Ensure that all releases include a descriptive change log. It is + recommended to ensure that the change log is human-readable and + includes details beyond commit messages, such as descriptions of the + security impact or relevance to different use cases. To ensure + machine readability, place the content under a markdown header + such as "## Changelog". + group: build-and-release +- id: OSPS-BR-05 + title: | + All build and release pipelines MUST use standardized tooling where + available to ingest dependencies at build time. + objective: | + Ensure that the project's build and release pipelines use standardized tools + and processes to manage dependencies, reducing the risk of compatibility + issues or security vulnerabilities in the software. + guidelines: + - reference-id: BPB + entries: + - reference-id: Q-B-2 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: 1.2d + - reference-id: 1.2f + - reference-id: 1.2h + - reference-id: 1.2j + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.3' + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 347-352 + - reference-id: 715-334 + - reference-id: SLSA + entries: + - reference-id: Isolation strength - isolated + assessment-requirements: + - id: OSPS-BR-05.01 + text: | + When a build and release pipeline ingests dependencies, it MUST + use standardized tooling where available. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Use a common tooling for your ecosystem, such as package managers or + dependency management tools to ingest dependencies at build time. This + may include using a dependency file, lock file, or manifest to specify + the required dependencies, which are then pulled in by the build + system. + group: build-and-release +- id: OSPS-BR-06 + title: | + Produce all released software assets with signatures and hashes. + objective: | + All released software assets MUST be signed or accounted for in a + signed manifest including each asset's cryptographic hashes. + guidelines: + - reference-id: SSDF + entries: + - reference-id: PO5.2 + - reference-id: PS2 + - reference-id: PS2.1 + - reference-id: PW6.2 + - reference-id: ScCrd + entries: + - reference-id: Signed-Releases + - reference-id: SLSA + entries: + - reference-id: Distribute provenance - Exists + assessment-requirements: + - id: OSPS-BR-06.01 + text: | + When an official release is created, that release MUST be signed or + accounted for in a signed manifest including each asset's + cryptographic hashes. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Sign all released software assets at build time with a cryptographic + signature or attestations, such as GPG or PGP signature, Sigstore + signatures, SLSA provenance, or SLSA VSAs. Include the cryptographic + hashes of each asset in a signed manifest or metadata file. + group: build-and-release +- id: OSPS-DO-01 + title: | + The project documentation MUST provide user guides for all basic + functionality. + objective: | + Ensure that users have a clear and comprehensive understanding of the + project's current features in order to prevent damage from misuse or + misconfiguration. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-1 + - reference-id: B-B-9 + - reference-id: B-S-7 + - reference-id: B-S-9 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: 1.2j + - reference-id: 1.2k + - reference-id: SSDF + entries: + - reference-id: PW1.2 + - reference-id: CSF + entries: + - reference-id: GV.OC-04 + - reference-id: GV.OC-05 + - reference-id: OC + entries: + - reference-id: 4.1.4 + - reference-id: OCRE + entries: + - reference-id: 036-275 + assessment-requirements: + - id: OSPS-DO-01.01 + text: | + When the project has made a release, the project documentation MUST + include user guides for all basic functionality. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Create user guides or documentation for all basic functionality of the + project, explaining how to install, configure, and use the project's + features. If there are any known dangerous or destructive actions + available, include highly-visible warnings. + group: documentation +- id: OSPS-DO-02 + title: | + The project MUST provide a mechanism for reporting defects. + objective: | + Enable users and contributors to report defects or issues with the + released software assets, facilitating communication and collaboration on + defect fixes and improvements. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-3 + - reference-id: R-B-1+ + - reference-id: R-B-1 + - reference-id: R-B-2 + - reference-id: R-S-2 + - reference-id: CRA + entries: + - reference-id: 1.2c + - reference-id: 1.2l + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.5' + - reference-id: '2.6' + - reference-id: SSDF + entries: + - reference-id: PW1.2 + - reference-id: RV1.1 + - reference-id: RV2.1 + - reference-id: RV1.2 + - reference-id: CSF + entries: + - reference-id: RS.MA-02 + - reference-id: GV.RM-05 + - reference-id: OC + entries: + - reference-id: 4.2.1 + assessment-requirements: + - id: OSPS-DO-02.01 + text: | + When the project has made a release, the project documentation MUST + include a guide for reporting defects. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + It is recommended that projects use their VCS default issue tracker. + If an external source is used, ensure that the project documentation + and contributing guide clearly and visibly explain how to use the + reporting system. It is recommended that project documentation also + sets expectations for how defects will be triaged and resolved. + group: documentation +- id: OSPS-DO-03 + title: | + The project documentation MUST contain instructions to verify the + integrity and authenticity of the release assets, including the + expected identity of the person or process authoring the software + release. + objective: | + Enable users to verify the authenticity and integrity of the project's + released software assets, reducing the risk of using tampered or + unauthorized versions of the software. + guidelines: + - reference-id: BPB + entries: + - reference-id: CC-B-8 + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: SSDF + entries: + - reference-id: PO4.2 + - reference-id: PS.2 + - reference-id: PS2.1 + - reference-id: PS3.1 + - reference-id: RV1.3 + - reference-id: OCRE + entries: + - reference-id: 171-222 + assessment-requirements: + - id: OSPS-DO-03.01 + text: | + When the project has made a release, the project documentation MUST + contain instructions to verify the integrity and authenticity of the + release assets. + applicability: + - Maturity3 + recommendation: | + Instructions in the project should contain information about the + technology used, the commands to run, and the expected output. + When possible, avoid storing this documentation in the same location + as the build and release pipeline to avoid a single breach + compromising both the software and the documentation for verifying the + integrity of the software. + - id: OSPS-DO-03.02 + text: | + When the project has made a release, the project documentation MUST + contain instructions to verify the expected identity of the person or + process authoring the software release. + applicability: + - Maturity3 + recommendation: | + The expected identity may be in the form of key IDs used to sign, + issuer and identity from a sigstore certificate, or other similar + forms. + When possible, avoid storing this documentation in the same location + as the build and release pipeline to avoid a single breach + compromising both the software and the documentation for verifying the + integrity of the software. + group: documentation +- id: OSPS-DO-04 + title: | + The project documentation MUST include a descriptive statement about + the scope and duration of support. + objective: | + Provide users with clear expectations regarding the project's support + lifecycle. This allows downstream consumers to take relevant actions to + ensure the continued functionality and security of their systems. + guidelines: + - reference-id: BPB + entries: + - reference-id: R-B-3 + - reference-id: SSDF + entries: + - reference-id: PO4.2 + - reference-id: PS3.1 + - reference-id: RV1.3 + - reference-id: OC + entries: + - reference-id: '4.1' + - reference-id: 4.3.1 + assessment-requirements: + - id: OSPS-DO-04.01 + text: | + When the project has made a release, the project documentation MUST + include a descriptive statement about the scope and duration of + support for each release. + applicability: + - Maturity3 + recommendation: | + In order to communicate the scope and duration of support for the + project's released software assets, the project should have a + SUPPORT.md or an OpenEoX file in a well known location. + group: documentation +- id: OSPS-DO-05 + title: | + The project documentation MUST provide a descriptive statement when + releases or versions will no longer receive security updates. + objective: | + Communicating when the project maintainers will no longer fix defects or + security vulnerabilities is crucial for downstream consumers to find + alternative solutions or alternative means of support for the project. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2c + - reference-id: '2.6' + - reference-id: OC + entries: + - reference-id: 4.1.1 + - reference-id: 4.3.1 + - reference-id: OCRE + entries: + - reference-id: 673-475 + - reference-id: 053-751 + assessment-requirements: + - id: OSPS-DO-05.01 + text: | + When the project has made a release, the project documentation MUST + provide a descriptive statement when releases or versions will no + longer receive security updates. + applicability: + - Maturity3 + recommendation: | + While a machine-readable OpenEoX file is recommended, this may also be + communicated in a SUPPORT.md or beneath a Support header in the + primary README.md. + group: documentation +- id: OSPS-DO-06 + title: | + The project documentation MUST include a description of how the + project selects, obtains, and tracks its dependencies. + objective: | + Provide information about how the project selects, obtains, and tracks + dependencies, libraries, frameworks, etc. to help downstream consumers + understand how the project operates in regards to third-party components + that are required necessary for the software to function. + guidelines: + - reference-id: BPB + entries: + - reference-id: A-S-1 + - reference-id: CRA + entries: + - reference-id: '2.1' + - reference-id: OCRE + entries: + - reference-id: 613-286 + - reference-id: 053-751 + - reference-id: CRA + entries: + - reference-id: Pinned-Dependencies + assessment-requirements: + - id: OSPS-DO-06.01 + text: | + When the project has made a release, the project documentation MUST + include a description of how the project selects, obtains, and tracks + its dependencies. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + It is recommended to publish this information alongside the project's + technical & design documentation on a publicly viewable resource such + as the source code repository, project website, or other channel. + group: documentation +- id: OSPS-GV-01 + title: | + The project documentation MUST include the roles and responsibilities + for members of the project. + objective: | + Documenting project roles and responsibilities helps project participants, + potential contributors, and downstream consumers have an accurate + understanding of who is working on the project and what areas of authority + they may have. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-3 + - reference-id: B-S-4 + - reference-id: OCRE + entries: + - reference-id: 013-021 + assessment-requirements: + - id: OSPS-GV-01.01 + text: | + While active, the project documentation MUST include a list of + project members with access to sensitive resources. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Document project participants and their roles through such artifacts + as members.md, governance.md, maintainers.md, or similar file within + the source code repository of the project. + This may be as simple as including names or account handles in a list + of maintainers, or more complex depending on the project's governance. + - id: OSPS-GV-01.02 + text: | + While active, the project documentation MUST include descriptions of + the roles and responsibilities for members of the project. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Document project participants and their roles through such artifacts + as members.md, governance.md, maintainers.md, or similar file within + the source code repository of the project. + group: governance +- id: OSPS-GV-02 + title: | + The project MUST have one or more mechanisms for public discussions + about proposed changes and usage obstacles. + objective: | + Encourages open communication and collaboration within the project + community, enabling users to provide feedback and discuss proposed changes + or usage challenges. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-3 + - reference-id: B-B-12 + - reference-id: CRA + entries: + - reference-id: 1.2l + - reference-id: '2.3' + - reference-id: '2.4' + - reference-id: '2.6' + - reference-id: SSDF + entries: + - reference-id: PS3 + - reference-id: PW1.2 + assessment-requirements: + - id: OSPS-GV-02.01 + text: | + While active, the project MUST have one or more mechanisms for public + discussions about proposed changes and usage obstacles. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Establish one or more mechanisms for public discussions within the + project, such as mailing lists, instant messaging, or issue trackers, + to facilitate open communication and feedback. + group: governance +- id: OSPS-GV-03 + title: | + The project documentation MUST include an explanation of the + contribution process. + objective: | + Provide guidance to new contributors on how to participate in the project, + outlining the steps required to submit changes or enhancements to the + project's codebase. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-4 + - reference-id: B-S-3 + - reference-id: B-B-4+ + - reference-id: R-B-1 + - reference-id: Q-G-2 + - reference-id: CRA + entries: + - reference-id: 1.2l + - reference-id: '2.4' + - reference-id: SSDF + entries: + - reference-id: PW1.2 + - reference-id: OC + entries: + - reference-id: 4.1.2 + assessment-requirements: + - id: OSPS-GV-03.01 + text: | + While active, the project documentation MUST include an explanation + of the contribution process. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Create a CONTRIBUTING.md or CONTRIBUTING/ directory to outline the + contribution process including the steps for submitting changes, and + engaging with the project maintainers. + - id: OSPS-GV-03.02 + text: | + While active, the project documentation MUST include a guide for code + contributors that includes requirements for acceptable contributions. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Extend the CONTRIBUTING.md or CONTRIBUTING/ contents in the project + documentation to outline the requirements for acceptable + contributions, including coding standards, testing requirements, and + submission guidelines for code contributors. It is recommended that + this guide is the source of truth for both contributors and approvers. + group: governance +- id: OSPS-GV-04 + title: | + The project documentation MUST have a policy that code contributors + are reviewed prior to granting escalated permissions to sensitive + resources. + objective: | + Ensure that code contributors are vetted and reviewed before being granted + elevated permissions to sensitive resources within the project, reducing + the risk of unauthorized access or misuse. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-5 + - reference-id: B-S-3 + - reference-id: B-B-4+ + - reference-id: Q-G-2 + - reference-id: CRA + entries: + - reference-id: 1.2d + - reference-id: 1.2l + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.5' + - reference-id: '2.6' + - reference-id: SSDF + entries: + - reference-id: PO2 + - reference-id: PO3.2 + - reference-id: CSF + entries: + - reference-id: PR.AA-02 + - reference-id: PR.AA-05 + - reference-id: OCRE + entries: + - reference-id: 123-124 + - reference-id: 152-725 + - reference-id: OC + entries: + - reference-id: 4.1.2 + assessment-requirements: + - id: OSPS-GV-04.01 + text: | + While active, the project documentation MUST have a policy that code + contributors are reviewed prior to granting escalated permissions to + sensitive resources. + applicability: + - Maturity3 + recommendation: | + Publish an enforceable policy in the project documentation that + requires code contributors to be reviewed and approved before being + granted escalated permissions to sensitive resources, such as merge + approval or access to secrets. It is recommended that vetting includes + establishing a justifiable lineage of identity such as confirming the + contributor's association with a known trusted organization. + group: governance +- id: OSPS-LE-01 + title: | + The version control system MUST require all code contributors to assert + that they are legally authorized to make the associated contributions + on every commit. + objective: | + Ensure that code contributors are aware of and acknowledge their legal + responsibility for the contributions they make to the project, reducing + the risk of intellectual property disputes against the project. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-1 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PS1 + - reference-id: PW1.2 + - reference-id: PW2.1 + assessment-requirements: + - id: OSPS-LE-01.01 + text: | + While active, the version control system MUST require all code + contributors to assert that they are legally authorized to make the + associated contributions on every commit. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Include a DCO or CLA in the project's repository, requiring code + contributors to assert that they are legally authorized to commit the + associated contributions on every commit. Use a status check to ensure + the assertion is made. + Some version control systems, such as GitHub, may include this in the + platform terms of service. + group: legal +- id: OSPS-LE-02 + title: | + All licenses for the project MUST meet the OSI Open Source Definition + or the FSF Free Software Definition. + objective: | + Ensure that the project's source code is distributed under a recognized + and legally enforceable open source software license, providing clarity on + how the code can be used and shared by others. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-6 + - reference-id: B-B-7 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: CSF + entries: + - reference-id: GV.OC-03 + assessment-requirements: + - id: OSPS-LE-02.01 + text: | + While active, the license for the source code MUST meet the OSI Open + Source Definition or the FSF Free Software Definition. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Add a LICENSE file to the project's repo with a license that is an + approved license by the Open Source Initiative (OSI), or a free + license as approved by the Free Software Foundation (FSF). Examples of + such licenses include the MIT, BSD 2-clause, BSD 3-clause revised, + Apache 2.0, Lesser GNU General Public License (LGPL), and the GNU + General Public License (GPL). Releasing to the public domain meets + this control if there are no other encumbrances such as patents. + - id: OSPS-LE-02.02 + text: | + While active, the license for the released software assets MUST meet + the OSI Open Source Definition or the FSF Free Software Definition. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + If a different license is included with released software assets, + ensure it is an approved license by the Open Source Initiative (OSI), + or a free license as approved by the Free Software Foundation (FSF). + Examples of such licenses include the MIT, BSD 2-clause, BSD 3-clause + revised, Apache 2.0, Lesser GNU General Public License (LGPL), and the + GNU General Public License (GPL). Note that the license for the + released software assets may be different than the source code. + group: legal +- id: OSPS-LE-03 + title: "All licenses for the project's source code MUST be maintained in a \nstandard location within the corresponding repository.\n" + objective: | + Ensure that the project's source code and released software assets are + distributed with the appropriate license terms, making it clear to users + and contributors how each can be used and shared. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-8 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: SSDF + entries: + - reference-id: PO3.2 + assessment-requirements: + - id: OSPS-LE-03.01 + text: | + While active, the license for the source code MUST be maintained in + the corresponding repository's LICENSE file, COPYING file, or + LICENSE/ directory. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Include the project's source code license in the project's LICENSE + file, COPYING file, or LICENSE/ directory to provide visibility and + clarity on the licensing terms. The filename MAY have an extension. + If the project has multiple repositories, ensure that each repository + includes the license file. + - id: OSPS-LE-03.02 + text: | + While active, the license for the released software assets MUST be + included in the released source code, or in a LICENSE file, COPYING + file, or LICENSE/ directory alongside the corresponding release + assets. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Include the project's released software assets license in the released + source code, or in a LICENSE file, COPYING file, or LICENSE/ directory + alongside the corresponding release assets to provide visibility and + clarity on the licensing terms. The filename MAY have an extension. + If the project has multiple repositories, ensure that each repository + includes the license file. + group: legal +- id: OSPS-QA-01 + title: | + The project's source code and change history MUST be publicly readable at + a static URL. + objective: | + Enable users to access and review the project's source code and history, + promoting transparency and collaboration within the project community. + guidelines: + - reference-id: BPB + entries: + - reference-id: CC-B-1 + - reference-id: CC-B-2 + - reference-id: CC-B-3 + - reference-id: R-B-5 + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: 1.2f + - reference-id: 1.2j + - reference-id: SSDF + entries: + - reference-id: PS1 + - reference-id: PS2 + - reference-id: PS3 + - reference-id: PW1.2 + - reference-id: PW2.1 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 757-271 + - reference-id: CSF + entries: + - reference-id: ID.AM-02 + - reference-id: ID.RA-01 + - reference-id: ID.RA-08 + - reference-id: OC + entries: + - reference-id: 4.1.4 + - reference-id: SLSA + entries: + - reference-id: Build platform - isolation strength - Isolated + assessment-requirements: + - id: OSPS-QA-01.01 + text: | + While active, the project's source code repository MUST be publicly + readable at a static URL. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Use a common VCS such as GitHub, GitLab, or Bitbucket. Ensure the + repository is publicly readable. Avoid duplication or mirroring of + repositories unless highly visible documentation clarifies the primary + source. Avoid frequent changes to the repository that would impact the + repository URL. Ensure the repository is public. + - id: OSPS-QA-01.02 + text: | + The version control system MUST contain a publicly readable record of + all changes made, who made the changes, and when the changes were + made. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Use a common VCS such as GitHub, GitLab, or Bitbucket to maintain a + publicly readable commit history. Avoid squashing or rewriting commits + in a way that would obscure the author of any commits. + group: quality +- id: OSPS-QA-02 + title: | + The project MUST provide a list of dependencies used in the software. + objective: | + Provide transparency and accountability for the project's dependencies + while enabling users and contributors to understand the software's direct + dependencies. + guidelines: + - reference-id: BPB + entries: + - reference-id: Q-S-8 + - reference-id: Q-S-9 + - reference-id: CRA + entries: + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.3' + - reference-id: SSDF + entries: + - reference-id: PO3.3 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: PS3.2 + - reference-id: PW4 + - reference-id: CSF + entries: + - reference-id: ID.AM.01 + - reference-id: ID.AM-02 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: 4.3.1 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: 673-475 + - reference-id: 863-521 + - reference-id: 613-286 + assessment-requirements: + - id: OSPS-QA-02.01 + text: | + When the package management system supports it, the source code + repository MUST contain a dependency list that accounts for the direct + language dependencies. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + This may take the form a package manager or language dependency file + that enumerates all direct dependencies such as package.json, Gemfile, + or go.mod. + - id: OSPS-QA-02.02 + text: | + When the project has made a release, all compiled released software + assets MUST be delivered with a software bill of materials. + applicability: + - Maturity3 + recommendation: | + It is recommended to auto-generate SBOMs at build time using a tool + that has been vetted for accuracy. This enables users to ingest this + data in a standardized approach alongside other projects in their + environment. + group: quality +- id: OSPS-QA-03 + title: | + Any automated status checks for commits MUST pass or require manual + acknowledgement prior to merge. + objective: | + Ensure that the project's approvers do not become accustomed to tolerating + failing status checks, even if arbitrary, because it increases the risk of + overlooking security vulnerabilities or defects identified by automated + checks. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2f + - reference-id: 1.2k + - reference-id: SSDF + entries: + - reference-id: PO4.1 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: RV1.2 + - reference-id: CSF + entries: + - reference-id: ID.IM-02 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: OCRE + entries: + - reference-id: 263-184 + - reference-id: 253-452 + assessment-requirements: + - id: OSPS-QA-03.01 + text: | + When a commit is made to the primary branch, any automated status + checks for commits MUST pass or be manually bypassed. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Configure the project's version control system to require that all + automated status checks pass or require manual acknowledgement before a + commit can be merged into the primary branch. It is recommended that + any optional status checks are NOT configured as a pass or fail + requirement that approvers may be tempted to bypass. + group: quality +- id: OSPS-QA-04 + title: | + Any additional subproject code repositories produced by the project + and compiled into a release MUST enforce security requirements as + applicable to the status and intent of the respective codebase. + objective: | + Ensure that additional code repositories or subprojects produced by the + project are held to a standard that is clear and appropriate for that + codebase. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: 1.2f + - reference-id: SSDF + entries: + - reference-id: PO3.2 + - reference-id: PO4.1 + - reference-id: PS1 + - reference-id: PS2 + - reference-id: RV1.2 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + - reference-id: SLSA + entries: + - reference-id: Build platform - isolation strength - Isolated + assessment-requirements: + - id: OSPS-QA-04.01 + text: | + While active, the project documentation MUST contain a list of any + codebases that are considered subprojects or additional repositories. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Document any additional subproject code repositories produced by the + project and compiled into a release. This documentation should include + the status and intent of the respective codebase. + - id: OSPS-QA-04.02 + text: | + When the project has made a release comprising multiple source code + repositories, all subprojects MUST enforce security requirements that + are as strict or stricter than the primary codebase. + applicability: + - Maturity3 + recommendation: | + Any additional subproject code repositories produced by the project + and compiled into a release must enforce security requirements as + applicable to the status and intent of the respective codebase. + In addition to following the corresponding OSPS Baseline requirements, + this may include requiring a security review, ensuring that it is + free of vulnerabilities, and ensuring that it is free of known + security issues. + group: quality +- id: OSPS-QA-05 + title: | + The version control system MUST NOT contain generated executable + artifacts. + objective: | + Reduce the risk of including generated executable artifacts in the + project's version control system, ensuring that only source code and + necessary files are stored in the repository. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2b + - reference-id: SSDF + entries: + - reference-id: PS1 + - reference-id: PS2 + - reference-id: OCRE + entries: + - reference-id: 486-813 + - reference-id: 124-564 + assessment-requirements: + - id: OSPS-QA-05.01 + text: | + While active, the version control system MUST NOT contain generated + executable artifacts. + applicability: + - Maturity1 + - Maturity2 + - Maturity3 + recommendation: | + Remove generated executable artifacts in the project's version control + system. It is recommended that any scenario where a generated + executable artifact appears critical to a process such as testing, it + should be instead be generated at build time or stored separately and + fetched during a specific well-documented pipeline step. + group: quality +- id: OSPS-QA-06 + title: | + The project MUST use at least one automated test suite for the source + code repository. + objective: | + Ensure that the project uses at least one automated test suite for the + source code repository which clearly documents when and how tests are run. + guidelines: + - reference-id: BPB + entries: + - reference-id: Q-B-4 + - reference-id: Q-B-8 + - reference-id: Q-B-9 + - reference-id: Q-B-10 + - reference-id: Q-S-2 + - reference-id: CRA + entries: + - reference-id: '2.3' + - reference-id: SSDF + entries: + - reference-id: PW8.2 + - reference-id: CSF + entries: + - reference-id: ID.AM-02 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: OCRE + entries: + - reference-id: 207-435 + - reference-id: 088-377 + assessment-requirements: + - id: OSPS-QA-06.01 + text: | + Prior to a commit being accepted, the project's CI/CD pipelines MUST + run at least one automated test suite to ensure the changes meet + expectations. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Automated tests should be run prior to every merge into the primary + branch. The test suite should be run in a CI/CD pipeline and the + results should be visible to all contributors. The test suite should + be run in a consistent environment and should be run in a way that + allows contributors to run the tests locally. + Examples of test suites include unit tests, integration tests, and + end-to-end tests. + - id: OSPS-QA-06.02 + text: | + While active, project's documentation MUST clearly document when and + how tests are run. + applicability: + - Maturity3 + recommendation: | + Add a section to the contributing documentation that explains how to + run the tests locally and how to run the tests in the CI/CD pipeline. + The documentation should explain what the tests are testing and how to + interpret the results. + - id: OSPS-QA-06.03 + text: | + While active, the project's documentation MUST include a policy that + all major changes to the software produced by the project should add + or update tests of the functionality in an automated test suite. + applicability: + - Maturity3 + recommendation: | + Add a section to the contributing documentation that explains the + policy for adding or updating tests. The policy should explain what + constitutes a major change and what tests should be added or updated. + group: quality +- id: OSPS-QA-07 + title: | + The project's version control system MUST require at least one + non-author approval of changes to the primary branch. + objective: | + Ensure that the project's version control system requires at least one + non-author approval of changes before merging into the release or primary + branch. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-G-3 + assessment-requirements: + - id: OSPS-QA-07.01 + text: | + When a commit is made to the primary branch, the project's version + control system MUST require at least one non-author approval of the + changes before merging. + applicability: + - Maturity3 + recommendation: | + Configure the project's version control system to require at least one + non-author approval of changes before merging into the release or + primary branch. This can be achieved by requiring a pull request to be + reviewed and approved by at least one other contributor before it can + be merged. + group: quality +- id: OSPS-SA-01 + title: | + The project documentation MUST provide design documentation demonstrating + all actions and actors within the system. + objective: | + Provide an overview of the project's design and architecture, illustrating + the interactions and components of the system to help contributors and + security reviewers understand the internal logic of the released software + assets. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-1 + - reference-id: B-S-7 + - reference-id: B-S-8 + - reference-id: CRA + entries: + - reference-id: 1.2a + - reference-id: 1.2b + - reference-id: SSDF + entries: + - reference-id: PO.1 + - reference-id: PO.2 + - reference-id: PO3.2 + - reference-id: CSF + entries: + - reference-id: ID.AM-02 + - reference-id: OCRE + entries: + - reference-id: 155-155 + - reference-id: 326-704 + - reference-id: 068-102 + - reference-id: 036-275 + - reference-id: 162-655 + assessment-requirements: + - id: OSPS-SA-01.01 + text: | + When the project has made a release, the project documentation MUST + include design documentation demonstrating all actions and actors + within the system. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Include designs in the project documentation that explains the actions + and actors. Actors include any subsystem or entity that can influence + another segment in the system. + Ensure this is updated for new features or breaking changes. + group: security-assessment +- id: OSPS-SA-02 + title: | + The project documentation MUST include descriptions of all external + software interfaces of the released software assets. + objective: | + Provide users and developers with an understanding of how to interact with + the project's software and integrate it with other systems, enabling them + to use the software effectively. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-B-10 + - reference-id: B-S-7 + - reference-id: CRA + entries: + - reference-id: 1.2a + - reference-id: 1.2b + - reference-id: SSDF + entries: + - reference-id: PW1.2 + - reference-id: CSF + entries: + - reference-id: GV.OC-05 + - reference-id: ID.AM-01 + - reference-id: OC + entries: + - reference-id: 4.1.4 + - reference-id: OCRE + entries: + - reference-id: 155-155 + - reference-id: 068-102 + - reference-id: 072-713 + - reference-id: 820-878 + assessment-requirements: + - id: OSPS-SA-02.01 + text: | + When the project has made a release, the project documentation MUST + include descriptions of all external software interfaces of the + released software assets. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Document all software interfaces (APIs) of the released software + assets, explaining how users can interact with the software and what + data is expected or produced. + Ensure this is updated for new features or breaking changes. + group: security-assessment +- id: OSPS-SA-03 + title: | + The project MUST assess the security posture of all software assets. + objective: | + Provide project maintainers an understanding of how the software can be + misused or broken allows them to plan mitigations to close off the potential + of those threats from occurring. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-8 + - reference-id: S-G-1 + - reference-id: CRA + entries: + - reference-id: '1.1' + - reference-id: 1.2j + - reference-id: 1.2k + - reference-id: '2.2' + - reference-id: SSDF + entries: + - reference-id: PO5.1 + - reference-id: PW1.1 + - reference-id: CSF + entries: + - reference-id: ID.RA-01 + - reference-id: ID.RA-04 + - reference-id: ID.RA-05 + - reference-id: DE.AE-07 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: OCRE + entries: + - reference-id: 068-102 + - reference-id: 154-031 + - reference-id: 888-770 + assessment-requirements: + - id: OSPS-SA-03.01 + text: | + When the project has made a release, the project MUST perform a + security assessment to understand the most likely and impactful + potential security problems that could occur within the software. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Performing a security assessment informs both project members as well + as downstream consumers that the project understands what problems + could arise within the software. Understanding what threats could be + realized helps the project manage and address risk. This information + is useful to downstream consumers to demonstrate the security acumen + and practices of the project. + Ensure this is updated for new features or breaking changes. + - id: OSPS-SA-03.02 + text: | + When the project has made a release, the project MUST perform a threat + modeling and attack surface analysis to understand and protect against + attacks on critical code paths, functions, and interactions within the + system. + applicability: + - Maturity3 + recommendation: "Threat modeling is an activity where the project looks at the \ncodebase, associated processes and infrastructure, interfaces, key\ncomponents and \"thinks like a hacker\" and brainstorms how the system\nbe be broken or compromised. Each identified threat is listed out so\nthe project can then think about how to proactively avoid or close off\nany gaps/vulnerabilities that could arise.\nEnsure this is updated for new features or breaking changes.\n" + group: security-assessment +- id: OSPS-VM-01 + title: | + The project documentation MUST include a policy for coordinated + vulnerability reporting, with a clear timeframe for response. + objective: "Establish a process for reporting and addressing vulnerabilities in the\nproject, ensuring that security issues are handled promptly and \ntransparently.\n" + guidelines: + - reference-id: BPB + entries: + - reference-id: R-B-6 + - reference-id: R-B-8 + - reference-id: R-S-2 + - reference-id: S-B-14 + - reference-id: S-B-15 + - reference-id: CRA + entries: + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.3' + - reference-id: '2.6' + - reference-id: '2.7' + - reference-id: '2.8' + - reference-id: SSDF + entries: + - reference-id: RV1.3 + - reference-id: CSF + entries: + - reference-id: GV.PO-01 + - reference-id: GV.PO-02 + - reference-id: ID.RA-01 + - reference-id: ID.RA-08 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: 4.2.1 + - reference-id: 4.3.2 + - reference-id: OCRE + entries: + - reference-id: 887-750 + - reference-id: ScCrd + entries: + - reference-id: Security-policy + assessment-requirements: + - id: OSPS-VM-01.01 + text: | + While active, the project documentation MUST + include a policy for coordinated vulnerability reporting, with a clear + timeframe for response. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Create a SECURITY.md file at the root of the directory, outlining the + project's policy for coordinated vulnerability reporting. Include a + method for reporting vulnerabilities. Set expectations for the how the + project will respond and address reported issues. + group: vulnerability-management +- id: OSPS-VM-02 + title: | + The project MUST publish contacts and process for reporting + vulnerabilities. + objective: | + Reports from researchers and users are an important source for identifying + vulnerabilities in a project. People with vulnerabilities to report should + have a clear understanding of the process so that they can quickly submit + the report to the project. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-8 + - reference-id: CRA + entries: + - reference-id: '2.5' + - reference-id: SSDF + entries: + - reference-id: RV1.3 + - reference-id: CSF + entries: + - reference-id: GV.PO-01 + - reference-id: GV.PO-02 + - reference-id: ID.RA-01 + - reference-id: OC + entries: + - reference-id: 4.1.1 + - reference-id: 4.1.3 + - reference-id: 4.1.5 + - reference-id: 4.2.2 + - reference-id: OCRE + entries: + - reference-id: 464-513 + - reference-id: ScCrd + entries: + - reference-id: Security-policy + assessment-requirements: + - id: OSPS-VM-02.01 + text: | + While active, the project documentation MUST contain + security contacts. + applicability: + - Maturity1 + recommendation: | + Create a security.md (or similarly-named) file that contains security + contacts for the project. + group: vulnerability-management +- id: OSPS-VM-03 + title: | + The project MUST provide a means for reporting security + vulnerabilities privately to the security contacts within the project. + objective: "Security vulnerabilities should not be shared with the public until such\ntime the project has been provided time to analyze and prepare \nremediations to protect users of the project.\n" + guidelines: + - reference-id: BPB + entries: + - reference-id: R-B-7 + - reference-id: CRA + entries: + - reference-id: '2.5' + - reference-id: '2.6' + - reference-id: OCRE + entries: + - reference-id: 308-514 + assessment-requirements: + - id: OSPS-VM-03.01 + text: | + While active, the project documentation MUST + provide a means for reporting security vulnerabilities privately to + the security contacts within the project. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Provide a means for security researchers to report vulnerabilities + privately to the project. This may be a dedicated email address, a + web form, VSC specialized tools, email addresses for security + contacts, or other methods. + group: vulnerability-management +- id: OSPS-VM-04 + title: | + The project MUST publicly publish data about discovered vulnerabilities. + objective: | + Consumers of the project must be informed about known vulnerabilities + found within the project. + guidelines: + - reference-id: CRA + entries: + - reference-id: 1.2a + - reference-id: 1.2b + - reference-id: '2.1' + - reference-id: '2.4' + - reference-id: '2.6' + - reference-id: SSDF + entries: + - reference-id: PO4.1 + - reference-id: RV2.1 + - reference-id: RV2.2 + - reference-id: CSF + entries: + - reference-id: ID.RA-01 + - reference-id: OC + entries: + - reference-id: 4.1.5 + assessment-requirements: + - id: OSPS-VM-04.01 + text: | + While active, the project documentation MUST + publicly publish data about discovered vulnerabilities. + applicability: + - Maturity2 + - Maturity3 + recommendation: | + Provide information about known vulnerabilities in a predictable + public channel, such as a CVE entry, blog post, or other medium. + To the degree possible, this information should include affected + version(s), how a consumer can determine if they are vulnerable, and + instructions for mitigation or remediation. + - id: OSPS-VM-04.02 + text: | + While active, any vulnerabilities in the + software components not affecting the project MUST be accounted for + in a VEX document, augmenting the vulnerability report with + non-exploitability details. + applicability: + - Maturity3 + recommendation: "Establish a VEX feed communicating the exploitability status of \nknown vulnerabilities, including assessment details or any\nmitigations in place preventing vulnerable code from being\nexecuted.\n" + group: vulnerability-management +- id: OSPS-VM-05 + title: | + The project MUST enforce a policy for addressing SCA findings. + objective: | + Ensure that the project clearly communicates the threshold for remediation + of SCA findings, including vulnerabilities and license issues in software + dependencies. + Ensure that violations of your SCA policy are addressed before software + is merged as well as before it releases, reducing the risk of compromised + delivery mechanisms or released software assets that are vulnerable or + malicious. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-8 + - reference-id: Q-B-12 + - reference-id: Q-S-9 + - reference-id: S-B-14 + - reference-id: S-B-15 + - reference-id: A-B-1 + - reference-id: A-B-3 + - reference-id: A-B-8 + - reference-id: A-S-1 + - reference-id: CRA + entries: + - reference-id: 1.2a + - reference-id: 1.2b + - reference-id: 1.2c + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.3' + - reference-id: '2.4' + - reference-id: SSDF + entries: + - reference-id: PO.4 + - reference-id: PW1.2 + - reference-id: PW8.1 + - reference-id: RV1.2 + - reference-id: RV1.3 + - reference-id: RV2.1 + - reference-id: RV 2.2 + - reference-id: CSF + entries: + - reference-id: GV.RM-05 + - reference-id: PV.RM-06 + - reference-id: PV.PO-01 + - reference-id: PV.PO-02 + - reference-id: ID.RA-01 + - reference-id: ID.RA-08 + - reference-id: ID.IM-02 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: 4.2.1 + - reference-id: 4.2.2 + - reference-id: 4.3.2 + - reference-id: OCRE + entries: + - reference-id: 155-155 + - reference-id: 124-564 + - reference-id: 757-271 + - reference-id: 464-513 + - reference-id: 611-158 + - reference-id: 207-435 + - reference-id: 088-377 + - reference-id: ScCrd + entries: + - reference-id: Security-policy + - reference-id: Vulnerabilities + assessment-requirements: + - id: OSPS-VM-05.01 + text: | + While active, the project documentation MUST include a policy that + defines a threshold for remediation of SCA findings related to + vulnerabilities and licenses. + applicability: + - Maturity3 + recommendation: | + Document a policy in the project that defines a threshold for + remediation of SCA findings related to vulnerabilities and licenses. + Include the process for identifying, prioritizing, and remediating + these findings. + - id: OSPS-VM-05.02 + text: | + While active, the project documentation MUST include a policy to + address SCA violations prior to any release. + applicability: + - Maturity3 + recommendation: | + Document a policy in the project to address applicable Software + Composition Analysis results before any release, and add status checks + that verify compliance with that policy prior to release. + - id: OSPS-VM-05.03 + text: | + While active, all changes to the project's codebase MUST be + automatically evaluated against a documented policy for malicious + dependencies and known vulnerabilities in dependencies, then blocked + in the event of violations, except when declared and suppressed as + non-exploitable. + applicability: + - Maturity3 + recommendation: | + Create a status check in the project's version control system that + runs a Software Composition Analysis tool on all changes + to the codebase. Require that the status check passes before changes + can be merged. + group: vulnerability-management +- id: OSPS-VM-06 + title: | + The project documentation MUST enforce a policy that defines a + threshold for remediation of SAST findings. + objective: | + Identify and address defects and security weaknesses in the project's + codebase early in the development process, reducing the risk of shipping + insecure software. + guidelines: + - reference-id: BPB + entries: + - reference-id: B-S-8 + - reference-id: Q-B-12 + - reference-id: Q-S-9 + - reference-id: S-B-14 + - reference-id: S-B-15 + - reference-id: A-B-1 + - reference-id: A-B-3 + - reference-id: A-B-8 + - reference-id: A-S-1 + - reference-id: CRA + entries: + - reference-id: 1.2a + - reference-id: 1.2b + - reference-id: 1.2c + - reference-id: '2.1' + - reference-id: '2.2' + - reference-id: '2.3' + - reference-id: '2.4' + - reference-id: SSDF + entries: + - reference-id: PO.4 + - reference-id: PW1.2 + - reference-id: PW8.1 + - reference-id: RV1.2 + - reference-id: RV1.3 + - reference-id: RV2.1 + - reference-id: RV 2.2 + - reference-id: CSF + entries: + - reference-id: GV.RM-05 + - reference-id: PV.RM-06 + - reference-id: PV.PO-01 + - reference-id: PV.PO-02 + - reference-id: ID.RA-01 + - reference-id: ID.RA-08 + - reference-id: ID.IM-02 + - reference-id: OC + entries: + - reference-id: 4.1.5 + - reference-id: 4.2.1 + - reference-id: 4.2.2 + - reference-id: 4.3.2 + - reference-id: OCRE + entries: + - reference-id: 155-155 + - reference-id: 124-564 + - reference-id: 757-271 + - reference-id: 464-513 + - reference-id: 611-158 + - reference-id: 207-435 + - reference-id: 088-377 + - reference-id: ScCrd + entries: + - reference-id: Security-policy + - reference-id: Vulnerabilities + assessment-requirements: + - id: OSPS-VM-06.01 + text: | + While active, the project documentation MUST include a policy that + defines a threshold for remediation of SAST findings. + applicability: + - Maturity3 + recommendation: | + Document a policy in the project that defines a threshold for + remediation of Static Application Security Testing (SAST) findings. + Include the process for identifying, prioritizing, and remediating + these findings. + - id: OSPS-VM-06.02 + text: | + While active, all changes to the project's codebase MUST be + automatically evaluated against a documented policy for security + weaknesses and blocked in the event of violations except when declared + and suppressed as non-exploitable. + applicability: + - Maturity3 + recommendation: | + Create a status check in the project's version control system that + runs a Static Application Security Testing (SAST) tool on all changes + to the codebase. Require that the status check passes before changes + can be merged. + group: vulnerability-management diff --git a/schemas/fixtures/good-policy.yaml b/schemas/fixtures/good-policy.yaml new file mode 100644 index 0000000..28190b6 --- /dev/null +++ b/schemas/fixtures/good-policy.yaml @@ -0,0 +1,97 @@ +metadata: + id: "security-policy-001" + type: Policy + gemara-version: "1.1.0" + description: "Establish comprehensive information security controls and procedures to protect organizational assets" + version: "2.1.0" + author: + id: security-team + name: "Security Team" + type: Human + contact: + name: "Security Team Lead" + affiliation: "Security Department" + email: "security-lead@company.com" + mapping-references: + - id: "NIST-800-53" + title: "NIST Special Publication 800-53" + version: "Rev. 5" + description: "Security and Privacy Controls for Federal Information Systems" + url: "https://csrc.nist.gov/publications/detail/sp/800-53/rev-5/final" + - id: "ISO-27001" + title: "ISO/IEC 27001" + version: "2022" + description: "Information security management systems" + url: "https://www.iso.org/standard/27001" + +title: "Information Security Policy" +contacts: + responsible: + - name: "IT Director" + affiliation: "Information Technology" + email: "it-director@company.com" + - name: "Compliance Officer" + affiliation: "Legal & Compliance" + email: "compliance@company.com" + accountable: + - name: "Chief Information Security Officer" + affiliation: "Executive Team" + email: "ciso@company.com" + consulted: + - name: "Legal Counsel" + affiliation: "Legal Department" + email: "legal@company.com" + informed: + - name: "All Employees" + affiliation: "Company-wide" + +scope: + in: + geopolitical: + - "United States" + - "European Union" + - "Canada" + technologies: + - "Cloud Computing" + - "Mobile Devices" + - "Web Applications" + - "Database Systems" + +imports: + catalogs: + - reference-id: "NIST-800-53" + constraints: + - id: "nist-cloud-constraint" + target-id: "AC-1" + text: "Enhanced access control requirements for cloud environments" + assessment-requirement-modifications: + - id: "nist-ac1-mod" + target-id: "AC-1.1" + modification-type: "Modify" + modification-rationale: "Clarified assessment procedures for multi-cloud environments" + text: "Assessment procedures must include multi-cloud environment considerations" + applicability: + - "cloud" + - "multi-cloud" + recommendation: "Conduct quarterly assessments" + guidance: + - reference-id: "ISO-27001" + +adherence: + evaluation-methods: + - id: "EV-AUTO-01" + type: "Behavioral" + mode: "Automated" + required: true + description: "Automated compliance scanning of cloud environments" + - id: "EV-MANUAL-01" + type: "Behavioral" + mode: "Manual" + description: "Annual security audit by external assessors" + enforcement-methods: + - id: "EM-GATE-01" + type: "Gate" + mode: "Automated" + required: true + description: "Pre-deployment compliance gate in CI/CD pipeline" + non-compliance: "Non-compliant systems will be quarantined pending remediation" diff --git a/schemas/fixtures/good-risk-catalog.yaml b/schemas/fixtures/good-risk-catalog.yaml new file mode 100644 index 0000000..48cdd78 --- /dev/null +++ b/schemas/fixtures/good-risk-catalog.yaml @@ -0,0 +1,92 @@ +metadata: + id: EXAMPLE-RISK-CATALOG + type: RiskCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Example Risk Catalog for cloud-native container environments + author: + id: risk-management-team + name: Risk Management Team + type: Human + mapping-references: + - id: EXAMPLE-THREAT-CATALOG + title: Example Threat Catalog + version: "1.0.0" + description: Container security threat catalog + +title: Cloud-Native Container Risk Catalog + +groups: + - id: CAT-OPERATIONAL + title: Operational Risk + description: Risks arising from failures in internal processes, systems, or external events that affect service availability and reliability + appetite: Moderate + - id: CAT-SECURITY + title: Security Risk + description: Risks arising from unauthorized access, data breaches, or exploitation of system vulnerabilities + appetite: Low + max-severity: High + - id: CAT-COMPLIANCE + title: Compliance Risk + description: Risks arising from failure to comply with applicable laws, regulations, or industry standards + appetite: Minimal + +risks: + - id: RISK-001 + title: Container Image Supply Chain Compromise + description: Third-party or base container images may contain known vulnerabilities or malicious code, leading to exploitation at runtime. + group: CAT-SECURITY + severity: High + rank: 2 + owner: + responsible: + - name: Platform Engineering Lead + affiliation: Platform Team + email: platform-lead@example.org + accountable: + - name: Chief Information Security Officer + affiliation: Security + email: ciso@example.org + impact: Unauthorized code execution in production workloads, potential data exfiltration, and lateral movement across the cluster. + threats: + - reference-id: EXAMPLE-THREAT-CATALOG + entries: + - reference-id: THREAT-001 + + - id: RISK-002 + title: Container Escape Leading to Host Compromise + description: Misconfigured or unpatched container runtimes may allow an attacker to escape container isolation and access the host. + group: CAT-SECURITY + severity: Critical + owner: + responsible: + - name: Platform Engineering Lead + affiliation: Platform Team + email: platform-lead@example.org + accountable: + - name: Chief Information Security Officer + affiliation: Security + email: ciso@example.org + consulted: + - name: Infrastructure Architect + affiliation: Architecture + impact: Full compromise of the underlying node, access to secrets, and disruption of co-located workloads. + threats: + - reference-id: EXAMPLE-THREAT-CATALOG + entries: + - reference-id: THREAT-002 + + - id: RISK-003 + title: Regulatory Non-Compliance from Unaudited Deployments + description: Deploying workloads without automated compliance gates may result in violations of regulatory requirements. + group: CAT-COMPLIANCE + severity: Medium + impact: Regulatory fines, audit findings, and reputational damage. + + - id: RISK-004 + title: Cluster Admin Credential Exposure + description: Long-lived admin credentials for the cluster control plane may be exposed through logs, tickets, or shared stores. + group: CAT-SECURITY + severity: High + rank: 1 + impact: Full cluster compromise; all workloads and secrets on the platform are at risk. diff --git a/schemas/fixtures/good-security-policy.yml b/schemas/fixtures/good-security-policy.yml new file mode 100644 index 0000000..e288157 --- /dev/null +++ b/schemas/fixtures/good-security-policy.yml @@ -0,0 +1,80 @@ +metadata: + id: "data-protection-policy-002" + type: Policy + gemara-version: "1.1.0" + description: "Ensure compliance with data protection regulations and safeguard personal information" + version: "1.5.0" + author: + id: privacy-team + name: "Privacy Team" + type: Human + contact: + name: "Privacy Officer" + affiliation: "Legal & Compliance" + email: "privacy@company.com" + mapping-references: + - id: "GDPR" + title: "General Data Protection Regulation" + version: "2016/679" + description: "EU regulation on data protection and privacy" + url: "https://gdpr-info.eu/" + - id: "CCPA" + title: "California Consumer Privacy Act" + version: "2020" + description: "California state law on consumer privacy" + url: "https://oag.ca.gov/privacy/ccpa" + +title: "Data Protection and Privacy Policy" +contacts: + responsible: + - name: "Data Protection Officer" + affiliation: "Legal & Compliance" + email: "dpo@company.com" + accountable: + - name: "Chief Privacy Officer" + affiliation: "Executive Team" + email: "cpo@company.com" + +scope: + in: + geopolitical: + - "European Union" + - "California" + - "United Kingdom" + technologies: + - "Customer Data Systems" + - "Analytics Platforms" + - "Marketing Tools" + - "HR Information Systems" + +imports: + guidance: + - reference-id: "GDPR" + constraints: + - id: "gdpr-encryption-constraint" + target-id: "Art. 32" + text: "Enhanced technical and organizational measures for data security" + catalogs: + - reference-id: "CCPA" + constraints: + - id: "ccpa-consumer-rights" + target-id: "1798.150" + text: "Enhanced consumer rights implementation" + +adherence: + evaluation-methods: + - id: "EV-AUTO-01" + type: "Behavioral" + mode: "Automated" + description: "Continuous data protection monitoring" + - id: "EV-MANUAL-01" + type: "Behavioral" + mode: "Manual" + required: true + description: "Quarterly privacy impact assessments" + enforcement-methods: + - id: "EM-GATE-01" + type: "Gate" + mode: "Automated" + description: "Data classification verification before processing" + non-compliance: "Data breaches must be reported within 72 hours of discovery" diff --git a/schemas/fixtures/good-threat-catalog.yaml b/schemas/fixtures/good-threat-catalog.yaml new file mode 100644 index 0000000..79ddc34 --- /dev/null +++ b/schemas/fixtures/good-threat-catalog.yaml @@ -0,0 +1,68 @@ +metadata: + id: EXAMPLE-THREAT-CATALOG + type: ThreatCatalog + gemara-version: "1.1.0" + version: "1.0.0" + description: Example Threat Catalog + author: + id: security-team + name: Security Team + type: Human + mapping-references: + - id: EXAMPLE-VECTOR-CATALOG + title: Example Attack Vector Catalog + version: "1.0.0" + - id: EXAMPLE-CAPABILITY-CATALOG + title: Example Capability Catalog + version: "1.0.0" + +title: Example Threat Catalog + +groups: + - id: stride-s + title: Spoofing + description: Impersonating something or someone to gain unauthorized access + - id: stride-t + title: Tampering + description: Modifying data or code without authorization + - id: stride-e + title: Elevation of Privilege + description: Gaining capabilities without proper authorization + +threats: + - id: THREAT-001 + title: Exploitation of Vulnerable Container Images + description: Attackers exploit known vulnerabilities in container images to gain unauthorized access or execute malicious code. + group: stride-t + capabilities: + - reference-id: EXAMPLE-CAPABILITY-CATALOG + entries: + - reference-id: CAP-002 + vectors: + - reference-id: EXAMPLE-VECTOR-CATALOG + entries: + - reference-id: VEC-001 + actors: + - id: external-attacker + name: External Attacker + type: Human + + - id: THREAT-002 + title: Host System Compromise via Container Escape + description: Attackers escape container isolation to gain access to the underlying host system and compromise other containers or host resources. + group: stride-e + capabilities: + - reference-id: EXAMPLE-CAPABILITY-CATALOG + entries: + - reference-id: CAP-001 + vectors: + - reference-id: EXAMPLE-VECTOR-CATALOG + entries: + - reference-id: VEC-002 + actors: + - id: external-attacker + name: External Attacker + type: Human + - id: malicious-insider + name: Malicious Insider + type: Human diff --git a/schemas/fixtures/good-vector-owasp-mapping.yaml b/schemas/fixtures/good-vector-owasp-mapping.yaml new file mode 100644 index 0000000..3a7eed8 --- /dev/null +++ b/schemas/fixtures/good-vector-owasp-mapping.yaml @@ -0,0 +1,219 @@ +# AIGF Risk Vectors to OWASP LLM Top 10 Mapping Document +title: AIGF Risk Vectors to OWASP Top 10 for LLMs 2025 +metadata: + id: AIR-OWASP-MAP-001 + version: "0.1.0" + type: MappingDocument + gemara-version: "1.1.0" + description: > + Maps AIGF risk vectors to OWASP Top 10 for LLM Applications 2025 + entries where a semantic relationship exists. Vectors without a + direct OWASP counterpart are recorded as no-match. + author: + id: finos + name: FINOS + type: Human + mapping-references: + - id: AIR-VEC + title: AI Governance Framework Risk Vectors + version: "0.1.0" + url: "https://aigf.finos.org/risks" + - id: OWASP-LLM-2025 + title: OWASP Top 10 for LLM Applications 2025 + version: "2025" + url: "https://genai.owasp.org/llm-top-10/" + +source-reference: + reference-id: AIR-VEC + entry-type: Vector +target-reference: + reference-id: OWASP-LLM-2025 + entry-type: Vector +remarks: > + AIGF risk vectors mapped to OWASP Top 10 for LLM Applications 2025. + Mappings derived from OWASP references in AIGF risk frontmatter. + +mappings: + # Information Leakage vectors → LLM02 Sensitive Information Disclosure + - id: MAP-RC001-01-LLM02 + source: AIR-RC-001-01 + relationship: relates-to + targets: + - entry-id: "LLM02:2025" + rationale: > + Model memorization of sensitive data from training or user + interactions directly contributes to sensitive information + disclosure. + + - id: MAP-RC001-02-LLM02 + source: AIR-RC-001-02 + relationship: relates-to + targets: + - entry-id: "LLM02:2025" + rationale: > + Prompt-based extraction techniques target memorized sensitive + information, a primary mechanism for LLM information disclosure. + + - id: MAP-RC001-03-LLM02 + source: AIR-RC-001-03 + relationship: relates-to + targets: + - entry-id: "LLM02:2025" + rationale: > + Inadequate provider data controls increase the likelihood + of sensitive information disclosure through hosted models. + + - id: MAP-RC001-04-LLM02 + source: AIR-RC-001-04 + relationship: relates-to + targets: + - entry-id: "LLM02:2025" + rationale: > + Deficient provider data handling practices around retention, + encryption, and deletion expose sensitive information. + + - id: MAP-RC001-05-LLM02 + source: AIR-RC-001-05 + relationship: relates-to + targets: + - entry-id: "LLM02:2025" + rationale: > + Fine-tuning with proprietary data embeds sensitive information + in model weights, creating persistent disclosure risk. + + # Data Poisoning vectors → LLM04 Data and Model Poisoning + - id: MAP-SEC009-01-LLM04 + source: AIR-SEC-009-01 + relationship: relates-to + targets: + - entry-id: "LLM04:2025" + rationale: > + Training data manipulation through label changes or crafted + data points is a direct form of data and model poisoning. + + - id: MAP-SEC009-02-LLM04 + source: AIR-SEC-009-02 + relationship: relates-to + targets: + - entry-id: "LLM04:2025" + rationale: > + Exploiting continuous learning pipelines to feed misleading + information is an ongoing form of data poisoning. + + - id: MAP-SEC009-03-supplychain + source: AIR-SEC-009-03 + relationship: relates-to + targets: + - entry-id: "LLM03:2025" + rationale: > + Compromise of third-party data feeds represents a supply chain + vulnerability that introduces poisoned data into AI systems. + - entry-id: "LLM04:2025" + rationale: > + Compromise of third-party data feeds represents a supply chain + vulnerability that introduces poisoned data into AI systems. + + - id: MAP-SEC009-04-poisoning + source: AIR-SEC-009-04 + relationship: relates-to + targets: + - entry-id: "LLM04:2025" + rationale: > + Deliberate bias introduction through data poisoning corrupts + model decision-making and produces discriminatory outputs. + - entry-id: "LLM05:2025" + rationale: > + Deliberate bias introduction through data poisoning corrupts + model decision-making and produces discriminatory outputs. + + # Model Availability vectors → LLM10 Unbounded Consumption + - id: MAP-OP007-01-LLM10 + source: AIR-OP-007-01 + relationship: relates-to + targets: + - entry-id: "LLM10:2025" + rationale: > + Denial of Wallet attacks exploit unbounded consumption through + excessive token usage, long prompts, or poorly throttled + agentic systems. + + - id: MAP-OP007-02-NOMATCH + source: AIR-OP-007-02 + relationship: no-match + remarks: > + TSP outage or degradation is an infrastructure availability + risk with no direct OWASP LLM Top 10 counterpart; it concerns + provider operational maturity rather than LLM-specific + vulnerabilities. + + - id: MAP-OP007-03-LLM10 + source: AIR-OP-007-03 + relationship: relates-to + targets: + - entry-id: "LLM10:2025" + rationale: > + VRAM exhaustion from configuration changes, caching, or memory + leaks is a resource exhaustion condition aligned with unbounded + consumption. + + # Prompt Injection vectors → LLM01 Prompt Injection + - id: MAP-SEC010-01-LLM01 + source: AIR-SEC-010-01 + relationship: relates-to + targets: + - entry-id: "LLM01:2025" + rationale: > + Direct prompt injection (jailbreaking) is the primary attack + pattern described in LLM01. + + - id: MAP-SEC010-02-injection + source: AIR-SEC-010-02 + relationship: relates-to + targets: + - entry-id: "LLM01:2025" + rationale: > + Indirect prompt injection via poisoned third-party content + is covered in LLM01 and can hijack multi-agent decision-making + aligning with LLM06 excessive agency risks. + - entry-id: "LLM06:2025" + rationale: > + Indirect prompt injection via poisoned third-party content + is covered in LLM01 and can hijack multi-agent decision-making + aligning with LLM06 excessive agency risks. + + - id: MAP-SEC010-03-probing + source: AIR-SEC-010-03 + relationship: relates-to + targets: + - entry-id: "LLM01:2025" + rationale: > + Model profiling and inversion use prompt injection techniques + to probe internal model structure and extract proprietary + system prompts and configurations. + - entry-id: "LLM07:2025" + rationale: > + Model profiling and inversion use prompt injection techniques + to probe internal model structure and extract proprietary + system prompts and configurations. + + # Model Overreach → LLM06 Excessive Agency + - id: MAP-OP018-LLM06 + source: AIR-OP-018 + relationship: relates-to + targets: + - entry-id: "LLM06:2025" + rationale: > + Model overreach and expanded use beyond validated scope aligns + with excessive agency where AI systems operate beyond intended + boundaries. + + # Reputational Risk → LLM09 Misinformation + - id: MAP-OP020-LLM09 + source: AIR-OP-020 + relationship: relates-to + targets: + - entry-id: "LLM09:2025" + rationale: > + AI-generated offensive, misleading, or inaccurate outputs that + damage reputation are a manifestation of LLM misinformation + risks. diff --git a/schemas/gemara-v1.schema.json b/schemas/gemara-v1.schema.json new file mode 100644 index 0000000..71a9104 --- /dev/null +++ b/schemas/gemara-v1.schema.json @@ -0,0 +1,2988 @@ +{ + "$defs": { + "AcceptedMethod": { + "additionalProperties": false, + "description": "AcceptedMethod defines a method for evaluation or enforcement.", + "properties": { + "description": { + "type": "string" + }, + "executor": { + "$ref": "#/$defs/Actor" + }, + "id": { + "type": "string" + }, + "mode": { + "$ref": "#/$defs/ModeType" + }, + "required": { + "type": "boolean" + }, + "type": { + "$ref": "#/$defs/MethodType" + } + }, + "required": [ + "id", + "mode", + "type" + ], + "type": "object" + }, + "AcceptedRisk": { + "additionalProperties": false, + "description": "AcceptedRisk documents a risk the organization has chosen to accept,\noptionally linking it to a mitigated risk when the acceptance covers\nresidual risk after partial mitigation.", + "properties": { + "id": { + "description": "id allows this accepted risk entry to be referenced", + "type": "string" + }, + "justification": { + "description": "justification explains why the risk is accepted", + "type": "string" + }, + "risk": { + "$ref": "#/$defs/EntryMapping", + "description": "risk references the risk being accepted" + }, + "scope": { + "$ref": "#/$defs/Scope", + "description": "scope defines where the risk acceptance applies" + }, + "target-id": { + "description": "target-id optionally links this acceptance to a mitigated risk entry", + "type": "string" + } + }, + "required": [ + "id", + "risk" + ], + "type": "object" + }, + "ActionResult": { + "additionalProperties": false, + "description": "ActionResult captures a performed enforcement action.", + "properties": { + "disposition": { + "$ref": "#/$defs/Disposition", + "description": "disposition is the enforcement action taken" + }, + "end": { + "$ref": "#/$defs/Datetime", + "description": "end is the timestamp when the enforcement action concluded" + }, + "justification": { + "$ref": "#/$defs/Justification", + "description": "justification links the action to its assessment findings and any applicable exceptions" + }, + "message": { + "description": "message provides additional context about the action", + "type": "string" + }, + "method": { + "$ref": "#/$defs/EntryMapping", + "description": "method references the specific AcceptedMethod entry within the Policy being enforced" + }, + "start": { + "$ref": "#/$defs/Datetime", + "description": "start is the timestamp when the enforcement action began" + }, + "steps": { + "description": "steps references the code paths or addresses that carried out this enforcement action", + "items": { + "$ref": "#/$defs/EnforcementStep" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/EnforcementStep" + } + ], + "type": "array" + } + }, + "required": [ + "disposition", + "justification", + "method", + "start", + "steps" + ], + "type": "object" + }, + "Actor": { + "$ref": "#/$defs/Entity", + "additionalProperties": false, + "description": "Actor represents an entity (human or tool) that performs actions in evaluations", + "properties": { + "contact": { + "$ref": "#/$defs/Contact", + "description": "contact is contact information for the actor" + } + }, + "type": "object" + }, + "Adherence": { + "additionalProperties": false, + "description": "Adherence defines evaluation methods, assessment plans, enforcement methods, and non-compliance notifications.", + "properties": { + "assessment-plans": { + "items": { + "$ref": "#/$defs/AssessmentPlan" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentPlan" + } + ], + "type": "array" + }, + "enforcement-methods": { + "items": { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EnforcementMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EnforcementMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ], + "type": "array" + }, + "evaluation-methods": { + "items": { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EvaluationMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EvaluationMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ], + "type": "array" + }, + "non-compliance": { + "type": "string" + } + }, + "type": "object" + }, + "ArtifactMapping": { + "additionalProperties": false, + "description": "ArtifactMapping represents a mapping to an external artifact or artifact entry", + "properties": { + "reference-id": { + "description": "reference-id identifies an element from a MappingReference in the artifact's metadata", + "type": "string" + }, + "remarks": { + "description": "remarks is prose regarding the mapped artifact or the mapping relationship", + "type": "string" + } + }, + "required": [ + "reference-id" + ], + "type": "object" + }, + "ArtifactType": { + "description": "ArtifactType identifies the kind of Gemara artifact for unambiguous parsing", + "enum": [ + "CapabilityCatalog", + "ControlCatalog", + "GuidanceCatalog", + "ThreatCatalog", + "RiskCatalog", + "Policy", + "MappingDocument", + "Lexicon", + "EvaluationLog", + "EnforcementLog", + "VectorCatalog", + "PrincipleCatalog", + "AuditLog" + ] + }, + "AssessmentFinding": { + "additionalProperties": false, + "description": "AssessmentFinding maps an enforcement action to its originating assessment data across Layer 2, Layer 3, and Layer 5.", + "properties": { + "log": { + "$ref": "#/$defs/EntryMapping", + "description": "log maps to the EvaluationLog entry containing the finding" + }, + "plan": { + "$ref": "#/$defs/EntryMapping", + "description": "plan maps to the Policy assessment plan that was executed" + }, + "requirement": { + "$ref": "#/$defs/EntryMapping", + "description": "requirement maps to the Layer 2 assessment requirement that was evaluated" + }, + "result": { + "$ref": "#/$defs/Result", + "description": "result is the assessment outcome that triggered the enforcement action" + } + }, + "required": [ + "log", + "result" + ], + "type": "object" + }, + "AssessmentLog": { + "additionalProperties": false, + "description": "AssessmentLog contains the results of executing a single assessment procedure for a control requirement.", + "properties": { + "applicability": { + "description": "Applicability is elevated from the Layer 2 Assessment Requirement to aid in execution and reporting.", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "confidence-level": { + "$ref": "#/$defs/ConfidenceLevel", + "description": "ConfidenceLevel indicates the evaluator's confidence level in this specific assessment result." + }, + "description": { + "description": "Description provides a summary of the assessment procedure.", + "type": "string" + }, + "end": { + "$ref": "#/$defs/Datetime", + "description": "End is the timestamp when the assessment concluded." + }, + "evidence": { + "description": "Evidence records the raw data cited to support this assessment's opinion.", + "items": { + "$ref": "#/$defs/Evidence" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Evidence" + } + ], + "type": "array" + }, + "message": { + "description": "Message provides additional context about the assessment result.", + "type": "string" + }, + "plan": { + "$ref": "#/$defs/EntryMapping", + "description": "Plan maps to the policy assessment plan being executed." + }, + "recommendation": { + "description": "Recommendation provides guidance on how to address a failed assessment.", + "type": "string" + }, + "requirement": { + "$ref": "#/$defs/EntryMapping", + "description": "Requirement should map to the assessment requirement for this assessment." + }, + "result": { + "$ref": "#/$defs/Result", + "description": "Result is the overall outcome of the assessment procedure, matching the result of the last step that was run." + }, + "start": { + "$ref": "#/$defs/Datetime", + "description": "Start is the timestamp when the assessment began.\nAssessments that never executed have no start time to record." + }, + "steps": { + "description": "Steps are sequential actions taken as part of the assessment, which may halt the assessment if a failure occurs.", + "items": { + "$ref": "#/$defs/AssessmentStep" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentStep" + } + ], + "type": "array" + }, + "steps-executed": { + "description": "Steps-executed is the number of steps that were executed as part of the assessment.", + "type": "integer" + } + }, + "required": [ + "applicability", + "description", + "message", + "requirement", + "result", + "steps" + ], + "type": "object" + }, + "AssessmentPlan": { + "additionalProperties": false, + "description": "AssessmentPlan defines how a specific assessment requirement is evaluated.", + "properties": { + "evaluation-methods": { + "items": { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EvaluationMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AcceptedMethod", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/$defs/EvaluationMethodType" + } + }, + "required": [ + "type" + ], + "type": "object" + } + ], + "type": "array" + }, + "evidence-requirements": { + "type": "string" + }, + "frequency": { + "type": "string" + }, + "id": { + "type": "string" + }, + "parameters": { + "items": { + "$ref": "#/$defs/Parameter" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Parameter" + } + ], + "type": "array" + }, + "requirement-id": { + "type": "string" + } + }, + "required": [ + "evaluation-methods", + "frequency", + "id", + "requirement-id" + ], + "type": "object" + }, + "AssessmentRequirement": { + "additionalProperties": false, + "description": "AssessmentRequirement describes a tightly scoped, verifiable condition that must be satisfied and confirmed by an evaluator", + "properties": { + "applicability": { + "description": "applicability is a list of strings describing the situations where this text functions as a requirement for its parent control", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "recommendation": { + "description": "recommendation provides readers with non-binding suggestions to aid in evaluation or enforcement of the requirement", + "type": "string" + }, + "replaced-by": { + "$ref": "#/$defs/EntryMapping", + "description": "replaced-by references the assessment requirement that supersedes this one when deprecated or retired" + }, + "state": { + "$ref": "#/$defs/Lifecycle", + "description": "state is the lifecycle state of this assessment requirement" + }, + "text": { + "description": "text is the body of the requirement, typically written as a MUST condition", + "type": "string" + } + }, + "required": [ + "applicability", + "id", + "text" + ], + "type": "object" + }, + "AssessmentRequirementModifier": { + "additionalProperties": false, + "description": "AssessmentRequirementModifier allows organizations to customize assessment requirements based on how an organization wants to gather evidence for the objective.", + "properties": { + "applicability": { + "description": "The updated applicability of the assessment requirement", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "id": { + "type": "string" + }, + "modification-rationale": { + "type": "string" + }, + "modification-type": { + "$ref": "#/$defs/ModType" + }, + "recommendation": { + "description": "The updated recommendation for the assessment requirement", + "type": "string" + }, + "target-id": { + "type": "string" + }, + "text": { + "description": "The updated text of the assessment requirement", + "type": "string" + } + }, + "required": [ + "id", + "modification-rationale", + "modification-type", + "target-id" + ], + "type": "object" + }, + "AssessmentStep": { + "type": "string" + }, + "AuditLog": { + "$ref": "#/$defs/Log", + "additionalProperties": false, + "description": "AuditLog records results from an audit performed against a target resource", + "properties": { + "criteria": { + "description": "criteria defines the acceptable state for the audited resource", + "items": { + "$ref": "#/$defs/ArtifactMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ArtifactMapping" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "AuditLog" + } + }, + "type": "object" + }, + "owner": { + "$ref": "#/$defs/RACI", + "description": "owner defines the RACI roles responsible for managing the audit" + }, + "results": { + "description": "results records audit results against the criteria", + "items": { + "$ref": "#/$defs/AuditResult" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AuditResult" + } + ], + "type": "array" + }, + "summary": { + "description": "summary provides the high-level conclusion", + "type": "string" + } + }, + "required": [ + "criteria", + "results", + "summary" + ], + "type": "object" + }, + "AuditResult": { + "additionalProperties": false, + "description": "AuditResult records a single result with supporting evidence and recommendations.", + "properties": { + "criteria-reference": { + "$ref": "#/$defs/MultiEntryMapping", + "description": "criteria-reference maps this result to specific criteria entries" + }, + "description": { + "description": "description explains the result in detail", + "type": "string" + }, + "evidence": { + "description": "evidence records the data sources that support this result", + "items": { + "$ref": "#/$defs/Evidence" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Evidence" + } + ], + "type": "array" + }, + "id": { + "description": "id uniquely identifies this result", + "type": "string" + }, + "recommendations": { + "description": "recommendations records corrective actions for this result", + "items": { + "$ref": "#/$defs/Recommendation" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Recommendation" + } + ], + "type": "array" + }, + "title": { + "description": "title describes this result at a glance", + "type": "string" + }, + "type": { + "$ref": "#/$defs/ResultType", + "description": "type classifies the nature of this result" + } + }, + "required": [ + "criteria-reference", + "description", + "id", + "title", + "type" + ], + "type": "object" + }, + "Capability": { + "additionalProperties": false, + "description": "Capability describes a system capability such as a feature, component or object.", + "properties": { + "description": { + "description": "description provides a detailed overview of this capability", + "type": "string" + }, + "group": { + "description": "group references by id a catalog group that this capability belongs to", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "title": { + "description": "title describes this capability at a glance", + "type": "string" + } + }, + "required": [ + "description", + "group", + "id", + "title" + ], + "type": "object" + }, + "CapabilityCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "CapabilityCatalog describes a collection of system capabilities", + "properties": { + "capabilities": { + "description": "capabilities is a list of capabilities defined by this catalog", + "items": { + "$ref": "#/$defs/Capability" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Capability" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "CapabilityCatalog" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "Catalog": { + "additionalProperties": false, + "description": "Catalog describes a set of topically-associated entries", + "properties": { + "extends": { + "description": "extends references catalogs that this catalog builds upon", + "items": { + "$ref": "#/$defs/ArtifactMapping" + }, + "type": "array" + }, + "groups": { + "description": "groups contains a list of groups that can be referenced by entries in this catalog", + "items": { + "$ref": "#/$defs/Group" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Group" + } + ], + "type": "array" + }, + "imports": { + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/Metadata", + "description": "metadata provides detailed data about this catalog" + }, + "title": { + "description": "title describes the purpose of this catalog at a glance", + "type": "string" + } + }, + "required": [ + "metadata", + "title" + ], + "type": "object" + }, + "CatalogImport": { + "additionalProperties": false, + "description": "CatalogImport defines how to import control catalogs with optional exclusions, constraints, and assessment requirement modifications.", + "properties": { + "assessment-requirement-modifications": { + "items": { + "$ref": "#/$defs/AssessmentRequirementModifier" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentRequirementModifier" + } + ], + "type": "array" + }, + "constraints": { + "items": { + "$ref": "#/$defs/Constraint" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Constraint" + } + ], + "type": "array" + }, + "exclusions": { + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "reference-id": { + "type": "string" + } + }, + "required": [ + "reference-id" + ], + "type": "object" + }, + "ConfidenceLevel": { + "description": "ConfidenceLevel indicates the evaluator's confidence level in an assessment result.", + "enum": [ + "Undetermined", + "Low", + "Medium", + "High" + ] + }, + "Constraint": { + "additionalProperties": false, + "description": "Constraint defines a prescriptive requirement that applies to a specific guidance or control.", + "properties": { + "id": { + "description": "Unique ID for this constraint to enable Layer 5/6 tracking", + "type": "string" + }, + "target-id": { + "description": "Links to the specific Guidance or Control being constrained", + "type": "string" + }, + "text": { + "description": "The prescriptive requirement/constraint text", + "type": "string" + } + }, + "required": [ + "id", + "target-id", + "text" + ], + "type": "object" + }, + "Contact": { + "additionalProperties": false, + "description": "Contact is the contact information for a person or group", + "properties": { + "affiliation": { + "description": "affiliation is the organization with which the contact entity is associated, such as a team, school, or employer", + "type": "string" + }, + "email": { + "$ref": "#/$defs/Email", + "description": "email is the preferred email address to reach the contact" + }, + "name": { + "description": "name is the preferred descriptor for the contact entity", + "type": "string" + }, + "social": { + "description": "social is a social media handle or other profile for the contact, such as GitHub", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "Control": { + "additionalProperties": false, + "description": "Control describes a safeguard or countermeasure with a clear objective and assessment requirements", + "properties": { + "assessment-requirements": { + "description": "assessment-requirements is a list of requirements that must be verified to confirm the control objective has been met", + "items": { + "$ref": "#/$defs/AssessmentRequirement" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentRequirement" + } + ], + "type": "array" + }, + "group": { + "description": "group references by id a catalog group that this control belongs to", + "type": "string" + }, + "guidelines": { + "description": "guidelines documents relationships between this control and Layer 1 guideline artifacts", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "objective": { + "description": "objective is a unified statement of intent, which may encompass multiple situationally applicable requirements", + "type": "string" + }, + "replaced-by": { + "$ref": "#/$defs/EntryMapping", + "description": "replaced-by references the control that supersedes this one when deprecated or retired" + }, + "state": { + "$ref": "#/$defs/Lifecycle", + "description": "state is the lifecycle state of this control" + }, + "threats": { + "description": "threats documents relationships between this control and Layer 2 threat artifacts", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "title": { + "description": "title describes the purpose of this control at a glance", + "type": "string" + } + }, + "required": [ + "assessment-requirements", + "group", + "id", + "objective", + "title" + ], + "type": "object" + }, + "ControlCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "ControlCatalog describes a set of related controls and relevant metadata", + "properties": { + "controls": { + "description": "controls is a list of unique controls defined by this catalog", + "items": { + "$ref": "#/$defs/Control" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Control" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "ControlCatalog" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "ControlEvaluation": { + "additionalProperties": false, + "description": "ControlEvaluation contains the results of evaluating a single Layer 5 control.", + "properties": { + "assessment-logs": { + "allOf": [ + { + "description": "Enforce that control reference and the assessments' references match\nThis formulation uses the control's reference if the assessment doesn't include a reference", + "items": { + "properties": { + "requirement": { + "properties": { + "reference-id": { + "$ref": "#/$defs/reference-id" + } + }, + "required": [ + "reference-id" + ], + "type": "object" + } + }, + "required": [ + "requirement" + ], + "type": "object" + }, + "type": "array" + }, + { + "description": "Require start timestamp on assessments that actually executed", + "items": { + "$ref": "#/$defs/_AssessmentLogStrict" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/_AssessmentLogStrict" + } + ], + "type": "array" + }, + { + "items": { + "$ref": "#/$defs/AssessmentLog" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentLog" + } + ], + "type": "array" + } + ], + "description": "Enforce that control reference and the assessments' references match\nThis formulation uses the control's reference if the assessment doesn't include a reference\n\nRequire start timestamp on assessments that actually executed" + }, + "control": { + "$ref": "#/$defs/EntryMapping" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "result": { + "$ref": "#/$defs/Result" + } + }, + "required": [ + "assessment-logs", + "control", + "message", + "name", + "result" + ], + "type": "object" + }, + "Datetime": { + "description": "Datetime represents an ISO 8601 formatted datetime string", + "format": "date-time", + "type": "string" + }, + "Dimensions": { + "additionalProperties": false, + "description": "Dimensions specify the applicability criteria for a policy", + "properties": { + "geopolitical": { + "description": "geopolitical is an optional list of geopolitical regions", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "groups": { + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "sensitivity": { + "description": "sensitivity is an optional list of data classification levels", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "technologies": { + "description": "technologies is an optional list of technology categories or services", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "users": { + "description": "users is an optional list of user roles", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "Disposition": { + "description": "Disposition enumerates the possible enforcement outcomes.", + "enum": [ + "Undetermined", + "Enforced", + "Tolerated", + "Clear" + ] + }, + "Email": { + "description": "Email represents a validated email address pattern", + "pattern": "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$", + "type": "string" + }, + "EnforcementLog": { + "$ref": "#/$defs/Log", + "additionalProperties": false, + "description": "EnforcementLog records actions taken in response to noncompliance findings from Layer 5 evaluations.", + "properties": { + "actions": { + "allOf": [ + { + "description": "Enforce that Clear dispositions only contain Passed assessment results", + "type": "array" + }, + { + "description": "actions is the list of enforcement actions performed", + "items": { + "$ref": "#/$defs/ActionResult" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ActionResult" + } + ], + "type": "array" + } + ], + "description": "actions is the list of enforcement actions performed\n\nEnforce that Clear dispositions only contain Passed assessment results" + }, + "disposition": { + "$ref": "#/$defs/Disposition", + "description": "disposition is the aggregate enforcement disposition across all actions in this log" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "EnforcementLog" + } + }, + "type": "object" + } + }, + "required": [ + "actions", + "disposition" + ], + "type": "object" + }, + "EnforcementMethodType": { + "enum": [ + "Gate", + "Remediation" + ] + }, + "EnforcementStep": { + "description": "EnforcementStep is a reference to the code that performed an enforcement action", + "type": "string" + }, + "Entity": { + "additionalProperties": false, + "description": "Entity represents a human or tool", + "properties": { + "description": { + "description": "description provides additional context about the entity", + "type": "string" + }, + "id": { + "description": "id uniquely identifies the entity and allows this entry to be referenced by other elements", + "type": "string" + }, + "name": { + "description": "name is the name of the entity", + "type": "string" + }, + "type": { + "$ref": "#/$defs/EntityType", + "description": "type specifies the type of entity interacting in the workflow" + }, + "uri": { + "description": "uri is a general URI for the entity information", + "pattern": "^https?://[^\\s]+$", + "type": "string" + }, + "version": { + "description": "version is the version of the entity (for tools; if applicable)", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "type": "object" + }, + "EntityType": { + "description": "EntityType specifies what entity is interacting in the workflow", + "enum": [ + "Human", + "Software", + "Software Assisted" + ] + }, + "EntryMapping": { + "additionalProperties": false, + "description": "EntryMapping represents how a specific entry maps to a MappingReference.", + "properties": { + "entry-id": { + "description": "entry-id is the identifier being mapped to in the referenced artifact", + "type": "string" + }, + "reference-id": { + "description": "reference-id is the id for a MappingReference entry in the artifact's metadata", + "type": "string" + }, + "remarks": { + "description": "remarks is prose describing the mapping relationship", + "type": "string" + } + }, + "required": [ + "entry-id", + "reference-id" + ], + "type": "object" + }, + "EntryType": { + "description": "EntryType enumerates the atomic units within Gemara artifacts that can participate in mappings", + "enum": [ + "Guideline", + "Statement", + "Control", + "AssessmentRequirement", + "Capability", + "Threat", + "Risk", + "Vector", + "Principle" + ] + }, + "EvaluationLog": { + "$ref": "#/$defs/Log", + "additionalProperties": false, + "description": "EvaluationLog contains the results of evaluating a set of Layer 2 controls.", + "properties": { + "evaluations": { + "items": { + "$ref": "#/$defs/ControlEvaluation" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ControlEvaluation" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "EvaluationLog" + } + }, + "type": "object" + }, + "result": { + "$ref": "#/$defs/Result", + "description": "result is the aggregate outcome across all evaluations in this log" + } + }, + "required": [ + "evaluations", + "result" + ], + "type": "object" + }, + "EvaluationMethodType": { + "enum": [ + "Intent", + "Behavioral" + ] + }, + "Evidence": { + "additionalProperties": false, + "description": "Evidence records what was cited to support an opinion for a specific activity:\nraw data for the evaluation layer, evaluation and enforcement artifacts for the audit layer.\nAt least one of payload or source MUST be present; an entry with neither is semantically incomplete.", + "properties": { + "collected-at": { + "$ref": "#/$defs/Datetime", + "description": "collected-at is the timestamp when the evidence was gathered" + }, + "description": { + "description": "description explains what this evidence represents", + "type": "string" + }, + "id": { + "description": "id uniquely identifies this evidence", + "type": "string" + }, + "payload": { + "description": "payload is the raw evidence data collected inline" + }, + "source": { + "$ref": "#/$defs/EvidenceMapping", + "description": "source identifies the artifact or system from which this evidence was collected" + }, + "type": { + "$ref": "#/$defs/EvidenceType", + "description": "type categorizes the kind of evidence" + } + }, + "required": [ + "collected-at", + "id", + "type" + ], + "type": "object" + }, + "EvidenceMapping": { + "additionalProperties": false, + "description": "EvidenceMapping identifies the source from which evidence was collected.\nreference-id names the MappingReference; coordinate or entry-id gives\nspecificity within it; digest pins the observed content at collection time.", + "properties": { + "coordinate": { + "description": "coordinate is the precise location within the stream identified by reference-id\n(e.g. an API path, file path, or JSON path expression).\nDo not set if entry-id is set.", + "type": "string" + }, + "digest": { + "description": "digest is a cryptographic hash of the observed content at collection time; format: algorithm:encoded (e.g. sha256:abc123...)", + "pattern": "^[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$", + "type": "string" + }, + "entry-id": { + "description": "entry-id identifies a specific entry within a referenced Gemara artifact.\nDo not set if coordinate is set.", + "type": "string" + }, + "reference-id": { + "description": "reference-id ties this evidence to a mapping-reference in the artifact's metadata", + "type": "string" + }, + "remarks": { + "description": "remarks is prose regarding this evidence reference", + "type": "string" + } + }, + "required": [ + "reference-id" + ], + "type": "object" + }, + "EvidenceType": { + "anyOf": [ + { + "$ref": "#/$defs/ArtifactType" + }, + { + "type": "string" + } + ], + "description": "EvidenceType categorizes the kind of evidence. It remains an open enum:\nrecommended values include artifact types already known to Gemara (e.g.\nEvaluationLog, EnforcementLog) plus categories for common evidence forms." + }, + "Exemption": { + "additionalProperties": false, + "description": "Exemption describes a single scenario where the catalog is not applicable", + "properties": { + "description": { + "description": "description identifies who or what is exempt from the full guidance", + "type": "string" + }, + "reason": { + "description": "reason explains why the exemption is granted", + "type": "string" + }, + "redirect": { + "$ref": "#/$defs/MultiEntryMapping", + "description": "redirect points to alternative guidelines or controls that should be followed instead" + } + }, + "required": [ + "description", + "reason" + ], + "type": "object" + }, + "Group": { + "additionalProperties": false, + "description": "Group represents a classification or grouping that can be used in different contexts with semantic meaning derived from its usage", + "properties": { + "description": { + "description": "description explains the significance and traits of entries to this group", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "title": { + "description": "title describes the purpose of this group at a glance", + "type": "string" + } + }, + "required": [ + "description", + "id", + "title" + ], + "type": "object" + }, + "GuidanceCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "GuidanceCatalog represents a concerted documentation effort to help bring about an optimal future without foreknowledge of the implementation details", + "properties": { + "exemptions": { + "description": "exemptions provides information about situations where this guidance is not applicable", + "items": { + "$ref": "#/$defs/Exemption" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Exemption" + } + ], + "type": "array" + }, + "front-matter": { + "description": "front-matter provides introductory text for the document to be used during rendering", + "type": "string" + }, + "guidelines": { + "description": "guidelines is a list of unique guidelines defined by this catalog", + "items": { + "$ref": "#/$defs/Guideline" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Guideline" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "GuidanceCatalog" + } + }, + "type": "object" + }, + "type": { + "$ref": "#/$defs/GuidanceType", + "description": "type categorizes this document based on the intent of its contents" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "GuidanceImport": { + "additionalProperties": false, + "description": "GuidanceImport defines how to import guidance documents with optional exclusions and constraints.", + "properties": { + "constraints": { + "description": "Constraints allow policy authors to define ad hoc minimum requirements (e.g., \"review at least annually\").", + "items": { + "$ref": "#/$defs/Constraint" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Constraint" + } + ], + "type": "array" + }, + "exclusions": { + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "reference-id": { + "type": "string" + } + }, + "required": [ + "reference-id" + ], + "type": "object" + }, + "GuidanceType": { + "description": "GuidanceType restricts the possible types that a catalog may be listed as", + "enum": [ + "Standard", + "Regulation", + "Best Practice", + "Framework" + ] + }, + "Guideline": { + "additionalProperties": false, + "description": "Guideline provides explanatory context and recommendations for designing optimal outcomes", + "properties": { + "applicability": { + "description": "applicability specifies the contexts in which this guideline applies", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "extends": { + "$ref": "#/$defs/EntryMapping", + "description": "extends is an id for a guideline which this guideline adds to, in this document or elsewhere" + }, + "group": { + "description": "group provides an id to the group that this guideline belongs to", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "objective": { + "description": "objective is a unified statement of intent, which may encompass multiple situationally applicable statements", + "type": "string" + }, + "principles": { + "description": "principles documents the relationship between this guideline and one or more principles", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "rationale": { + "$ref": "#/$defs/Rationale", + "description": "rationale provides the context for this guideline" + }, + "recommendations": { + "description": "recommendations is a list of non-binding suggestions to aid in evaluation or enforcement of the guideline", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "replaced-by": { + "$ref": "#/$defs/EntryMapping", + "description": "replaced-by references the guideline that supersedes this one when deprecated or retired" + }, + "see-also": { + "description": "see-also lists related guideline IDs within the same GuidanceCatalog", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "state": { + "$ref": "#/$defs/Lifecycle", + "description": "state is the lifecycle state of this guideline" + }, + "statements": { + "description": "statements is a list of structural sub-requirements within a guideline", + "items": { + "$ref": "#/$defs/Statement" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Statement" + } + ], + "type": "array" + }, + "title": { + "description": "title describes the contents of this guideline", + "type": "string" + }, + "vectors": { + "description": "vector-mappings documents the relationship between this guideline and one or more vectors", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + } + }, + "required": [ + "group", + "id", + "objective", + "title" + ], + "type": "object" + }, + "ImplementationDetails": { + "additionalProperties": false, + "description": "ImplementationDetails specifies the timeline for policy implementation.", + "properties": { + "end": { + "$ref": "#/$defs/Datetime" + }, + "notes": { + "type": "string" + }, + "start": { + "$ref": "#/$defs/Datetime" + } + }, + "required": [ + "notes", + "start" + ], + "type": "object" + }, + "ImplementationPlan": { + "additionalProperties": false, + "description": "ImplementationPlan defines when and how the policy becomes active.", + "properties": { + "enforcement-timeline": { + "$ref": "#/$defs/ImplementationDetails" + }, + "evaluation-timeline": { + "$ref": "#/$defs/ImplementationDetails" + }, + "notification-process": { + "type": "string" + } + }, + "required": [ + "enforcement-timeline", + "evaluation-timeline" + ], + "type": "object" + }, + "Imports": { + "additionalProperties": false, + "description": "Imports defines external policies, controls, and guidelines required by this policy.", + "properties": { + "catalogs": { + "items": { + "$ref": "#/$defs/CatalogImport" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/CatalogImport" + } + ], + "type": "array" + }, + "guidance": { + "items": { + "$ref": "#/$defs/GuidanceImport" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/GuidanceImport" + } + ], + "type": "array" + }, + "policies": { + "items": { + "$ref": "#/$defs/ArtifactMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ArtifactMapping" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "Justification": { + "additionalProperties": false, + "description": "Justification provides the assessment data and exception references that justify an enforcement action.", + "properties": { + "assessments": { + "description": "assessments links the action to one or more Assessment Findings", + "items": { + "$ref": "#/$defs/AssessmentFinding" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AssessmentFinding" + } + ], + "type": "array" + }, + "exceptions": { + "description": "exceptions references approved Policy exceptions that authorize the action", + "items": { + "$ref": "#/$defs/ArtifactMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ArtifactMapping" + } + ], + "type": "array" + } + }, + "required": [ + "assessments" + ], + "type": "object" + }, + "Lexicon": { + "additionalProperties": false, + "description": "Lexicon is a controlled vocabulary or glossary artifact referenced by Metadata.lexicon", + "properties": { + "metadata": { + "$ref": "#/$defs/Metadata", + "additionalProperties": false, + "description": "metadata provides detailed data about this document", + "properties": { + "type": { + "const": "Lexicon" + } + }, + "type": "object" + }, + "terms": { + "description": "terms is one or more defined entries for linking and rendering", + "items": { + "$ref": "#/$defs/LexiconTerm" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/LexiconTerm" + } + ], + "type": "array" + }, + "title": { + "description": "title describes the purpose of this lexicon at a glance", + "type": "string" + } + }, + "required": [ + "metadata", + "terms", + "title" + ], + "type": "object" + }, + "LexiconReference": { + "additionalProperties": false, + "description": "LexiconReference cites a source supporting a lexicon definition", + "properties": { + "citation": { + "description": "citation identifies the source material in prose", + "type": "string" + }, + "url": { + "description": "url points to supporting material when available", + "pattern": "^(https?|file)://[^\\s]+$", + "type": "string" + } + }, + "required": [ + "citation" + ], + "type": "object" + }, + "LexiconTerm": { + "additionalProperties": false, + "description": "LexiconTerm is a single definition within a lexicon", + "properties": { + "definition": { + "description": "definition explains the meaning of the term", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced for anchors and tooling", + "type": "string" + }, + "references": { + "description": "references cites external authorities supporting the definition", + "items": { + "$ref": "#/$defs/LexiconReference" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/LexiconReference" + } + ], + "type": "array" + }, + "synonyms": { + "description": "synonyms lists alternative labels that should resolve to this term for linking", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "title": { + "description": "title is the canonical name of the defined concept", + "type": "string" + } + }, + "required": [ + "definition", + "id", + "title" + ], + "type": "object" + }, + "Lifecycle": { + "description": "Lifecycle represents the lifecycle state of a guideline, control, or assessment requirement", + "enum": [ + "Active", + "Draft", + "Deprecated", + "Retired" + ] + }, + "Log": { + "additionalProperties": false, + "description": "Log describes a set of recorded entries from a measurement activity", + "properties": { + "metadata": { + "$ref": "#/$defs/Metadata", + "description": "metadata provides detailed data about this log" + }, + "target": { + "$ref": "#/$defs/Resource", + "description": "target identifies the resource being evaluated" + } + }, + "required": [ + "metadata", + "target" + ], + "type": "object" + }, + "Mapping": { + "additionalProperties": false, + "description": "Mapping represents a relationship between a source entry and one or more target entries", + "properties": { + "id": { + "description": "id allows this mapping to be referenced by other elements", + "type": "string" + }, + "relationship": { + "$ref": "#/$defs/RelationshipType", + "description": "relationship describes the nature of the mapping between source and all targets" + }, + "remarks": { + "description": "remarks is general prose regarding this mapping", + "type": "string" + }, + "source": { + "description": "source identifies the entry being mapped from by its entry-id", + "type": "string" + }, + "targets": { + "description": "targets identifies the entries being mapped to; absent when relationship is no-match", + "items": { + "$ref": "#/$defs/MappingTarget" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MappingTarget" + } + ], + "type": "array" + } + }, + "required": [ + "id", + "relationship", + "source" + ], + "type": "object" + }, + "MappingDocument": { + "additionalProperties": false, + "description": "MappingDocument captures the user's intent for how entries in a source artifact relate to entries in a target artifact", + "properties": { + "mappings": { + "description": "mappings is one or more atomic relationships between entries in the referenced artifacts", + "items": { + "$ref": "#/$defs/_MappingStrict" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/_MappingStrict" + } + ], + "type": "array" + }, + "metadata": { + "$ref": "#/$defs/Metadata", + "additionalProperties": false, + "description": "metadata provides detailed data about this document", + "properties": { + "mapping-references": { + "items": { + "$ref": "#/$defs/MappingReference" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MappingReference" + } + ], + "type": "array" + }, + "type": { + "const": "MappingDocument" + } + }, + "required": [ + "mapping-references" + ], + "type": "object" + }, + "remarks": { + "description": "remarks is prose regarding this mapping document", + "type": "string" + }, + "source-reference": { + "$ref": "#/$defs/TypedMapping", + "description": "source-reference identifies the artifact being mapped from; must match a mapping-reference id" + }, + "target-reference": { + "$ref": "#/$defs/TypedMapping", + "description": "target-reference identifies the artifact being mapped to; must match a mapping-reference id" + }, + "title": { + "description": "title describes the purpose of this mapping document at a glance", + "type": "string" + } + }, + "required": [ + "mappings", + "metadata", + "source-reference", + "target-reference", + "title" + ], + "type": "object" + }, + "MappingReference": { + "additionalProperties": false, + "description": "MappingReference represents a reference to an external document with full metadata.", + "properties": { + "description": { + "description": "description is prose regarding the artifact's purpose or content", + "type": "string" + }, + "id": { + "description": "id identifies this mapping reference within the artifact and, when url\nis absent, the referenced artifact's metadata.id.", + "type": "string" + }, + "title": { + "description": "title describes the purpose of this mapping reference at a glance", + "type": "string" + }, + "url": { + "description": "url is the path where the artifact may be retrieved; preferably responds with Gemara-compatible YAML/JSON", + "pattern": "^(https?|file)://[^\\s]+$", + "type": "string" + }, + "version": { + "description": "version is the version identifier of the artifact being mapped to", + "type": "string" + } + }, + "required": [ + "id", + "title", + "version" + ], + "type": "object" + }, + "MappingTarget": { + "additionalProperties": false, + "description": "MappingTarget identifies a target entry with optional per-target metadata", + "properties": { + "applicability": { + "description": "applicability constrains the contexts in which this target mapping holds", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "confidence-level": { + "$ref": "#/$defs/ConfidenceLevel" + }, + "entry-id": { + "description": "entry-id identifies the specific entry in the target artifact", + "type": "string" + }, + "rationale": { + "description": "rationale explains why this relationship exists for this target", + "type": "string" + }, + "remarks": { + "description": "remarks is general prose regarding this target mapping", + "type": "string" + }, + "strength": { + "allOf": [ + { + "type": "number" + }, + { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + ], + "description": "strength is the author's estimate of how completely the source satisfies this target; range 1-10" + } + }, + "required": [ + "entry-id" + ], + "type": "object" + }, + "Metadata": { + "additionalProperties": false, + "description": "Metadata represents common metadata fields shared across all layers", + "properties": { + "applicability-groups": { + "description": "applicability-groups is a list of groups used to classify within this artifact to specify scope", + "items": { + "$ref": "#/$defs/Group" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Group" + } + ], + "type": "array" + }, + "author": { + "$ref": "#/$defs/Actor", + "description": "author is the person or group primarily responsible for this artifact" + }, + "date": { + "$ref": "#/$defs/Datetime", + "description": "date is the publication or effective date of this artifact" + }, + "description": { + "description": "description provides a high-level summary of the artifact's purpose and scope", + "type": "string" + }, + "draft": { + "description": "draft indicates whether this artifact is a pre-release version; open to modification", + "type": "boolean" + }, + "gemara-version": { + "description": "gemara-version declares which version of the Gemara specification this artifact conforms to", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "lexicon": { + "$ref": "#/$defs/ArtifactMapping", + "description": "lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact" + }, + "mapping-references": { + "description": "mapping-references is a list of external documents referenced within this artifact", + "items": { + "$ref": "#/$defs/MappingReference" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MappingReference" + } + ], + "type": "array" + }, + "type": { + "$ref": "#/$defs/ArtifactType", + "description": "type identifies the kind of Gemara artifact for unambiguous parsing" + }, + "version": { + "description": "version is the version identifier of this artifact", + "type": "string" + } + }, + "required": [ + "author", + "description", + "gemara-version", + "id", + "type" + ], + "type": "object" + }, + "MethodType": { + "enum": [ + "Behavioral", + "Intent", + "Remediation", + "Gate" + ] + }, + "MitigatedRisk": { + "additionalProperties": false, + "description": "MitigatedRisk represents a risk addressed by the policy", + "properties": { + "id": { + "description": "id allows this mitigated risk entry to be referenced by accepted risks", + "type": "string" + }, + "risk": { + "$ref": "#/$defs/EntryMapping", + "description": "risk references the risk being mitigated" + } + }, + "required": [ + "id", + "risk" + ], + "type": "object" + }, + "ModType": { + "description": "ModType defines the type of modification to the assessment requirement.", + "enum": [ + "Add", + "Modify", + "Remove", + "Replace", + "Override" + ] + }, + "ModeType": { + "enum": [ + "Manual", + "Automated" + ] + }, + "MultiEntryMapping": { + "$ref": "#/$defs/ArtifactMapping", + "additionalProperties": false, + "description": "MultiEntryMapping represents a mapping to an external reference with one or more entries.", + "properties": { + "entries": { + "description": "entries is a list of mapping entries", + "items": { + "$ref": "#/$defs/ArtifactMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/ArtifactMapping" + } + ], + "type": "array" + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "Parameter": { + "additionalProperties": false, + "description": "Parameter defines a configurable parameter for assessment or enforcement activities.", + "properties": { + "accepted-values": { + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "label" + ], + "type": "object" + }, + "Policy": { + "additionalProperties": false, + "description": "Policy represents a policy document with metadata, contacts, scope, imports, implementation plan, risks, and adherence requirements.", + "properties": { + "adherence": { + "$ref": "#/$defs/Adherence" + }, + "contacts": { + "$ref": "#/$defs/RACI" + }, + "implementation-plan": { + "$ref": "#/$defs/ImplementationPlan" + }, + "imports": { + "$ref": "#/$defs/Imports" + }, + "metadata": { + "$ref": "#/$defs/Metadata", + "additionalProperties": false, + "properties": { + "type": { + "const": "Policy" + } + }, + "type": "object" + }, + "risks": { + "$ref": "#/$defs/Risks" + }, + "scope": { + "$ref": "#/$defs/Scope" + }, + "title": { + "type": "string" + } + }, + "required": [ + "contacts", + "metadata", + "title" + ], + "type": "object" + }, + "Principle": { + "additionalProperties": false, + "description": "Principle represents a foundational value or tenet that guides governance, design, and operational decisions", + "properties": { + "description": { + "description": "description explains the principle and its expected outcomes", + "type": "string" + }, + "group": { + "description": "group references by id a catalog group that this principle belongs to", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "rationale": { + "description": "rationale provides the context for this principle", + "type": "string" + }, + "title": { + "description": "title describes the principle at a glance", + "type": "string" + } + }, + "required": [ + "description", + "group", + "id", + "title" + ], + "type": "object" + }, + "PrincipleCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "PrincipleCatalog describes a set of related principles and relevant metadata", + "properties": { + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "PrincipleCatalog" + } + }, + "type": "object" + }, + "principles": { + "description": "principles is a list of unique principles defined by this catalog", + "items": { + "$ref": "#/$defs/Principle" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Principle" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "RACI": { + "additionalProperties": false, + "description": "RACI defines the roles responsible for managing an artifact", + "properties": { + "accountable": { + "description": "accountable identifies the entity ultimately accountable for the outcome", + "items": { + "$ref": "#/$defs/Contact" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Contact" + } + ], + "type": "array" + }, + "consulted": { + "description": "consulted identifies entities whose input is required when assessing or responding to the artifact", + "items": { + "$ref": "#/$defs/Contact" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Contact" + } + ], + "type": "array" + }, + "informed": { + "description": "informed identifies entities that should be notified about changes to the artifact status", + "items": { + "$ref": "#/$defs/Contact" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Contact" + } + ], + "type": "array" + }, + "responsible": { + "description": "responsible identifies the entities responsible for executing work to manage or mitigate the artifact", + "items": { + "$ref": "#/$defs/Contact" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Contact" + } + ], + "type": "array" + } + }, + "required": [ + "accountable", + "responsible" + ], + "type": "object" + }, + "Rationale": { + "additionalProperties": false, + "description": "Rationale provides a structured way to communicate a guideline author's intent", + "properties": { + "goals": { + "description": "goals is a list of outcomes this guideline seeks to achieve", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "importance": { + "description": "importance is an explanation of why this guideline matters", + "type": "string" + } + }, + "required": [ + "goals", + "importance" + ], + "type": "object" + }, + "Recommendation": { + "additionalProperties": false, + "description": "Recommendation provides a corrective action for an audit result", + "properties": { + "id": { + "description": "id uniquely identifies this recommendation", + "type": "string" + }, + "required": { + "description": "required indicates whether this recommendation is a mandatory corrective action", + "type": "boolean" + }, + "text": { + "description": "text describes the recommended corrective action", + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "RelationshipType": { + "description": "RelationshipType enumerates the nature of the mapping between entries.", + "enum": [ + "implements", + "implemented-by", + "supports", + "supported-by", + "equivalent", + "subsumes", + "no-match", + "relates-to" + ] + }, + "Resource": { + "$ref": "#/$defs/Entity", + "additionalProperties": false, + "description": "Resource represents an entity that exists in the system and can be evaluated", + "properties": { + "environment": { + "description": "environment describes where the resource exists (e.g., production, staging, development, specific region)", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/Contact", + "description": "owner is the contact information for the person or group responsible for managing or owning this resource" + } + }, + "type": "object" + }, + "Result": { + "enum": [ + "Not Run", + "Passed", + "Failed", + "Needs Review", + "Not Applicable", + "Unknown" + ] + }, + "ResultType": { + "description": "ResultType classifies the nature of an audit result", + "enum": [ + "Gap", + "Finding", + "Observation", + "Strength" + ] + }, + "Risk": { + "additionalProperties": false, + "description": "A Risk represents the potential for negative impact resulting from one or more threats.", + "properties": { + "description": { + "description": "description explains the risk scenario", + "type": "string" + }, + "group": { + "description": "group references by id a catalog group that this risk belongs to", + "type": "string" + }, + "id": { + "description": "id allows this risk to be referenced by other elements", + "type": "string" + }, + "impact": { + "description": "impact describes the business or operational impact", + "type": "string" + }, + "owner": { + "$ref": "#/$defs/RACI", + "description": "owner defines the RACI roles responsible for managing this risk" + }, + "rank": { + "description": "rank optionally orders risks for the same catalog (e.g. when several share the same severity).\nLower values mean higher relative importance. Omitted when the four severity levels are enough.\nWhen set, each value must be unique among all risks in the catalog that specify rank.", + "type": "integer" + }, + "severity": { + "$ref": "#/$defs/Severity", + "description": "severity describes the assessed level of this risk" + }, + "threats": { + "description": "threats link this risk to Layer 2 threats", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "title": { + "description": "title describes the risk", + "type": "string" + } + }, + "required": [ + "description", + "group", + "id", + "severity", + "title" + ], + "type": "object" + }, + "RiskAppetite": { + "description": "RiskAppetite defines the acceptable level of exposure for a risk category", + "enum": [ + "Minimal", + "Low", + "Moderate", + "High" + ] + }, + "RiskCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "A RiskCatalog is a structured collection of documented risks that may affect an organization,\nsystem, or service. It provides a centralized reference for risks that can be mapped to threats\nand referenced by policies when documenting how those risks are mitigated or accepted.", + "properties": { + "groups": { + "description": "groups narrows the base groups to risk categories with appetite and severity boundaries", + "items": { + "$ref": "#/$defs/RiskCategory" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/RiskCategory" + } + ], + "type": "array" + }, + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "RiskCatalog" + } + }, + "type": "object" + }, + "risks": { + "description": "risks is a list of risks defined by this catalog", + "items": { + "$ref": "#/$defs/Risk" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Risk" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "RiskCategory": { + "$ref": "#/$defs/Group", + "additionalProperties": false, + "description": "RiskCategory describes a grouping of risks and defines appetite boundaries", + "properties": { + "appetite": { + "$ref": "#/$defs/RiskAppetite", + "description": "appetite defines the acceptable level of risk for this category" + }, + "max-severity": { + "$ref": "#/$defs/Severity", + "description": "max-severity defines the risk tolerance boundary: the highest severity\nthe organization will accept within this category" + } + }, + "required": [ + "appetite" + ], + "type": "object" + }, + "Risks": { + "additionalProperties": false, + "description": "Risks defines mitigated and accepted risks addressed by this policy.", + "properties": { + "accepted": { + "description": "Accepted risks require rationale (justification) and may include scope. Controls addressing these risks are implicitly identified through threat mappings.", + "items": { + "$ref": "#/$defs/AcceptedRisk" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/AcceptedRisk" + } + ], + "type": "array" + }, + "mitigated": { + "description": "Mitigated risks only need reference-id and risk-id (no justification required)", + "items": { + "$ref": "#/$defs/MitigatedRisk" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MitigatedRisk" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "Scope": { + "additionalProperties": false, + "description": "Scope defines what is included and excluded from policy applicability.", + "properties": { + "in": { + "$ref": "#/$defs/Dimensions" + }, + "out": { + "$ref": "#/$defs/Dimensions" + } + }, + "type": "object" + }, + "Severity": { + "description": "Severity defines the assessed level of a risk based on its potential impact and likelihood", + "enum": [ + "Low", + "Medium", + "High", + "Critical" + ] + }, + "Statement": { + "additionalProperties": false, + "description": "Statement represents a structural sub-requirement within a guideline;\nThey do not increase strictness and all statements within a guideline apply together", + "properties": { + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "recommendations": { + "description": "recommendations is a list of non-binding suggestions to aid in evaluation or enforcement of the statement", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "text": { + "description": "text is the body of this statement", + "type": "string" + }, + "title": { + "description": "title describes the contents of this statement", + "type": "string" + } + }, + "required": [ + "id", + "text" + ], + "type": "object" + }, + "Threat": { + "additionalProperties": false, + "description": "Threat describes a specifically-scoped opportunity for a negative impact to the organization", + "properties": { + "actors": { + "description": "actors describes the relevant internal or external threat actors", + "items": { + "$ref": "#/$defs/Actor" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Actor" + } + ], + "type": "array" + }, + "capabilities": { + "description": "capabilities documents the relationship between this threat and a system capability", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + }, + "description": { + "description": "description provides a detailed explanation of an opportunity for negative impact", + "type": "string" + }, + "group": { + "description": "group references by id a catalog group that this threat belongs to", + "type": "string" + }, + "id": { + "description": "id allows this entry to be referenced by other elements", + "type": "string" + }, + "title": { + "description": "title describes this threat at a glance", + "type": "string" + }, + "vectors": { + "description": "vectors documents the relationship between this threat and one or more vectors", + "items": { + "$ref": "#/$defs/MultiEntryMapping" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/MultiEntryMapping" + } + ], + "type": "array" + } + }, + "required": [ + "capabilities", + "description", + "group", + "id", + "title" + ], + "type": "object" + }, + "ThreatCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "description": "ThreatCatalog describes a set of topically-associated threats", + "properties": { + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "ThreatCatalog" + } + }, + "type": "object" + }, + "threats": { + "description": "threats is a list of threats defined by this catalog", + "items": { + "$ref": "#/$defs/Threat" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Threat" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "TypedMapping": { + "$ref": "#/$defs/ArtifactMapping", + "additionalProperties": false, + "description": "TypedMapping extends ArtifactMapping with a required entry-type for all entries in this direction", + "properties": { + "entry-type": { + "$ref": "#/$defs/EntryType", + "description": "entry-type identifies the type of atomic unit entries in this direction" + } + }, + "required": [ + "entry-type" + ], + "type": "object" + }, + "Vector": { + "additionalProperties": false, + "description": "A Vector represents a method, pathway, or technique through which a threat may be realized or an attack may be carried out.", + "properties": { + "applicability": { + "description": "applicability specifies the contexts in which this vector can manifest", + "items": { + "type": "string" + }, + "minItems": 1, + "prefixItems": [ + { + "type": "string" + } + ], + "type": "array" + }, + "description": { + "description": "description explains how the attack vector works", + "type": "string" + }, + "group": { + "description": "group references by id a catalog group that this vector belongs to", + "type": "string" + }, + "id": { + "description": "id allows this vector to be referenced by other elements", + "type": "string" + }, + "title": { + "description": "title describes the vector", + "type": "string" + } + }, + "required": [ + "description", + "group", + "id", + "title" + ], + "type": "object" + }, + "VectorCatalog": { + "$ref": "#/$defs/Catalog", + "additionalProperties": false, + "properties": { + "metadata": { + "additionalProperties": false, + "properties": { + "type": { + "const": "VectorCatalog" + } + }, + "type": "object" + }, + "vectors": { + "description": "vectors is a list of attack vectors documented in this catalog", + "items": { + "$ref": "#/$defs/Vector" + }, + "minItems": 1, + "prefixItems": [ + { + "$ref": "#/$defs/Vector" + } + ], + "type": "array" + } + }, + "type": "object" + }, + "_AssessmentLogStrict": { + "$ref": "#/$defs/AssessmentLog", + "description": "_AssessmentLogStrict layers the \"start required unless unexecuted\" rule on top of #AssessmentLog", + "type": "object" + }, + "_MappingStrict": { + "$ref": "#/$defs/Mapping", + "description": "_MappingStrict layers the \"targets required when not no-match\" rule on top of #Mapping", + "type": "object" + }, + "reference-id": { + "description": "reference-id is the id for a MappingReference entry in the artifact's metadata", + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/schemas/provenance.json b/schemas/provenance.json new file mode 100644 index 0000000..4552207 --- /dev/null +++ b/schemas/provenance.json @@ -0,0 +1,25 @@ +{ + "commit": "9d36c253484d14922010252bfffe58bdcd49a144", + "cue_version": "v0.17.1", + "definition_count": 93, + "document_types": [ + "AuditLog", + "CapabilityCatalog", + "ControlCatalog", + "EnforcementLog", + "EvaluationLog", + "GuidanceCatalog", + "Lexicon", + "MappingDocument", + "Policy", + "PrincipleCatalog", + "RiskCatalog", + "ThreatCatalog", + "VectorCatalog" + ], + "module": "github.com/gemaraproj/gemara", + "ref": "v1.5.0", + "repository": "https://github.com/gemaraproj/gemara", + "retrieved": "2026-09-05", + "schema_sha256": "dbd5aa73969648ae80a7f52d28136d4682fc30d3053a441ffe3de475af98647f" +} diff --git a/src/gemara/v1/__init__.py b/src/gemara/v1/__init__.py new file mode 100644 index 0000000..337dd63 --- /dev/null +++ b/src/gemara/v1/__init__.py @@ -0,0 +1,26 @@ +"""Gemara v1 schema types as Pydantic v2 models. + + from gemara.v1 import load, DOCUMENT_TYPES, ControlCatalog + + doc = load("catalog.yaml") # dispatches on metadata.type + +The models are a *structural* validator. See the README's known limitations. +""" + +from __future__ import annotations + +from gemara.v1._loader import GemaraError, UnknownDocumentTypeError, load, loads +from gemara.v1._models import * # noqa: F403 +from gemara.v1._models import __all__ as _MODEL_NAMES +from gemara.v1._registry import DOCUMENT_TYPES, SCHEMA_VERSION, GemaraDocument + +__all__ = [ + "DOCUMENT_TYPES", + "SCHEMA_VERSION", + "GemaraDocument", + "GemaraError", + "UnknownDocumentTypeError", + "load", + "loads", + *_MODEL_NAMES, +] diff --git a/src/gemara/v1/_loader.py b/src/gemara/v1/_loader.py new file mode 100644 index 0000000..65e8345 --- /dev/null +++ b/src/gemara/v1/_loader.py @@ -0,0 +1,118 @@ +"""Read a Gemara document and dispatch it to the right model. + +Hand-written; passes `mypy --strict`. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import IO, Any, cast + +from gemara.v1._registry import DOCUMENT_TYPES, GemaraDocument + +__all__ = ["GemaraError", "UnknownDocumentTypeError", "load", "loads"] + + +class GemaraError(Exception): + """Base class for every error this package raises.""" + + +class UnknownDocumentTypeError(GemaraError): + """`metadata.type` was absent or not one of the known document types.""" + + def __init__(self, value: object) -> None: + self.value = value + known = ", ".join(sorted(DOCUMENT_TYPES)) + super().__init__(f"unknown document type {value!r}; expected one of: {known}") + + +def _decode(data: bytes | bytearray | memoryview) -> str: + """Decode UTF-8 bytes, turning a decoding failure into a `GemaraError`. + + Decoding is the first step that can reject a document, so it must raise from + the same hierarchy as parsing -- otherwise widening `loads` to accept + bytes-likes would reopen the leak that wrapping `yaml.YAMLError` closed. + """ + try: + return bytes(data).decode("utf-8") + except UnicodeDecodeError as exc: + raise GemaraError(f"document is not valid UTF-8: {exc}") from exc + + +def _parse(text: str) -> Any: + try: + import yaml + except ModuleNotFoundError: + try: + return json.loads(text) + except json.JSONDecodeError as exc: + raise GemaraError( + "could not parse the document as JSON and PyYAML is not installed; " + "install the yaml extra with `pip install py-gemara[yaml]` to read YAML" + ) from exc + try: + parsed: Any = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise GemaraError(f"could not parse the document as YAML or JSON: {exc}") from exc + return parsed + + +def _dispatch(raw: Any) -> GemaraDocument: + if not isinstance(raw, dict): + raise GemaraError(f"a Gemara document must be a mapping, got {type(raw).__name__}") + metadata = raw.get("metadata") + document_type = metadata.get("type") if isinstance(metadata, dict) else None + if not isinstance(document_type, str) or document_type not in DOCUMENT_TYPES: + raise UnknownDocumentTypeError(document_type) + model = DOCUMENT_TYPES[document_type] + # DOCUMENT_TYPES' declared value type is `type[BaseModel]` (see + # tools/generate.py's render_registry) so that adding a document type never + # requires widening it by hand. The cast is sound because + # `check_document_types` (run at generation time) guarantees DOCUMENT_TYPES' + # values are exactly the GemaraDocument union's members -- a fact + # `test_registry.py::test_gemara_document_alias_matches_document_types` + # keeps honest. + return cast(GemaraDocument, model.model_validate(raw)) + + +def loads(text: str | bytes | bytearray | memoryview) -> GemaraDocument: + """Parse a Gemara document from JSON or YAML text. + + `text` follows the `json.loads`/`pickle.loads` convention: a `str`, or + bytes-like (`bytes`, `bytearray`, or `memoryview`) UTF-8-encoded text. + + Dispatches on `metadata.type`. Raises `GemaraError` (or a subclass) for + every failure this function can produce: `GemaraError` itself if bytes-like + input is not valid UTF-8, if the text cannot be parsed as YAML or JSON, or + if the parsed value is not a mapping; + `UnknownDocumentTypeError` (a `GemaraError` subclass) if `metadata.type` is + missing or unrecognised; and `pydantic.ValidationError` if the document + does not match its model. No other exception type -- in particular no + `yaml.YAMLError` or `json.JSONDecodeError` -- escapes this function, + regardless of whether the optional `yaml` extra is installed. + """ + if isinstance(text, str): + decoded = text + else: + # `isinstance(text, bytes)` is False for `bytearray`/`memoryview`, so a + # narrower check would let those buffers reach here undecoded and fail + # deep inside YAML/JSON with an unhelpful internals error instead. + decoded = _decode(text) + return _dispatch(_parse(decoded)) + + +def load(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> GemaraDocument: + """Read and parse a Gemara document from a file path or an open file. + + `source` follows the `json.load`/`pickle.load` convention: a path (`str` + or `os.PathLike`), or an already-open file object providing `.read()` + (e.g. the result of `open(path)`). Like `json.load`, the file may be opened + in either text or binary mode; binary content is decoded as UTF-8. + + Raises the same exceptions as `loads`, to which it delegates. + """ + if isinstance(source, (str, os.PathLike)): + return loads(Path(source).read_bytes()) + return loads(source.read()) diff --git a/src/gemara/v1/_models.py b/src/gemara/v1/_models.py new file mode 100644 index 0000000..59732fc --- /dev/null +++ b/src/gemara/v1/_models.py @@ -0,0 +1,2028 @@ +# GENERATED by tools/generate.py. Do not edit. +# generated by datamodel-codegen: +# filename: gemara-v1.schema.json + +from __future__ import annotations + +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, RootModel + + +class Model(RootModel[Any]): + root: Any + + +class ArtifactMapping(BaseModel): + """ArtifactMapping represents a mapping to an external artifact or artifact entry""" + + model_config = ConfigDict( + populate_by_name=True, + ) + reference_id: Annotated[str, Field(alias="reference-id")] + """reference-id identifies an element from a MappingReference in the artifact's metadata""" + remarks: str | None = None + """remarks is prose regarding the mapped artifact or the mapping relationship""" + + +class ArtifactType(Enum): + """ + ArtifactType identifies the kind of Gemara artifact for unambiguous parsing + """ + + capability_catalog = "CapabilityCatalog" + control_catalog = "ControlCatalog" + guidance_catalog = "GuidanceCatalog" + threat_catalog = "ThreatCatalog" + risk_catalog = "RiskCatalog" + policy = "Policy" + mapping_document = "MappingDocument" + lexicon = "Lexicon" + evaluation_log = "EvaluationLog" + enforcement_log = "EnforcementLog" + vector_catalog = "VectorCatalog" + principle_catalog = "PrincipleCatalog" + audit_log = "AuditLog" + + +class Type(Enum): + """ + type identifies the kind of Gemara artifact for unambiguous parsing + """ + + capability_catalog = "CapabilityCatalog" + control_catalog = "ControlCatalog" + guidance_catalog = "GuidanceCatalog" + threat_catalog = "ThreatCatalog" + risk_catalog = "RiskCatalog" + policy = "Policy" + mapping_document = "MappingDocument" + lexicon = "Lexicon" + evaluation_log = "EvaluationLog" + enforcement_log = "EnforcementLog" + vector_catalog = "VectorCatalog" + principle_catalog = "PrincipleCatalog" + audit_log = "AuditLog" + + +class Capability(BaseModel): + """Capability describes a system capability such as a feature, component or object.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description provides a detailed overview of this capability""" + group: str + """group references by id a catalog group that this capability belongs to""" + id: str + """id allows this entry to be referenced by other elements""" + title: str + """title describes this capability at a glance""" + + +class ConfidenceLevel(Enum): + """ + ConfidenceLevel indicates the evaluator's confidence level in an assessment result. + """ + + undetermined = "Undetermined" + low = "Low" + medium = "Medium" + high = "High" + + +class Constraint(BaseModel): + """Constraint defines a prescriptive requirement that applies to a specific guidance or control.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """Unique ID for this constraint to enable Layer 5/6 tracking""" + target_id: Annotated[str, Field(alias="target-id")] + """Links to the specific Guidance or Control being constrained""" + text: str + """The prescriptive requirement/constraint text""" + + +class Dimensions(BaseModel): + """Dimensions specify the applicability criteria for a policy""" + + model_config = ConfigDict( + populate_by_name=True, + ) + geopolitical: Annotated[list[str] | None, Field(min_length=1)] = None + """geopolitical is an optional list of geopolitical regions""" + groups: Annotated[list[str] | None, Field(min_length=1)] = None + sensitivity: Annotated[list[str] | None, Field(min_length=1)] = None + """sensitivity is an optional list of data classification levels""" + technologies: Annotated[list[str] | None, Field(min_length=1)] = None + """technologies is an optional list of technology categories or services""" + users: Annotated[list[str] | None, Field(min_length=1)] = None + """users is an optional list of user roles""" + + +class Disposition(Enum): + """ + Disposition enumerates the possible enforcement outcomes. + """ + + undetermined = "Undetermined" + enforced = "Enforced" + tolerated = "Tolerated" + clear = "Clear" + + +class EnforcementMethodType(Enum): + gate = "Gate" + remediation = "Remediation" + + +class EntityType(Enum): + """ + EntityType specifies what entity is interacting in the workflow + """ + + human = "Human" + software = "Software" + software_assisted = "Software Assisted" + + +class EntryMapping(BaseModel): + """EntryMapping represents how a specific entry maps to a MappingReference.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + entry_id: Annotated[str, Field(alias="entry-id")] + """entry-id is the identifier being mapped to in the referenced artifact""" + reference_id: Annotated[str, Field(alias="reference-id")] + """reference-id is the id for a MappingReference entry in the artifact's metadata""" + remarks: str | None = None + """remarks is prose describing the mapping relationship""" + + +class EntryType(Enum): + """ + EntryType enumerates the atomic units within Gemara artifacts that can participate in mappings + """ + + guideline = "Guideline" + statement = "Statement" + control = "Control" + assessment_requirement = "AssessmentRequirement" + capability = "Capability" + threat = "Threat" + risk = "Risk" + vector = "Vector" + principle = "Principle" + + +class EvaluationMethodType(Enum): + intent = "Intent" + behavioral = "Behavioral" + + +class EvidenceMapping(BaseModel): + """ + EvidenceMapping identifies the source from which evidence was collected. + reference-id names the MappingReference; coordinate or entry-id gives + specificity within it; digest pins the observed content at collection time. + """ + + model_config = ConfigDict( + populate_by_name=True, + ) + coordinate: str | None = None + """ + coordinate is the precise location within the stream identified by reference-id + (e.g. an API path, file path, or JSON path expression). + Do not set if entry-id is set. + """ + digest: Annotated[str | None, Field(pattern="^[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+$")] = None + """digest is a cryptographic hash of the observed content at collection time; format: algorithm:encoded (e.g. sha256:abc123...)""" + entry_id: Annotated[str | None, Field(alias="entry-id")] = None + """ + entry-id identifies a specific entry within a referenced Gemara artifact. + Do not set if coordinate is set. + """ + reference_id: Annotated[str, Field(alias="reference-id")] + """reference-id ties this evidence to a mapping-reference in the artifact's metadata""" + remarks: str | None = None + """remarks is prose regarding this evidence reference""" + + +class Group(BaseModel): + """Group represents a classification or grouping that can be used in different contexts with semantic meaning derived from its usage""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description explains the significance and traits of entries to this group""" + id: str + """id allows this entry to be referenced by other elements""" + title: str + """title describes the purpose of this group at a glance""" + + +class GuidanceImport(BaseModel): + """GuidanceImport defines how to import guidance documents with optional exclusions and constraints.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + constraints: Annotated[list[Constraint] | None, Field(min_length=1)] = None + """Constraints allow policy authors to define ad hoc minimum requirements (e.g., "review at least annually").""" + exclusions: Annotated[list[str] | None, Field(min_length=1)] = None + reference_id: Annotated[str, Field(alias="reference-id")] + + +class GuidanceType(Enum): + """ + GuidanceType restricts the possible types that a catalog may be listed as + """ + + standard = "Standard" + regulation = "Regulation" + best_practice = "Best Practice" + framework = "Framework" + + +class ImplementationDetails(BaseModel): + """ImplementationDetails specifies the timeline for policy implementation.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + end: AwareDatetime | None = None + """Datetime represents an ISO 8601 formatted datetime string""" + notes: str + start: AwareDatetime + """Datetime represents an ISO 8601 formatted datetime string""" + + +class ImplementationPlan(BaseModel): + """ImplementationPlan defines when and how the policy becomes active.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + enforcement_timeline: Annotated[ImplementationDetails, Field(alias="enforcement-timeline")] + evaluation_timeline: Annotated[ImplementationDetails, Field(alias="evaluation-timeline")] + notification_process: Annotated[str | None, Field(alias="notification-process")] = None + + +class LexiconReference(BaseModel): + """LexiconReference cites a source supporting a lexicon definition""" + + model_config = ConfigDict( + populate_by_name=True, + ) + citation: str + """citation identifies the source material in prose""" + url: Annotated[str | None, Field(pattern="^(https?|file)://[^\\s]+$")] = None + """url points to supporting material when available""" + + +class LexiconTerm(BaseModel): + """LexiconTerm is a single definition within a lexicon""" + + model_config = ConfigDict( + populate_by_name=True, + ) + definition: str + """definition explains the meaning of the term""" + id: str + """id allows this entry to be referenced for anchors and tooling""" + references: Annotated[list[LexiconReference] | None, Field(min_length=1)] = None + """references cites external authorities supporting the definition""" + synonyms: Annotated[list[str] | None, Field(min_length=1)] = None + """synonyms lists alternative labels that should resolve to this term for linking""" + title: str + """title is the canonical name of the defined concept""" + + +class Lifecycle(Enum): + """ + Lifecycle represents the lifecycle state of a guideline, control, or assessment requirement + """ + + active = "Active" + draft = "Draft" + deprecated = "Deprecated" + retired = "Retired" + + +class MappingReference(BaseModel): + """MappingReference represents a reference to an external document with full metadata.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + """description is prose regarding the artifact's purpose or content""" + id: str + """ + id identifies this mapping reference within the artifact and, when url + is absent, the referenced artifact's metadata.id. + """ + title: str + """title describes the purpose of this mapping reference at a glance""" + url: Annotated[str | None, Field(pattern="^(https?|file)://[^\\s]+$")] = None + """url is the path where the artifact may be retrieved; preferably responds with Gemara-compatible YAML/JSON""" + version: str + """version is the version identifier of the artifact being mapped to""" + + +class MappingTarget(BaseModel): + """MappingTarget identifies a target entry with optional per-target metadata""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str] | None, Field(min_length=1)] = None + """applicability constrains the contexts in which this target mapping holds""" + confidence_level: Annotated[ConfidenceLevel | None, Field(alias="confidence-level")] = None + entry_id: Annotated[str, Field(alias="entry-id")] + """entry-id identifies the specific entry in the target artifact""" + rationale: str | None = None + """rationale explains why this relationship exists for this target""" + remarks: str | None = None + """remarks is general prose regarding this target mapping""" + strength: float | None = None + """strength is the author's estimate of how completely the source satisfies this target; range 1-10""" + + +class MethodType(Enum): + behavioral = "Behavioral" + intent = "Intent" + remediation = "Remediation" + gate = "Gate" + + +class MitigatedRisk(BaseModel): + """MitigatedRisk represents a risk addressed by the policy""" + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """id allows this mitigated risk entry to be referenced by accepted risks""" + risk: EntryMapping + """risk references the risk being mitigated""" + + +class ModType(Enum): + """ + ModType defines the type of modification to the assessment requirement. + """ + + add = "Add" + modify = "Modify" + remove = "Remove" + replace_ = "Replace" + override = "Override" + + +class ModeType(Enum): + manual = "Manual" + automated = "Automated" + + +class MultiEntryMapping(BaseModel): + """MultiEntryMapping represents a mapping to an external reference with one or more entries.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + reference_id: Annotated[str, Field(alias="reference-id")] + """reference-id identifies an element from a MappingReference in the artifact's metadata""" + remarks: str | None = None + """remarks is prose regarding the mapped artifact or the mapping relationship""" + entries: Annotated[list[ArtifactMapping], Field(min_length=1)] + """entries is a list of mapping entries""" + + +class Parameter(BaseModel): + """Parameter defines a configurable parameter for assessment or enforcement activities.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + accepted_values: Annotated[list[str] | None, Field(alias="accepted-values", min_length=1)] = None + description: str + id: str + label: str + + +class Principle(BaseModel): + """Principle represents a foundational value or tenet that guides governance, design, and operational decisions""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description explains the principle and its expected outcomes""" + group: str + """group references by id a catalog group that this principle belongs to""" + id: str + """id allows this entry to be referenced by other elements""" + rationale: str | None = None + """rationale provides the context for this principle""" + title: str + """title describes the principle at a glance""" + + +class Rationale(BaseModel): + """Rationale provides a structured way to communicate a guideline author's intent""" + + model_config = ConfigDict( + populate_by_name=True, + ) + goals: Annotated[list[str], Field(min_length=1)] + """goals is a list of outcomes this guideline seeks to achieve""" + importance: str + """importance is an explanation of why this guideline matters""" + + +class Recommendation(BaseModel): + """Recommendation provides a corrective action for an audit result""" + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str | None = None + """id uniquely identifies this recommendation""" + required: bool | None = None + """required indicates whether this recommendation is a mandatory corrective action""" + text: str + """text describes the recommended corrective action""" + + +class RelationshipType(Enum): + """ + RelationshipType enumerates the nature of the mapping between entries. + """ + + implements = "implements" + implemented_by = "implemented-by" + supports = "supports" + supported_by = "supported-by" + equivalent = "equivalent" + subsumes = "subsumes" + no_match = "no-match" + relates_to = "relates-to" + + +class Result(Enum): + not_run = "Not Run" + passed = "Passed" + failed = "Failed" + needs_review = "Needs Review" + not_applicable = "Not Applicable" + unknown = "Unknown" + + +class ResultType(Enum): + """ + ResultType classifies the nature of an audit result + """ + + gap = "Gap" + finding = "Finding" + observation = "Observation" + strength = "Strength" + + +class RiskAppetite(Enum): + """ + RiskAppetite defines the acceptable level of exposure for a risk category + """ + + minimal = "Minimal" + low = "Low" + moderate = "Moderate" + high = "High" + + +class Scope(BaseModel): + """Scope defines what is included and excluded from policy applicability.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + in_: Annotated[Dimensions | None, Field(alias="in")] = None + out: Dimensions | None = None + + +class Severity(Enum): + """ + Severity defines the assessed level of a risk based on its potential impact and likelihood + """ + + low = "Low" + medium = "Medium" + high = "High" + critical = "Critical" + + +class Statement(BaseModel): + """ + Statement represents a structural sub-requirement within a guideline; + They do not increase strictness and all statements within a guideline apply together + """ + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """id allows this entry to be referenced by other elements""" + recommendations: Annotated[list[str] | None, Field(min_length=1)] = None + """recommendations is a list of non-binding suggestions to aid in evaluation or enforcement of the statement""" + text: str + """text is the body of this statement""" + title: str | None = None + """title describes the contents of this statement""" + + +class TypedMapping(BaseModel): + """TypedMapping extends ArtifactMapping with a required entry-type for all entries in this direction""" + + model_config = ConfigDict( + populate_by_name=True, + ) + reference_id: Annotated[str, Field(alias="reference-id")] + """reference-id identifies an element from a MappingReference in the artifact's metadata""" + remarks: str | None = None + """remarks is prose regarding the mapped artifact or the mapping relationship""" + entry_type: Annotated[EntryType, Field(alias="entry-type")] + """entry-type identifies the type of atomic unit entries in this direction""" + + +class Vector(BaseModel): + """A Vector represents a method, pathway, or technique through which a threat may be realized or an attack may be carried out.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str] | None, Field(min_length=1)] = None + """applicability specifies the contexts in which this vector can manifest""" + description: str + """description explains how the attack vector works""" + group: str + """group references by id a catalog group that this vector belongs to""" + id: str + """id allows this vector to be referenced by other elements""" + title: str + """title describes the vector""" + + +class FieldMappingStrict(BaseModel): + """_MappingStrict layers the "targets required when not no-match" rule on top of #Mapping""" + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """id allows this mapping to be referenced by other elements""" + relationship: RelationshipType + """relationship describes the nature of the mapping between source and all targets""" + remarks: str | None = None + """remarks is general prose regarding this mapping""" + source: str + """source identifies the entry being mapped from by its entry-id""" + targets: Annotated[list[MappingTarget] | None, Field(min_length=1)] = None + """targets identifies the entries being mapped to; absent when relationship is no-match""" + + +class ReferenceId(RootModel[str]): + root: str + """reference-id is the id for a MappingReference entry in the artifact's metadata""" + + +class AcceptedRisk(BaseModel): + """ + AcceptedRisk documents a risk the organization has chosen to accept, + optionally linking it to a mitigated risk when the acceptance covers + residual risk after partial mitigation. + """ + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """id allows this accepted risk entry to be referenced""" + justification: str | None = None + """justification explains why the risk is accepted""" + risk: EntryMapping + """risk references the risk being accepted""" + scope: Scope | None = None + """scope defines where the risk acceptance applies""" + target_id: Annotated[str | None, Field(alias="target-id")] = None + """target-id optionally links this acceptance to a mitigated risk entry""" + + +class AssessmentFinding(BaseModel): + """AssessmentFinding maps an enforcement action to its originating assessment data across Layer 2, Layer 3, and Layer 5.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + log: EntryMapping + """log maps to the EvaluationLog entry containing the finding""" + plan: EntryMapping | None = None + """plan maps to the Policy assessment plan that was executed""" + requirement: EntryMapping | None = None + """requirement maps to the Layer 2 assessment requirement that was evaluated""" + result: Result + """result is the assessment outcome that triggered the enforcement action""" + + +class AssessmentRequirement(BaseModel): + """AssessmentRequirement describes a tightly scoped, verifiable condition that must be satisfied and confirmed by an evaluator""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str], Field(min_length=1)] + """applicability is a list of strings describing the situations where this text functions as a requirement for its parent control""" + id: str + """id allows this entry to be referenced by other elements""" + recommendation: str | None = None + """recommendation provides readers with non-binding suggestions to aid in evaluation or enforcement of the requirement""" + replaced_by: Annotated[EntryMapping | None, Field(alias="replaced-by")] = None + """replaced-by references the assessment requirement that supersedes this one when deprecated or retired""" + state: Lifecycle | None = None + """state is the lifecycle state of this assessment requirement""" + text: str + """text is the body of the requirement, typically written as a MUST condition""" + + +class AssessmentRequirementModifier(BaseModel): + """AssessmentRequirementModifier allows organizations to customize assessment requirements based on how an organization wants to gather evidence for the objective.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str] | None, Field(min_length=1)] = None + """The updated applicability of the assessment requirement""" + id: str + modification_rationale: Annotated[str, Field(alias="modification-rationale")] + modification_type: Annotated[ModType, Field(alias="modification-type")] + recommendation: str | None = None + """The updated recommendation for the assessment requirement""" + target_id: Annotated[str, Field(alias="target-id")] + text: str | None = None + """The updated text of the assessment requirement""" + + +class CatalogImport(BaseModel): + """CatalogImport defines how to import control catalogs with optional exclusions, constraints, and assessment requirement modifications.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + assessment_requirement_modifications: Annotated[ + list[AssessmentRequirementModifier] | None, + Field(alias="assessment-requirement-modifications", min_length=1), + ] = None + constraints: Annotated[list[Constraint] | None, Field(min_length=1)] = None + exclusions: Annotated[list[str] | None, Field(min_length=1)] = None + reference_id: Annotated[str, Field(alias="reference-id")] + + +class Contact(BaseModel): + """Contact is the contact information for a person or group""" + + model_config = ConfigDict( + populate_by_name=True, + ) + affiliation: str | None = None + """affiliation is the organization with which the contact entity is associated, such as a team, school, or employer""" + email: Annotated[str | None, Field(pattern="^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")] = None + """email is the preferred email address to reach the contact""" + name: str + """name is the preferred descriptor for the contact entity""" + social: str | None = None + """social is a social media handle or other profile for the contact, such as GitHub""" + + +class Control(BaseModel): + """Control describes a safeguard or countermeasure with a clear objective and assessment requirements""" + + model_config = ConfigDict( + populate_by_name=True, + ) + assessment_requirements: Annotated[ + list[AssessmentRequirement], + Field(alias="assessment-requirements", min_length=1), + ] + """assessment-requirements is a list of requirements that must be verified to confirm the control objective has been met""" + group: str + """group references by id a catalog group that this control belongs to""" + guidelines: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """guidelines documents relationships between this control and Layer 1 guideline artifacts""" + id: str + """id allows this entry to be referenced by other elements""" + objective: str + """objective is a unified statement of intent, which may encompass multiple situationally applicable requirements""" + replaced_by: Annotated[EntryMapping | None, Field(alias="replaced-by")] = None + """replaced-by references the control that supersedes this one when deprecated or retired""" + state: Lifecycle | None = None + """state is the lifecycle state of this control""" + threats: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """threats documents relationships between this control and Layer 2 threat artifacts""" + title: str + """title describes the purpose of this control at a glance""" + + +class ControlEvaluation(BaseModel): + """ControlEvaluation contains the results of evaluating a single Layer 5 control.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + assessment_logs: Annotated[list[dict[str, Any]], Field(alias="assessment-logs")] + """ + Enforce that control reference and the assessments' references match + This formulation uses the control's reference if the assessment doesn't include a reference + + Require start timestamp on assessments that actually executed + """ + control: EntryMapping + message: str + name: str + result: Result + + +class Entity(BaseModel): + """Entity represents a human or tool""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + """description provides additional context about the entity""" + id: str + """id uniquely identifies the entity and allows this entry to be referenced by other elements""" + name: str + """name is the name of the entity""" + type: EntityType + """type specifies the type of entity interacting in the workflow""" + uri: Annotated[str | None, Field(pattern="^https?://[^\\s]+$")] = None + """uri is a general URI for the entity information""" + version: str | None = None + """version is the version of the entity (for tools; if applicable)""" + + +class Evidence(BaseModel): + """ + Evidence records what was cited to support an opinion for a specific activity: + raw data for the evaluation layer, evaluation and enforcement artifacts for the audit layer. + At least one of payload or source MUST be present; an entry with neither is semantically incomplete. + """ + + model_config = ConfigDict( + populate_by_name=True, + ) + collected_at: Annotated[AwareDatetime, Field(alias="collected-at")] + """collected-at is the timestamp when the evidence was gathered""" + description: str | None = None + """description explains what this evidence represents""" + id: str + """id uniquely identifies this evidence""" + payload: Any | None = None + """payload is the raw evidence data collected inline""" + source: EvidenceMapping | None = None + """source identifies the artifact or system from which this evidence was collected""" + type: ArtifactType | str + """type categorizes the kind of evidence""" + + +class Exemption(BaseModel): + """Exemption describes a single scenario where the catalog is not applicable""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description identifies who or what is exempt from the full guidance""" + reason: str + """reason explains why the exemption is granted""" + redirect: MultiEntryMapping | None = None + """redirect points to alternative guidelines or controls that should be followed instead""" + + +class Guideline(BaseModel): + """Guideline provides explanatory context and recommendations for designing optimal outcomes""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str] | None, Field(min_length=1)] = None + """applicability specifies the contexts in which this guideline applies""" + extends: EntryMapping | None = None + """extends is an id for a guideline which this guideline adds to, in this document or elsewhere""" + group: str + """group provides an id to the group that this guideline belongs to""" + id: str + """id allows this entry to be referenced by other elements""" + objective: str + """objective is a unified statement of intent, which may encompass multiple situationally applicable statements""" + principles: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """principles documents the relationship between this guideline and one or more principles""" + rationale: Rationale | None = None + """rationale provides the context for this guideline""" + recommendations: Annotated[list[str] | None, Field(min_length=1)] = None + """recommendations is a list of non-binding suggestions to aid in evaluation or enforcement of the guideline""" + replaced_by: Annotated[EntryMapping | None, Field(alias="replaced-by")] = None + """replaced-by references the guideline that supersedes this one when deprecated or retired""" + see_also: Annotated[list[str] | None, Field(alias="see-also", min_length=1)] = None + """see-also lists related guideline IDs within the same GuidanceCatalog""" + state: Lifecycle | None = None + """state is the lifecycle state of this guideline""" + statements: Annotated[list[Statement] | None, Field(min_length=1)] = None + """statements is a list of structural sub-requirements within a guideline""" + title: str + """title describes the contents of this guideline""" + vectors: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """vector-mappings documents the relationship between this guideline and one or more vectors""" + + +class Imports(BaseModel): + """Imports defines external policies, controls, and guidelines required by this policy.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + catalogs: Annotated[list[CatalogImport] | None, Field(min_length=1)] = None + guidance: Annotated[list[GuidanceImport] | None, Field(min_length=1)] = None + policies: Annotated[list[ArtifactMapping] | None, Field(min_length=1)] = None + + +class Justification(BaseModel): + """Justification provides the assessment data and exception references that justify an enforcement action.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + assessments: Annotated[list[AssessmentFinding], Field(min_length=1)] + """assessments links the action to one or more Assessment Findings""" + exceptions: Annotated[list[ArtifactMapping] | None, Field(min_length=1)] = None + """exceptions references approved Policy exceptions that authorize the action""" + + +class Mapping(BaseModel): + """Mapping represents a relationship between a source entry and one or more target entries""" + + model_config = ConfigDict( + populate_by_name=True, + ) + id: str + """id allows this mapping to be referenced by other elements""" + relationship: RelationshipType + """relationship describes the nature of the mapping between source and all targets""" + remarks: str | None = None + """remarks is general prose regarding this mapping""" + source: str + """source identifies the entry being mapped from by its entry-id""" + targets: Annotated[list[MappingTarget] | None, Field(min_length=1)] = None + """targets identifies the entries being mapped to; absent when relationship is no-match""" + + +class RACI(BaseModel): + """RACI defines the roles responsible for managing an artifact""" + + model_config = ConfigDict( + populate_by_name=True, + ) + accountable: Annotated[list[Contact], Field(min_length=1)] + """accountable identifies the entity ultimately accountable for the outcome""" + consulted: Annotated[list[Contact] | None, Field(min_length=1)] = None + """consulted identifies entities whose input is required when assessing or responding to the artifact""" + informed: Annotated[list[Contact] | None, Field(min_length=1)] = None + """informed identifies entities that should be notified about changes to the artifact status""" + responsible: Annotated[list[Contact], Field(min_length=1)] + """responsible identifies the entities responsible for executing work to manage or mitigate the artifact""" + + +class Resource(BaseModel): + """Resource represents an entity that exists in the system and can be evaluated""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + """description provides additional context about the entity""" + id: str + """id uniquely identifies the entity and allows this entry to be referenced by other elements""" + name: str + """name is the name of the entity""" + type: EntityType + """type specifies the type of entity interacting in the workflow""" + uri: Annotated[str | None, Field(pattern="^https?://[^\\s]+$")] = None + """uri is a general URI for the entity information""" + version: str | None = None + """version is the version of the entity (for tools; if applicable)""" + environment: str | None = None + """environment describes where the resource exists (e.g., production, staging, development, specific region)""" + owner: Contact | None = None + """owner is the contact information for the person or group responsible for managing or owning this resource""" + + +class Risk(BaseModel): + """A Risk represents the potential for negative impact resulting from one or more threats.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description explains the risk scenario""" + group: str + """group references by id a catalog group that this risk belongs to""" + id: str + """id allows this risk to be referenced by other elements""" + impact: str | None = None + """impact describes the business or operational impact""" + owner: RACI | None = None + """owner defines the RACI roles responsible for managing this risk""" + rank: int | None = None + """ + rank optionally orders risks for the same catalog (e.g. when several share the same severity). + Lower values mean higher relative importance. Omitted when the four severity levels are enough. + When set, each value must be unique among all risks in the catalog that specify rank. + """ + severity: Severity + """severity describes the assessed level of this risk""" + threats: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """threats link this risk to Layer 2 threats""" + title: str + """title describes the risk""" + + +class RiskCategory(BaseModel): + """RiskCategory describes a grouping of risks and defines appetite boundaries""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str + """description explains the significance and traits of entries to this group""" + id: str + """id allows this entry to be referenced by other elements""" + title: str + """title describes the purpose of this group at a glance""" + appetite: RiskAppetite + """appetite defines the acceptable level of risk for this category""" + max_severity: Annotated[Severity | None, Field(alias="max-severity")] = None + """ + max-severity defines the risk tolerance boundary: the highest severity + the organization will accept within this category + """ + + +class Risks(BaseModel): + """Risks defines mitigated and accepted risks addressed by this policy.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + accepted: Annotated[list[AcceptedRisk] | None, Field(min_length=1)] = None + """Accepted risks require rationale (justification) and may include scope. Controls addressing these risks are implicitly identified through threat mappings.""" + mitigated: Annotated[list[MitigatedRisk] | None, Field(min_length=1)] = None + """Mitigated risks only need reference-id and risk-id (no justification required)""" + + +class FieldAssessmentLogStrict(BaseModel): + """_AssessmentLogStrict layers the "start required unless unexecuted" rule on top of #AssessmentLog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str], Field(min_length=1)] + """Applicability is elevated from the Layer 2 Assessment Requirement to aid in execution and reporting.""" + confidence_level: Annotated[ConfidenceLevel | None, Field(alias="confidence-level")] = None + """ConfidenceLevel indicates the evaluator's confidence level in this specific assessment result.""" + description: str + """Description provides a summary of the assessment procedure.""" + end: AwareDatetime | None = None + """End is the timestamp when the assessment concluded.""" + evidence: Annotated[list[Evidence] | None, Field(min_length=1)] = None + """Evidence records the raw data cited to support this assessment's opinion.""" + message: str + """Message provides additional context about the assessment result.""" + plan: EntryMapping | None = None + """Plan maps to the policy assessment plan being executed.""" + recommendation: str | None = None + """Recommendation provides guidance on how to address a failed assessment.""" + requirement: EntryMapping + """Requirement should map to the assessment requirement for this assessment.""" + result: Result + """Result is the overall outcome of the assessment procedure, matching the result of the last step that was run.""" + start: AwareDatetime | None = None + """ + Start is the timestamp when the assessment began. + Assessments that never executed have no start time to record. + """ + steps: Annotated[list[str], Field(min_length=1)] + """Steps are sequential actions taken as part of the assessment, which may halt the assessment if a failure occurs.""" + steps_executed: Annotated[int | None, Field(alias="steps-executed")] = None + """Steps-executed is the number of steps that were executed as part of the assessment.""" + + +class ActionResult(BaseModel): + """ActionResult captures a performed enforcement action.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + disposition: Disposition + """disposition is the enforcement action taken""" + end: AwareDatetime | None = None + """end is the timestamp when the enforcement action concluded""" + justification: Justification + """justification links the action to its assessment findings and any applicable exceptions""" + message: str | None = None + """message provides additional context about the action""" + method: EntryMapping + """method references the specific AcceptedMethod entry within the Policy being enforced""" + start: AwareDatetime + """start is the timestamp when the enforcement action began""" + steps: Annotated[list[str], Field(min_length=1)] + """steps references the code paths or addresses that carried out this enforcement action""" + + +class Actor(BaseModel): + """Actor represents an entity (human or tool) that performs actions in evaluations""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + """description provides additional context about the entity""" + id: str + """id uniquely identifies the entity and allows this entry to be referenced by other elements""" + name: str + """name is the name of the entity""" + type: EntityType + """type specifies the type of entity interacting in the workflow""" + uri: Annotated[str | None, Field(pattern="^https?://[^\\s]+$")] = None + """uri is a general URI for the entity information""" + version: str | None = None + """version is the version of the entity (for tools; if applicable)""" + contact: Contact | None = None + """contact is contact information for the actor""" + + +class EnforcementMethod(BaseModel): + """AcceptedMethod defines a method for evaluation or enforcement.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + executor: Actor | None = None + id: str + mode: ModeType + required: bool | None = None + type: EnforcementMethodType + + +class EvaluationMethod(BaseModel): + """AcceptedMethod defines a method for evaluation or enforcement.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + executor: Actor | None = None + id: str + mode: ModeType + required: bool | None = None + type: EvaluationMethodType + + +class AssessmentLog(BaseModel): + """AssessmentLog contains the results of executing a single assessment procedure for a control requirement.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability: Annotated[list[str], Field(min_length=1)] + """Applicability is elevated from the Layer 2 Assessment Requirement to aid in execution and reporting.""" + confidence_level: Annotated[ConfidenceLevel | None, Field(alias="confidence-level")] = None + """ConfidenceLevel indicates the evaluator's confidence level in this specific assessment result.""" + description: str + """Description provides a summary of the assessment procedure.""" + end: AwareDatetime | None = None + """End is the timestamp when the assessment concluded.""" + evidence: Annotated[list[Evidence] | None, Field(min_length=1)] = None + """Evidence records the raw data cited to support this assessment's opinion.""" + message: str + """Message provides additional context about the assessment result.""" + plan: EntryMapping | None = None + """Plan maps to the policy assessment plan being executed.""" + recommendation: str | None = None + """Recommendation provides guidance on how to address a failed assessment.""" + requirement: EntryMapping + """Requirement should map to the assessment requirement for this assessment.""" + result: Result + """Result is the overall outcome of the assessment procedure, matching the result of the last step that was run.""" + start: AwareDatetime | None = None + """ + Start is the timestamp when the assessment began. + Assessments that never executed have no start time to record. + """ + steps: Annotated[list[str], Field(min_length=1)] + """Steps are sequential actions taken as part of the assessment, which may halt the assessment if a failure occurs.""" + steps_executed: Annotated[int | None, Field(alias="steps-executed")] = None + """Steps-executed is the number of steps that were executed as part of the assessment.""" + + +class AssessmentPlan(BaseModel): + """AssessmentPlan defines how a specific assessment requirement is evaluated.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + evaluation_methods: Annotated[list[EvaluationMethod], Field(alias="evaluation-methods", min_length=1)] + evidence_requirements: Annotated[str | None, Field(alias="evidence-requirements")] = None + frequency: str + id: str + parameters: Annotated[list[Parameter] | None, Field(min_length=1)] = None + requirement_id: Annotated[str, Field(alias="requirement-id")] + + +class AuditLogMetadata(BaseModel): + """metadata provides detailed data about this log""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["AuditLog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class AuditResult(BaseModel): + """AuditResult records a single result with supporting evidence and recommendations.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + criteria_reference: Annotated[MultiEntryMapping, Field(alias="criteria-reference")] + """criteria-reference maps this result to specific criteria entries""" + description: str + """description explains the result in detail""" + evidence: Annotated[list[Evidence] | None, Field(min_length=1)] = None + """evidence records the data sources that support this result""" + id: str + """id uniquely identifies this result""" + recommendations: Annotated[list[Recommendation] | None, Field(min_length=1)] = None + """recommendations records corrective actions for this result""" + title: str + """title describes this result at a glance""" + type: ResultType + """type classifies the nature of this result""" + + +class CapabilityCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["CapabilityCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class CapabilityCatalog(BaseModel): + """CapabilityCatalog describes a collection of system capabilities""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[CapabilityCatalogMetadata, Field(title="CapabilityCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + capabilities: Annotated[list[Capability] | None, Field(min_length=1)] = None + """capabilities is a list of capabilities defined by this catalog""" + + +class ControlCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["ControlCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class ControlCatalog(BaseModel): + """ControlCatalog describes a set of related controls and relevant metadata""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[ControlCatalogMetadata, Field(title="ControlCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + controls: Annotated[list[Control] | None, Field(min_length=1)] = None + """controls is a list of unique controls defined by this catalog""" + + +class EnforcementLogMetadata(BaseModel): + """metadata provides detailed data about this log""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["EnforcementLog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class EnforcementLog(BaseModel): + """EnforcementLog records actions taken in response to noncompliance findings from Layer 5 evaluations.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: Annotated[EnforcementLogMetadata, Field(title="EnforcementLogMetadata")] + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + actions: Annotated[list[ActionResult], Field(min_length=1)] + """actions is the list of enforcement actions performed""" + disposition: Disposition + """disposition is the aggregate enforcement disposition across all actions in this log""" + + +class EvaluationLogMetadata(BaseModel): + """metadata provides detailed data about this log""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["EvaluationLog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class EvaluationLog(BaseModel): + """EvaluationLog contains the results of evaluating a set of Layer 2 controls.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: Annotated[EvaluationLogMetadata, Field(title="EvaluationLogMetadata")] + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + evaluations: Annotated[list[ControlEvaluation], Field(min_length=1)] + result: Result + """result is the aggregate outcome across all evaluations in this log""" + + +class GuidanceCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["GuidanceCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class GuidanceCatalog(BaseModel): + """GuidanceCatalog represents a concerted documentation effort to help bring about an optimal future without foreknowledge of the implementation details""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[GuidanceCatalogMetadata, Field(title="GuidanceCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + exemptions: Annotated[list[Exemption] | None, Field(min_length=1)] = None + """exemptions provides information about situations where this guidance is not applicable""" + front_matter: Annotated[str | None, Field(alias="front-matter")] = None + """front-matter provides introductory text for the document to be used during rendering""" + guidelines: Annotated[list[Guideline] | None, Field(min_length=1)] = None + """guidelines is a list of unique guidelines defined by this catalog""" + type: GuidanceType + """type categorizes this document based on the intent of its contents""" + + +class LexiconMetadata(BaseModel): + """metadata provides detailed data about this document""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["Lexicon"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class Lexicon(BaseModel): + """Lexicon is a controlled vocabulary or glossary artifact referenced by Metadata.lexicon""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: Annotated[LexiconMetadata, Field(title="LexiconMetadata")] + """metadata provides detailed data about this document""" + terms: Annotated[list[LexiconTerm], Field(min_length=1)] + """terms is one or more defined entries for linking and rendering""" + title: str + """title describes the purpose of this lexicon at a glance""" + + +class MappingDocumentMetadata(BaseModel): + """metadata provides detailed data about this document""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference], Field(alias="mapping-references", min_length=1)] + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["MappingDocument"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class MappingDocument(BaseModel): + """MappingDocument captures the user's intent for how entries in a source artifact relate to entries in a target artifact""" + + model_config = ConfigDict( + populate_by_name=True, + ) + mappings: Annotated[list[FieldMappingStrict], Field(min_length=1)] + """mappings is one or more atomic relationships between entries in the referenced artifacts""" + metadata: Annotated[MappingDocumentMetadata, Field(title="MappingDocumentMetadata")] + """metadata provides detailed data about this document""" + remarks: str | None = None + """remarks is prose regarding this mapping document""" + source_reference: Annotated[TypedMapping, Field(alias="source-reference")] + """source-reference identifies the artifact being mapped from; must match a mapping-reference id""" + target_reference: Annotated[TypedMapping, Field(alias="target-reference")] + """target-reference identifies the artifact being mapped to; must match a mapping-reference id""" + title: str + """title describes the purpose of this mapping document at a glance""" + + +class Metadata(BaseModel): + """Metadata represents common metadata fields shared across all layers""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: ArtifactType + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class PolicyMetadata(BaseModel): + """Metadata represents common metadata fields shared across all layers""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["Policy"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class PrincipleCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["PrincipleCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class PrincipleCatalog(BaseModel): + """PrincipleCatalog describes a set of related principles and relevant metadata""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[PrincipleCatalogMetadata, Field(title="PrincipleCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + principles: Annotated[list[Principle] | None, Field(min_length=1)] = None + """principles is a list of unique principles defined by this catalog""" + + +class RiskCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["RiskCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class RiskCatalog(BaseModel): + """ + A RiskCatalog is a structured collection of documented risks that may affect an organization, + system, or service. It provides a centralized reference for risks that can be mapped to threats + and referenced by policies when documenting how those risks are mitigated or accepted. + """ + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group | RiskCategory] | None, Field(min_length=1)] = None + """groups narrows the base groups to risk categories with appetite and severity boundaries""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[RiskCatalogMetadata, Field(title="RiskCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + risks: Annotated[list[Risk] | None, Field(min_length=1)] = None + """risks is a list of risks defined by this catalog""" + + +class Threat(BaseModel): + """Threat describes a specifically-scoped opportunity for a negative impact to the organization""" + + model_config = ConfigDict( + populate_by_name=True, + ) + actors: Annotated[list[Actor] | None, Field(min_length=1)] = None + """actors describes the relevant internal or external threat actors""" + capabilities: Annotated[list[MultiEntryMapping], Field(min_length=1)] + """capabilities documents the relationship between this threat and a system capability""" + description: str + """description provides a detailed explanation of an opportunity for negative impact""" + group: str + """group references by id a catalog group that this threat belongs to""" + id: str + """id allows this entry to be referenced by other elements""" + title: str + """title describes this threat at a glance""" + vectors: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """vectors documents the relationship between this threat and one or more vectors""" + + +class ThreatCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["ThreatCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class ThreatCatalog(BaseModel): + """ThreatCatalog describes a set of topically-associated threats""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[ThreatCatalogMetadata, Field(title="ThreatCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + threats: Annotated[list[Threat] | None, Field(min_length=1)] = None + """threats is a list of threats defined by this catalog""" + + +class VectorCatalogMetadata(BaseModel): + """metadata provides detailed data about this catalog""" + + model_config = ConfigDict( + populate_by_name=True, + ) + applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None + """applicability-groups is a list of groups used to classify within this artifact to specify scope""" + author: Actor + """author is the person or group primarily responsible for this artifact""" + date: AwareDatetime | None = None + """date is the publication or effective date of this artifact""" + description: str + """description provides a high-level summary of the artifact's purpose and scope""" + draft: bool | None = None + """draft indicates whether this artifact is a pre-release version; open to modification""" + gemara_version: Annotated[str, Field(alias="gemara-version")] + """gemara-version declares which version of the Gemara specification this artifact conforms to""" + id: str + """id allows this entry to be referenced by other elements""" + lexicon: ArtifactMapping | None = None + """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" + mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + """mapping-references is a list of external documents referenced within this artifact""" + type: Literal["VectorCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + version: str | None = None + """version is the version identifier of this artifact""" + + +class VectorCatalog(BaseModel): + """Catalog describes a set of topically-associated entries""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Annotated[VectorCatalogMetadata, Field(title="VectorCatalogMetadata")] + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + vectors: Annotated[list[Vector] | None, Field(min_length=1)] = None + """vectors is a list of attack vectors documented in this catalog""" + + +class AcceptedMethod(BaseModel): + """AcceptedMethod defines a method for evaluation or enforcement.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + description: str | None = None + executor: Actor | None = None + id: str + mode: ModeType + required: bool | None = None + type: MethodType + + +class Adherence(BaseModel): + """Adherence defines evaluation methods, assessment plans, enforcement methods, and non-compliance notifications.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + assessment_plans: Annotated[list[AssessmentPlan] | None, Field(alias="assessment-plans", min_length=1)] = None + enforcement_methods: Annotated[list[EnforcementMethod] | None, Field(alias="enforcement-methods", min_length=1)] = ( + None + ) + evaluation_methods: Annotated[list[EvaluationMethod] | None, Field(alias="evaluation-methods", min_length=1)] = None + non_compliance: Annotated[str | None, Field(alias="non-compliance")] = None + + +class AuditLog(BaseModel): + """AuditLog records results from an audit performed against a target resource""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: Annotated[AuditLogMetadata, Field(title="AuditLogMetadata")] + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + criteria: Annotated[list[ArtifactMapping], Field(min_length=1)] + """criteria defines the acceptable state for the audited resource""" + owner: RACI | None = None + """owner defines the RACI roles responsible for managing the audit""" + results: Annotated[list[AuditResult], Field(min_length=1)] + """results records audit results against the criteria""" + summary: str + """summary provides the high-level conclusion""" + + +class Catalog(BaseModel): + """Catalog describes a set of topically-associated entries""" + + model_config = ConfigDict( + populate_by_name=True, + ) + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Metadata + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + + +class Log(BaseModel): + """Log describes a set of recorded entries from a measurement activity""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: Metadata + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + + +class Policy(BaseModel): + """Policy represents a policy document with metadata, contacts, scope, imports, implementation plan, risks, and adherence requirements.""" + + model_config = ConfigDict( + populate_by_name=True, + ) + adherence: Adherence | None = None + contacts: RACI + implementation_plan: Annotated[ImplementationPlan | None, Field(alias="implementation-plan")] = None + imports: Imports | None = None + metadata: Annotated[PolicyMetadata, Field(title="PolicyMetadata")] + """Metadata represents common metadata fields shared across all layers""" + risks: Risks | None = None + scope: Scope | None = None + title: str + + +__all__ = [ + "AcceptedMethod", + "AcceptedRisk", + "ActionResult", + "Actor", + "Adherence", + "ArtifactMapping", + "ArtifactType", + "AssessmentFinding", + "AssessmentLog", + "AssessmentPlan", + "AssessmentRequirement", + "AssessmentRequirementModifier", + "AuditLog", + "AuditLogMetadata", + "AuditResult", + "Capability", + "CapabilityCatalog", + "CapabilityCatalogMetadata", + "Catalog", + "CatalogImport", + "ConfidenceLevel", + "Constraint", + "Contact", + "Control", + "ControlCatalog", + "ControlCatalogMetadata", + "ControlEvaluation", + "Dimensions", + "Disposition", + "EnforcementLog", + "EnforcementLogMetadata", + "EnforcementMethod", + "EnforcementMethodType", + "Entity", + "EntityType", + "EntryMapping", + "EntryType", + "EvaluationLog", + "EvaluationLogMetadata", + "EvaluationMethod", + "EvaluationMethodType", + "Evidence", + "EvidenceMapping", + "Exemption", + "FieldMappingStrict", + "Group", + "GuidanceCatalog", + "GuidanceCatalogMetadata", + "GuidanceImport", + "GuidanceType", + "Guideline", + "ImplementationDetails", + "ImplementationPlan", + "Imports", + "Justification", + "Lexicon", + "LexiconMetadata", + "LexiconReference", + "LexiconTerm", + "Lifecycle", + "Log", + "Mapping", + "MappingDocument", + "MappingDocumentMetadata", + "MappingReference", + "MappingTarget", + "Metadata", + "MethodType", + "MitigatedRisk", + "ModType", + "ModeType", + "MultiEntryMapping", + "Parameter", + "Policy", + "PolicyMetadata", + "Principle", + "PrincipleCatalog", + "PrincipleCatalogMetadata", + "RACI", + "Rationale", + "Recommendation", + "ReferenceId", + "RelationshipType", + "Resource", + "Result", + "ResultType", + "Risk", + "RiskAppetite", + "RiskCatalog", + "RiskCatalogMetadata", + "RiskCategory", + "Risks", + "Scope", + "Severity", + "Statement", + "Threat", + "ThreatCatalog", + "ThreatCatalogMetadata", + "TypedMapping", + "Vector", + "VectorCatalog", + "VectorCatalogMetadata", +] diff --git a/src/gemara/v1/_registry.py b/src/gemara/v1/_registry.py new file mode 100644 index 0000000..f1f5306 --- /dev/null +++ b/src/gemara/v1/_registry.py @@ -0,0 +1,49 @@ +# GENERATED by tools/generate.py. Do not edit. +"""Document-type registry derived from the schema's `metadata.type` discriminators.""" + +from __future__ import annotations + +from typing import Final, TypeAlias + +from pydantic import BaseModel + +from gemara.v1 import _models + +SCHEMA_VERSION: Final[str] = "1.5.0" +"""The Gemara schema version these models were generated from.""" + +DOCUMENT_TYPES: Final[dict[str, type[BaseModel]]] = { + "AuditLog": _models.AuditLog, + "CapabilityCatalog": _models.CapabilityCatalog, + "ControlCatalog": _models.ControlCatalog, + "EnforcementLog": _models.EnforcementLog, + "EvaluationLog": _models.EvaluationLog, + "GuidanceCatalog": _models.GuidanceCatalog, + "Lexicon": _models.Lexicon, + "MappingDocument": _models.MappingDocument, + "Policy": _models.Policy, + "PrincipleCatalog": _models.PrincipleCatalog, + "RiskCatalog": _models.RiskCatalog, + "ThreatCatalog": _models.ThreatCatalog, + "VectorCatalog": _models.VectorCatalog, +} +"""Maps a document's `metadata.type` to its model. Never hand-maintain this.""" + +GemaraDocument: TypeAlias = ( + _models.AuditLog + | _models.CapabilityCatalog + | _models.ControlCatalog + | _models.EnforcementLog + | _models.EvaluationLog + | _models.GuidanceCatalog + | _models.Lexicon + | _models.MappingDocument + | _models.Policy + | _models.PrincipleCatalog + | _models.RiskCatalog + | _models.ThreatCatalog + | _models.VectorCatalog +) +"""Union of every document-type model. The return type of `load`/`loads`.""" + +__all__ = ["DOCUMENT_TYPES", "GemaraDocument", "SCHEMA_VERSION"] diff --git a/src/gemara/v1/py.typed b/src/gemara/v1/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e46a4ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,6 @@ +"""pytest hook module. Shared, importable-by-name helpers live in `support.py` +instead -- `conftest` is a pytest-reserved filename, so importing it by name +elsewhere is a misuse. +""" + +from __future__ import annotations diff --git a/tests/support.py b/tests/support.py new file mode 100644 index 0000000..fcf643c --- /dev/null +++ b/tests/support.py @@ -0,0 +1,14 @@ +"""Shared test helpers, importable by name (unlike `conftest`).""" + +from __future__ import annotations + +from pathlib import Path + +FIXTURE_DIR = Path(__file__).resolve().parents[1] / "schemas" / "fixtures" + + +def fixture_paths(prefix: str) -> list[Path]: + paths = sorted(p for p in FIXTURE_DIR.iterdir() if p.name.startswith(prefix)) + if not paths: + raise AssertionError(f"no {prefix}* fixtures vendored in {FIXTURE_DIR}") + return paths diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..f90d914 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,112 @@ +"""Conformance against the upstream good-*/bad-* corpus. + +These fixtures are vendored under schemas/fixtures/, so this suite runs on a +bare CI runner. The predecessor read them from ~/.cache/cue and silently +skipped 39 of 40 tests. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from pydantic import ValidationError +from support import fixture_paths + +from gemara.v1 import UnknownDocumentTypeError, load + +GOOD = fixture_paths("good-") +BAD = fixture_paths("bad-") + +# bad-* fixtures that the models correctly reject on structure alone. +STRUCTURALLY_REJECTED = { + "bad-audit-log", + "bad-audit-log-invalid-digest", + "bad-enforcement-log", + "bad-enforcement-missing-log", + "bad-mapping-document", +} + +# bad-* fixtures that parse anyway. Some are CUE cross-field semantics +# (uniqueness via hidden _unique* fields, referential checks via +# comprehensions) that cannot survive projection into JSON Schema -- but not +# all of these are guaranteed to be that; two of the previous fourteen +# (bad-enforcement-log, bad-enforcement-missing-log) turned out to be a +# codegen fidelity loss instead and were moved to STRUCTURALLY_REJECTED once +# `recover_array_allof_element_type` fixed it. Treat "still in this set" as +# "not yet proven recoverable", not as "provably a CUE limitation". They are +# pinned here and asserted to STILL parse: if a schema or Pydantic change +# starts catching one, this test fails and we find out rather than never +# noticing. +SEMANTIC_GAPS = { + "bad-audit-log-undeclared-criteria", + "bad-capability-invalid-group", + "bad-control-invalid-group", + "bad-enforcement-clear-failed", + "bad-evaluation-log-missing-start", + "bad-lexicon-duplicate-term-id", + "bad-lifecycle", + "bad-mapping-no-target", + "bad-no-groups", + "bad-principle-invalid-group", + "bad-risk-catalog-duplicate-rank", + "bad-threat-invalid-group", +} + + +def test_the_corpus_is_fully_accounted_for() -> None: + """Every bad-* fixture is classified; no fixture is silently ignored.""" + assert {p.stem for p in BAD} == STRUCTURALLY_REJECTED | SEMANTIC_GAPS + assert len(GOOD) == 19 + assert len(BAD) == 17 + + +@pytest.mark.parametrize("path", GOOD, ids=lambda p: p.stem) +def test_good_fixture_validates(path: Path) -> None: + load(path) + + +@pytest.mark.parametrize("path", GOOD, ids=lambda p: p.stem) +def test_good_fixture_round_trips(path: Path) -> None: + """Dumping by alias in JSON mode must not lose or rename anything. + + The property asserted is that the JSON-mode dump is a fixed point: re-validating + it and dumping again reproduces it exactly. Strict model equality (`second == + first`) is deliberately NOT asserted, because it cannot hold in general. + `ControlEvaluation.assessment-logs` projects to `dict[str, Any]` instead of a + fixed element model -- NOT because it is an unconstrained CUE type (it $refs + the strict `_AssessmentLogStrict`/`AssessmentLog` definitions), but because its + schema `allOf`s three array arms and all three carry `items`, so which is + authoritative is ambiguous: typing it as `list[AssessmentLog]` would reject the + good fixture `good-evaluation-log-unstarted`, because CUE defaults + `requirement.reference-id` in from the control, a value the JSON Schema + projection cannot supply. See `recover_array_allof_element_type` in + `tools/generate.py`. Inside that `Any` region nothing is coerced. PyYAML parses + an ISO timestamp into a native `datetime` while JSON leaves it a `str`, so the + two representations differ by parser, not by information. The fixed point still + proves no key is dropped or renamed and no value changes. + """ + first = load(path) + dumped = first.model_dump(by_alias=True, mode="json", exclude_none=True) + redumped = type(first).model_validate(dumped).model_dump(by_alias=True, mode="json", exclude_none=True) + assert redumped == dumped + + +@pytest.mark.parametrize( + "path", + [p for p in BAD if p.stem in STRUCTURALLY_REJECTED], + ids=lambda p: p.stem, +) +def test_bad_fixture_is_rejected(path: Path) -> None: + with pytest.raises((ValidationError, UnknownDocumentTypeError)): + load(path) + + +@pytest.mark.parametrize( + "path", + [p for p in BAD if p.stem in SEMANTIC_GAPS], + ids=lambda p: p.stem, +) +def test_known_semantic_gap_still_parses(path: Path) -> None: + """Pinned so that newly-gained strictness is surfaced, not absorbed.""" + load(path) diff --git a/tests/test_generate.py b/tests/test_generate.py new file mode 100644 index 0000000..2479a25 --- /dev/null +++ b/tests/test_generate.py @@ -0,0 +1,193 @@ +"""Unit tests for the hermetic parts of codegen (no datamodel-codegen run).""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +import generate # noqa: E402 + + +def _schema() -> dict[str, Any]: + return { + "$defs": { + "ArtifactType": {"enum": ["ControlCatalog", "Lexicon"]}, + "ControlCatalog": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": {"type": {"const": "ControlCatalog"}}, + } + }, + }, + "Lexicon": { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": {"type": {"const": "Lexicon"}}, + } + }, + }, + "Catalog": { + "type": "object", + "properties": {"metadata": {"$ref": "#/$defs/Metadata"}}, + }, + } + } + + +def test_inject_metadata_titles_names_each_narrowed_metadata() -> None: + schema = _schema() + generate.inject_metadata_titles(schema) + props = schema["$defs"]["ControlCatalog"]["properties"] + assert props["metadata"]["title"] == "ControlCatalogMetadata" + + +def test_inject_metadata_titles_returns_the_discriminator_map() -> None: + doc_types = generate.inject_metadata_titles(_schema()) + assert doc_types == {"ControlCatalog": "ControlCatalog", "Lexicon": "Lexicon"} + + +def test_inject_metadata_titles_leaves_unnarrowed_metadata_alone() -> None: + """The base Catalog's metadata is a plain $ref and must not be retitled.""" + schema = _schema() + generate.inject_metadata_titles(schema) + assert schema["$defs"]["Catalog"]["properties"]["metadata"] == {"$ref": "#/$defs/Metadata"} + + +def test_check_document_types_accepts_agreement_with_artifact_type() -> None: + schema = _schema() + doc_types = generate.inject_metadata_titles(schema) + generate.check_document_types(schema, doc_types) # does not raise + + +def test_check_document_types_rejects_a_missing_discriminator() -> None: + """Defect 4 regression guard: a destroyed discriminator must fail loudly.""" + schema = _schema() + del schema["$defs"]["Lexicon"]["properties"]["metadata"]["properties"] + doc_types = generate.inject_metadata_titles(schema) + with pytest.raises(generate.GenerateError, match="Lexicon"): + generate.check_document_types(schema, doc_types) + + +def test_ignore_unknown_properties_reopens_closed_objects() -> None: + """Gemara v1 evolves additively, so a v1.5.0 reader must tolerate v1.6.0 fields. + + The key is removed rather than set to `true`: an unset `additionalProperties` + generates no `extra` setting, and pydantic's default is "ignore" -- unknown + properties are accepted and not carried onto the model, matching go-gemara, + where a field absent from the struct is neither stored nor re-marshalled. + """ + schema = {"$defs": {"Doc": {"type": "object", "additionalProperties": False, "properties": {}}}} + generate.ignore_unknown_properties(schema) + assert "additionalProperties" not in schema["$defs"]["Doc"] + + +def test_ignore_unknown_properties_reaches_nested_objects() -> None: + schema: dict[str, Any] = { + "$defs": { + "Doc": { + "type": "object", + "additionalProperties": False, + "properties": {"inner": {"type": "object", "additionalProperties": False}}, + } + } + } + generate.ignore_unknown_properties(schema) + assert "additionalProperties" not in schema["$defs"]["Doc"]["properties"]["inner"] + + +def test_ignore_unknown_properties_leaves_schemas_alone_that_never_closed() -> None: + """Only `false` is flipped; a schema constraining additionalProperties by type is untouched.""" + schema = {"$defs": {"Doc": {"type": "object", "additionalProperties": {"type": "string"}}}} + generate.ignore_unknown_properties(schema) + assert schema["$defs"]["Doc"]["additionalProperties"] == {"type": "string"} + + +def test_render_registry_emits_sorted_typed_entries() -> None: + source = generate.render_registry( + {"Lexicon": "Lexicon", "ControlCatalog": "ControlCatalog"}, + schema_version="1.5.0", + model_names=["ControlCatalog", "Lexicon"], + ) + assert 'SCHEMA_VERSION: Final[str] = "1.5.0"' in source + assert '"ControlCatalog": _models.ControlCatalog,' in source + assert source.index('"ControlCatalog"') < source.index('"Lexicon"') + assert "Do not edit" in source + assert "GemaraDocument: TypeAlias = _models.ControlCatalog | _models.Lexicon" in source + assert '"GemaraDocument"' in source + + +def test_public_model_names_reads_classes_from_source() -> None: + names = generate.public_model_names("class Alpha(BaseModel):\n pass\n\n\nclass _Private:\n pass\n") + assert names == ["Alpha"] + + +def test_render_models_excludes_denylisted_names_from_all() -> None: + source = generate.render_models("class Alpha(BaseModel):\n pass\n", ["Alpha", "Model", "Type"]) + assert '"Alpha",' in source + assert '"Model",' not in source + assert '"Type",' not in source + + +def test_recover_array_allof_element_type_collapses_a_single_items_arm() -> None: + """EnforcementLog.actions shape: one arm is comprehension metadata with no + `items`, the other carries the real element type. Exactly one arm has + `items`, so the node collapses to that arm, keeping the outer `description` + since the winning arm does not define one of its own. + """ + node: dict[str, Any] = { + "allOf": [ + {"description": "Enforce that Clear dispositions only contain Passed results", "type": "array"}, + {"items": {"$ref": "#/$defs/ActionResult"}, "minItems": 1, "type": "array"}, + ], + "description": "actions is the list of enforcement actions performed", + } + schema: dict[str, Any] = {"$defs": {"EnforcementLog": {"properties": {"actions": node}}}} + collapsed = generate.recover_array_allof_element_type(schema) + actions = schema["$defs"]["EnforcementLog"]["properties"]["actions"] + assert "allOf" not in actions + assert actions["items"] == {"$ref": "#/$defs/ActionResult"} + assert actions["minItems"] == 1 + assert actions["description"] == "actions is the list of enforcement actions performed" + assert collapsed == 1 + + +def test_recover_array_allof_element_type_leaves_multi_item_arms_untouched() -> None: + """ControlEvaluation.assessment-logs shape: all three arms carry `items`, so + which is authoritative is ambiguous and the node must be left alone. + """ + node: dict[str, Any] = { + "allOf": [ + {"items": {"type": "object"}, "type": "array"}, + {"items": {"$ref": "#/$defs/_AssessmentLogStrict"}, "minItems": 1, "type": "array"}, + {"items": {"$ref": "#/$defs/AssessmentLog"}, "minItems": 1, "type": "array"}, + ], + "description": "assessment logs", + } + schema: dict[str, Any] = {"$defs": {"ControlEvaluation": {"properties": {"assessment-logs": node}}}} + before = json.loads(json.dumps(schema)) + collapsed = generate.recover_array_allof_element_type(schema) + assert schema == before + assert collapsed == 0 + + +def test_recover_array_allof_element_type_leaves_non_array_allof_untouched() -> None: + """MappingTarget.strength shape: arms are `number`/`integer`, not `array`.""" + node: dict[str, Any] = { + "allOf": [{"type": "number"}, {"maximum": 10, "minimum": 1, "type": "integer"}], + "description": "strength", + } + schema: dict[str, Any] = {"$defs": {"MappingTarget": {"properties": {"strength": node}}}} + before = json.loads(json.dumps(schema)) + collapsed = generate.recover_array_allof_element_type(schema) + assert schema == before + assert collapsed == 0 diff --git a/tests/test_loader.py b/tests/test_loader.py new file mode 100644 index 0000000..68ac7a2 --- /dev/null +++ b/tests/test_loader.py @@ -0,0 +1,198 @@ +"""Loader dispatch, error surface, and the yaml extra.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from gemara.v1 import ( + DOCUMENT_TYPES, + SCHEMA_VERSION, + ControlCatalog, + GemaraError, + UnknownDocumentTypeError, + load, + loads, +) + +CATALOG = { + "metadata": { + "id": "example", + "author": {"id": "author-1", "name": "Example Author", "type": "Human"}, + "description": "A minimal catalog used to exercise dispatch.", + "gemara-version": "1.5.0", + "type": "ControlCatalog", + }, + "title": "Example catalog", +} + + +def test_loads_dispatches_json_on_metadata_type() -> None: + doc = loads(json.dumps(CATALOG)) + assert isinstance(doc, ControlCatalog) + assert doc.metadata.type == "ControlCatalog" + + +def test_loads_accepts_yaml() -> None: + text = "\n".join( + [ + "metadata:", + " id: example", + " author:", + " id: author-1", + " name: Example Author", + " type: Human", + " description: A minimal catalog used to exercise dispatch.", + " gemara-version: 1.5.0", + " type: ControlCatalog", + "title: Example catalog", + ] + ) + doc = loads(text) + assert isinstance(doc, ControlCatalog) + + +def test_structurally_invalid_document_raises_validation_error() -> None: + """Dispatch succeeds, then the model rejects it -- pydantic's error, not ours.""" + with pytest.raises(ValidationError): + loads(json.dumps({"metadata": {"type": "ControlCatalog"}})) + + +def test_loads_accepts_bytes() -> None: + doc = loads(json.dumps(CATALOG).encode()) + assert isinstance(doc, ControlCatalog) + + +def test_load_reads_a_path(tmp_path: Path) -> None: + path = tmp_path / "catalog.json" + path.write_text(json.dumps(CATALOG), encoding="utf-8") + assert isinstance(load(path), ControlCatalog) + + +def test_load_reads_an_open_file_object(tmp_path: Path) -> None: + """`load` follows `json.load`'s contract: it also accepts a file object.""" + path = tmp_path / "catalog.json" + path.write_text(json.dumps(CATALOG), encoding="utf-8") + with path.open(encoding="utf-8") as f: + assert isinstance(load(f), ControlCatalog) + + +def test_loads_accepts_bytearray() -> None: + doc = loads(bytearray(json.dumps(CATALOG), "utf-8")) + assert isinstance(doc, ControlCatalog) + + +def test_loads_accepts_memoryview() -> None: + doc = loads(memoryview(json.dumps(CATALOG).encode("utf-8"))) + assert isinstance(doc, ControlCatalog) + + +def test_unknown_type_names_the_value_and_the_valid_set() -> None: + with pytest.raises(UnknownDocumentTypeError) as excinfo: + loads(json.dumps({"metadata": {"type": "NotAThing"}})) + message = str(excinfo.value) + assert excinfo.value.value == "NotAThing" + assert "NotAThing" in message + for name in DOCUMENT_TYPES: + assert name in message + + +def test_missing_type_raises_unknown_document_type_with_none() -> None: + with pytest.raises(UnknownDocumentTypeError) as excinfo: + loads(json.dumps({"metadata": {}})) + assert excinfo.value.value is None + + +def test_non_mapping_document_is_rejected() -> None: + with pytest.raises(GemaraError, match="mapping"): + loads("[1, 2, 3]") + + +def test_malformed_yaml_raises_gemara_error_not_the_leaked_yaml_exception() -> None: + """`yaml.parser.ParserError` must never escape; it is not in the docstring's + exception contract and forces a consumer to import an optional dependency. + """ + with pytest.raises(GemaraError): + loads("a: [") + + +def test_tab_indented_json_raises_gemara_error_not_the_leaked_yaml_exception() -> None: + """Valid JSON, tab-indented: PyYAML's scanner rejects tabs before JSON's + parser gets a chance, and used to leak `yaml.scanner.ScannerError`. + """ + with pytest.raises(GemaraError): + loads('\t{"a": 1}') + + +@pytest.mark.parametrize( + "buffer", + [ + pytest.param(b"\xff\xfe not utf-8", id="bytes"), + pytest.param(bytearray(b"\xff\xfe not utf-8"), id="bytearray"), + pytest.param(memoryview(b"\xff\xfe not utf-8"), id="memoryview"), + ], +) +def test_loads_contains_undecodable_bytes(buffer: bytes | bytearray | memoryview) -> None: + """Decoding failure is a parse failure and must not leak `UnicodeDecodeError`. + + `loads` documents that no exception outside the `GemaraError` hierarchy (plus + `ValidationError`) escapes it. Widening the accepted input to bytes-likes added + a decode step in front of the parser, and that step is a second way in. + """ + with pytest.raises(GemaraError): + loads(buffer) + + +def test_loads_names_the_encoding_problem() -> None: + with pytest.raises(GemaraError, match="UTF-8"): + loads(b"\xff\xfe") + + +def test_load_contains_undecodable_bytes_from_a_path(tmp_path: Path) -> None: + path = tmp_path / "catalog.yaml" + path.write_bytes(b"\xff\xfe not utf-8") + with pytest.raises(GemaraError): + load(path) + + +def test_load_contains_undecodable_bytes_from_a_file_object() -> None: + with pytest.raises(GemaraError): + load(io.BytesIO(b"\xff\xfe not utf-8")) + + +def test_a_field_from_a_later_minor_is_accepted() -> None: + """Gemara v1 is additive, so a v1.5.0 reader must not reject a v1.6.0 document.""" + doc = dict(CATALOG) + doc["field-added-in-a-later-minor"] = "hello" + assert isinstance(loads(json.dumps(doc)), ControlCatalog) + + +def test_a_field_from_a_later_minor_is_not_carried_onto_the_model() -> None: + """Unknown properties are accepted, then dropped -- not stored, not re-serialised. + + This matches go-gemara, where a property absent from the Go struct is neither + retained nor re-marshalled, and it keeps the models honest as a typed surface: + an attribute kept at runtime but absent from the stubs would be invisible to + every type checker, in a package whose entire product is types. + """ + doc = dict(CATALOG) + doc["field-added-in-a-later-minor"] = "hello" + parsed = loads(json.dumps(doc)) + assert not hasattr(parsed, "field-added-in-a-later-minor") + dumped = parsed.model_dump(by_alias=True, mode="json", exclude_none=True) + assert "field-added-in-a-later-minor" not in dumped + + +def test_unknown_document_type_is_a_gemara_error() -> None: + assert issubclass(UnknownDocumentTypeError, GemaraError) + + +def test_schema_version_matches_provenance() -> None: + provenance = json.loads( + (Path(__file__).resolve().parents[1] / "schemas" / "provenance.json").read_text(encoding="utf-8") + ) + assert SCHEMA_VERSION == provenance["ref"].lstrip("v") diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..07ac03a --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,52 @@ +"""Packaging guarantees: PEP 420 namespace layout and the PEP 561 marker.""" + +from __future__ import annotations + +import subprocess +import sys +import zipfile +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _build_wheel(tmp_path: Path) -> zipfile.ZipFile: + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(tmp_path)], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) + wheels = sorted(tmp_path.glob("*.whl")) + assert len(wheels) == 1, f"expected one wheel, got {wheels}" + return zipfile.ZipFile(wheels[0]) + + +def test_wheel_ships_py_typed(tmp_path: Path) -> None: + with _build_wheel(tmp_path) as wheel: + assert "gemara/v1/py.typed" in wheel.namelist() + + +def test_wheel_has_no_namespace_init(tmp_path: Path) -> None: + """gemara must stay a PEP 420 implicit namespace so gemara.v2 can coexist.""" + with _build_wheel(tmp_path) as wheel: + assert "gemara/__init__.py" not in wheel.namelist() + + +def test_wheel_version_is_not_zero(tmp_path: Path) -> None: + """Defect 3: every previous artifact shipped as 0.0.0.""" + with _build_wheel(tmp_path) as wheel: + assert wheel.filename is not None + name = Path(wheel.filename).name + assert "-0.0.0-" not in name, name + + +def test_package_imports_under_its_namespace() -> None: + result = subprocess.run( + [sys.executable, "-c", "import gemara.v1; print(gemara.v1.__name__)"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + text=True, + ) + assert result.stdout.strip() == "gemara.v1" diff --git a/tests/test_readme.py b/tests/test_readme.py new file mode 100644 index 0000000..8f26c82 --- /dev/null +++ b/tests/test_readme.py @@ -0,0 +1,36 @@ +"""The README must carry the limitation the spec requires to be stated.""" + +from __future__ import annotations + +import json +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +README = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") + + +def test_readme_states_the_structural_validator_limitation() -> None: + # Extract the "## Known limitations" section + assert "## Known limitations" in README, "README must have a '## Known limitations' section" + + # Get content from "## Known limitations" to the next "## " heading (or EOF) + known_limitations_start = README.find("## Known limitations") + remaining = README[known_limitations_start:] + next_section = remaining.find("## ", 2) # Skip the current "##" and find the next one + if next_section == -1: + known_limitations_section = remaining + else: + known_limitations_section = remaining[:next_section] + + # Both the limitation statement and escape hatch must be in the section + assert "structural validator, not a full Gemara validator" in known_limitations_section, ( + "Limitation statement must appear in '## Known limitations' section" + ) + assert "cue vet" in known_limitations_section, ( + "'cue vet' escape hatch must appear in '## Known limitations' section" + ) + + +def test_readme_pins_the_same_schema_version_as_provenance() -> None: + provenance = json.loads((PROJECT_ROOT / "schemas" / "provenance.json").read_text(encoding="utf-8")) + assert provenance["ref"] in README diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..8a1b463 --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,43 @@ +"""The registry must be derived, complete, and in step with #ArtifactType.""" + +from __future__ import annotations + +import json +import typing +from pathlib import Path + +from pydantic import BaseModel + +from gemara.v1 import DOCUMENT_TYPES, GemaraDocument + +SCHEMA_DIR = Path(__file__).resolve().parents[1] / "schemas" + + +def test_registry_matches_the_artifact_type_enum() -> None: + schema = json.loads((SCHEMA_DIR / "gemara-v1.schema.json").read_text(encoding="utf-8")) + declared = set(schema["$defs"]["ArtifactType"]["enum"]) + assert set(DOCUMENT_TYPES) == declared + + +def test_registry_has_thirteen_document_types() -> None: + assert len(DOCUMENT_TYPES) == 13 + + +def test_every_registry_entry_is_a_model() -> None: + for name, model in DOCUMENT_TYPES.items(): + assert issubclass(model, BaseModel), name + + +def test_every_model_narrows_its_metadata_type() -> None: + """Defect 4 regression guard: dispatch depends on this narrowing.""" + for name, model in DOCUMENT_TYPES.items(): + metadata = model.model_fields["metadata"].annotation + assert metadata is not None + assert metadata.__name__ == f"{name}Metadata", name + + +def test_gemara_document_alias_matches_document_types() -> None: + """`GemaraDocument` must cover exactly the registry, so it cannot drift.""" + members = set(typing.get_args(GemaraDocument)) + assert members == set(DOCUMENT_TYPES.values()) + assert len(members) == 13 diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..3b872ec --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,70 @@ +"""Guards on the vendored schema's own integrity: its provenance record, and +that every `$ref` in it actually resolves. + +Hermetic and fast: no cue, no network -- these only read the committed +`schemas/gemara-v1.schema.json` and `schemas/provenance.json`. + +Commit a21d87e fixed a dangling-`$ref` bug caused by cue's quoted identifiers +(e.g. `#"reference-id"`). The regression guards added there were unit tests on +synthetic inputs in `tests/test_sync_schema.py` -- nothing walked the real, +vendored schema's actual `$ref`s. A future upstream ref with a second quoted +shape `sync_schema.py` doesn't anticipate could reproduce that bug with every +existing test still green. `test_every_ref_resolves` is the guard that would +catch it. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = PROJECT_ROOT / "schemas" / "gemara-v1.schema.json" +PROVENANCE_PATH = PROJECT_ROOT / "schemas" / "provenance.json" + +SCHEMA_BYTES = SCHEMA_PATH.read_bytes() +SCHEMA = json.loads(SCHEMA_BYTES) +PROVENANCE = json.loads(PROVENANCE_PATH.read_text(encoding="utf-8")) + + +def _iter_refs(node: Any) -> list[str]: + """Every `$ref` string pointer found anywhere under `node`.""" + refs: list[str] = [] + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + refs.append(ref) + for value in node.values(): + refs.extend(_iter_refs(value)) + elif isinstance(node, list): + for item in node: + refs.extend(_iter_refs(item)) + return refs + + +def test_provenance_digest_matches_the_schema() -> None: + assert hashlib.sha256(SCHEMA_BYTES).hexdigest() == PROVENANCE["schema_sha256"] + + +def test_schema_has_the_recorded_definition_count() -> None: + assert len(SCHEMA["$defs"]) == PROVENANCE["definition_count"] == 93 + + +def test_every_ref_resolves() -> None: + """Walk every `$ref` in the schema; each must name a real `$defs` entry.""" + refs = _iter_refs(SCHEMA) + assert len(refs) >= 50, f"only walked {len(refs)} $refs; the walk itself may be broken" + + defs = SCHEMA["$defs"] + unresolved = [] + for ref in refs: + if not ref.startswith("#/$defs/"): + unresolved.append(ref) + continue + name = ref.removeprefix("#/$defs/") + if name not in defs: + unresolved.append(ref) + + assert unresolved == [], f"dangling $ref(s): {unresolved}" diff --git a/tests/test_sync_schema.py b/tests/test_sync_schema.py new file mode 100644 index 0000000..ed5a7fc --- /dev/null +++ b/tests/test_sync_schema.py @@ -0,0 +1,101 @@ +"""Unit tests for the pure parts of the schema sync (no cue, no network).""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) + +import sync_schema # noqa: E402 + + +def test_merge_exports_flattens_nested_defs_and_roots() -> None: + exports: dict[str, dict[str, Any]] = { + "#Catalog": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": {"Group": {"type": "object"}}, + "type": "object", + "properties": {"groups": {"$ref": "#/$defs/Group"}}, + }, + "#ArtifactType": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "enum": ["ControlCatalog", "Lexicon"], + }, + } + merged = sync_schema.merge_exports(exports) + + assert merged["$schema"] == "https://json-schema.org/draft/2020-12/schema" + assert set(merged["$defs"]) == {"Group", "Catalog", "ArtifactType"} + # The root of an export becomes the def body, minus $schema/$defs. + assert merged["$defs"]["Catalog"] == { + "type": "object", + "properties": {"groups": {"$ref": "#/$defs/Group"}}, + } + assert merged["$defs"]["ArtifactType"] == {"enum": ["ControlCatalog", "Lexicon"]} + + +def test_merge_exports_keeps_the_first_nested_def_seen() -> None: + """The same nested def is exported by many parents; they agree, so first wins.""" + exports: dict[str, dict[str, Any]] = { + "#A": {"$defs": {"Shared": {"type": "string"}}, "type": "object"}, + "#B": {"$defs": {"Shared": {"type": "string"}}, "type": "object"}, + } + merged = sync_schema.merge_exports(exports) + assert merged["$defs"]["Shared"] == {"type": "string"} + + +def test_merge_exports_rejects_conflicting_nested_defs() -> None: + exports: dict[str, dict[str, Any]] = { + "#A": {"$defs": {"Shared": {"type": "string"}}, "type": "object"}, + "#B": {"$defs": {"Shared": {"type": "integer"}}, "type": "object"}, + } + with pytest.raises(sync_schema.SyncError, match="conflicting definition 'Shared'"): + sync_schema.merge_exports(exports) + + +def test_document_type_names_reads_the_artifact_type_enum() -> None: + schema = {"$defs": {"ArtifactType": {"enum": ["Lexicon", "ControlCatalog"]}}} + assert sync_schema.document_type_names(schema) == ["ControlCatalog", "Lexicon"] + + +def test_document_type_names_rejects_a_missing_enum() -> None: + with pytest.raises(sync_schema.SyncError, match="ArtifactType"): + sync_schema.document_type_names({"$defs": {}}) + + +def test_strip_ref_encoding_removes_cue_percent_escapes() -> None: + raw = '{"$ref": "#/$defs/%23Catalog"}' + assert sync_schema.strip_ref_encoding(raw) == '{"$ref": "#/$defs/Catalog"}' + + +def test_strip_ref_encoding_removes_quoted_identifier_escapes() -> None: + """cue emits a quoted identifier's surrounding quotes as `%22` in $ref pointers.""" + raw = '{"$ref": "#/$defs/%22reference-id%22"}' + assert sync_schema.strip_ref_encoding(raw) == '{"$ref": "#/$defs/reference-id"}' + + +def test_merge_exports_unquotes_quoted_identifier_names() -> None: + """cue's quoted identifiers (e.g. #"reference-id") keep literal quote characters + in both the root export name and any nested $defs key; both must be stripped so + the stored key matches a %22-decoded $ref pointing at it. + """ + exports: dict[str, dict[str, Any]] = { + '#"reference-id"': { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + }, + "#Field": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": {'"reference-id"': {"type": "string"}}, + "type": "object", + "properties": {"ref": {"$ref": "#/$defs/reference-id"}}, + }, + } + merged = sync_schema.merge_exports(exports) + assert "reference-id" in merged["$defs"] + assert merged["$defs"]["reference-id"] == {"type": "string"} + assert not any('"' in key for key in merged["$defs"]) diff --git a/tools/generate.py b/tools/generate.py new file mode 100644 index 0000000..c876177 --- /dev/null +++ b/tools/generate.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""Generate Pydantic v2 models and the document registry from the vendored schema. + +Hermetic: no cue, no network, no Go. Reads `schemas/gemara-v1.schema.json`, +writes `src/gemara/v1/_models.py` and `src/gemara/v1/_registry.py`. + +Usage: python tools/generate.py +""" + +from __future__ import annotations + +import ast +import json +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = PROJECT_ROOT / "schemas" / "gemara-v1.schema.json" +PROVENANCE_PATH = PROJECT_ROOT / "schemas" / "provenance.json" +PACKAGE_DIR = PROJECT_ROOT / "src" / "gemara" / "v1" +MODELS_PATH = PACKAGE_DIR / "_models.py" +REGISTRY_PATH = PACKAGE_DIR / "_registry.py" + +# The codegen header embeds the input file's basename, so it must be stable +# across runs or the drift gate fails on the header alone. +CODEGEN_INPUT_NAME = "gemara-v1.schema.json" +CODEGEN_PRESET = "practical-py311-20260619" + +GENERATED_MARKER = "# GENERATED by tools/generate.py. Do not edit." + +# Generated names excluded from the public surface (`__all__`), each for its +# own reason -- none of these is something a consumer should reach via +# `from gemara.v1 import *`: +# - `Model` is a bare `RootModel[Any]` datamodel-codegen emits for the +# schema's own JSON Schema root; it carries no Gemara semantics. +# - `Type` duplicates `ArtifactType` (same enum values, generated a second +# time from an inline `type` property instead of a `$ref`), and its name +# is exactly the kind of generic identifier `import *` should not export. +# - `FieldAssessmentLogStrict` is dead: nothing in the generated module +# references it (only `AssessmentLog` is used), it exists only because +# `ControlEvaluation.assessment-logs`'s untouched ambiguous `allOf` (see +# `recover_array_allof_element_type`) still pulls in the `$ref`'d +# `_AssessmentLogStrict` definition. +# `ReferenceId` is deliberately NOT here: it is a real schema type. +DENYLISTED_MODEL_NAMES = frozenset({"Model", "Type", "FieldAssessmentLogStrict"}) + + +class GenerateError(RuntimeError): + """The schema could not be turned into models.""" + + +def inject_metadata_titles(schema: dict[str, Any]) -> dict[str, str]: + """Name each per-document metadata subschema and collect the discriminators. + + A document type is a definition whose `metadata` property narrows `type` to + a `const`. Without a `title`, datamodel-codegen names these `Metadata1`… + `MetadataN`. The old generator instead replaced them with a generic $ref, + destroying the discriminator entirely -- that is defect 4, and it must never + be reintroduced. + """ + doc_types: dict[str, str] = {} + for name, definition in schema["$defs"].items(): + if not isinstance(definition, dict): + continue + metadata = (definition.get("properties") or {}).get("metadata") + if not isinstance(metadata, dict): + continue + type_schema = (metadata.get("properties") or {}).get("type") + if not isinstance(type_schema, dict) or "const" not in type_schema: + continue + metadata["title"] = f"{name}Metadata" + doc_types[str(type_schema["const"])] = name + return doc_types + + +def check_document_types(schema: dict[str, Any], doc_types: dict[str, str]) -> None: + """The discriminators must reproduce #ArtifactType exactly.""" + artifact_type = schema.get("$defs", {}).get("ArtifactType", {}) + declared = set(artifact_type.get("enum", [])) + if not declared: + raise GenerateError("schema has no #ArtifactType enum") + found = set(doc_types) + if declared != found: + missing = ", ".join(sorted(declared - found)) or "none" + extra = ", ".join(sorted(found - declared)) or "none" + raise GenerateError(f"document types disagree with #ArtifactType (missing: {missing}; unexpected: {extra})") + + +def ignore_unknown_properties(schema: dict[str, Any]) -> int: + """Reopen every closed object so unknown properties are ignored, not rejected. + + CUE emits closed structs, which project to `additionalProperties: false` and + generate `ConfigDict(extra="forbid")`. That makes forward compatibility + impossible: Gemara v1 evolves additively, so the moment upstream adds an + optional field, every already-released reader rejects every document using + it -- until the reader re-syncs and ships. A schema library that rejects + valid documents of its own major version is worse than no library. + + The key is removed rather than set to `true`. An unset `additionalProperties` + emits no `extra` setting at all, and pydantic's default is "ignore": unknown + properties are accepted, then dropped rather than carried onto the model. + That is deliberate on two counts. It matches go-gemara, where a property with + no corresponding struct field is neither stored nor re-marshalled, so the same + document processed by either SDK yields the same output. And it keeps this + package's models honest as a typed surface -- `extra="allow"` would attach + attributes at runtime that appear in no stub and are invisible to every type + checker, which is a poor trade in a distribution whose entire product is types. + + Strictness is not lost, only relocated: required fields, enums, patterns and + length bounds still apply, and CUE remains the source of truth for the + cross-field semantics no structural validator can express (see the README's + known limitations). Measured against the vendored corpus, no `bad-*` fixture + is detected via `extra="forbid"` -- every rejection comes from a missing + field, an enum, a pattern, or a length bound. + + Only an explicit `false` is removed; a schema that constrains + `additionalProperties` by type is left alone. Mutates `schema` in place. + Returns the number of objects reopened, for logging. + """ + reopened = 0 + + def visit(node: Any) -> None: + nonlocal reopened + if isinstance(node, dict): + if node.get("additionalProperties") is False: + del node["additionalProperties"] + reopened += 1 + for value in node.values(): + visit(value) + elif isinstance(node, list): + for item in node: + visit(item) + + visit(schema) + return reopened + + +def recover_array_allof_element_type(schema: dict[str, Any]) -> int: + """Collapse an array-typed `allOf` node into its one strongly-typed arm. + + CUE compiles a comprehension's array constraint (e.g. "Clear dispositions + only contain Passed results") and its element-typed constraint into + separate `allOf` arms that both narrow `type: array`. `datamodel-codegen` + does not merge an `allOf` of arrays, so every arm collapses to `Any`, + discarding the element type and any `minItems`/`items` constraint. + + This is only safe to undo when exactly one arm carries `items`: that arm + is then the sole source of element shape. When two or more arms carry + `items`, which one is authoritative is ambiguous -- they may even + disagree, as with `ControlEvaluation.assessment-logs`, where CUE defaults + a field in from elsewhere that a fixed element type cannot express -- so + the node is left untouched rather than guessing. + + Known limitation: the losing arm(s) are discarded whole. Only the winning + arm and the outer node's own sibling keys survive, so any keyword a losing + arm carries is dropped silently. That is lossless against the schema this + was written for -- Gemara v1.5.0's sole collapsing site, + `EnforcementLog.actions`, has a losing arm holding just `description` and + `type`, comprehension metadata that no structural validator can enforce + anyway (`bad-enforcement-clear-failed`, written to test exactly that rule, + parses here and in go-gemara alike). But it is an assumption about the + shape of CUE's output, not a checked invariant: if a future upstream ref + ever emits a losing arm carrying a real array constraint -- `maxItems`, + `uniqueItems`, its own `minItems` -- that constraint vanishes, and the only + signal is a field validating more loosely in the `_models.py` drift diff, + which is an absence and easy to miss. + + Deliberately not guarded with an assertion. Failing generation over a + keyword nobody has seen would block a future `sync-schema` on a schema + change that is very likely irrelevant, and merging losing arms properly + means implementing `allOf` intersection semantics per keyword -- the class + of repair pass this project banned after the predecessor's + `flatten_struct_embedding` got it wrong. If it ever bites, the fix is to + merge the specific keyword rather than to generalise. + + Mutates `schema` in place (this pass runs on the in-memory dict before + codegen; it never touches anything under `schemas/`). Returns the number + of nodes collapsed, for logging. + """ + collapsed = 0 + + def visit(node: Any) -> None: + nonlocal collapsed + if isinstance(node, dict): + all_of = node.get("allOf") + if ( + isinstance(all_of, list) + and all_of + and all(isinstance(arm, dict) and arm.get("type") == "array" for arm in all_of) + ): + arms_with_items = [arm for arm in all_of if "items" in arm] + if len(arms_with_items) == 1: + winner = arms_with_items[0] + merged = dict(winner) + for key, value in node.items(): + if key != "allOf" and key not in merged: + merged[key] = value + node.clear() + node.update(merged) + collapsed += 1 + for value in node.values(): + visit(value) + elif isinstance(node, list): + for item in node: + visit(item) + + visit(schema) + return collapsed + + +def public_model_names(source: str) -> list[str]: + """Top-level class names in the generated module, in definition order.""" + tree = ast.parse(source) + return [node.name for node in tree.body if isinstance(node, ast.ClassDef) and not node.name.startswith("_")] + + +def run_codegen(schema: dict[str, Any]) -> str: + with tempfile.TemporaryDirectory() as tmp: + tmp_dir = Path(tmp) + (tmp_dir / CODEGEN_INPUT_NAME).write_text(json.dumps(schema, indent=2, sort_keys=True), encoding="utf-8") + output = tmp_dir / "models.py" + result = subprocess.run( + [ + "datamodel-codegen", + "--input", + CODEGEN_INPUT_NAME, + "--input-file-type", + "jsonschema", + "--schema-version", + "2020-12", + "--preset", + CODEGEN_PRESET, + "--output", + str(output), + ], + cwd=tmp_dir, + capture_output=True, + encoding="utf-8", + check=False, + ) + if result.returncode != 0: + raise GenerateError(f"datamodel-codegen failed: {result.stderr.strip()}") + return output.read_text(encoding="utf-8") + + +def render_models(body: str, model_names: list[str]) -> str: + exports = "\n".join(f' "{name}",' for name in sorted(model_names) if name not in DENYLISTED_MODEL_NAMES) + return f"{GENERATED_MARKER}\n{body.rstrip()}\n\n\n__all__ = [\n{exports}\n]\n" + + +def render_registry(doc_types: dict[str, str], schema_version: str, model_names: list[str]) -> str: + known = set(model_names) + entries = [] + document_models = [] + for const in sorted(doc_types): + model = doc_types[const] + if model not in known: + raise GenerateError(f"document type {const!r} maps to unknown model {model!r}") + entries.append(f' "{const}": _models.{model},') + document_models.append(model) + body = "\n".join(entries) + union = " | ".join(f"_models.{model}" for model in document_models) + return f'''{GENERATED_MARKER} +"""Document-type registry derived from the schema's `metadata.type` discriminators.""" + +from __future__ import annotations + +from typing import Final, TypeAlias + +from pydantic import BaseModel + +from gemara.v1 import _models + +SCHEMA_VERSION: Final[str] = "{schema_version}" +"""The Gemara schema version these models were generated from.""" + +DOCUMENT_TYPES: Final[dict[str, type[BaseModel]]] = {{ +{body} +}} +"""Maps a document's `metadata.type` to its model. Never hand-maintain this.""" + +GemaraDocument: TypeAlias = {union} +"""Union of every document-type model. The return type of `load`/`loads`.""" + +__all__ = ["DOCUMENT_TYPES", "GemaraDocument", "SCHEMA_VERSION"] +''' + + +def ruff_format(*paths: Path) -> None: + """Format the output so the drift gate compares formatted bytes to formatted bytes.""" + result = subprocess.run( + ["ruff", "format", *(str(p) for p in paths)], + capture_output=True, + encoding="utf-8", + check=False, + ) + if result.returncode != 0: + raise GenerateError(f"ruff format failed: {result.stderr.strip()}") + + +def main() -> int: + if not SCHEMA_PATH.exists(): + raise GenerateError(f"{SCHEMA_PATH} is missing; run `poe sync-schema` first") + + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + provenance = json.loads(PROVENANCE_PATH.read_text(encoding="utf-8")) + schema_version = str(provenance["ref"]).lstrip("v") + + print(f"Generating models for Gemara {schema_version} ({len(schema['$defs'])} $defs)") + + doc_types = inject_metadata_titles(schema) + check_document_types(schema, doc_types) + print(f" {len(doc_types)} document types match #ArtifactType") + + recovered = recover_array_allof_element_type(schema) + print(f" Recovered element type for {recovered} array allOf site(s)") + + reopened = ignore_unknown_properties(schema) + print(f" Reopened {reopened} closed objects for forward compatibility") + + body = run_codegen(schema) + model_names = public_model_names(body) + print(f" Generated {len(model_names)} models") + + PACKAGE_DIR.mkdir(parents=True, exist_ok=True) + MODELS_PATH.write_text(render_models(body, model_names), encoding="utf-8") + REGISTRY_PATH.write_text(render_registry(doc_types, schema_version, model_names), encoding="utf-8") + ruff_format(MODELS_PATH, REGISTRY_PATH) + + print(f" Wrote {MODELS_PATH.relative_to(PROJECT_ROOT)}") + print(f" Wrote {REGISTRY_PATH.relative_to(PROJECT_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/sync_schema.py b/tools/sync_schema.py new file mode 100644 index 0000000..c6e2e9d --- /dev/null +++ b/tools/sync_schema.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Vendor the Gemara v1 JSON Schema, its provenance, and its test fixtures. + +Maintainer-only: requires `cue` on PATH and network access. Everything it +produces is committed, so `tools/generate.py` and the test suite are hermetic. + +Usage: python tools/sync_schema.py [--ref v1.5.0] +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import hashlib +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +CUE_MODULE = "github.com/gemaraproj/gemara" +REPOSITORY = "https://github.com/gemaraproj/gemara" +DEFAULT_REF = "v1.5.0" +SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = PROJECT_ROOT / "schemas" +SCHEMA_PATH = SCHEMA_DIR / "gemara-v1.schema.json" +PROVENANCE_PATH = SCHEMA_DIR / "provenance.json" +FIXTURE_DIR = SCHEMA_DIR / "fixtures" + +FIXTURE_GLOBS = ("good-*", "bad-*") + + +class SyncError(RuntimeError): + """A schema export or merge could not be completed.""" + + +def strip_ref_encoding(raw: str) -> str: + """cue emits `#` in $ref pointers as `%23`, and a quoted identifier's + surrounding quotes as `%22`. Both escapes are removed outright (not decoded + to a literal `"`, which would break the surrounding JSON) so a $ref lines up + with the matching key produced by `_normalize_def_name`. + """ + return raw.replace("%23", "").replace("%22", "") + + +def _normalize_def_name(raw: str) -> str: + """Strip cue's leading `#` and, for a quoted identifier, its surrounding quotes. + + cue emits a plain definition as `#Name` and a quoted one (e.g. one containing + a hyphen) as `#"a-name"`; nested $defs keys carry the same shapes minus the + root's own name. Both must normalize to the bare name so they match a + `strip_ref_encoding`-decoded $ref pointing at them. + """ + name = raw.lstrip("#") + if name.startswith('"') and name.endswith('"'): + name = name[1:-1] + return name + + +def _cue(*args: str) -> str: + result = subprocess.run(["cue", *args], capture_output=True, encoding="utf-8", check=False) + if result.returncode != 0: + raise SyncError(f"cue {' '.join(args)} failed: {result.stderr.strip()}") + return result.stdout + + +def cue_version() -> str: + for line in _cue("version").splitlines(): + if line.startswith("cue version "): + return line.removeprefix("cue version ").strip() + raise SyncError("could not parse `cue version` output") + + +def discover_definitions(ref: str) -> list[str]: + """Every exported top-level definition, e.g. '#ControlCatalog'.""" + stdout = _cue("eval", f"{CUE_MODULE}@{ref}") + names = { + line.split(":")[0].split(" ")[0].strip() + for line in stdout.splitlines() + if line.startswith("#") and not line.startswith("#_") + } + if not names: + raise SyncError(f"no definitions found in {CUE_MODULE}@{ref}") + return sorted(names) + + +def export_definitions(names: list[str], ref: str) -> dict[str, dict[str, Any]]: + exports: dict[str, dict[str, Any]] = {} + for name in names: + raw = _cue("def", "-e", name, "--out", "jsonschema", f"{CUE_MODULE}@{ref}") + try: + exports[name] = json.loads(strip_ref_encoding(raw)) + except json.JSONDecodeError as exc: + raise SyncError(f"{name} exported invalid JSON Schema: {exc}") from exc + return exports + + +def merge_exports(exports: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Fold per-definition exports into one schema with a flat `$defs`. + + Each export carries its transitive dependencies under `$defs` and the + definition itself at the root. Nested defs are shared across exports and + must agree; a disagreement means the exports are not from one build. + """ + defs: dict[str, Any] = {} + for export in exports.values(): + for key, value in export.get("$defs", {}).items(): + key = _normalize_def_name(key) + if key in defs and defs[key] != value: + raise SyncError(f"conflicting definition '{key}' across exports") + defs.setdefault(key, value) + for name, export in exports.items(): + body = {k: v for k, v in export.items() if k not in ("$schema", "$defs")} + defs[_normalize_def_name(name)] = body + return {"$schema": SCHEMA_DIALECT, "$defs": defs} + + +def document_type_names(schema: dict[str, Any]) -> list[str]: + artifact_type = schema.get("$defs", {}).get("ArtifactType") + if not isinstance(artifact_type, dict) or not isinstance(artifact_type.get("enum"), list): + raise SyncError("schema has no #ArtifactType enum to derive document types from") + return sorted(str(value) for value in artifact_type["enum"]) + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def vendor_fixtures(ref: str) -> str: + """Clone the schema repo at `ref` and copy its good-*/bad-* test data.""" + with tempfile.TemporaryDirectory() as tmp: + clone = Path(tmp) / "gemara" + cloned = subprocess.run( + ["git", "clone", "--depth", "1", "--branch", ref, REPOSITORY, str(clone)], + capture_output=True, + encoding="utf-8", + check=False, + ) + if cloned.returncode != 0: + raise SyncError(f"git clone --branch {ref} failed: {cloned.stderr.strip()}") + + rev_parsed = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=clone, + capture_output=True, + encoding="utf-8", + check=False, + ) + if rev_parsed.returncode != 0: + raise SyncError(f"git rev-parse HEAD failed: {rev_parsed.stderr.strip()}") + commit = rev_parsed.stdout.strip() + + source = clone / "test" / "test-data" + if not source.is_dir(): + raise SyncError(f"{ref} has no test/test-data directory") + + if FIXTURE_DIR.exists(): + shutil.rmtree(FIXTURE_DIR) + FIXTURE_DIR.mkdir(parents=True) + + copied = 0 + for pattern in FIXTURE_GLOBS: + for path in sorted(source.glob(pattern)): + shutil.copy2(path, FIXTURE_DIR / path.name) + copied += 1 + if copied == 0: + raise SyncError(f"no fixtures matched {FIXTURE_GLOBS} at {ref}") + print(f" vendored {copied} fixtures") + return commit + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ref", default=DEFAULT_REF, help=f"upstream tag (default: {DEFAULT_REF})") + args = parser.parse_args() + ref: str = args.ref + + print(f"Syncing {CUE_MODULE}@{ref}") + + print(" Discovering definitions...") + names = discover_definitions(ref) + print(f" Found {len(names)} definitions") + + print(" Exporting JSON Schema...") + schema = merge_exports(export_definitions(names, ref)) + print(f" Merged into {len(schema['$defs'])} $defs") + + doc_types = document_type_names(schema) + print(f" #ArtifactType declares {len(doc_types)} document types") + + print(" Vendoring fixtures...") + commit = vendor_fixtures(ref) + + write_json(SCHEMA_PATH, schema) + digest = hashlib.sha256(SCHEMA_PATH.read_bytes()).hexdigest() + + write_json( + PROVENANCE_PATH, + { + "module": CUE_MODULE, + "repository": REPOSITORY, + "ref": ref, + "commit": commit, + "cue_version": cue_version(), + "retrieved": dt.date.today().isoformat(), + "schema_sha256": digest, + "definition_count": len(schema["$defs"]), + "document_types": doc_types, + }, + ) + + print(f" Wrote {SCHEMA_PATH.relative_to(PROJECT_ROOT)}") + print(f" Wrote {PROVENANCE_PATH.relative_to(PROJECT_ROOT)}") + print("Run `uv run poe generate` next.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..ebb57e7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,882 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version < '3.14'", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/c0/5bb6885a9608d86ee5712c0d88bc405d3a49f3e44231576e130ea2f53d34/ast_serialize-0.9.0.tar.gz", hash = "sha256:79fe8be1c934aa572940d1811d8dbe4d1b6f22291e3f16755c9b062e9ac92fb7", size = 951293, upload-time = "2026-09-02T15:50:45.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/76/497f19d9bdb3899a1efd82e2957f455d0c6e0cb9ebbc254735acb1f74235/ast_serialize-0.9.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ae1c46eb97865823f9843c4b80145e011874923e1a4a44b45738a5309d83e9f5", size = 889442, upload-time = "2026-09-02T15:49:21.144Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c5/9fb64b7106c5534739322c74be7b743c4f2e3b5fd05d5b8e677f05c54d5f/ast_serialize-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af082cb7e6c4fa3a428aa616c13d709a944076eba84a73184de15621cc1a915d", size = 1226721, upload-time = "2026-09-02T15:49:22.612Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/b550fc81aa0133808410783c6d9a1b925e31610d226e836e21337850af55/ast_serialize-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e9f2540741ad10657a209209f7e5cc6b530eb3ed145fd77258ab43542d96ad7", size = 1207369, upload-time = "2026-09-02T15:49:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/ed9e66deb7da63e44d0c0fd3a8feef698882ed56ea521a29494d4616eb46/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95485d5e8704af2ecc7f723757b88f992ae8122028d687ccf877cad2b4c3da4", size = 1273073, upload-time = "2026-09-02T15:49:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/cd337551d7a68c982425bbf91f183943d7ccc62394002c74807a7f0e60db/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b559cffac5a71a698d9194e4295765ff2132a10fd1284860f02b30f12c1f729e", size = 1279045, upload-time = "2026-09-02T15:49:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/98dc41eb4122d5da83241e805739838ed59e1e1b9006cbed89ded635a17f/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dad8a3f7106efcf252fc289c092ee0cee5c3512c0088bcf3fffa01458323092f", size = 1539300, upload-time = "2026-09-02T15:49:28.213Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/d94cede4b3f2a4e329d8ca92218f0846cfcc9257be91c5bf1168671f4ab5/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24de2bc930b7e1ca86641136b9875a1e5f80f52b484deddc67893eeaf9077bd9", size = 1291957, upload-time = "2026-09-02T15:49:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/8e/85/8ac18d754225cf13392786b23d4ba84273ceee562699362f22e61942ce64/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ac7b4cdf89ca8318aae157d824017596784982851c8a89a621973f261574696", size = 1291779, upload-time = "2026-09-02T15:49:31.199Z" }, + { url = "https://files.pythonhosted.org/packages/62/ed/cc757fec9e96e29f19a6f818e05147e4f2949356258a3243412756f22a2e/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:95dcb93f30258dcf09d9b6a302dac9323b70e9df8c18ee0bf4ea1fa7cc5f1875", size = 1299730, upload-time = "2026-09-02T15:49:32.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a575ae0ae954f21a187b4c1d8cec28d81a009693bd13a1388eec72d9b55a/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed5824b4b2fa37ad93fa2db23d8243a3d315b3e2d7ca70f99b2727d8788af05", size = 1344671, upload-time = "2026-09-02T15:49:34.217Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/0b2b15c0ae5f9a95f433016d1a59a3227eebbb378654d6354206c8ac8e8d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c69f2ce565c786bd3853ce42495e8a63610516f79651c7cb4f2cd0ddfaee52", size = 1448527, upload-time = "2026-09-02T15:49:35.68Z" }, + { url = "https://files.pythonhosted.org/packages/18/be/ee89cb6a5d3427946532f0611b514befdd69564803e9a9f9ce712f9d2654/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8c824d822a2ac54ec4228b88c0d170ab0024cf285eeee57b9d4f994003fb553", size = 1554045, upload-time = "2026-09-02T15:49:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4d/eaada807f98a2f0d370fec4b46c84f0e551a62911e751a76ecb32bef4dde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c55010ea0fafc6bc8231809d328bda781bfe41a01c43522be5eb3713fd855cda", size = 1547578, upload-time = "2026-09-02T15:49:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/046c531f4af4e1bc3314077a84b3099f38b45e2d969faa24acedb3066d92/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:322282ac5337e5e776bc416f2b5201e680fa4dacf92ea739bd35e46a28a66c41", size = 1671896, upload-time = "2026-09-02T15:49:40.273Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/8d32aa0cf4e3e566399b2079a4c47f8ad3c62155f6ee1fe63631b6d3fdde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ded5ed06ec469407d6cd571ace7a7a25809cc388e4bac1dd35c7747469fc7fdf", size = 1472895, upload-time = "2026-09-02T15:49:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/8204c7e3d8abd4b0c7a56a8d5cce05fcacfd6a5d63ffbd812b8b94040d6d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3ee40752ddb4fb5c6a67161d13f3ce3df7987dcb9272260542a86b0be1519ae2", size = 1492731, upload-time = "2026-09-02T15:49:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/f3/72/ff5c44c19409686798feeb1fbe209f2be78b4b64948d5aa2ddeec8901591/ast_serialize-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:d9c635eacfc02b91da6796d3b5ff9086e511b8a29b19f9b3f4f978b8d170f838", size = 1112847, upload-time = "2026-09-02T15:49:45.181Z" }, + { url = "https://files.pythonhosted.org/packages/20/75/fa5be1a94d189adafadf9c5f07fffd66af7a8061c4cff92f75285ef79d10/ast_serialize-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:62a96e327e2a178d6c295b10422e95c992a9286f0ec1b2bc7cd5b4873252a38c", size = 1146846, upload-time = "2026-09-02T15:49:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ef/b2bfb331b6e379d435543f6d3be0b590e7a2f441d31ccc17d75f2d2d7cb8/ast_serialize-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:41da4332492222d56345d5e436eed4fbec76caadee959f6ffa3cd2fc1bd51895", size = 1119605, upload-time = "2026-09-02T15:49:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f9/a4af1bf8b35927814c09d90c3965dbfaa75c489ba34372bffafbc2209f40/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:383f56e3ae925f154632458f01b4bfcde3dd382f3ec04f5c7f6d72f76524ff48", size = 1226344, upload-time = "2026-09-02T15:49:49.79Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/d3a95823a803c21f5c9df595a0bb93aada22e7aa22bf875fe00d89422d7f/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9ef3d4173907bd19aa8f1683be9f06e7862e6cdf2ca6bca3633305c0df32063", size = 1207384, upload-time = "2026-09-02T15:49:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/6e7f46c8455b738609c29d1b7655307a168c4b40ce4c7a2c678c8ed9cf2e/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9482672ca8ec09f85cd050a053fb88c30c882c8e20ce7a140d8defe19c0ef2eb", size = 1273139, upload-time = "2026-09-02T15:49:52.679Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6e/25b70733f061766865cb04d913dc5332037c595796b871d52ab5b569abb8/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6a568d1d489f0669a31aed90ca4845aa7f08e1b8cd5d05e1905dbdc3ae9b2b0", size = 1278242, upload-time = "2026-09-02T15:49:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f0/b7820399d9c5a0b7f07c239b6da93d2e21a1b3785137fa00e16528e414b3/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4dd005d4095a13eb312dc712c943c7730f262b000a87df963925328a38ffdb", size = 1541009, upload-time = "2026-09-02T15:49:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/08/56/5146f1d2a77516e697f6f42825df79137e43560675cb4605c467775f8b4a/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:207ac73afa1f4654840593853c130eac2591dd942434176eea33f730afb3359b", size = 1290898, upload-time = "2026-09-02T15:49:57.502Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/87cd16228796d703de795a369b90b0f57f0f017f90f55c4c5876e3513a03/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae01129e2cc5d57a3c434d8a990019de039350310a2e1dd3c9f61311964cf25", size = 1291742, upload-time = "2026-09-02T15:49:58.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/eb/13465c297268c5170b2bb746d75f37a8fad44a94a89b593071affc1071d0/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4c522377f383670abfe21c94edc3032cb3bd34d8fcacd280fa9556907d4edd4b", size = 1300180, upload-time = "2026-09-02T15:50:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a7/10b84c4274b2507b0ed9cc1654058ad64bcedb6ac574753d6a461bc6e204/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd886f6e900f5e13f758cb8ef359652e690e4f3f9c257a7269e095940534167", size = 1345857, upload-time = "2026-09-02T15:50:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/c7/dc/2702182c9773a15de9aabfaf66da7cb87548a56a6bac24f0c9176a4a13c3/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f3f0f3359cf0f22bf096b07021ffe6bf0ec88ac8a2cf7ce5f4701af973112faa", size = 1448544, upload-time = "2026-09-02T15:50:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/eeef2124c9563b9861707ef4db91f153f3bb37b3e0cca9543bb88a4e9e53/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:6c428444656cffbd32c1e76626c6eec5237b58c8fcb0b5d3df75941cd50c4f3c", size = 1551572, upload-time = "2026-09-02T15:50:04.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8c/81d18349f1dffdcfeb80671bd737e342c86348e183692d9d0d8f573d1385/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:54f0babed5e2a4eb86a0716ac612aff33f933e7572e5bc067adcdbe672a26321", size = 1548118, upload-time = "2026-09-02T15:50:06.522Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1f/339131b60d1b0df13d9f3470cfac70f858b5649188ea01b2e7b39caeb720/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b779fdaee34d19900a5ba5fd6bd4cefe225650081f9de28de256eacee5113c2", size = 1674707, upload-time = "2026-09-02T15:50:07.919Z" }, + { url = "https://files.pythonhosted.org/packages/18/0a/ca77596fa229d88f96eca180d45dbe8efa11306f8b2b4f4ee301b3fe465f/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4db7f524eaa857fbe650cac33b9cedb5ccda14393d40f640b75dfb06aa13c98c", size = 1473618, upload-time = "2026-09-02T15:50:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/88/5c/6aebeb54dd226b480014ff4488e150aa23b1de3204e2bf3f87de27e6542a/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:d2a37795a90809da6094825e7063118b4cf723b8701b134973b4566ed8b9ea09", size = 1492025, upload-time = "2026-09-02T15:50:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/c355d470a230f778311c28b80f6d934a497d094139f07230433eea18651b/ast_serialize-0.9.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:4411d1cba9eeecb301365343a7e96813b4a44fcdb20181557867ff7e751804cf", size = 1113010, upload-time = "2026-09-02T15:50:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0d/66609ace58564727b68731293cc986c2ea1d5e6ef40e96571e7fb515f0af/ast_serialize-0.9.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:b5c3724faf780e25def89c369eb6340a15dff06ac348160c61781e2373a4cd10", size = 1146404, upload-time = "2026-09-02T15:50:13.761Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/da28e1c85f05fb9976f247d2a3aefce68866cb2939abcbdbddd9a5e3b835/ast_serialize-0.9.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:1de0933a4c1d104d77d6e75f053f5e628b54cf8f9fea809b8250cb04cda07bd3", size = 1118328, upload-time = "2026-09-02T15:50:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/487a158a99e4564244e000ed18475255dfea53fd34a84d8ca73633710500/ast_serialize-0.9.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5fc57f17fb4ce49b4eeccfcd2670e4a55659bd740bb8e8aedbe511ccab8b5f03", size = 889484, upload-time = "2026-09-02T15:50:16.643Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/175b0a64d6c96bc1b96598c6474ce8d1ef34e0b774bcf7183f4ce696fb10/ast_serialize-0.9.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dac690f99538d9df0d23ce0299e946add2744b007a36b480a292fe361c82553d", size = 1232635, upload-time = "2026-09-02T15:50:18.133Z" }, + { url = "https://files.pythonhosted.org/packages/28/0c/d51d8463aca43aaa833fdf1f25134d6cc1b483764896decca61306ad1f6e/ast_serialize-0.9.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:2223ead73b5a5399d39610cf9c4164ad0b2bf2025226626b87ae15226d93d3f7", size = 1219313, upload-time = "2026-09-02T15:50:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/ef/19/c88bdc64f86095a9d6ab325ae422b2a5e1395cd63cd8aa539003d4d4ae1d/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b7c5f5838408fb000d76abd14e886836412b7ec7eccd028dbb5ed5819780008", size = 1279981, upload-time = "2026-09-02T15:50:20.811Z" }, + { url = "https://files.pythonhosted.org/packages/86/58/a492075826df1753896dc8e8f6ababae4016d8883b670ee3a1c34788b154/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e05701fde79affa1cc53e391867f9da3eb03fa8501f87354292796b0f8398fd", size = 1286319, upload-time = "2026-09-02T15:50:22.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/79/3f6754eaa42fd2a6c36aac066890870cd44cbe0e25f75a67b1b99a2f4d82/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d013c36eb2f2ac0cb7d4d0e79918a92ab00fbce8f1542fe47f34a46e06168f82", size = 1551547, upload-time = "2026-09-02T15:50:23.528Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/8cfb7caadfaf28febaa6b61d31d778262f87f9366eda4dd9bd07ac940b75/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19cde5c2110f7b90ab1210a599178524f6c9f34862b20ba2b9aa7832c67bb35d", size = 1302468, upload-time = "2026-09-02T15:50:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e2/750a0b136bb02ff8e4a17d65a3a78cd478ee50724704df8215797a226ba3/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1514f4a39704e2e815f9fc675fc13f19f694f212086b520110840782cf3c5295", size = 1300563, upload-time = "2026-09-02T15:50:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/17/4c0aa852ff1e4f2d6723e8ce827136c1e1febf2845d7941ccc45426778de/ast_serialize-0.9.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:30651ccdec6d23c49ee4711b1a1096d8dbd3be38eecf2f09fdd98a608ce7ac24", size = 1308999, upload-time = "2026-09-02T15:50:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1b/6e73d0a29aedb0db30cc68f2557acaac06cd24c9783ccb90f84f89e4ce87/ast_serialize-0.9.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9a46caf5e3f2cd266e8638b2f4ea8bf54cf376f015f8418397b4633fdb38e9b", size = 1358191, upload-time = "2026-09-02T15:50:29.237Z" }, + { url = "https://files.pythonhosted.org/packages/dc/38/2cf5d552de99e0e9804a16fea73e54d0a7382498adddf57c0f6dc09cbc70/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aed6e413c6c22a23c33c47a01dd2adce01d7a7ed408748e896903f47d0a1aa47", size = 1458944, upload-time = "2026-09-02T15:50:30.77Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0b/5ef87adf955b6a027f616eb7b55f55a154c35ba600e9dd2d06ad2d30e5c2/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:871fb7c5b049897ee137b67efad7fe4545ad270f7eccd970da877833f8e63aa7", size = 1563421, upload-time = "2026-09-02T15:50:32.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/dd/9ced05a17feeb0f83e84010d80f5a1b7b7aa19e75f0376f4d3780803654c/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bb378efb5537b43f38660e2e6d6e138a40885cf191d43443bb3ff7ff47e9cd9b", size = 1558536, upload-time = "2026-09-02T15:50:33.861Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8b/8ad486e44fc7081a2471055befc433dddc2e51c3a88dff141b3026f64602/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:b373beff65b01fcffca5aaad3269ae629f3a998b09efdd3635e48039008a5dec", size = 1682749, upload-time = "2026-09-02T15:50:35.257Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4c/7c282aba9cfb0b92d79fac45c04e4557d9a7f08d872e5a43577a50867e30/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:25c8d517c45cf2b1820fc2af6ac593783654818f79d05646d25d624360678a4e", size = 1482441, upload-time = "2026-09-02T15:50:37.319Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/e45914e8cad81b660915f3784d255460a6384183b76bfc2089fdd79ec7df/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1675dc46578298ae00936164a160997801a6ca2385913150d8d16df634296cf3", size = 1499042, upload-time = "2026-09-02T15:50:38.76Z" }, + { url = "https://files.pythonhosted.org/packages/99/09/6988921dec19c810beef53539fec2e90ae551cd93853b3303a99fe45f772/ast_serialize-0.9.0-cp39-abi3-win32.whl", hash = "sha256:20fce3885eeff05a3d6afefa845c8168016e3ea1f6fc9cdc84c8db28b863a550", size = 1116391, upload-time = "2026-09-02T15:50:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/fd/eb/839598a22a1f9af56d39e188451cad93dbcb0ce6539a45ac18fb8bf123fa/ast_serialize-0.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:161914666a21d48b681982146ac0fa4086ef099d91c637cf595387f5f06aa099", size = 1156055, upload-time = "2026-09-02T15:50:42.05Z" }, + { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" }, +] + +[[package]] +name = "black" +version = "26.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, + { name = "pytokens" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "datamodel-code-generator" +version = "0.76.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, + { name = "jinja2" }, + { name = "pydantic" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/cd/97b5ce0bc7452199a06204e1252fe10ad63cc37c76c2e9f76146b766b708/datamodel_code_generator-0.76.2.tar.gz", hash = "sha256:c8c25e24b5b90c1c45fc40309fedd0a2f571425c7b6efae97efb317c03325c32", size = 2260230, upload-time = "2026-09-04T11:37:47.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/7a/00b736585a6fac1e9c94275a07d8e470a3accdaab37b54d13fdc1c985bc0/datamodel_code_generator-0.76.2-py3-none-any.whl", hash = "sha256:8cc2bffa5a7e81a4b5a1cfce28530eaf9be465f3591bb7cb53030eb0c956f6d9", size = 661183, upload-time = "2026-09-04T11:37:45.73Z" }, +] + +[[package]] +name = "genson" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/52/06790ced2ac7117f890c21bda43c39c958ec82aa665c0718e821d33ff939/librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22", size = 148039, upload-time = "2026-08-07T10:46:41.165Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/8e150b7fc449a1f33c8a760965cc1f43b14fc1577d9d0b50ab2701420e74/librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63", size = 153067, upload-time = "2026-08-07T10:46:42.418Z" }, + { url = "https://files.pythonhosted.org/packages/51/87/a162bc5a66a35599dc619ecb215145f4de7d68e886b479b6d12593139f7c/librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef", size = 493087, upload-time = "2026-08-07T10:46:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/e5/3a/aeea1fc620cf48060d3065b37614edbf97043c099d0f50782bc8ca61d897/librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f", size = 485608, upload-time = "2026-08-07T10:46:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/fe571ad416f0856fd0d5578ffc2e6dc531891e586e36b647bcf50569cab8/librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8", size = 498723, upload-time = "2026-08-07T10:46:46.35Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/7a65eb5dedb1f00aebd948cdd8e17add48bf066cab3514e9daf84ab45a6c/librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a", size = 516002, upload-time = "2026-08-07T10:46:47.599Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/59832b0ebfbd08c2742e6ece372ceb53f18bf1faef5d33c8daf3abebf749/librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18", size = 508607, upload-time = "2026-08-07T10:46:48.873Z" }, + { url = "https://files.pythonhosted.org/packages/ea/0d/37fa73f3b43ebd8259f91ae9102a15e5a54e65d581e48dea72df3e81d7a4/librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c", size = 530422, upload-time = "2026-08-07T10:46:50.45Z" }, + { url = "https://files.pythonhosted.org/packages/26/02/e046c6fe7a5881ac34623242192f484426ba8a75595fd18f22c53a3f530f/librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a", size = 534303, upload-time = "2026-08-07T10:46:51.693Z" }, + { url = "https://files.pythonhosted.org/packages/95/32/d5e6d861ab0366f3edf74f887ab0c9eb9f535aaf01d32b80b4f734daa179/librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091", size = 536084, upload-time = "2026-08-07T10:46:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/2a/de/d69d725513fe53fc90c6d7a1f86e4428939bad2fb905b17fe4c18d413dde/librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40", size = 514307, upload-time = "2026-08-07T10:46:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/93/f8aded0d6682b4f25820fa86e0690f87f01df9fd7bd09ddb04d9167ad021/librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0", size = 557686, upload-time = "2026-08-07T10:46:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/ffeb6bdeb6cd862b4272fddc8ad05f938dd25d020ed517e631813917d80a/librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb", size = 104917, upload-time = "2026-08-07T10:46:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/96/28/7e2313a3ffbf0b4de7ba3da58a09e488507b4bd1ea2b5e69378354a23415/librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db", size = 125886, upload-time = "2026-08-07T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/39/9e/04b8c3cde014ef255ee785730425268354543acc38902093a40afa0dc164/librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56", size = 111885, upload-time = "2026-08-07T10:46:58.787Z" }, + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/be/c624d4241484f37dc62839e177ab607a9b8b3e96f0866544ca99e8e41d51/mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531", size = 13936739, upload-time = "2026-08-15T03:03:26.475Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/e3cf72f90dce5960871c82551c8fba6da05fc1018f79be41c047bd126bdd/mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8", size = 14166460, upload-time = "2026-08-15T03:01:50.565Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ff/6b97d58aa0f79a5ab9b472db1f6d6df1b11a51d74d0c08ab3760d3a613ba/mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8", size = 15100476, upload-time = "2026-08-15T03:03:12.079Z" }, + { url = "https://files.pythonhosted.org/packages/da/f0/cbb4b7d2ae3ac635f6b4f2d9b04070b8a92edf50da599d3b39e5ed109001/mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01", size = 15347826, upload-time = "2026-08-15T03:03:02.856Z" }, + { url = "https://files.pythonhosted.org/packages/5f/10/91dcdc6f8d43fc08e6a06ab1f9732f3abaaf835ac1b2e67b9dff56910855/mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1", size = 11142615, upload-time = "2026-08-15T03:03:36.316Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8a/28d54535bf4b9aa43b2d8918c2ef660378b9f66b23d78dcee052744ae622/mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6", size = 10141145, upload-time = "2026-08-15T03:03:07.406Z" }, + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pastel" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "poethepoet" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pastel" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/92/93a4af9511b8c7c647874521d9e6c904266be98067c2ee1eb2e74520d208/poethepoet-0.48.0.tar.gz", hash = "sha256:a06f49d244fadfc2e2e7faa78b54e64a9694727e4ce1d50e08f23cea3ded74f1", size = 148679, upload-time = "2026-07-05T21:48:30.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8d/d7c9455b15f8d2d7ce57e7b71a8ef8d02d9992ae4283c9777120620c9022/poethepoet-0.48.0-py3-none-any.whl", hash = "sha256:98da6096d060f49b8d84034770265863fb7dc92a40233b7694b9d216ac68737d", size = 185808, upload-time = "2026-07-05T21:48:28.601Z" }, +] + +[[package]] +name = "py-gemara" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, +] + +[package.optional-dependencies] +yaml = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "datamodel-code-generator" }, + { name = "mypy" }, + { name = "poethepoet" }, + { name = "pytest" }, + { name = "pyyaml" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.9" }, + { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, +] +provides-extras = ["yaml"] + +[package.metadata.requires-dev] +dev = [ + { name = "datamodel-code-generator", specifier = "==0.76.2" }, + { name = "mypy", specifier = ">=2.3" }, + { name = "poethepoet", specifier = ">=0.30" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = "==0.16.6" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytokens" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260815" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/72/b56089aeee6c496d969bac42376bedb6e3eeab4682e1018fa3137122f94b/types_pyyaml-6.0.12.20260815.tar.gz", hash = "sha256:28764110c9cf35846e733da32d8d734df7473c5dde9ef67c3b7332ec0e819858", size = 18545, upload-time = "2026-08-15T02:41:51.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/52/eefeba09be4ef2a1eb989eb92934561e8e502a6ee3c32654996e4be7e399/types_pyyaml-6.0.12.20260815-py3-none-any.whl", hash = "sha256:6f332212b7e191f3afd5016a713c510b6340593b7ebec573c7d5d20aa5386d3b", size = 21148, upload-time = "2026-08-15T02:41:50.555Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] From 28c65725d704d3c3d7f44c8d75d6963176564a78 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 5 Sep 2026 15:10:27 -0400 Subject: [PATCH 02/27] ci: harden workflows against zizmor findings, add audit job and dependabot The workflows tripped every high-severity zizmor audit. Fixed at the source rather than suppressed: - unpinned-uses: pin every action to a commit SHA, keeping the current major (checkout v5.1.0, setup-uv v7.6.0, upload-artifact v4.6.2, download-artifact v4.3.0, gh-action-pypi-publish v1.14.2). Major bumps are left for dependabot to propose so they get reviewed. - excessive-permissions: default CI to contents: read. - artipacked: persist-credentials: false on every checkout, so the job token is not left behind in .git/config for later steps to pick up. - cache-poisoning: enable-cache: false for setup-uv on the release path, which builds the artifacts that get published and must not restore a cache a pull request run could have written. Add a CI job running zizmor so this cannot regress. It reports via inline annotations instead of SARIF, which keeps it working without code scanning enabled on the repository. Add dependabot for github-actions and uv. SHA pins only stay safe if they also stay current, and the 7-day cooldown avoids opening a PR for a release fresh enough that a malicious version likely has not been yanked yet. Assisted-by: Claude Opus 5 Signed-off-by: Jennifer Power --- .github/dependabot.yml | 41 ++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 48 +++++++++++++++++++++++++++++------ .github/workflows/release.yml | 27 +++++++++++++++----- 3 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1fadbf3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 + +updates: + # Actions are pinned to commit SHAs so that a compromised tag cannot silently + # change what runs in CI; dependabot is what keeps those pins from going stale. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + # Wait out the window in which a freshly published malicious release is + # most likely to be caught and yanked before we open a PR for it. + cooldown: + default-days: 7 + commit-message: + prefix: "ci" + groups: + actions: + patterns: ["*"] + update-types: ["minor", "patch"] + + # Resolves against pyproject.toml and keeps uv.lock in step, which the + # `uv sync --frozen` steps in CI require. + - package-ecosystem: uv + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + # Wait out the window in which a freshly published malicious release is + # most likely to be caught and yanked before we open a PR for it. + cooldown: + default-days: 7 + commit-message: + prefix: "deps" + prefix-development: "chore" + groups: + python-minor-patch: + patterns: ["*"] + update-types: ["minor", "patch"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1765cb..b0e3315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,16 +5,23 @@ on: branches: [main] pull_request: +# Read-only by default; no job here needs to write back to the repository. +permissions: + contents: read + jobs: test: + name: Tests runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true - run: uv sync --frozen --python ${{ matrix.python-version }} @@ -29,10 +36,13 @@ jobs: fi quality: + name: Lint, format and types runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true - run: uv sync --frozen @@ -40,11 +50,30 @@ jobs: - run: uv run ruff format --check . - run: uv run poe typecheck + workflows: + name: Workflow security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - name: Audit workflows and dependabot config with zizmor + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + with: + # Emit inline annotations rather than SARIF: results land on the PR + # without depending on code scanning being enabled for the repo. + # The two options are mutually exclusive. + advanced-security: false + annotations: true + drift: + name: Generated-model drift runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true - run: uv sync --frozen @@ -56,12 +85,15 @@ jobs: || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } lower-bounds: + name: Lowest direct dependencies runs-on: ubuntu-latest env: UV_RESOLUTION: lowest-direct steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - name: Resolve and install the lowest declared direct dependencies run: uv sync - name: Show what actually got installed diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0400ea..f22a978 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,10 +9,17 @@ permissions: jobs: build: + name: Build and verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + # The release path builds the artifacts that get published, so it must + # not restore a cache that a pull request workflow could have poisoned. + enable-cache: false - run: uv sync --frozen - name: Verify the tag matches the static version run: | @@ -42,35 +49,41 @@ jobs: - run: uv run poe lint - run: uv run poe typecheck - run: uv build - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist/ publish-testpypi: + name: Publish to TestPyPI needs: build runs-on: ubuntu-latest environment: testpypi permissions: + # Trusted publishing: mint a short-lived OIDC token instead of holding a + # long-lived PyPI API token in repository secrets. id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ publish-pypi: + name: Publish to PyPI needs: publish-testpypi runs-on: ubuntu-latest environment: pypi permissions: + # Trusted publishing: mint a short-lived OIDC token instead of holding a + # long-lived PyPI API token in repository secrets. id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist/ - - uses: pypa/gh-action-pypi-publish@release/v1 + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 From 4c88606e8ea7f61dc62947777e4a4f2c37e6ade0 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 5 Sep 2026 15:50:43 -0400 Subject: [PATCH 03/27] refactor: make pyyaml a core dependency, split docs and release workflows Four changes from review feedback. pyyaml moves from a `[yaml]` extra into `dependencies`. The extra made the default install unable to read the format the ecosystem actually uses: 35 of the 36 vendored fixtures are YAML, so `pip install py-gemara` followed by `load("catalog.yaml")` -- the most obvious first thing anyone does -- failed with an install-the-extra message. It also bought a branching `_parse` with a bespoke fallback error and an exception contract that had to promise behaviour "regardless of whether the optional yaml extra is installed". go-gemara depends on goccy/go-yaml outright. `_parse` is now four lines. README is trimmed to what a consumer needs: install, usage, the newer-minor reading behaviour, and the known limitations. The usage example now shows `match` narrowing over the GemaraDocument union, which typechecks under `mypy --strict` without the `isinstance` assert the old example carried. Development instructions, the sync-schema/generate pipeline, the never-edit-generated-files rule, and the release procedure move to a new CONTRIBUTING.md. tests/test_readme.py is deleted. It policed prose placement by slicing the file between `##` headings, which is brittle -- rewording that improves the prose breaks CI -- and a substring cannot verify that something is "stated plainly" anyway. SEMANTIC_GAPS already fails if the validation strength it describes actually changes, which is the property worth guarding. TestPyPI publishing splits into its own workflow_dispatch workflow. Chaining it ahead of PyPI on a tag meant you could not rehearse without burning a real version, and a TestPyPI hiccup -- most often "version already exists" -- would fail the job and block a PyPI release that was otherwise fine. It now runs manually against any ref with skip-existing, and release.yml goes straight from build to PyPI. Verified clean under zizmor. Assisted-by: Claude Opus 5 Signed-off-by: Jennifer Power --- .github/workflows/publish-testpypi.yml | 48 +++++++++++++++ .github/workflows/release.yml | 20 +------ CONTRIBUTING.md | 70 ++++++++++++++++++++++ README.md | 83 ++++++++++---------------- pyproject.toml | 6 +- src/gemara/v1/_loader.py | 17 ++---- tests/test_loader.py | 2 +- tests/test_readme.py | 36 ----------- uv.lock | 9 +-- 9 files changed, 157 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/publish-testpypi.yml create mode 100644 CONTRIBUTING.md delete mode 100644 tests/test_readme.py diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml new file mode 100644 index 0000000..3453a1b --- /dev/null +++ b/.github/workflows/publish-testpypi.yml @@ -0,0 +1,48 @@ +name: Publish to TestPyPI + +# Manual only. TestPyPI is a rehearsal space, so it is deliberately not chained +# to the tag-driven release: coupling them means you cannot rehearse without +# burning a real version, and a TestPyPI hiccup (most often "version already +# exists") would block a PyPI release that was otherwise fine. +# +# Pick the ref to publish in the Actions UI when dispatching. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + name: Build and publish to TestPyPI + runs-on: ubuntu-latest + environment: testpypi + permissions: + # Trusted publishing: mint a short-lived OIDC token instead of holding a + # long-lived PyPI API token in repository secrets. + id-token: write + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + # Publishes real artifacts, so it must not restore a cache that a pull + # request workflow could have poisoned. + enable-cache: false + - run: uv sync --frozen + - name: Run tests, failing if any is skipped + run: | + set -o pipefail + uv run pytest -q | tee summary.txt + if grep -qE '[0-9]+ skipped' summary.txt; then + echo "::error::tests were skipped; the fixture corpus must always run" + exit 1 + fi + - run: uv build + - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + # Rehearsals get re-run against an unchanged version; that should be a + # no-op rather than a failure. + skip-existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f22a978..180e330 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,27 +54,9 @@ jobs: name: dist path: dist/ - publish-testpypi: - name: Publish to TestPyPI - needs: build - runs-on: ubuntu-latest - environment: testpypi - permissions: - # Trusted publishing: mint a short-lived OIDC token instead of holding a - # long-lived PyPI API token in repository secrets. - id-token: write - steps: - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: dist - path: dist/ - - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - repository-url: https://test.pypi.org/legacy/ - publish-pypi: name: Publish to PyPI - needs: publish-testpypi + needs: build runs-on: ubuntu-latest environment: pypi permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb019b2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing + +```bash +uv sync +uv run poe test # pytest +uv run poe lint # ruff check +uv run poe typecheck # mypy --strict +uv run poe format # ruff format +``` + +## How the models are produced + +Two steps, deliberately separated by whether they need the outside world. + +`poe sync-schema` is maintainer-only and needs `cue` on PATH plus network +access. It exports every `#Definition` from the upstream CUE module as JSON +Schema, merges them into `schemas/gemara-v1.schema.json`, records the exact ref +and digest in `schemas/provenance.json`, and re-vendors the upstream +`good-*`/`bad-*` corpus into `schemas/fixtures/`. JSON Schema rather than +upstream's OpenAPI projection, which loses integer types and flattens +`date-time` to `date`. + +`poe generate` is hermetic — no `cue`, no network. It reads the vendored schema, +applies its repair passes, runs `datamodel-codegen`, and writes +`src/gemara/v1/_models.py` and `_registry.py`. + +Both generated files are committed. **Never edit them by hand**: CI regenerates +them and fails on any diff, so a hand edit is reverted on the next run. Change +`tools/generate.py` instead, then `poe generate`. + +The codegen invocation is fixed at +`--preset practical-py311-20260619 --schema-version 2020-12`, and +`datamodel-code-generator` and `ruff` are pinned exactly. Both touch generated +bytes, so an unpinned bump would churn thousands of committed lines and fail the +drift gate for no semantic reason. Do not add hand-picked generator flags. + +## Bumping the schema version + +```bash +uv run poe sync-schema # optionally --ref vX.Y.Z +uv run poe generate +uv run poe test +``` + +Review the diff to `schemas/` and `src/gemara/v1/_models.py` together. A field +that got *looser* is the thing to watch for — see the known limitation +documented on `recover_array_allof_element_type` in `tools/generate.py`. + +Update `SEMANTIC_GAPS` in `tests/test_fixtures.py` if the corpus changed. Those +entries are asserted to *still parse*, so newly-gained strictness fails the +suite rather than passing unnoticed — that is the point of them. + +## Tests + +The fixture corpus is vendored, so the suite runs anywhere with no `cue` and no +warm cache. CI fails the build if **any** test is skipped: the predecessor read +its fixtures from `~/.cache/cue`, reported "1 passed, 39 skipped" on every run, +and stayed green on a single assertion for its entire life. + +## Releasing + +Publishing uses Trusted Publishing (OIDC); no API tokens are stored. The +`testpypi` and `pypi` GitHub environments must exist with a matching pending +publisher registered on each index. + +- **Rehearse:** run the *Publish to TestPyPI* workflow manually + (`workflow_dispatch`) against any ref. +- **Release:** set `version` in `pyproject.toml`, then push a matching `vX.Y.Z` + tag. The release workflow refuses a tag that disagrees with that version, and + refuses `0.0.0` outright. diff --git a/README.md b/README.md index a7a191d..45c6eb7 100644 --- a/README.md +++ b/README.md @@ -4,53 +4,48 @@ models, generated from the upstream CUE schemas. ```bash -pip install py-gemara # core: pydantic only -pip install py-gemara[yaml] # adds YAML support +pip install py-gemara ``` ```python -from gemara.v1 import load, DOCUMENT_TYPES, SCHEMA_VERSION, ControlCatalog +from gemara.v1 import ControlCatalog, Lexicon, load doc = load("catalog.yaml") # dispatches on metadata.type -assert isinstance(doc, ControlCatalog) + +match doc: + case ControlCatalog(): + print(len(doc.controls or [])) + case Lexicon(): + print(len(doc.terms or [])) ``` -`DOCUMENT_TYPES` maps each of the 13 `metadata.type` values to its model, and is -generated from the schema's discriminators — you never need to hand-maintain a -dispatch table. `load` and `loads` raise `UnknownDocumentTypeError` (naming the -offending value and the 13 valid ones) or `pydantic.ValidationError`. +`load` takes a path or an open file, `loads` takes text or bytes, and both read +JSON or YAML. They dispatch on `metadata.type` through `DOCUMENT_TYPES` — a +registry generated from the schema's own discriminators, so you never hand-write +a dispatch table — and return a `GemaraDocument`, the union of the 13 document +models. Narrowing it with `match` or `isinstance` typechecks under `mypy +--strict`; the package ships `py.typed`. -Models are fully typed and the package ships `py.typed`. +Failures raise from one hierarchy: `UnknownDocumentTypeError` when +`metadata.type` is missing or unrecognised (its message names the offending +value and the 13 valid ones), `GemaraError` for anything unparseable, and +`pydantic.ValidationError` when a document does not match its model. -## Versioning +`SCHEMA_VERSION` reports the Gemara release these models were generated from: +currently **v1.5.0**. -`SCHEMA_VERSION` reports the Gemara schema release these models were generated -from: currently **v1.5.0**. Changes within Gemara v1 are additive by -construction — upstream CI enforces this with `oasdiff` — so one model set reads -every v1.x document. +## Reading documents from a newer v1.x -**Documents from a newer v1.x are read, not rejected.** Properties these models -do not know about are ignored: accepted on the way in, then dropped rather than -carried onto the model or written back out — the same behaviour as -[go-gemara](https://github.com/gemaraproj/go-gemara), where a property with no -corresponding struct field is neither stored nor re-marshalled. Without this, -every additive release upstream would break every already-installed reader until -it re-synced, and a schema library that rejects valid documents of its own major -version is worse than no library. +Changes within Gemara v1 are additive, so these models read every v1.x document, +including ones written against a minor newer than `SCHEMA_VERSION`. Properties +they do not recognise are ignored — accepted, then dropped rather than carried +onto the model. This matches +[go-gemara](https://github.com/gemaraproj/go-gemara). The consequence worth knowing: `load` followed by `model_dump` is **not** a -faithful copy of a document written against a newer minor — unknown fields are -absent from the output. Treat these models as a reader, not a round-tripping -editor, and keep the source document if you need to preserve it byte for byte. - -Strictness is relocated, not lost: required fields, enums, patterns and length -bounds still apply, and `cue vet` remains the source of truth for the rest. - -There is also deliberately no per-minor namespace. Pinning to an older minor -would buy breakage, not safety. - -A future `gemara.v2` will ship as a separate distribution, installable -side by side, because `gemara` is a PEP 420 namespace package. +faithful copy of such a document, because its newer fields are absent from the +output. These models are a reader, not a round-tripping editor — keep the source +if you need to preserve it byte for byte. ## Known limitations @@ -62,24 +57,8 @@ fixtures parse successfully, including `bad-lexicon-duplicate-term-id`, `bad-risk-catalog-duplicate-rank`, `bad-evaluation-log-missing-start`, and the `bad-*-invalid-group` family. -If you need full validation, run `cue vet` against the Gemara schemas. The gaps -are pinned in `SEMANTIC_GAPS` in `tests/test_fixtures.py`, so any newly-gained -strictness fails the test suite instead of passing unnoticed. - -## Development - -```bash -uv sync -uv run poe test # pytest -uv run poe lint # ruff check -uv run poe typecheck # mypy --strict -uv run poe generate # regenerate _models.py and _registry.py (hermetic) -uv run poe sync-schema # re-vendor from upstream (maintainer only; needs cue) -``` - -`src/gemara/v1/_models.py` and `_registry.py` are generated and committed. CI -regenerates them and fails on any diff, so edit `tools/generate.py`, never the -output. +What still applies: required fields, enums, patterns, and length bounds. If you +need full validation, run `cue vet` against the Gemara schemas. ## License diff --git a/pyproject.toml b/pyproject.toml index d6b2ccf..4bf1d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Gemara v1 schema types as Pydantic v2 models" readme = "README.md" license = "Apache-2.0" requires-python = ">=3.11" -dependencies = ["pydantic>=2.9"] +dependencies = ["pydantic>=2.9", "pyyaml>=6.0"] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python :: 3.11", @@ -19,9 +19,6 @@ classifiers = [ "Typing :: Typed", ] -[project.optional-dependencies] -yaml = ["pyyaml>=6.0"] - [project.urls] Homepage = "https://github.com/jpower432/py-gemara" Repository = "https://github.com/jpower432/py-gemara" @@ -34,7 +31,6 @@ dev = [ "mypy>=2.3", "poethepoet>=0.30", "pytest>=8.0", - "pyyaml>=6.0", "types-PyYAML>=6.0", ] diff --git a/src/gemara/v1/_loader.py b/src/gemara/v1/_loader.py index 65e8345..8076fcc 100644 --- a/src/gemara/v1/_loader.py +++ b/src/gemara/v1/_loader.py @@ -5,11 +5,12 @@ from __future__ import annotations -import json import os from pathlib import Path from typing import IO, Any, cast +import yaml + from gemara.v1._registry import DOCUMENT_TYPES, GemaraDocument __all__ = ["GemaraError", "UnknownDocumentTypeError", "load", "loads"] @@ -42,16 +43,7 @@ def _decode(data: bytes | bytearray | memoryview) -> str: def _parse(text: str) -> Any: - try: - import yaml - except ModuleNotFoundError: - try: - return json.loads(text) - except json.JSONDecodeError as exc: - raise GemaraError( - "could not parse the document as JSON and PyYAML is not installed; " - "install the yaml extra with `pip install py-gemara[yaml]` to read YAML" - ) from exc + """Parse JSON or YAML. YAML is a superset of JSON, so one parser covers both.""" try: parsed: Any = yaml.safe_load(text) except yaml.YAMLError as exc: @@ -90,8 +82,7 @@ def loads(text: str | bytes | bytearray | memoryview) -> GemaraDocument: `UnknownDocumentTypeError` (a `GemaraError` subclass) if `metadata.type` is missing or unrecognised; and `pydantic.ValidationError` if the document does not match its model. No other exception type -- in particular no - `yaml.YAMLError` or `json.JSONDecodeError` -- escapes this function, - regardless of whether the optional `yaml` extra is installed. + `yaml.YAMLError` -- escapes this function. """ if isinstance(text, str): decoded = text diff --git a/tests/test_loader.py b/tests/test_loader.py index 68ac7a2..49419f9 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1,4 +1,4 @@ -"""Loader dispatch, error surface, and the yaml extra.""" +"""Loader dispatch, input handling, and the error surface.""" from __future__ import annotations diff --git a/tests/test_readme.py b/tests/test_readme.py deleted file mode 100644 index 8f26c82..0000000 --- a/tests/test_readme.py +++ /dev/null @@ -1,36 +0,0 @@ -"""The README must carry the limitation the spec requires to be stated.""" - -from __future__ import annotations - -import json -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -README = (PROJECT_ROOT / "README.md").read_text(encoding="utf-8") - - -def test_readme_states_the_structural_validator_limitation() -> None: - # Extract the "## Known limitations" section - assert "## Known limitations" in README, "README must have a '## Known limitations' section" - - # Get content from "## Known limitations" to the next "## " heading (or EOF) - known_limitations_start = README.find("## Known limitations") - remaining = README[known_limitations_start:] - next_section = remaining.find("## ", 2) # Skip the current "##" and find the next one - if next_section == -1: - known_limitations_section = remaining - else: - known_limitations_section = remaining[:next_section] - - # Both the limitation statement and escape hatch must be in the section - assert "structural validator, not a full Gemara validator" in known_limitations_section, ( - "Limitation statement must appear in '## Known limitations' section" - ) - assert "cue vet" in known_limitations_section, ( - "'cue vet' escape hatch must appear in '## Known limitations' section" - ) - - -def test_readme_pins_the_same_schema_version_as_provenance() -> None: - provenance = json.loads((PROJECT_ROOT / "schemas" / "provenance.json").read_text(encoding="utf-8")) - assert provenance["ref"] in README diff --git a/uv.lock b/uv.lock index ebb57e7..5b52074 100644 --- a/uv.lock +++ b/uv.lock @@ -547,10 +547,6 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, -] - -[package.optional-dependencies] -yaml = [ { name = "pyyaml" }, ] @@ -560,7 +556,6 @@ dev = [ { name = "mypy" }, { name = "poethepoet" }, { name = "pytest" }, - { name = "pyyaml" }, { name = "ruff" }, { name = "types-pyyaml" }, ] @@ -568,9 +563,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "pydantic", specifier = ">=2.9" }, - { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=6.0" }, + { name = "pyyaml", specifier = ">=6.0" }, ] -provides-extras = ["yaml"] [package.metadata.requires-dev] dev = [ @@ -578,7 +572,6 @@ dev = [ { name = "mypy", specifier = ">=2.3" }, { name = "poethepoet", specifier = ">=0.30" }, { name = "pytest", specifier = ">=8.0" }, - { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = "==0.16.6" }, { name = "types-pyyaml", specifier = ">=6.0" }, ] From 0230821c20bc2a6a46a161cc47ad4116e8d03016 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 5 Sep 2026 16:10:42 -0400 Subject: [PATCH 04/27] refactor: call datamodel-code-generator in-process, scope dependency groups Three fixes from review. Codegen no longer shells out. datamodel_code_generator.generate() takes the schema as a mapping and returns the source, so the temp file, the JSON round-trip, the cwd juggling and the stderr parsing all go away, and the CLI need not be on PATH. The header's recorded filename is now set explicitly via input_filename rather than relying on the CLI deriving it from the input's basename -- which is what the temp-dir dance existed to control. Verified byte-identical to the previous CLI invocation, and the drift gate confirms the committed output is unchanged. A side benefit worth having: the API types `preset` as a Literal of valid names, so CODEGEN_PRESET is now `Final` and a typo in it is a mypy error rather than a runtime failure partway through generation. Dependency groups are split by purpose -- test, lint, codegen -- with dev including all three. Everything was previously lumped into dev, so running the test suite pulled datamodel-code-generator, which is only needed to regenerate models. CI jobs and contributors can now install just what they use. Project URLs pointed at jpower432/py-gemara, which is a fork; corrected to gemaraproj/py-gemara. Assisted-by: Claude Opus 5 Signed-off-by: Jennifer Power --- CONTRIBUTING.md | 7 +++++- pyproject.toml | 26 +++++++++++++++------- tools/generate.py | 56 +++++++++++++++++++++++------------------------ uv.lock | 18 +++++++++++++++ 4 files changed, 70 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb019b2..1dc91c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,11 @@ uv run poe typecheck # mypy --strict uv run poe format # ruff format ``` +Dependencies are split into purpose-scoped groups, so a job or a contributor can +install only what it needs: `test`, `lint`, `codegen` (regenerating the models), +and `dev`, which includes all three plus the task runner. `uv sync` installs +`dev`; `uv sync --only-group lint` is enough to run the linters. + ## How the models are produced Two steps, deliberately separated by whether they need the outside world. @@ -21,7 +26,7 @@ upstream's OpenAPI projection, which loses integer types and flattens `date-time` to `date`. `poe generate` is hermetic — no `cue`, no network. It reads the vendored schema, -applies its repair passes, runs `datamodel-codegen`, and writes +applies its repair passes, calls `datamodel-code-generator` in-process, and writes `src/gemara/v1/_models.py` and `_registry.py`. Both generated files are committed. **Never edit them by hand**: CI regenerates diff --git a/pyproject.toml b/pyproject.toml index 4bf1d63..2ef0598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,18 +20,28 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/jpower432/py-gemara" -Repository = "https://github.com/jpower432/py-gemara" -Issues = "https://github.com/jpower432/py-gemara/issues" +Homepage = "https://github.com/gemaraproj/py-gemara" +Repository = "https://github.com/gemaraproj/py-gemara" +Issues = "https://github.com/gemaraproj/py-gemara/issues" [dependency-groups] +test = ["pytest>=8.0"] + +# ruff is pinned exactly because `poe generate` formats generated output with it, +# so an unpinned bump would churn committed bytes and fail the drift gate. +lint = ["ruff==0.16.6", "mypy>=2.3", "types-PyYAML>=6.0"] + +# Needed only to regenerate the models. Pinned exactly for the same reason as +# ruff: it decides the committed bytes the drift gate compares against. +codegen = ["datamodel-code-generator==0.76.2"] + +# Maintainer-only: re-vendoring the schema also needs `cue` on PATH, which is not +# a Python dependency and cannot be declared here. See CONTRIBUTING.md. dev = [ - "datamodel-code-generator==0.76.2", - "ruff==0.16.6", - "mypy>=2.3", + {include-group = "test"}, + {include-group = "lint"}, + {include-group = "codegen"}, "poethepoet>=0.30", - "pytest>=8.0", - "types-PyYAML>=6.0", ] [tool.uv.build-backend] diff --git a/tools/generate.py b/tools/generate.py index c876177..5d6c5fb 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -15,7 +15,9 @@ import sys import tempfile from pathlib import Path -from typing import Any +from typing import Any, Final + +from datamodel_code_generator import Error, InputFileType, generate PROJECT_ROOT = Path(__file__).resolve().parents[1] SCHEMA_PATH = PROJECT_ROOT / "schemas" / "gemara-v1.schema.json" @@ -24,10 +26,12 @@ MODELS_PATH = PACKAGE_DIR / "_models.py" REGISTRY_PATH = PACKAGE_DIR / "_registry.py" -# The codegen header embeds the input file's basename, so it must be stable -# across runs or the drift gate fails on the header alone. +# Recorded in the generated header, so it must be stable across runs or the +# drift gate fails on the header alone. CODEGEN_INPUT_NAME = "gemara-v1.schema.json" -CODEGEN_PRESET = "practical-py311-20260619" +# `Final` so mypy narrows this to its literal type: the API types `preset` as a +# Literal of valid names, so a typo is now a type error rather than a runtime one. +CODEGEN_PRESET: Final = "practical-py311-20260619" GENERATED_MARKER = "# GENERATED by tools/generate.py. Do not edit." @@ -218,31 +222,27 @@ def public_model_names(source: str) -> list[str]: def run_codegen(schema: dict[str, Any]) -> str: + """Generate the models from the in-memory schema, in-process. + + `datamodel_code_generator.generate` takes the schema as a mapping and returns + the source, so nothing is serialised to disk and the CLI need not be on PATH. + `input_filename` sets the name the generated header records; it is fixed + because the drift gate compares committed bytes, and a varying header would + fail it on its own. Verified byte-identical to the equivalent CLI invocation. + """ with tempfile.TemporaryDirectory() as tmp: - tmp_dir = Path(tmp) - (tmp_dir / CODEGEN_INPUT_NAME).write_text(json.dumps(schema, indent=2, sort_keys=True), encoding="utf-8") - output = tmp_dir / "models.py" - result = subprocess.run( - [ - "datamodel-codegen", - "--input", - CODEGEN_INPUT_NAME, - "--input-file-type", - "jsonschema", - "--schema-version", - "2020-12", - "--preset", - CODEGEN_PRESET, - "--output", - str(output), - ], - cwd=tmp_dir, - capture_output=True, - encoding="utf-8", - check=False, - ) - if result.returncode != 0: - raise GenerateError(f"datamodel-codegen failed: {result.stderr.strip()}") + output = Path(tmp) / "models.py" + try: + generate( + schema, + input_file_type=InputFileType.JsonSchema, + input_filename=CODEGEN_INPUT_NAME, + schema_version="2020-12", + preset=CODEGEN_PRESET, + output=output, + ) + except Error as exc: # datamodel-code-generator's own error type + raise GenerateError(f"codegen failed: {exc}") from exc return output.read_text(encoding="utf-8") diff --git a/uv.lock b/uv.lock index 5b52074..2debff9 100644 --- a/uv.lock +++ b/uv.lock @@ -551,6 +551,9 @@ dependencies = [ ] [package.dev-dependencies] +codegen = [ + { name = "datamodel-code-generator" }, +] dev = [ { name = "datamodel-code-generator" }, { name = "mypy" }, @@ -559,6 +562,14 @@ dev = [ { name = "ruff" }, { name = "types-pyyaml" }, ] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +test = [ + { name = "pytest" }, +] [package.metadata] requires-dist = [ @@ -567,6 +578,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +codegen = [{ name = "datamodel-code-generator", specifier = "==0.76.2" }] dev = [ { name = "datamodel-code-generator", specifier = "==0.76.2" }, { name = "mypy", specifier = ">=2.3" }, @@ -575,6 +587,12 @@ dev = [ { name = "ruff", specifier = "==0.16.6" }, { name = "types-pyyaml", specifier = ">=6.0" }, ] +lint = [ + { name = "mypy", specifier = ">=2.3" }, + { name = "ruff", specifier = "==0.16.6" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] +test = [{ name = "pytest", specifier = ">=8.0" }] [[package]] name = "pydantic" From 38e2db6fb221bbb3c590a9cf8a018e62f7052fea Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Sat, 5 Sep 2026 16:14:18 -0400 Subject: [PATCH 05/27] ci: cancel superseded runs and install only what each job needs Adds a concurrency group so a push supersedes the in-flight run for the same ref, which is the largest practical saving on an active pull request: the older run's result is already irrelevant. `main` is excluded so pushes there always produce a complete record. Narrows installs per job now that dependency groups are scoped. The test matrix takes `--group test` alone: 12 packages instead of 34, across four parallel jobs, since it needs neither the linters nor the code generator. The drift job takes codegen and lint -- generate.py formats its own output with ruff -- and calls tools/generate.py directly, poe being a dev-group tool. Quality keeps the full sync: mypy typechecks tools/generate.py and the test suite, so it needs essentially everything anyway, and narrowing it would save only the task runner. Also enables the uv cache on lower-bounds, the one uv job that lacked it. Fixes a regression from the previous commit while doing so: moving codegen in-process put `from datamodel_code_generator import ...` at module scope in tools/generate.py, which coupled the whole test suite to that dependency at collection time -- test_generate.py imports the module for its pure functions. The import is now inside run_codegen, so the module stays importable without the generator installed. That coupling is exactly what surfaced when the test job stopped installing it. Each job's install and commands were run locally as CI will run them. Assisted-by: Claude Opus 5 Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 16 +++++++++++++--- tools/generate.py | 8 ++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0e3315..d309982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: +# Supersede in-flight runs for the same ref: a push that lands while CI is still +# working makes the older run's result irrelevant. `main` is excluded so pushes +# there always produce a complete record. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + # Read-only by default; no job here needs to write back to the repository. permissions: contents: read @@ -24,7 +31,7 @@ jobs: - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true - - run: uv sync --frozen --python ${{ matrix.python-version }} + - run: uv sync --frozen --no-default-groups --group test --python ${{ matrix.python-version }} - name: Run tests, failing if any is skipped run: | # Defect 1 was a suite that skipped 39 of 40 tests and stayed green. @@ -76,9 +83,10 @@ jobs: - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: true - - run: uv sync --frozen + - run: uv sync --frozen --no-default-groups --group codegen --group lint - name: Regenerate models from the vendored schema - run: uv run poe generate + # Called directly rather than through poe, which lives in the dev group. + run: uv run python tools/generate.py - name: Fail if the committed output drifted run: | git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ @@ -94,6 +102,8 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: true - name: Resolve and install the lowest declared direct dependencies run: uv sync - name: Show what actually got installed diff --git a/tools/generate.py b/tools/generate.py index 5d6c5fb..194638c 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -17,8 +17,6 @@ from pathlib import Path from typing import Any, Final -from datamodel_code_generator import Error, InputFileType, generate - PROJECT_ROOT = Path(__file__).resolve().parents[1] SCHEMA_PATH = PROJECT_ROOT / "schemas" / "gemara-v1.schema.json" PROVENANCE_PATH = PROJECT_ROOT / "schemas" / "provenance.json" @@ -229,7 +227,13 @@ def run_codegen(schema: dict[str, Any]) -> str: `input_filename` sets the name the generated header records; it is fixed because the drift gate compares committed bytes, and a varying header would fail it on its own. Verified byte-identical to the equivalent CLI invocation. + + Imported here rather than at module scope so this module's pure functions -- + the ones the unit tests exercise -- stay importable without the codegen + dependency installed. Only regenerating needs it. """ + from datamodel_code_generator import Error, InputFileType, generate + with tempfile.TemporaryDirectory() as tmp: output = Path(tmp) / "models.py" try: From 6740679cd65bfe29c3d2f2ff62006ee6ea1da3a5 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:34:58 -0400 Subject: [PATCH 06/27] feat: add typed document loading constructors Add from_text and from_file constructors to generated document models. Fold transparent hidden schema definitions during generation and reorganize associated tests by package domain. Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- src/gemara/v1/_document.py | 27 ++++++++ src/gemara/v1/_loader.py | 30 ++++++--- src/gemara/v1/_models.py | 86 +++++--------------------- tests/conftest.py | 6 -- tests/{ => gemara/v1}/test_loader.py | 34 ++++++++-- tests/{ => gemara/v1}/test_registry.py | 2 +- tests/test_fixtures.py | 2 +- tests/{support.py => test_helpers.py} | 2 +- tests/{ => tools}/test_generate.py | 52 +++++++++++++++- tests/{ => tools}/test_schema.py | 2 +- tests/{ => tools}/test_sync_schema.py | 2 +- tools/generate.py | 78 ++++++++++++++++++++--- 12 files changed, 219 insertions(+), 104 deletions(-) create mode 100644 src/gemara/v1/_document.py delete mode 100644 tests/conftest.py rename tests/{ => gemara/v1}/test_loader.py (84%) rename tests/{ => gemara/v1}/test_registry.py (95%) rename tests/{support.py => test_helpers.py} (85%) rename tests/{ => tools}/test_generate.py (78%) rename tests/{ => tools}/test_schema.py (97%) rename tests/{ => tools}/test_sync_schema.py (98%) diff --git a/src/gemara/v1/_document.py b/src/gemara/v1/_document.py new file mode 100644 index 0000000..8be1c25 --- /dev/null +++ b/src/gemara/v1/_document.py @@ -0,0 +1,27 @@ +"""Shared typed loading constructors for generated document models.""" + +from __future__ import annotations + +import os +from typing import IO, Self + +from pydantic import BaseModel + + +class GemaraDocumentModel(BaseModel): + """Base for top-level Gemara documents with typed parsing constructors.""" + + @classmethod + def from_text(cls, text: str | bytes | bytearray | memoryview) -> Self: + """Parse JSON or YAML text into this document type.""" + # Import lazily: the loader's registry imports the generated models. + from gemara.v1._loader import _loads_as + + return _loads_as(cls, text) + + @classmethod + def from_file(cls, source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> Self: + """Read JSON or YAML from a path or open file into this document type.""" + from gemara.v1._loader import _read_source + + return cls.from_text(_read_source(source)) diff --git a/src/gemara/v1/_loader.py b/src/gemara/v1/_loader.py index 8076fcc..5779a6d 100644 --- a/src/gemara/v1/_loader.py +++ b/src/gemara/v1/_loader.py @@ -1,20 +1,21 @@ """Read a Gemara document and dispatch it to the right model. - -Hand-written; passes `mypy --strict`. """ from __future__ import annotations import os from pathlib import Path -from typing import IO, Any, cast +from typing import IO, Any, TypeVar, cast import yaml +from pydantic import BaseModel from gemara.v1._registry import DOCUMENT_TYPES, GemaraDocument __all__ = ["GemaraError", "UnknownDocumentTypeError", "load", "loads"] +T = TypeVar("T", bound=BaseModel) + class GemaraError(Exception): """Base class for every error this package raises.""" @@ -51,6 +52,22 @@ def _parse(text: str) -> Any: return parsed +def _loads_as(model: type[T], text: str | bytes | bytearray | memoryview) -> T: + """Parse text and validate it as one explicitly selected document model.""" + if isinstance(text, str): + decoded = text + else: + decoded = _decode(text) + return model.model_validate(_parse(decoded)) + + +def _read_source(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> str | bytes: + """Read a path or an already-open text or binary file.""" + if isinstance(source, (str, os.PathLike)): + return Path(source).read_bytes() + return source.read() + + def _dispatch(raw: Any) -> GemaraDocument: if not isinstance(raw, dict): raise GemaraError(f"a Gemara document must be a mapping, got {type(raw).__name__}") @@ -102,8 +119,7 @@ def load(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> GemaraDocument (e.g. the result of `open(path)`). Like `json.load`, the file may be opened in either text or binary mode; binary content is decoded as UTF-8. - Raises the same exceptions as `loads`, to which it delegates. + Raises the parsing and validation exceptions documented by `loads`, plus + filesystem or stream I/O exceptions from reading `source`. """ - if isinstance(source, (str, os.PathLike)): - return loads(Path(source).read_bytes()) - return loads(source.read()) + return loads(_read_source(source)) diff --git a/src/gemara/v1/_models.py b/src/gemara/v1/_models.py index 59732fc..d5c763a 100644 --- a/src/gemara/v1/_models.py +++ b/src/gemara/v1/_models.py @@ -4,6 +4,8 @@ from __future__ import annotations +from gemara.v1._document import GemaraDocumentModel + from enum import Enum from typing import Annotated, Any, Literal @@ -580,24 +582,6 @@ class Vector(BaseModel): """title describes the vector""" -class FieldMappingStrict(BaseModel): - """_MappingStrict layers the "targets required when not no-match" rule on top of #Mapping""" - - model_config = ConfigDict( - populate_by_name=True, - ) - id: str - """id allows this mapping to be referenced by other elements""" - relationship: RelationshipType - """relationship describes the nature of the mapping between source and all targets""" - remarks: str | None = None - """remarks is general prose regarding this mapping""" - source: str - """source identifies the entry being mapped from by its entry-id""" - targets: Annotated[list[MappingTarget] | None, Field(min_length=1)] = None - """targets identifies the entries being mapped to; absent when relationship is no-match""" - - class ReferenceId(RootModel[str]): root: str """reference-id is the id for a MappingReference entry in the artifact's metadata""" @@ -996,43 +980,6 @@ class Risks(BaseModel): """Mitigated risks only need reference-id and risk-id (no justification required)""" -class FieldAssessmentLogStrict(BaseModel): - """_AssessmentLogStrict layers the "start required unless unexecuted" rule on top of #AssessmentLog""" - - model_config = ConfigDict( - populate_by_name=True, - ) - applicability: Annotated[list[str], Field(min_length=1)] - """Applicability is elevated from the Layer 2 Assessment Requirement to aid in execution and reporting.""" - confidence_level: Annotated[ConfidenceLevel | None, Field(alias="confidence-level")] = None - """ConfidenceLevel indicates the evaluator's confidence level in this specific assessment result.""" - description: str - """Description provides a summary of the assessment procedure.""" - end: AwareDatetime | None = None - """End is the timestamp when the assessment concluded.""" - evidence: Annotated[list[Evidence] | None, Field(min_length=1)] = None - """Evidence records the raw data cited to support this assessment's opinion.""" - message: str - """Message provides additional context about the assessment result.""" - plan: EntryMapping | None = None - """Plan maps to the policy assessment plan being executed.""" - recommendation: str | None = None - """Recommendation provides guidance on how to address a failed assessment.""" - requirement: EntryMapping - """Requirement should map to the assessment requirement for this assessment.""" - result: Result - """Result is the overall outcome of the assessment procedure, matching the result of the last step that was run.""" - start: AwareDatetime | None = None - """ - Start is the timestamp when the assessment began. - Assessments that never executed have no start time to record. - """ - steps: Annotated[list[str], Field(min_length=1)] - """Steps are sequential actions taken as part of the assessment, which may halt the assessment if a failure occurs.""" - steps_executed: Annotated[int | None, Field(alias="steps-executed")] = None - """Steps-executed is the number of steps that were executed as part of the assessment.""" - - class ActionResult(BaseModel): """ActionResult captures a performed enforcement action.""" @@ -1238,7 +1185,7 @@ class CapabilityCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class CapabilityCatalog(BaseModel): +class CapabilityCatalog(GemaraDocumentModel): """CapabilityCatalog describes a collection of system capabilities""" model_config = ConfigDict( @@ -1287,7 +1234,7 @@ class ControlCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class ControlCatalog(BaseModel): +class ControlCatalog(GemaraDocumentModel): """ControlCatalog describes a set of related controls and relevant metadata""" model_config = ConfigDict( @@ -1336,7 +1283,7 @@ class EnforcementLogMetadata(BaseModel): """version is the version identifier of this artifact""" -class EnforcementLog(BaseModel): +class EnforcementLog(GemaraDocumentModel): """EnforcementLog records actions taken in response to noncompliance findings from Layer 5 evaluations.""" model_config = ConfigDict( @@ -1382,7 +1329,7 @@ class EvaluationLogMetadata(BaseModel): """version is the version identifier of this artifact""" -class EvaluationLog(BaseModel): +class EvaluationLog(GemaraDocumentModel): """EvaluationLog contains the results of evaluating a set of Layer 2 controls.""" model_config = ConfigDict( @@ -1427,7 +1374,7 @@ class GuidanceCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class GuidanceCatalog(BaseModel): +class GuidanceCatalog(GemaraDocumentModel): """GuidanceCatalog represents a concerted documentation effort to help bring about an optimal future without foreknowledge of the implementation details""" model_config = ConfigDict( @@ -1482,7 +1429,7 @@ class LexiconMetadata(BaseModel): """version is the version identifier of this artifact""" -class Lexicon(BaseModel): +class Lexicon(GemaraDocumentModel): """Lexicon is a controlled vocabulary or glossary artifact referenced by Metadata.lexicon""" model_config = ConfigDict( @@ -1526,13 +1473,13 @@ class MappingDocumentMetadata(BaseModel): """version is the version identifier of this artifact""" -class MappingDocument(BaseModel): +class MappingDocument(GemaraDocumentModel): """MappingDocument captures the user's intent for how entries in a source artifact relate to entries in a target artifact""" model_config = ConfigDict( populate_by_name=True, ) - mappings: Annotated[list[FieldMappingStrict], Field(min_length=1)] + mappings: Annotated[list[Mapping], Field(min_length=1)] """mappings is one or more atomic relationships between entries in the referenced artifacts""" metadata: Annotated[MappingDocumentMetadata, Field(title="MappingDocumentMetadata")] """metadata provides detailed data about this document""" @@ -1636,7 +1583,7 @@ class PrincipleCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class PrincipleCatalog(BaseModel): +class PrincipleCatalog(GemaraDocumentModel): """PrincipleCatalog describes a set of related principles and relevant metadata""" model_config = ConfigDict( @@ -1685,7 +1632,7 @@ class RiskCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class RiskCatalog(BaseModel): +class RiskCatalog(GemaraDocumentModel): """ A RiskCatalog is a structured collection of documented risks that may affect an organization, system, or service. It provides a centralized reference for risks that can be mapped to threats @@ -1760,7 +1707,7 @@ class ThreatCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class ThreatCatalog(BaseModel): +class ThreatCatalog(GemaraDocumentModel): """ThreatCatalog describes a set of topically-associated threats""" model_config = ConfigDict( @@ -1809,7 +1756,7 @@ class VectorCatalogMetadata(BaseModel): """version is the version identifier of this artifact""" -class VectorCatalog(BaseModel): +class VectorCatalog(GemaraDocumentModel): """Catalog describes a set of topically-associated entries""" model_config = ConfigDict( @@ -1856,7 +1803,7 @@ class Adherence(BaseModel): non_compliance: Annotated[str | None, Field(alias="non-compliance")] = None -class AuditLog(BaseModel): +class AuditLog(GemaraDocumentModel): """AuditLog records results from an audit performed against a target resource""" model_config = ConfigDict( @@ -1905,7 +1852,7 @@ class Log(BaseModel): """target identifies the resource being evaluated""" -class Policy(BaseModel): +class Policy(GemaraDocumentModel): """Policy represents a policy document with metadata, contacts, scope, imports, implementation plan, risks, and adherence requirements.""" model_config = ConfigDict( @@ -1967,7 +1914,6 @@ class Policy(BaseModel): "Evidence", "EvidenceMapping", "Exemption", - "FieldMappingStrict", "Group", "GuidanceCatalog", "GuidanceCatalogMetadata", diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index e46a4ba..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,6 +0,0 @@ -"""pytest hook module. Shared, importable-by-name helpers live in `support.py` -instead -- `conftest` is a pytest-reserved filename, so importing it by name -elsewhere is a misuse. -""" - -from __future__ import annotations diff --git a/tests/test_loader.py b/tests/gemara/v1/test_loader.py similarity index 84% rename from tests/test_loader.py rename to tests/gemara/v1/test_loader.py index 49419f9..9d8e3ab 100644 --- a/tests/test_loader.py +++ b/tests/gemara/v1/test_loader.py @@ -5,6 +5,7 @@ import io import json from pathlib import Path +from typing import Any import pytest from pydantic import ValidationError @@ -14,12 +15,13 @@ SCHEMA_VERSION, ControlCatalog, GemaraError, + GuidanceCatalog, UnknownDocumentTypeError, load, loads, ) -CATALOG = { +CATALOG: dict[str, Any] = { "metadata": { "id": "example", "author": {"id": "author-1", "name": "Example Author", "type": "Human"}, @@ -81,6 +83,26 @@ def test_load_reads_an_open_file_object(tmp_path: Path) -> None: assert isinstance(load(f), ControlCatalog) +def test_document_model_from_file_returns_its_declared_type(tmp_path: Path) -> None: + path = tmp_path / "catalog.yaml" + path.write_text(json.dumps(CATALOG), encoding="utf-8") + + catalog = ControlCatalog.from_file(path) + + assert isinstance(catalog, ControlCatalog) + + +def test_document_model_from_text_returns_its_declared_type() -> None: + catalog = ControlCatalog.from_text(json.dumps(CATALOG)) + + assert isinstance(catalog, ControlCatalog) + + +def test_document_model_typed_loading_rejects_another_document_type() -> None: + with pytest.raises(ValidationError, match="GuidanceCatalog"): + GuidanceCatalog.from_text(json.dumps(CATALOG)) + + def test_loads_accepts_bytearray() -> None: doc = loads(bytearray(json.dumps(CATALOG), "utf-8")) assert isinstance(doc, ControlCatalog) @@ -166,8 +188,9 @@ def test_load_contains_undecodable_bytes_from_a_file_object() -> None: def test_a_field_from_a_later_minor_is_accepted() -> None: """Gemara v1 is additive, so a v1.5.0 reader must not reject a v1.6.0 document.""" - doc = dict(CATALOG) + doc = {**CATALOG, "metadata": dict(CATALOG["metadata"])} doc["field-added-in-a-later-minor"] = "hello" + doc["metadata"]["field-added-in-a-later-minor"] = "hello" assert isinstance(loads(json.dumps(doc)), ControlCatalog) @@ -179,12 +202,15 @@ def test_a_field_from_a_later_minor_is_not_carried_onto_the_model() -> None: an attribute kept at runtime but absent from the stubs would be invisible to every type checker, in a package whose entire product is types. """ - doc = dict(CATALOG) + doc = {**CATALOG, "metadata": dict(CATALOG["metadata"])} doc["field-added-in-a-later-minor"] = "hello" + doc["metadata"]["field-added-in-a-later-minor"] = "hello" parsed = loads(json.dumps(doc)) assert not hasattr(parsed, "field-added-in-a-later-minor") + assert not hasattr(parsed.metadata, "field-added-in-a-later-minor") dumped = parsed.model_dump(by_alias=True, mode="json", exclude_none=True) assert "field-added-in-a-later-minor" not in dumped + assert "field-added-in-a-later-minor" not in dumped["metadata"] def test_unknown_document_type_is_a_gemara_error() -> None: @@ -193,6 +219,6 @@ def test_unknown_document_type_is_a_gemara_error() -> None: def test_schema_version_matches_provenance() -> None: provenance = json.loads( - (Path(__file__).resolve().parents[1] / "schemas" / "provenance.json").read_text(encoding="utf-8") + (Path(__file__).resolve().parents[3] / "schemas" / "provenance.json").read_text(encoding="utf-8") ) assert SCHEMA_VERSION == provenance["ref"].lstrip("v") diff --git a/tests/test_registry.py b/tests/gemara/v1/test_registry.py similarity index 95% rename from tests/test_registry.py rename to tests/gemara/v1/test_registry.py index 8a1b463..c2e86ce 100644 --- a/tests/test_registry.py +++ b/tests/gemara/v1/test_registry.py @@ -10,7 +10,7 @@ from gemara.v1 import DOCUMENT_TYPES, GemaraDocument -SCHEMA_DIR = Path(__file__).resolve().parents[1] / "schemas" +SCHEMA_DIR = Path(__file__).resolve().parents[3] / "schemas" def test_registry_matches_the_artifact_type_enum() -> None: diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index f90d914..ea34aab 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -11,7 +11,7 @@ import pytest from pydantic import ValidationError -from support import fixture_paths +from test_helpers import fixture_paths from gemara.v1 import UnknownDocumentTypeError, load diff --git a/tests/support.py b/tests/test_helpers.py similarity index 85% rename from tests/support.py rename to tests/test_helpers.py index fcf643c..08a16ff 100644 --- a/tests/support.py +++ b/tests/test_helpers.py @@ -1,4 +1,4 @@ -"""Shared test helpers, importable by name (unlike `conftest`).""" +"""Shared test helpers, importable by name""" from __future__ import annotations diff --git a/tests/test_generate.py b/tests/tools/test_generate.py similarity index 78% rename from tests/test_generate.py rename to tests/tools/test_generate.py index 2479a25..8e5a127 100644 --- a/tests/test_generate.py +++ b/tests/tools/test_generate.py @@ -9,7 +9,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) import generate # noqa: E402 @@ -112,6 +112,48 @@ def test_ignore_unknown_properties_leaves_schemas_alone_that_never_closed() -> N assert schema["$defs"]["Doc"]["additionalProperties"] == {"type": "string"} +def test_fold_hidden_definitions_replaces_refs_with_base_definitions() -> None: + schema: dict[str, Any] = { + "$defs": { + "Mapping": {"type": "object"}, + "AssessmentLog": {"type": "object"}, + "FutureBase": {"type": "object"}, + "_MappingStrict": {"$ref": "#/$defs/Mapping", "type": "object"}, + "_AssessmentLogStrict": {"$ref": "#/$defs/AssessmentLog", "type": "object"}, + "_FutureWrapper": {"$ref": "#/$defs/FutureBase", "type": "object"}, + "Document": { + "properties": { + "mapping": {"$ref": "#/$defs/_MappingStrict"}, + "logs": {"items": {"$ref": "#/$defs/_AssessmentLogStrict"}, "type": "array"}, + "future": {"$ref": "#/$defs/_FutureWrapper"}, + } + }, + } + } + + merged = generate.fold_hidden_definitions(schema) + + assert merged == 3 + assert schema["$defs"]["Document"]["properties"]["mapping"] == {"$ref": "#/$defs/Mapping"} + assert schema["$defs"]["Document"]["properties"]["logs"]["items"] == {"$ref": "#/$defs/AssessmentLog"} + assert schema["$defs"]["Document"]["properties"]["future"] == {"$ref": "#/$defs/FutureBase"} + assert "_MappingStrict" not in schema["$defs"] + assert "_AssessmentLogStrict" not in schema["$defs"] + assert "_FutureWrapper" not in schema["$defs"] + + +def test_fold_hidden_definitions_rejects_hidden_constraints() -> None: + schema: dict[str, Any] = { + "$defs": { + "Base": {"type": "object"}, + "_Constrained": {"$ref": "#/$defs/Base", "required": ["value"], "type": "object"}, + } + } + + with pytest.raises(generate.GenerateError, match="cannot be safely folded"): + generate.fold_hidden_definitions(schema) + + def test_render_registry_emits_sorted_typed_entries() -> None: source = generate.render_registry( {"Lexicon": "Lexicon", "ControlCatalog": "ControlCatalog"}, @@ -132,10 +174,16 @@ def test_public_model_names_reads_classes_from_source() -> None: def test_render_models_excludes_denylisted_names_from_all() -> None: - source = generate.render_models("class Alpha(BaseModel):\n pass\n", ["Alpha", "Model", "Type"]) + source = generate.render_models( + "from __future__ import annotations\n\nclass Alpha(BaseModel):\n pass\n", + ["Alpha", "Model", "Type"], + ["Alpha"], + ) assert '"Alpha",' in source assert '"Model",' not in source assert '"Type",' not in source + assert "from gemara.v1._document import GemaraDocumentModel" in source + assert "class Alpha(GemaraDocumentModel):" in source def test_recover_array_allof_element_type_collapses_a_single_items_arm() -> None: diff --git a/tests/test_schema.py b/tests/tools/test_schema.py similarity index 97% rename from tests/test_schema.py rename to tests/tools/test_schema.py index 3b872ec..cdbf46d 100644 --- a/tests/test_schema.py +++ b/tests/tools/test_schema.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any -PROJECT_ROOT = Path(__file__).resolve().parents[1] +PROJECT_ROOT = Path(__file__).resolve().parents[2] SCHEMA_PATH = PROJECT_ROOT / "schemas" / "gemara-v1.schema.json" PROVENANCE_PATH = PROJECT_ROOT / "schemas" / "provenance.json" diff --git a/tests/test_sync_schema.py b/tests/tools/test_sync_schema.py similarity index 98% rename from tests/test_sync_schema.py rename to tests/tools/test_sync_schema.py index ed5a7fc..68554fe 100644 --- a/tests/test_sync_schema.py +++ b/tests/tools/test_sync_schema.py @@ -8,7 +8,7 @@ import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools")) +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) import sync_schema # noqa: E402 diff --git a/tools/generate.py b/tools/generate.py index 194638c..1a3a6c5 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Generate Pydantic v2 models and the document registry from the vendored schema. -Hermetic: no cue, no network, no Go. Reads `schemas/gemara-v1.schema.json`, -writes `src/gemara/v1/_models.py` and `src/gemara/v1/_registry.py`. +Reads `schemas/gemara-v1.schema.json`, writes `src/gemara/v1/_models.py` +and `src/gemara/v1/_registry.py`. Usage: python tools/generate.py """ @@ -41,13 +41,8 @@ # - `Type` duplicates `ArtifactType` (same enum values, generated a second # time from an inline `type` property instead of a `$ref`), and its name # is exactly the kind of generic identifier `import *` should not export. -# - `FieldAssessmentLogStrict` is dead: nothing in the generated module -# references it (only `AssessmentLog` is used), it exists only because -# `ControlEvaluation.assessment-logs`'s untouched ambiguous `allOf` (see -# `recover_array_allof_element_type`) still pulls in the `$ref`'d -# `_AssessmentLogStrict` definition. # `ReferenceId` is deliberately NOT here: it is a real schema type. -DENYLISTED_MODEL_NAMES = frozenset({"Model", "Type", "FieldAssessmentLogStrict"}) +DENYLISTED_MODEL_NAMES = frozenset({"Model", "Type"}) class GenerateError(RuntimeError): @@ -91,6 +86,57 @@ def check_document_types(schema: dict[str, Any], doc_types: dict[str, str]) -> N raise GenerateError(f"document types disagree with #ArtifactType (missing: {missing}; unexpected: {extra})") +def fold_hidden_definitions(schema: dict[str, Any]) -> int: + """Replace transparent CUE-internal definitions with their base definitions. + + Hidden CUE structs are exported as underscore-prefixed `$defs`. A transparent + wrapper contains only a reference and descriptive metadata, so it can point + directly to its base type instead of becoming a duplicate generated model. + Generation fails for a non-transparent hidden definition rather than silently + discarding a constraint it might carry. + """ + definitions = schema.get("$defs") + if not isinstance(definitions, dict): + raise GenerateError("schema has no $defs") + + hidden_refs: dict[str, str] = {} + for name, definition in definitions.items(): + if not isinstance(name, str) or not name.startswith("_"): + continue + if not isinstance(definition, dict): + raise GenerateError(f"hidden definition {name!r} is not an object") + unsupported = set(definition) - {"$ref", "description", "type"} + ref = definition.get("$ref") + if unsupported or not isinstance(ref, str) or not ref.startswith("#/$defs/"): + raise GenerateError(f"hidden definition {name!r} cannot be safely folded") + hidden_refs[name] = ref + + def resolve(ref: str, seen: set[str]) -> str: + name = ref.removeprefix("#/$defs/") + if name not in hidden_refs: + return ref + if name in seen: + raise GenerateError(f"hidden definition reference cycle: {name!r}") + return resolve(hidden_refs[name], seen | {name}) + + replacements = {f"#/$defs/{name}": resolve(ref, {name}) for name, ref in hidden_refs.items()} + + def visit(node: Any) -> None: + if isinstance(node, dict): + if node.get("$ref") in replacements: + node["$ref"] = replacements[node["$ref"]] + for value in node.values(): + visit(value) + elif isinstance(node, list): + for item in node: + visit(item) + + visit(schema) + for name in hidden_refs: + del definitions[name] + return len(replacements) + + def ignore_unknown_properties(schema: dict[str, Any]) -> int: """Reopen every closed object so unknown properties are ignored, not rejected. @@ -250,7 +296,16 @@ def run_codegen(schema: dict[str, Any]) -> str: return output.read_text(encoding="utf-8") -def render_models(body: str, model_names: list[str]) -> str: +def render_models(body: str, model_names: list[str], document_models: list[str]) -> str: + future_import = "from __future__ import annotations\n" + if body.count(future_import) != 1: + raise GenerateError("generated models have an unexpected future-import layout") + body = body.replace(future_import, f"{future_import}\nfrom gemara.v1._document import GemaraDocumentModel\n", 1) + for model in document_models: + declaration = f"class {model}(BaseModel):" + if body.count(declaration) != 1: + raise GenerateError(f"generated models have an unexpected declaration for document model {model!r}") + body = body.replace(declaration, f"class {model}(GemaraDocumentModel):") exports = "\n".join(f' "{name}",' for name in sorted(model_names) if name not in DENYLISTED_MODEL_NAMES) return f"{GENERATED_MARKER}\n{body.rstrip()}\n\n\n__all__ = [\n{exports}\n]\n" @@ -319,6 +374,9 @@ def main() -> int: check_document_types(schema, doc_types) print(f" {len(doc_types)} document types match #ArtifactType") + folded = fold_hidden_definitions(schema) + print(f" Folded {folded} hidden definition(s)") + recovered = recover_array_allof_element_type(schema) print(f" Recovered element type for {recovered} array allOf site(s)") @@ -330,7 +388,7 @@ def main() -> int: print(f" Generated {len(model_names)} models") PACKAGE_DIR.mkdir(parents=True, exist_ok=True) - MODELS_PATH.write_text(render_models(body, model_names), encoding="utf-8") + MODELS_PATH.write_text(render_models(body, model_names, sorted(doc_types.values())), encoding="utf-8") REGISTRY_PATH.write_text(render_registry(doc_types, schema_version, model_names), encoding="utf-8") ruff_format(MODELS_PATH, REGISTRY_PATH) From 62901267046a72184cc8c7013f99537ca2530a16 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:35:23 -0400 Subject: [PATCH 07/27] build: rename distribution to gemara-python Update the package metadata, repository links, and lockfile for the gemara-python distribution name. Signed-off-by: Jennifer Power --- pyproject.toml | 13 +++--- uv.lock | 110 +++++++++++++++++++++++++------------------------ 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2ef0598..e416921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["uv_build>=0.9.0,<0.15.0"] build-backend = "uv_build" [project] -name = "py-gemara" +name = "gemara-python" version = "0.1.0" description = "Gemara v1 schema types as Pydantic v2 models" readme = "README.md" @@ -20,9 +20,9 @@ classifiers = [ ] [project.urls] -Homepage = "https://github.com/gemaraproj/py-gemara" -Repository = "https://github.com/gemaraproj/py-gemara" -Issues = "https://github.com/gemaraproj/py-gemara/issues" +Homepage = "https://github.com/gemaraproj/gemara-python" +Repository = "https://github.com/gemaraproj/gemara-python" +Issues = "https://github.com/gemaraproj/gemara-python/issues" [dependency-groups] test = ["pytest>=8.0"] @@ -33,15 +33,14 @@ lint = ["ruff==0.16.6", "mypy>=2.3", "types-PyYAML>=6.0"] # Needed only to regenerate the models. Pinned exactly for the same reason as # ruff: it decides the committed bytes the drift gate compares against. -codegen = ["datamodel-code-generator==0.76.2"] +codegen = ["datamodel-code-generator==0.76.2", "poethepoet>=0.30"] -# Maintainer-only: re-vendoring the schema also needs `cue` on PATH, which is not +# IMPORTANT: re-vendoring the schema also needs `cue` on PATH, which is not # a Python dependency and cannot be declared here. See CONTRIBUTING.md. dev = [ {include-group = "test"}, {include-group = "lint"}, {include-group = "codegen"}, - "poethepoet>=0.30", ] [tool.uv.build-backend] diff --git a/uv.lock b/uv.lock index 2debff9..386ec04 100644 --- a/uv.lock +++ b/uv.lock @@ -163,6 +163,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/7a/00b736585a6fac1e9c94275a07d8e470a3accdaab37b54d13fdc1c985bc0/datamodel_code_generator-0.76.2-py3-none-any.whl", hash = "sha256:8cc2bffa5a7e81a4b5a1cfce28530eaf9be465f3591bb7cb53030eb0c956f6d9", size = 661183, upload-time = "2026-09-04T11:37:45.73Z" }, ] +[[package]] +name = "gemara-python" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pydantic" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +codegen = [ + { name = "datamodel-code-generator" }, + { name = "poethepoet" }, +] +dev = [ + { name = "datamodel-code-generator" }, + { name = "mypy" }, + { name = "poethepoet" }, + { name = "pytest" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +lint = [ + { name = "mypy" }, + { name = "ruff" }, + { name = "types-pyyaml" }, +] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.9" }, + { name = "pyyaml", specifier = ">=6.0" }, +] + +[package.metadata.requires-dev] +codegen = [ + { name = "datamodel-code-generator", specifier = "==0.76.2" }, + { name = "poethepoet", specifier = ">=0.30" }, +] +dev = [ + { name = "datamodel-code-generator", specifier = "==0.76.2" }, + { name = "mypy", specifier = ">=2.3" }, + { name = "poethepoet", specifier = ">=0.30" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = "==0.16.6" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] +lint = [ + { name = "mypy", specifier = ">=2.3" }, + { name = "ruff", specifier = "==0.16.6" }, + { name = "types-pyyaml", specifier = ">=6.0" }, +] +test = [{ name = "pytest", specifier = ">=8.0" }] + [[package]] name = "genson" version = "1.4.0" @@ -541,59 +598,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8d/d7c9455b15f8d2d7ce57e7b71a8ef8d02d9992ae4283c9777120620c9022/poethepoet-0.48.0-py3-none-any.whl", hash = "sha256:98da6096d060f49b8d84034770265863fb7dc92a40233b7694b9d216ac68737d", size = 185808, upload-time = "2026-07-05T21:48:28.601Z" }, ] -[[package]] -name = "py-gemara" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, -] - -[package.dev-dependencies] -codegen = [ - { name = "datamodel-code-generator" }, -] -dev = [ - { name = "datamodel-code-generator" }, - { name = "mypy" }, - { name = "poethepoet" }, - { name = "pytest" }, - { name = "ruff" }, - { name = "types-pyyaml" }, -] -lint = [ - { name = "mypy" }, - { name = "ruff" }, - { name = "types-pyyaml" }, -] -test = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "pydantic", specifier = ">=2.9" }, - { name = "pyyaml", specifier = ">=6.0" }, -] - -[package.metadata.requires-dev] -codegen = [{ name = "datamodel-code-generator", specifier = "==0.76.2" }] -dev = [ - { name = "datamodel-code-generator", specifier = "==0.76.2" }, - { name = "mypy", specifier = ">=2.3" }, - { name = "poethepoet", specifier = ">=0.30" }, - { name = "pytest", specifier = ">=8.0" }, - { name = "ruff", specifier = "==0.16.6" }, - { name = "types-pyyaml", specifier = ">=6.0" }, -] -lint = [ - { name = "mypy", specifier = ">=2.3" }, - { name = "ruff", specifier = "==0.16.6" }, - { name = "types-pyyaml", specifier = ">=6.0" }, -] -test = [{ name = "pytest", specifier = ">=8.0" }] - [[package]] name = "pydantic" version = "2.13.5" From 6e038e4f936357a76ba1d2de9ed4c47374be9294 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:35:27 -0400 Subject: [PATCH 08/27] docs: document package usage and schema maintenance Document typed document loading, distribution installation, schema update steps, and the TestPyPI rehearsal tag convention. Signed-off-by: Jennifer Power --- CONTRIBUTING.md | 27 +++++++--------- README.md | 66 +++++++++++++++++++++------------------ schemas/README.md | 53 +++++++++++++++++++++++++++++++ src/gemara/v1/__init__.py | 9 +----- 4 files changed, 101 insertions(+), 54 deletions(-) create mode 100644 schemas/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1dc91c4..4ae277f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,28 +10,25 @@ uv run poe format # ruff format Dependencies are split into purpose-scoped groups, so a job or a contributor can install only what it needs: `test`, `lint`, `codegen` (regenerating the models), -and `dev`, which includes all three plus the task runner. `uv sync` installs -`dev`; `uv sync --only-group lint` is enough to run the linters. +and `dev`, which includes all three. `uv sync` installs `dev`; `uv sync --only-group lint` +is enough to run the linters. ## How the models are produced -Two steps, deliberately separated by whether they need the outside world. +This consists of two steps. -`poe sync-schema` is maintainer-only and needs `cue` on PATH plus network +1. `poe sync-schema`: needs `cue` on PATH plus network access. It exports every `#Definition` from the upstream CUE module as JSON Schema, merges them into `schemas/gemara-v1.schema.json`, records the exact ref and digest in `schemas/provenance.json`, and re-vendors the upstream -`good-*`/`bad-*` corpus into `schemas/fixtures/`. JSON Schema rather than -upstream's OpenAPI projection, which loses integer types and flattens -`date-time` to `date`. +`good-*`/`bad-*` corpus into `schemas/fixtures/`. -`poe generate` is hermetic — no `cue`, no network. It reads the vendored schema, -applies its repair passes, calls `datamodel-code-generator` in-process, and writes -`src/gemara/v1/_models.py` and `_registry.py`. +2. `poe generate`: reads the vendored schema, applies its repair passes, +calls `datamodel-code-generator` in-process, and writes `src/gemara/v1/_models.py` +and `_registry.py`. Both generated files are committed. **Never edit them by hand**: CI regenerates -them and fails on any diff, so a hand edit is reverted on the next run. Change -`tools/generate.py` instead, then `poe generate`. +them and fails on any diff, so a hand edit is reverted on the next run. The codegen invocation is fixed at `--preset practical-py311-20260619 --schema-version 2020-12`, and @@ -58,9 +55,7 @@ suite rather than passing unnoticed — that is the point of them. ## Tests The fixture corpus is vendored, so the suite runs anywhere with no `cue` and no -warm cache. CI fails the build if **any** test is skipped: the predecessor read -its fixtures from `~/.cache/cue`, reported "1 passed, 39 skipped" on every run, -and stayed green on a single assertion for its entire life. +warm cache. CI fails the build if any test is skipped. ## Releasing @@ -69,7 +64,7 @@ Publishing uses Trusted Publishing (OIDC); no API tokens are stored. The publisher registered on each index. - **Rehearse:** run the *Publish to TestPyPI* workflow manually - (`workflow_dispatch`) against any ref. + (`workflow_dispatch`) against any ref or push a test tag matching `test-vX.Y.X`. - **Release:** set `version` in `pyproject.toml`, then push a matching `vX.Y.Z` tag. The release workflow refuses a tag that disagrees with that version, and refuses `0.0.0` outright. diff --git a/README.md b/README.md index 45c6eb7..4a52904 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,19 @@ -# py-gemara +# gemara-python -[Gemara](https://github.com/gemaraproj/gemara) v1 schema types as Pydantic v2 -models, generated from the upstream CUE schemas. +## What This Is + +`gemara-python` provides generated Pydantic v2 models for +[Gemara](https://github.com/gemaraproj/gemara) v1 documents. +Use it to load, validate, and work with Gemara JSON or YAML in Python. + +## How to Install ```bash -pip install py-gemara +pip install gemara-python ``` +## Getting Started + ```python from gemara.v1 import ControlCatalog, Lexicon, load @@ -19,43 +26,42 @@ match doc: print(len(doc.terms or [])) ``` -`load` takes a path or an open file, `loads` takes text or bytes, and both read -JSON or YAML. They dispatch on `metadata.type` through `DOCUMENT_TYPES` — a -registry generated from the schema's own discriminators, so you never hand-write -a dispatch table — and return a `GemaraDocument`, the union of the 13 document -models. Narrowing it with `match` or `isinstance` typechecks under `mypy ---strict`; the package ships `py.typed`. +`load` accepts a file path or open file. Use `loads` for JSON or YAML text and +bytes. Both return the model selected by `metadata.type`. + +When the expected document type is already known, load it directly from the +model to receive that concrete type without dispatching: + +```python +from gemara.v1 import GuidanceCatalog + +guidance = GuidanceCatalog.from_file("guidance.yaml") +``` + +`from_file` accepts a file path or open file. `from_text` accepts JSON or YAML +text and bytes. Both validate the input as the selected document model. -Failures raise from one hierarchy: `UnknownDocumentTypeError` when -`metadata.type` is missing or unrecognised (its message names the offending -value and the 13 valid ones), `GemaraError` for anything unparseable, and -`pydantic.ValidationError` when a document does not match its model. +## Reference -`SCHEMA_VERSION` reports the Gemara release these models were generated from: -currently **v1.5.0**. +- `DOCUMENT_TYPES` contains the supported document models. +- `SCHEMA_VERSION` is the Gemara release used to generate the models +- Invalid input raises `GemaraError`, `UnknownDocumentTypeError`, or + `pydantic.ValidationError`. +- `load` and `from_file` also propagate filesystem and stream I/O exceptions. -## Reading documents from a newer v1.x +## Compatibility Changes within Gemara v1 are additive, so these models read every v1.x document, including ones written against a minor newer than `SCHEMA_VERSION`. Properties -they do not recognise are ignored — accepted, then dropped rather than carried -onto the model. This matches -[go-gemara](https://github.com/gemaraproj/go-gemara). - -The consequence worth knowing: `load` followed by `model_dump` is **not** a -faithful copy of such a document, because its newer fields are absent from the -output. These models are a reader, not a round-tripping editor — keep the source -if you need to preserve it byte for byte. +they do not recognize are accepted and dropped rather than carried onto the +model. ## Known limitations **These models are a structural validator, not a full Gemara validator.** CUE enforces cross-field semantics — uniqueness via hidden `_unique*` fields, -referential integrity via comprehensions — that cannot survive projection into -JSON Schema. Measured against the upstream corpus at v1.5.0, 12 of 17 `bad-*` -fixtures parse successfully, including `bad-lexicon-duplicate-term-id`, -`bad-risk-catalog-duplicate-rank`, `bad-evaluation-log-missing-start`, and the -`bad-*-invalid-group` family. +referential integrity via comprehensions -- that cannot survive projection into +JSON Schema. What still applies: required fields, enums, patterns, and length bounds. If you need full validation, run `cue vet` against the Gemara schemas. diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..9df2713 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,53 @@ +# Schema Directory + +This directory contains the vendored Gemara v1 JSON Schema and its upstream +conformance fixtures. Keeping these inputs in the repository makes code +generation and tests hermetic: contributors and CI do not need `cue` or network +to run them. + +## Contents + +- `gemara-v1.schema.json` is a single JSON Schema document containing every + exported upstream definition. +- `fixtures/` contains the upstream `good-*` and `bad-*` test documents. +- `provenance.json` records the upstream tag and commit, CUE version, retrieval + date, exported definitions, document types, and SHA-256 digest of the schema. + +Do not edit these files by hand. Regenerate them from the named upstream +release so the schema, fixtures, and provenance remain a single traceable +snapshot. + +## Updating the Schema + +Install `cue`, ensure it is on `PATH`, and use a checkout with network access. +Then run the sync command for the intended upstream tag: + +```bash +uv run poe sync-schema # optionally --ref vX.Y.Z +uv run poe generate +uv run poe test +``` + +Omit `--ref` to use the script's default Gemara version. The sync command: + +1. Discovers and exports every public CUE definition as JSON Schema. +2. Merges the exports into `gemara-v1.schema.json`. +3. Clones the same upstream tag and replaces `fixtures/` with its `good-*` and + `bad-*` corpus. +4. Writes the exact upstream commit and schema digest to `provenance.json`. + +`poe generate` then produces the Pydantic models from the vendored schema. +Review changes to `schemas/` and `src/gemara/v1/` together before committing. + +## Fixture Process + +Fixtures are copied verbatim from Gemara's `test/test-data` directory. The test +suite loads every `good-*` fixture and accounts for every `bad-*` fixture; an +empty fixture directory or an unclassified bad fixture fails the tests. + +When the upstream corpus changes, update the fixture classification in +`tests/test_fixtures.py`. Fixtures in `STRUCTURALLY_REJECTED` must fail model +validation. Fixtures in `SEMANTIC_GAPS` are known CUE cross-field rules that +JSON Schema cannot currently express and are deliberately asserted to parse. +Move an entry between those sets only when the test result and underlying +validation capability have changed. diff --git a/src/gemara/v1/__init__.py b/src/gemara/v1/__init__.py index 337dd63..20cfc25 100644 --- a/src/gemara/v1/__init__.py +++ b/src/gemara/v1/__init__.py @@ -1,11 +1,4 @@ -"""Gemara v1 schema types as Pydantic v2 models. - - from gemara.v1 import load, DOCUMENT_TYPES, ControlCatalog - - doc = load("catalog.yaml") # dispatches on metadata.type - -The models are a *structural* validator. See the README's known limitations. -""" +"""Gemara v1 schema types as Pydantic v2 models.""" from __future__ import annotations From 309b7473c4f2939364b4fb031f887777f64ba814 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:35:38 -0400 Subject: [PATCH 09/27] ci: verify TestPyPI artifacts after publishing Run workflow security checks independently and install the published TestPyPI package before executing the fixture suite against it. Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 42 +-------------------- .github/workflows/publish-testpypi.yml | 47 +++++++++++++++++------ .github/workflows/zizmor.yml | 38 +++++++++++++++++++ tests/test_packaging.py | 52 -------------------------- 4 files changed, 76 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/zizmor.yml delete mode 100644 tests/test_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d309982..18bc330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,14 +5,11 @@ on: branches: [main] pull_request: -# Supersede in-flight runs for the same ref: a push that lands while CI is still -# working makes the older run's result irrelevant. `main` is excluded so pushes -# there always produce a complete record. concurrency: group: ci-${{ github.ref }} + # `main` is excluded so pushes there always produce a complete record. cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -# Read-only by default; no job here needs to write back to the repository. permissions: contents: read @@ -57,22 +54,6 @@ jobs: - run: uv run ruff format --check . - run: uv run poe typecheck - workflows: - name: Workflow security audit - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - persist-credentials: false - - name: Audit workflows and dependabot config with zizmor - uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 - with: - # Emit inline annotations rather than SARIF: results land on the PR - # without depending on code scanning being enabled for the repo. - # The two options are mutually exclusive. - advanced-security: false - annotations: true - drift: name: Generated-model drift runs-on: ubuntu-latest @@ -85,27 +66,8 @@ jobs: enable-cache: true - run: uv sync --frozen --no-default-groups --group codegen --group lint - name: Regenerate models from the vendored schema - # Called directly rather than through poe, which lives in the dev group. - run: uv run python tools/generate.py + run: uv run poe generate - name: Fail if the committed output drifted run: | git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } - - lower-bounds: - name: Lowest direct dependencies - runs-on: ubuntu-latest - env: - UV_RESOLUTION: lowest-direct - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 - with: - persist-credentials: false - - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 - with: - enable-cache: true - - name: Resolve and install the lowest declared direct dependencies - run: uv sync - - name: Show what actually got installed - run: uv run python -c "import pydantic, pytest; print('pydantic', pydantic.VERSION, '| pytest', pytest.__version__)" - - run: uv run pytest -q diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 3453a1b..3924b04 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -1,13 +1,9 @@ name: Publish to TestPyPI -# Manual only. TestPyPI is a rehearsal space, so it is deliberately not chained -# to the tag-driven release: coupling them means you cannot rehearse without -# burning a real version, and a TestPyPI hiccup (most often "version already -# exists") would block a PyPI release that was otherwise fine. -# -# Pick the ref to publish in the Actions UI when dispatching. on: workflow_dispatch: + push: + tags: [ "test-v*" ] permissions: contents: read @@ -18,17 +14,13 @@ jobs: runs-on: ubuntu-latest environment: testpypi permissions: - # Trusted publishing: mint a short-lived OIDC token instead of holding a - # long-lived PyPI API token in repository secrets. - id-token: write + id-token: write # For trusted publishing steps: - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: - # Publishes real artifacts, so it must not restore a cache that a pull - # request workflow could have poisoned. enable-cache: false - run: uv sync --frozen - name: Run tests, failing if any is skipped @@ -46,3 +38,36 @@ jobs: # Rehearsals get re-run against an unchanged version; that should be a # no-op rather than a failure. skip-existing: true + + verify-published: + name: Test the TestPyPI package + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: false + - name: Install the published package from TestPyPI + run: | + version="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + uv venv --clear .testpypi-venv + uv pip install --python .testpypi-venv/bin/python \ + --index-url https://test.pypi.org/simple \ + --extra-index-url https://pypi.org/simple \ + "gemara-python==$version" pytest + - name: Verify imports use the installed artifact + run: | + .testpypi-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".testpypi-venv")' + - name: Run tests against the TestPyPI package + run: | + set -o pipefail + PYTHONPATH=tests .testpypi-venv/bin/python -m pytest -q \ + tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py \ + tests/test_fixtures.py | tee summary.txt + if grep -qE '[0-9]+ skipped' summary.txt; then + echo "::error::tests were skipped; the fixture corpus must always run" + exit 1 + fi diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..804529b --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,38 @@ +name: Audit Workflow Changes +on: + push: + branches: + - main + paths: + - '.github/workflows/**' # Triggers only when workflow files change + pull_request: + branches: + - main + paths: + - '.github/workflows/**' + + +concurrency: + group: ci-${{ github.ref }} + # `main` is excluded so pushes there always produce a complete record. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + workflows: + name: Workflow security audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - name: Audit workflows and dependabot config with zizmor + uses: zizmorcore/zizmor-action@70fb788f84895a7701f5643d103d587e460b5c99 # v0.6.3 + with: + # Emit inline annotations rather than SARIF: results land on the PR + # without depending on code scanning being enabled for the repo. + # The two options are mutually exclusive. + advanced-security: false + annotations: true diff --git a/tests/test_packaging.py b/tests/test_packaging.py deleted file mode 100644 index 07ac03a..0000000 --- a/tests/test_packaging.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Packaging guarantees: PEP 420 namespace layout and the PEP 561 marker.""" - -from __future__ import annotations - -import subprocess -import sys -import zipfile -from pathlib import Path - -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def _build_wheel(tmp_path: Path) -> zipfile.ZipFile: - subprocess.run( - ["uv", "build", "--wheel", "--out-dir", str(tmp_path)], - cwd=PROJECT_ROOT, - check=True, - capture_output=True, - ) - wheels = sorted(tmp_path.glob("*.whl")) - assert len(wheels) == 1, f"expected one wheel, got {wheels}" - return zipfile.ZipFile(wheels[0]) - - -def test_wheel_ships_py_typed(tmp_path: Path) -> None: - with _build_wheel(tmp_path) as wheel: - assert "gemara/v1/py.typed" in wheel.namelist() - - -def test_wheel_has_no_namespace_init(tmp_path: Path) -> None: - """gemara must stay a PEP 420 implicit namespace so gemara.v2 can coexist.""" - with _build_wheel(tmp_path) as wheel: - assert "gemara/__init__.py" not in wheel.namelist() - - -def test_wheel_version_is_not_zero(tmp_path: Path) -> None: - """Defect 3: every previous artifact shipped as 0.0.0.""" - with _build_wheel(tmp_path) as wheel: - assert wheel.filename is not None - name = Path(wheel.filename).name - assert "-0.0.0-" not in name, name - - -def test_package_imports_under_its_namespace() -> None: - result = subprocess.run( - [sys.executable, "-c", "import gemara.v1; print(gemara.v1.__name__)"], - cwd=PROJECT_ROOT, - check=True, - capture_output=True, - text=True, - ) - assert result.stdout.strip() == "gemara.v1" From a03b5904d7ca730dc66f319ef6df7f950f2d7e8c Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:35:42 -0400 Subject: [PATCH 10/27] chore: simplify Dependabot configuration Remove redundant comments while preserving the existing update policy. Signed-off-by: Jennifer Power --- .github/dependabot.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1fadbf3..7301af3 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,16 +1,12 @@ version: 2 updates: - # Actions are pinned to commit SHAs so that a compromised tag cannot silently - # change what runs in CI; dependabot is what keeps those pins from going stale. - package-ecosystem: github-actions directory: / schedule: interval: weekly day: monday open-pull-requests-limit: 5 - # Wait out the window in which a freshly published malicious release is - # most likely to be caught and yanked before we open a PR for it. cooldown: default-days: 7 commit-message: @@ -28,8 +24,6 @@ updates: interval: weekly day: monday open-pull-requests-limit: 5 - # Wait out the window in which a freshly published malicious release is - # most likely to be caught and yanked before we open a PR for it. cooldown: default-days: 7 commit-message: From 3347a493ce741b3d833d3e8edd79ae2788d2c562 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 17:44:31 -0400 Subject: [PATCH 11/27] docs: shorten array allOf recovery docstring Signed-off-by: Jennifer Power --- tools/generate.py | 45 +++++---------------------------------------- 1 file changed, 5 insertions(+), 40 deletions(-) diff --git a/tools/generate.py b/tools/generate.py index 1a3a6c5..c7de9ea 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -187,46 +187,11 @@ def visit(node: Any) -> None: def recover_array_allof_element_type(schema: dict[str, Any]) -> int: - """Collapse an array-typed `allOf` node into its one strongly-typed arm. - - CUE compiles a comprehension's array constraint (e.g. "Clear dispositions - only contain Passed results") and its element-typed constraint into - separate `allOf` arms that both narrow `type: array`. `datamodel-codegen` - does not merge an `allOf` of arrays, so every arm collapses to `Any`, - discarding the element type and any `minItems`/`items` constraint. - - This is only safe to undo when exactly one arm carries `items`: that arm - is then the sole source of element shape. When two or more arms carry - `items`, which one is authoritative is ambiguous -- they may even - disagree, as with `ControlEvaluation.assessment-logs`, where CUE defaults - a field in from elsewhere that a fixed element type cannot express -- so - the node is left untouched rather than guessing. - - Known limitation: the losing arm(s) are discarded whole. Only the winning - arm and the outer node's own sibling keys survive, so any keyword a losing - arm carries is dropped silently. That is lossless against the schema this - was written for -- Gemara v1.5.0's sole collapsing site, - `EnforcementLog.actions`, has a losing arm holding just `description` and - `type`, comprehension metadata that no structural validator can enforce - anyway (`bad-enforcement-clear-failed`, written to test exactly that rule, - parses here and in go-gemara alike). But it is an assumption about the - shape of CUE's output, not a checked invariant: if a future upstream ref - ever emits a losing arm carrying a real array constraint -- `maxItems`, - `uniqueItems`, its own `minItems` -- that constraint vanishes, and the only - signal is a field validating more loosely in the `_models.py` drift diff, - which is an absence and easy to miss. - - Deliberately not guarded with an assertion. Failing generation over a - keyword nobody has seen would block a future `sync-schema` on a schema - change that is very likely irrelevant, and merging losing arms properly - means implementing `allOf` intersection semantics per keyword -- the class - of repair pass this project banned after the predecessor's - `flatten_struct_embedding` got it wrong. If it ever bites, the fix is to - merge the specific keyword rather than to generalise. - - Mutates `schema` in place (this pass runs on the in-memory dict before - codegen; it never touches anything under `schemas/`). Returns the number - of nodes collapsed, for logging. + """Preserve an array's item type when codegen cannot merge its `allOf`. + + Collapses array-only `allOf` nodes that have exactly one arm with `items`. + Nodes with multiple item definitions are left unchanged to avoid guessing. + Mutates the in-memory schema and returns the number of nodes collapsed. """ collapsed = 0 From 532865c3e652b1856c807e9373fa070820d0ebe1 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 19:34:25 -0400 Subject: [PATCH 12/27] refactor: share catalog and log runtime bases Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- src/gemara/v1/_models.py | 783 ++++++++++--------------------- tests/gemara/v1/test_registry.py | 17 +- tests/tools/test_generate.py | 21 +- tools/generate.py | 52 +- 4 files changed, 325 insertions(+), 548 deletions(-) diff --git a/src/gemara/v1/_models.py b/src/gemara/v1/_models.py index d5c763a..0894403 100644 --- a/src/gemara/v1/_models.py +++ b/src/gemara/v1/_models.py @@ -48,26 +48,6 @@ class ArtifactType(Enum): audit_log = "AuditLog" -class Type(Enum): - """ - type identifies the kind of Gemara artifact for unambiguous parsing - """ - - capability_catalog = "CapabilityCatalog" - control_catalog = "ControlCatalog" - guidance_catalog = "GuidanceCatalog" - threat_catalog = "ThreatCatalog" - risk_catalog = "RiskCatalog" - policy = "Policy" - mapping_document = "MappingDocument" - lexicon = "Lexicon" - evaluation_log = "EvaluationLog" - enforcement_log = "EnforcementLog" - vector_catalog = "VectorCatalog" - principle_catalog = "PrincipleCatalog" - audit_log = "AuditLog" - - class Capability(BaseModel): """Capability describes a system capability such as a feature, component or object.""" @@ -587,6 +567,26 @@ class ReferenceId(RootModel[str]): """reference-id is the id for a MappingReference entry in the artifact's metadata""" +class Type(Enum): + """ + type identifies the kind of Gemara artifact for unambiguous parsing + """ + + capability_catalog = "CapabilityCatalog" + control_catalog = "ControlCatalog" + guidance_catalog = "GuidanceCatalog" + threat_catalog = "ThreatCatalog" + risk_catalog = "RiskCatalog" + policy = "Policy" + mapping_document = "MappingDocument" + lexicon = "Lexicon" + evaluation_log = "EvaluationLog" + enforcement_log = "EnforcementLog" + vector_catalog = "VectorCatalog" + principle_catalog = "PrincipleCatalog" + audit_log = "AuditLog" + + class AcceptedRisk(BaseModel): """ AcceptedRisk documents a risk the organization has chosen to accept, @@ -1103,36 +1103,6 @@ class AssessmentPlan(BaseModel): requirement_id: Annotated[str, Field(alias="requirement-id")] -class AuditLogMetadata(BaseModel): - """metadata provides detailed data about this log""" - - model_config = ConfigDict( - populate_by_name=True, - ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["AuditLog"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" - - class AuditResult(BaseModel): """AuditResult records a single result with supporting evidence and recommendations.""" @@ -1155,8 +1125,8 @@ class AuditResult(BaseModel): """type classifies the nature of this result""" -class CapabilityCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class Metadata(BaseModel): + """Metadata represents common metadata fields shared across all layers""" model_config = ConfigDict( populate_by_name=True, @@ -1179,412 +1149,219 @@ class CapabilityCatalogMetadata(BaseModel): """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["CapabilityCatalog"] + type: ArtifactType """type identifies the kind of Gemara artifact for unambiguous parsing""" version: str | None = None """version is the version identifier of this artifact""" -class CapabilityCatalog(GemaraDocumentModel): - """CapabilityCatalog describes a collection of system capabilities""" +class Threat(BaseModel): + """Threat describes a specifically-scoped opportunity for a negative impact to the organization""" model_config = ConfigDict( populate_by_name=True, ) - extends: list[ArtifactMapping] | None = None - """extends references catalogs that this catalog builds upon""" - groups: Annotated[list[Group] | None, Field(min_length=1)] = None - """groups contains a list of groups that can be referenced by entries in this catalog""" - imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[CapabilityCatalogMetadata, Field(title="CapabilityCatalogMetadata")] - """metadata provides detailed data about this catalog""" + actors: Annotated[list[Actor] | None, Field(min_length=1)] = None + """actors describes the relevant internal or external threat actors""" + capabilities: Annotated[list[MultiEntryMapping], Field(min_length=1)] + """capabilities documents the relationship between this threat and a system capability""" + description: str + """description provides a detailed explanation of an opportunity for negative impact""" + group: str + """group references by id a catalog group that this threat belongs to""" + id: str + """id allows this entry to be referenced by other elements""" title: str - """title describes the purpose of this catalog at a glance""" - capabilities: Annotated[list[Capability] | None, Field(min_length=1)] = None - """capabilities is a list of capabilities defined by this catalog""" - + """title describes this threat at a glance""" + vectors: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + """vectors documents the relationship between this threat and one or more vectors""" -class ControlCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class AuditLogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["ControlCatalog"] + type: Literal["AuditLog"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" - -class ControlCatalog(GemaraDocumentModel): - """ControlCatalog describes a set of related controls and relevant metadata""" +class CapabilityCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - extends: list[ArtifactMapping] | None = None - """extends references catalogs that this catalog builds upon""" - groups: Annotated[list[Group] | None, Field(min_length=1)] = None - """groups contains a list of groups that can be referenced by entries in this catalog""" - imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[ControlCatalogMetadata, Field(title="ControlCatalogMetadata")] - """metadata provides detailed data about this catalog""" - title: str - """title describes the purpose of this catalog at a glance""" - controls: Annotated[list[Control] | None, Field(min_length=1)] = None - """controls is a list of unique controls defined by this catalog""" - + type: Literal["CapabilityCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" -class EnforcementLogMetadata(BaseModel): - """metadata provides detailed data about this log""" +class ControlCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["EnforcementLog"] + type: Literal["ControlCatalog"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" - -class EnforcementLog(GemaraDocumentModel): - """EnforcementLog records actions taken in response to noncompliance findings from Layer 5 evaluations.""" +class EnforcementLogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - metadata: Annotated[EnforcementLogMetadata, Field(title="EnforcementLogMetadata")] - """metadata provides detailed data about this log""" - target: Resource - """target identifies the resource being evaluated""" - actions: Annotated[list[ActionResult], Field(min_length=1)] - """actions is the list of enforcement actions performed""" - disposition: Disposition - """disposition is the aggregate enforcement disposition across all actions in this log""" - + type: Literal["EnforcementLog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" -class EvaluationLogMetadata(BaseModel): - """metadata provides detailed data about this log""" +class EvaluationLogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" type: Literal["EvaluationLog"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" -class EvaluationLog(GemaraDocumentModel): - """EvaluationLog contains the results of evaluating a set of Layer 2 controls.""" - +class GuidanceCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - metadata: Annotated[EvaluationLogMetadata, Field(title="EvaluationLogMetadata")] - """metadata provides detailed data about this log""" - target: Resource - """target identifies the resource being evaluated""" - evaluations: Annotated[list[ControlEvaluation], Field(min_length=1)] - result: Result - """result is the aggregate outcome across all evaluations in this log""" + type: Literal["GuidanceCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" -class GuidanceCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class LexiconMetadata(Metadata): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Literal["Lexicon"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + +class MappingDocumentMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None + mapping_references: Annotated[list[MappingReference], Field(alias="mapping-references", min_length=1)] """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["GuidanceCatalog"] + type: Literal["MappingDocument"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" - -class GuidanceCatalog(GemaraDocumentModel): - """GuidanceCatalog represents a concerted documentation effort to help bring about an optimal future without foreknowledge of the implementation details""" +class PolicyMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - extends: list[ArtifactMapping] | None = None - """extends references catalogs that this catalog builds upon""" - groups: Annotated[list[Group] | None, Field(min_length=1)] = None - """groups contains a list of groups that can be referenced by entries in this catalog""" - imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[GuidanceCatalogMetadata, Field(title="GuidanceCatalogMetadata")] - """metadata provides detailed data about this catalog""" - title: str - """title describes the purpose of this catalog at a glance""" - exemptions: Annotated[list[Exemption] | None, Field(min_length=1)] = None - """exemptions provides information about situations where this guidance is not applicable""" - front_matter: Annotated[str | None, Field(alias="front-matter")] = None - """front-matter provides introductory text for the document to be used during rendering""" - guidelines: Annotated[list[Guideline] | None, Field(min_length=1)] = None - """guidelines is a list of unique guidelines defined by this catalog""" - type: GuidanceType - """type categorizes this document based on the intent of its contents""" - + type: Literal["Policy"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" -class LexiconMetadata(BaseModel): - """metadata provides detailed data about this document""" +class PrincipleCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["Lexicon"] + type: Literal["PrincipleCatalog"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" - -class Lexicon(GemaraDocumentModel): - """Lexicon is a controlled vocabulary or glossary artifact referenced by Metadata.lexicon""" +class RiskCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - metadata: Annotated[LexiconMetadata, Field(title="LexiconMetadata")] - """metadata provides detailed data about this document""" - terms: Annotated[list[LexiconTerm], Field(min_length=1)] - """terms is one or more defined entries for linking and rendering""" - title: str - """title describes the purpose of this lexicon at a glance""" + type: Literal["RiskCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" + +class ThreatCatalogMetadata(Metadata): + model_config = ConfigDict( + populate_by_name=True, + ) + type: Literal["ThreatCatalog"] + """type identifies the kind of Gemara artifact for unambiguous parsing""" -class MappingDocumentMetadata(BaseModel): - """metadata provides detailed data about this document""" +class VectorCatalogMetadata(Metadata): model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference], Field(alias="mapping-references", min_length=1)] - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["MappingDocument"] + type: Literal["VectorCatalog"] """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" -class MappingDocument(GemaraDocumentModel): - """MappingDocument captures the user's intent for how entries in a source artifact relate to entries in a target artifact""" +class Catalog(GemaraDocumentModel): + """Catalog describes a set of topically-associated entries""" model_config = ConfigDict( populate_by_name=True, ) - mappings: Annotated[list[Mapping], Field(min_length=1)] - """mappings is one or more atomic relationships between entries in the referenced artifacts""" - metadata: Annotated[MappingDocumentMetadata, Field(title="MappingDocumentMetadata")] - """metadata provides detailed data about this document""" - remarks: str | None = None - """remarks is prose regarding this mapping document""" - source_reference: Annotated[TypedMapping, Field(alias="source-reference")] - """source-reference identifies the artifact being mapped from; must match a mapping-reference id""" - target_reference: Annotated[TypedMapping, Field(alias="target-reference")] - """target-reference identifies the artifact being mapped to; must match a mapping-reference id""" + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: Metadata + """metadata provides detailed data about this catalog""" title: str - """title describes the purpose of this mapping document at a glance""" + """title describes the purpose of this catalog at a glance""" -class Metadata(BaseModel): - """Metadata represents common metadata fields shared across all layers""" +class Log(GemaraDocumentModel): + """Log describes a set of recorded entries from a measurement activity""" model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: ArtifactType - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + metadata: Metadata + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" -class PolicyMetadata(BaseModel): - """Metadata represents common metadata fields shared across all layers""" +class AcceptedMethod(BaseModel): + """AcceptedMethod defines a method for evaluation or enforcement.""" model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" + description: str | None = None + executor: Actor | None = None id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["Policy"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + mode: ModeType + required: bool | None = None + type: MethodType -class PrincipleCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class Adherence(BaseModel): + """Adherence defines evaluation methods, assessment plans, enforcement methods, and non-compliance notifications.""" model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["PrincipleCatalog"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + assessment_plans: Annotated[list[AssessmentPlan] | None, Field(alias="assessment-plans", min_length=1)] = None + enforcement_methods: Annotated[list[EnforcementMethod] | None, Field(alias="enforcement-methods", min_length=1)] = ( + None + ) + evaluation_methods: Annotated[list[EvaluationMethod] | None, Field(alias="evaluation-methods", min_length=1)] = None + non_compliance: Annotated[str | None, Field(alias="non-compliance")] = None -class PrincipleCatalog(GemaraDocumentModel): - """PrincipleCatalog describes a set of related principles and relevant metadata""" +class AuditLog(Log): + """AuditLog records results from an audit performed against a target resource""" + + model_config = ConfigDict( + populate_by_name=True, + ) + metadata: AuditLogMetadata + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + criteria: Annotated[list[ArtifactMapping], Field(min_length=1)] + """criteria defines the acceptable state for the audited resource""" + owner: RACI | None = None + """owner defines the RACI roles responsible for managing the audit""" + results: Annotated[list[AuditResult], Field(min_length=1)] + """results records audit results against the criteria""" + summary: str + """summary provides the high-level conclusion""" + + +class CapabilityCatalog(Catalog): + """CapabilityCatalog describes a collection of system capabilities""" model_config = ConfigDict( populate_by_name=True, @@ -1594,121 +1371,66 @@ class PrincipleCatalog(GemaraDocumentModel): groups: Annotated[list[Group] | None, Field(min_length=1)] = None """groups contains a list of groups that can be referenced by entries in this catalog""" imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[PrincipleCatalogMetadata, Field(title="PrincipleCatalogMetadata")] + metadata: CapabilityCatalogMetadata """metadata provides detailed data about this catalog""" title: str """title describes the purpose of this catalog at a glance""" - principles: Annotated[list[Principle] | None, Field(min_length=1)] = None - """principles is a list of unique principles defined by this catalog""" - - -class RiskCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" - - model_config = ConfigDict( - populate_by_name=True, - ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["RiskCatalog"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + capabilities: Annotated[list[Capability] | None, Field(min_length=1)] = None + """capabilities is a list of capabilities defined by this catalog""" -class RiskCatalog(GemaraDocumentModel): - """ - A RiskCatalog is a structured collection of documented risks that may affect an organization, - system, or service. It provides a centralized reference for risks that can be mapped to threats - and referenced by policies when documenting how those risks are mitigated or accepted. - """ +class ControlCatalog(Catalog): + """ControlCatalog describes a set of related controls and relevant metadata""" model_config = ConfigDict( populate_by_name=True, ) extends: list[ArtifactMapping] | None = None """extends references catalogs that this catalog builds upon""" - groups: Annotated[list[Group | RiskCategory] | None, Field(min_length=1)] = None - """groups narrows the base groups to risk categories with appetite and severity boundaries""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[RiskCatalogMetadata, Field(title="RiskCatalogMetadata")] + metadata: ControlCatalogMetadata """metadata provides detailed data about this catalog""" title: str """title describes the purpose of this catalog at a glance""" - risks: Annotated[list[Risk] | None, Field(min_length=1)] = None - """risks is a list of risks defined by this catalog""" + controls: Annotated[list[Control] | None, Field(min_length=1)] = None + """controls is a list of unique controls defined by this catalog""" -class Threat(BaseModel): - """Threat describes a specifically-scoped opportunity for a negative impact to the organization""" +class EnforcementLog(Log): + """EnforcementLog records actions taken in response to noncompliance findings from Layer 5 evaluations.""" model_config = ConfigDict( populate_by_name=True, ) - actors: Annotated[list[Actor] | None, Field(min_length=1)] = None - """actors describes the relevant internal or external threat actors""" - capabilities: Annotated[list[MultiEntryMapping], Field(min_length=1)] - """capabilities documents the relationship between this threat and a system capability""" - description: str - """description provides a detailed explanation of an opportunity for negative impact""" - group: str - """group references by id a catalog group that this threat belongs to""" - id: str - """id allows this entry to be referenced by other elements""" - title: str - """title describes this threat at a glance""" - vectors: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - """vectors documents the relationship between this threat and one or more vectors""" + metadata: EnforcementLogMetadata + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + actions: Annotated[list[ActionResult], Field(min_length=1)] + """actions is the list of enforcement actions performed""" + disposition: Disposition + """disposition is the aggregate enforcement disposition across all actions in this log""" -class ThreatCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class EvaluationLog(Log): + """EvaluationLog contains the results of evaluating a set of Layer 2 controls.""" model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["ThreatCatalog"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + metadata: EvaluationLogMetadata + """metadata provides detailed data about this log""" + target: Resource + """target identifies the resource being evaluated""" + evaluations: Annotated[list[ControlEvaluation], Field(min_length=1)] + result: Result + """result is the aggregate outcome across all evaluations in this log""" -class ThreatCatalog(GemaraDocumentModel): - """ThreatCatalog describes a set of topically-associated threats""" +class GuidanceCatalog(Catalog): + """GuidanceCatalog represents a concerted documentation effort to help bring about an optimal future without foreknowledge of the implementation details""" model_config = ConfigDict( populate_by_name=True, @@ -1718,113 +1440,112 @@ class ThreatCatalog(GemaraDocumentModel): groups: Annotated[list[Group] | None, Field(min_length=1)] = None """groups contains a list of groups that can be referenced by entries in this catalog""" imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[ThreatCatalogMetadata, Field(title="ThreatCatalogMetadata")] + metadata: GuidanceCatalogMetadata """metadata provides detailed data about this catalog""" title: str """title describes the purpose of this catalog at a glance""" - threats: Annotated[list[Threat] | None, Field(min_length=1)] = None - """threats is a list of threats defined by this catalog""" + exemptions: Annotated[list[Exemption] | None, Field(min_length=1)] = None + """exemptions provides information about situations where this guidance is not applicable""" + front_matter: Annotated[str | None, Field(alias="front-matter")] = None + """front-matter provides introductory text for the document to be used during rendering""" + guidelines: Annotated[list[Guideline] | None, Field(min_length=1)] = None + """guidelines is a list of unique guidelines defined by this catalog""" + type: GuidanceType + """type categorizes this document based on the intent of its contents""" -class VectorCatalogMetadata(BaseModel): - """metadata provides detailed data about this catalog""" +class Lexicon(GemaraDocumentModel): + """Lexicon is a controlled vocabulary or glossary artifact referenced by Metadata.lexicon""" model_config = ConfigDict( populate_by_name=True, ) - applicability_groups: Annotated[list[Group] | None, Field(alias="applicability-groups", min_length=1)] = None - """applicability-groups is a list of groups used to classify within this artifact to specify scope""" - author: Actor - """author is the person or group primarily responsible for this artifact""" - date: AwareDatetime | None = None - """date is the publication or effective date of this artifact""" - description: str - """description provides a high-level summary of the artifact's purpose and scope""" - draft: bool | None = None - """draft indicates whether this artifact is a pre-release version; open to modification""" - gemara_version: Annotated[str, Field(alias="gemara-version")] - """gemara-version declares which version of the Gemara specification this artifact conforms to""" - id: str - """id allows this entry to be referenced by other elements""" - lexicon: ArtifactMapping | None = None - """lexicon is a URI pointing to a controlled vocabulary or glossary relevant to this artifact""" - mapping_references: Annotated[list[MappingReference] | None, Field(alias="mapping-references", min_length=1)] = None - """mapping-references is a list of external documents referenced within this artifact""" - type: Literal["VectorCatalog"] - """type identifies the kind of Gemara artifact for unambiguous parsing""" - version: str | None = None - """version is the version identifier of this artifact""" + metadata: LexiconMetadata + terms: Annotated[list[LexiconTerm], Field(min_length=1)] + """terms is one or more defined entries for linking and rendering""" + title: str + """title describes the purpose of this lexicon at a glance""" -class VectorCatalog(GemaraDocumentModel): - """Catalog describes a set of topically-associated entries""" +class MappingDocument(GemaraDocumentModel): + """MappingDocument captures the user's intent for how entries in a source artifact relate to entries in a target artifact""" model_config = ConfigDict( populate_by_name=True, ) - extends: list[ArtifactMapping] | None = None - """extends references catalogs that this catalog builds upon""" - groups: Annotated[list[Group] | None, Field(min_length=1)] = None - """groups contains a list of groups that can be referenced by entries in this catalog""" - imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Annotated[VectorCatalogMetadata, Field(title="VectorCatalogMetadata")] - """metadata provides detailed data about this catalog""" + mappings: Annotated[list[Mapping], Field(min_length=1)] + """mappings is one or more atomic relationships between entries in the referenced artifacts""" + metadata: MappingDocumentMetadata + remarks: str | None = None + """remarks is prose regarding this mapping document""" + source_reference: Annotated[TypedMapping, Field(alias="source-reference")] + """source-reference identifies the artifact being mapped from; must match a mapping-reference id""" + target_reference: Annotated[TypedMapping, Field(alias="target-reference")] + """target-reference identifies the artifact being mapped to; must match a mapping-reference id""" title: str - """title describes the purpose of this catalog at a glance""" - vectors: Annotated[list[Vector] | None, Field(min_length=1)] = None - """vectors is a list of attack vectors documented in this catalog""" + """title describes the purpose of this mapping document at a glance""" -class AcceptedMethod(BaseModel): - """AcceptedMethod defines a method for evaluation or enforcement.""" +class Policy(GemaraDocumentModel): + """Policy represents a policy document with metadata, contacts, scope, imports, implementation plan, risks, and adherence requirements.""" model_config = ConfigDict( populate_by_name=True, ) - description: str | None = None - executor: Actor | None = None - id: str - mode: ModeType - required: bool | None = None - type: MethodType + adherence: Adherence | None = None + contacts: RACI + implementation_plan: Annotated[ImplementationPlan | None, Field(alias="implementation-plan")] = None + imports: Imports | None = None + metadata: PolicyMetadata + risks: Risks | None = None + scope: Scope | None = None + title: str -class Adherence(BaseModel): - """Adherence defines evaluation methods, assessment plans, enforcement methods, and non-compliance notifications.""" +class PrincipleCatalog(Catalog): + """PrincipleCatalog describes a set of related principles and relevant metadata""" model_config = ConfigDict( populate_by_name=True, ) - assessment_plans: Annotated[list[AssessmentPlan] | None, Field(alias="assessment-plans", min_length=1)] = None - enforcement_methods: Annotated[list[EnforcementMethod] | None, Field(alias="enforcement-methods", min_length=1)] = ( - None - ) - evaluation_methods: Annotated[list[EvaluationMethod] | None, Field(alias="evaluation-methods", min_length=1)] = None - non_compliance: Annotated[str | None, Field(alias="non-compliance")] = None + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: PrincipleCatalogMetadata + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + principles: Annotated[list[Principle] | None, Field(min_length=1)] = None + """principles is a list of unique principles defined by this catalog""" -class AuditLog(GemaraDocumentModel): - """AuditLog records results from an audit performed against a target resource""" +class RiskCatalog(Catalog): + """ + A RiskCatalog is a structured collection of documented risks that may affect an organization, + system, or service. It provides a centralized reference for risks that can be mapped to threats + and referenced by policies when documenting how those risks are mitigated or accepted. + """ model_config = ConfigDict( populate_by_name=True, ) - metadata: Annotated[AuditLogMetadata, Field(title="AuditLogMetadata")] - """metadata provides detailed data about this log""" - target: Resource - """target identifies the resource being evaluated""" - criteria: Annotated[list[ArtifactMapping], Field(min_length=1)] - """criteria defines the acceptable state for the audited resource""" - owner: RACI | None = None - """owner defines the RACI roles responsible for managing the audit""" - results: Annotated[list[AuditResult], Field(min_length=1)] - """results records audit results against the criteria""" - summary: str - """summary provides the high-level conclusion""" + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group | RiskCategory] | None, Field(min_length=1)] = None + """groups narrows the base groups to risk categories with appetite and severity boundaries""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: RiskCatalogMetadata + """metadata provides detailed data about this catalog""" + title: str + """title describes the purpose of this catalog at a glance""" + risks: Annotated[list[Risk] | None, Field(min_length=1)] = None + """risks is a list of risks defined by this catalog""" -class Catalog(BaseModel): - """Catalog describes a set of topically-associated entries""" +class ThreatCatalog(Catalog): + """ThreatCatalog describes a set of topically-associated threats""" model_config = ConfigDict( populate_by_name=True, @@ -1834,39 +1555,31 @@ class Catalog(BaseModel): groups: Annotated[list[Group] | None, Field(min_length=1)] = None """groups contains a list of groups that can be referenced by entries in this catalog""" imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None - metadata: Metadata + metadata: ThreatCatalogMetadata """metadata provides detailed data about this catalog""" title: str """title describes the purpose of this catalog at a glance""" + threats: Annotated[list[Threat] | None, Field(min_length=1)] = None + """threats is a list of threats defined by this catalog""" -class Log(BaseModel): - """Log describes a set of recorded entries from a measurement activity""" - - model_config = ConfigDict( - populate_by_name=True, - ) - metadata: Metadata - """metadata provides detailed data about this log""" - target: Resource - """target identifies the resource being evaluated""" - - -class Policy(GemaraDocumentModel): - """Policy represents a policy document with metadata, contacts, scope, imports, implementation plan, risks, and adherence requirements.""" +class VectorCatalog(Catalog): + """Catalog describes a set of topically-associated entries""" model_config = ConfigDict( populate_by_name=True, ) - adherence: Adherence | None = None - contacts: RACI - implementation_plan: Annotated[ImplementationPlan | None, Field(alias="implementation-plan")] = None - imports: Imports | None = None - metadata: Annotated[PolicyMetadata, Field(title="PolicyMetadata")] - """Metadata represents common metadata fields shared across all layers""" - risks: Risks | None = None - scope: Scope | None = None + extends: list[ArtifactMapping] | None = None + """extends references catalogs that this catalog builds upon""" + groups: Annotated[list[Group] | None, Field(min_length=1)] = None + """groups contains a list of groups that can be referenced by entries in this catalog""" + imports: Annotated[list[MultiEntryMapping] | None, Field(min_length=1)] = None + metadata: VectorCatalogMetadata + """metadata provides detailed data about this catalog""" title: str + """title describes the purpose of this catalog at a glance""" + vectors: Annotated[list[Vector] | None, Field(min_length=1)] = None + """vectors is a list of attack vectors documented in this catalog""" __all__ = [ diff --git a/tests/gemara/v1/test_registry.py b/tests/gemara/v1/test_registry.py index c2e86ce..5d8f981 100644 --- a/tests/gemara/v1/test_registry.py +++ b/tests/gemara/v1/test_registry.py @@ -8,7 +8,8 @@ from pydantic import BaseModel -from gemara.v1 import DOCUMENT_TYPES, GemaraDocument +from gemara.v1 import DOCUMENT_TYPES, Catalog, GemaraDocument, Log, Metadata +from gemara.v1._document import GemaraDocumentModel SCHEMA_DIR = Path(__file__).resolve().parents[3] / "schemas" @@ -26,6 +27,7 @@ def test_registry_has_thirteen_document_types() -> None: def test_every_registry_entry_is_a_model() -> None: for name, model in DOCUMENT_TYPES.items(): assert issubclass(model, BaseModel), name + assert issubclass(model, GemaraDocumentModel), name def test_every_model_narrows_its_metadata_type() -> None: @@ -34,6 +36,7 @@ def test_every_model_narrows_its_metadata_type() -> None: metadata = model.model_fields["metadata"].annotation assert metadata is not None assert metadata.__name__ == f"{name}Metadata", name + assert issubclass(metadata, Metadata), name def test_gemara_document_alias_matches_document_types() -> None: @@ -41,3 +44,15 @@ def test_gemara_document_alias_matches_document_types() -> None: members = set(typing.get_args(GemaraDocument)) assert members == set(DOCUMENT_TYPES.values()) assert len(members) == 13 + + +def test_catalog_models_share_the_catalog_runtime_base() -> None: + assert "imports" in Catalog.model_fields + for name, model in DOCUMENT_TYPES.items(): + assert issubclass(model, Catalog) is name.endswith("Catalog") + + +def test_log_models_share_the_log_runtime_base() -> None: + assert "target" in Log.model_fields + for name, model in DOCUMENT_TYPES.items(): + assert issubclass(model, Log) is name.endswith("Log") diff --git a/tests/tools/test_generate.py b/tests/tools/test_generate.py index 8e5a127..6e409ec 100644 --- a/tests/tools/test_generate.py +++ b/tests/tools/test_generate.py @@ -44,11 +44,17 @@ def _schema() -> dict[str, Any]: } -def test_inject_metadata_titles_names_each_narrowed_metadata() -> None: +def test_inject_metadata_titles_extracts_each_narrowed_metadata() -> None: schema = _schema() generate.inject_metadata_titles(schema) props = schema["$defs"]["ControlCatalog"]["properties"] - assert props["metadata"]["title"] == "ControlCatalogMetadata" + assert props["metadata"] == {"$ref": "#/$defs/ControlCatalogMetadata"} + assert schema["$defs"]["ControlCatalogMetadata"] == { + "allOf": [ + {"$ref": "#/$defs/Metadata"}, + {"properties": {"type": {"const": "ControlCatalog"}}, "type": "object"}, + ] + } def test_inject_metadata_titles_returns_the_discriminator_map() -> None: @@ -186,6 +192,17 @@ def test_render_models_excludes_denylisted_names_from_all() -> None: assert "class Alpha(GemaraDocumentModel):" in source +def test_render_models_makes_category_bases_document_models() -> None: + source = generate.render_models( + "from __future__ import annotations\n\nclass Catalog(BaseModel):\n pass\n\n\n" + "class ControlCatalog(BaseModel):\n pass\n", + ["Catalog", "ControlCatalog"], + ["ControlCatalog"], + ) + assert "class Catalog(GemaraDocumentModel):" in source + assert "class ControlCatalog(Catalog):" in source + + def test_recover_array_allof_element_type_collapses_a_single_items_arm() -> None: """EnforcementLog.actions shape: one arm is comprehension metadata with no `items`, the other carries the real element type. Exactly one arm has diff --git a/tools/generate.py b/tools/generate.py index c7de9ea..3759d72 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -50,25 +50,38 @@ class GenerateError(RuntimeError): def inject_metadata_titles(schema: dict[str, Any]) -> dict[str, str]: - """Name each per-document metadata subschema and collect the discriminators. + """Extract typed metadata subclasses and collect document discriminators. A document type is a definition whose `metadata` property narrows `type` to - a `const`. Without a `title`, datamodel-codegen names these `Metadata1`… - `MetadataN`. The old generator instead replaced them with a generic $ref, - destroying the discriminator entirely -- that is defect 4, and it must never - be reintroduced. + a `const`. Make that narrow metadata schema an `allOf` extension of the + shared `Metadata` definition, so codegen emits a small typed subclass rather + than duplicating the common fields in every document model. """ doc_types: dict[str, str] = {} - for name, definition in schema["$defs"].items(): + definitions = schema["$defs"] + for name, definition in list(definitions.items()): if not isinstance(definition, dict): continue - metadata = (definition.get("properties") or {}).get("metadata") + properties = definition.get("properties") or {} + metadata = properties.get("metadata") if not isinstance(metadata, dict): continue type_schema = (metadata.get("properties") or {}).get("type") if not isinstance(type_schema, dict) or "const" not in type_schema: continue - metadata["title"] = f"{name}Metadata" + metadata_name = f"{name}Metadata" + narrow_metadata = metadata.copy() + narrow_metadata.pop("$ref", None) + narrow_metadata.pop("additionalProperties", None) + narrow_metadata.pop("description", None) + narrow_metadata.pop("title", None) + definitions[metadata_name] = { + "allOf": [ + {"$ref": "#/$defs/Metadata"}, + narrow_metadata, + ] + } + properties["metadata"] = {"$ref": f"#/$defs/{metadata_name}"} doc_types[str(type_schema["const"])] = name return doc_types @@ -86,6 +99,17 @@ def check_document_types(schema: dict[str, Any], doc_types: dict[str, str]) -> N raise GenerateError(f"document types disagree with #ArtifactType (missing: {missing}; unexpected: {extra})") +def prioritize_category_bases(schema: dict[str, Any]) -> None: + """Emit the shared Catalog and Log models before their concrete subclasses.""" + definitions = schema.get("$defs") + if not isinstance(definitions, dict): + raise GenerateError("schema has no $defs") + missing = {"Catalog", "Log"} - definitions.keys() + if missing: + raise GenerateError(f"schema has no category base definitions: {', '.join(sorted(missing))}") + schema["$defs"] = {name: definitions[name] for name in ("Catalog", "Log")} | definitions + + def fold_hidden_definitions(schema: dict[str, Any]) -> int: """Replace transparent CUE-internal definitions with their base definitions. @@ -265,12 +289,19 @@ def render_models(body: str, model_names: list[str], document_models: list[str]) future_import = "from __future__ import annotations\n" if body.count(future_import) != 1: raise GenerateError("generated models have an unexpected future-import layout") - body = body.replace(future_import, f"{future_import}\nfrom gemara.v1._document import GemaraDocumentModel\n", 1) + body = body.replace( + future_import, + f"{future_import}\nfrom gemara.v1._document import GemaraDocumentModel\n", + 1, + ) + for base in ("Catalog", "Log"): + body = body.replace(f"class {base}(BaseModel):", f"class {base}(GemaraDocumentModel):") for model in document_models: declaration = f"class {model}(BaseModel):" if body.count(declaration) != 1: raise GenerateError(f"generated models have an unexpected declaration for document model {model!r}") - body = body.replace(declaration, f"class {model}(GemaraDocumentModel):") + base = "Catalog" if model.endswith("Catalog") else "Log" if model.endswith("Log") else "GemaraDocumentModel" + body = body.replace(declaration, f"class {model}({base}):") exports = "\n".join(f' "{name}",' for name in sorted(model_names) if name not in DENYLISTED_MODEL_NAMES) return f"{GENERATED_MARKER}\n{body.rstrip()}\n\n\n__all__ = [\n{exports}\n]\n" @@ -337,6 +368,7 @@ def main() -> int: doc_types = inject_metadata_titles(schema) check_document_types(schema, doc_types) + prioritize_category_bases(schema) print(f" {len(doc_types)} document types match #ArtifactType") folded = fold_hidden_definitions(schema) From cb8952336fcd879c41608686a58bd9fdfdc63264 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Tue, 8 Sep 2026 20:02:18 -0400 Subject: [PATCH 13/27] fix: guard root-vs-nested def conflicts and fix stale README example merge_exports() previously only checked nested-def-vs-nested-def agreement, letting a root export silently clobber a stored nested def of the same name. Also drop a misleading `or []` on Lexicon.terms, which is a required field and can never be falsy. Assisted-by: Claude (Anthropic, Opus 5) Signed-off-by: Jennifer Power --- README.md | 2 +- tools/sync_schema.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a52904..dffe816 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ match doc: case ControlCatalog(): print(len(doc.controls or [])) case Lexicon(): - print(len(doc.terms or [])) + print(len(doc.terms)) ``` `load` accepts a file path or open file. Use `loads` for JSON or YAML text and diff --git a/tools/sync_schema.py b/tools/sync_schema.py index c6e2e9d..164d038 100644 --- a/tools/sync_schema.py +++ b/tools/sync_schema.py @@ -115,7 +115,10 @@ def merge_exports(exports: dict[str, dict[str, Any]]) -> dict[str, Any]: defs.setdefault(key, value) for name, export in exports.items(): body = {k: v for k, v in export.items() if k not in ("$schema", "$defs")} - defs[_normalize_def_name(name)] = body + key = _normalize_def_name(name) + if key in defs and defs[key] != body: + raise SyncError(f"conflicting definition '{key}' across exports") + defs[key] = body return {"$schema": SCHEMA_DIALECT, "$defs": defs} From 9825d5d7e8926bf7f72e8971b52ab7435e9aed8e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 13:47:48 -0400 Subject: [PATCH 14/27] chore: apply suggestions from code review on workflows Signed-off-by: Jennifer Power Co-authored-by: Eddie Knight <21176439+eddie-knight@users.noreply.github.com> Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 1 + .github/workflows/zizmor.yml | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18bc330..b27b5d4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: pull_request: concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} # `main` is excluded so pushes there always produce a complete record. cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 180e330..2972594 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,7 @@ jobs: git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } - run: uv run poe lint + - run: uv run ruff format --check . - run: uv run poe typecheck - run: uv build - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 804529b..3c0d4a1 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -10,10 +10,11 @@ on: - main paths: - '.github/workflows/**' + - '.github/dependabot.yml' concurrency: - group: ci-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} # `main` is excluded so pushes there always produce a complete record. cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} From 9102e460758274d0476c23f6d1fae0871dea9957 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 13:54:30 -0400 Subject: [PATCH 15/27] chore: apply suggestions from code review Signed-off-by: Jennifer Power Co-authored-by: Eddie Knight <21176439+eddie-knight@users.noreply.github.com> --- pyproject.toml | 3 --- src/gemara/v1/__init__.py | 2 +- src/gemara/v1/_loader.py | 3 +-- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e416921..c275145 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,9 +56,6 @@ select = ["E", "F", "I", "UP", "B"] # _models.py is generated; lint rules would only create churn against the drift gate. exclude = ["src/gemara/v1/_models.py"] -[tool.ruff.lint.per-file-ignores] -# The public API deliberately re-exports every model via a star import. -"src/gemara/v1/__init__.py" = ["F403", "F405", "F822"] [tool.mypy] python_version = "3.11" diff --git a/src/gemara/v1/__init__.py b/src/gemara/v1/__init__.py index 20cfc25..f3796d9 100644 --- a/src/gemara/v1/__init__.py +++ b/src/gemara/v1/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from gemara.v1._loader import GemaraError, UnknownDocumentTypeError, load, loads -from gemara.v1._models import * # noqa: F403 +from gemara.v1._models import * from gemara.v1._models import __all__ as _MODEL_NAMES from gemara.v1._registry import DOCUMENT_TYPES, SCHEMA_VERSION, GemaraDocument diff --git a/src/gemara/v1/_loader.py b/src/gemara/v1/_loader.py index 5779a6d..d726ad5 100644 --- a/src/gemara/v1/_loader.py +++ b/src/gemara/v1/_loader.py @@ -1,5 +1,4 @@ -"""Read a Gemara document and dispatch it to the right model. -""" +"""Read a Gemara document and dispatch it to the right model.""" from __future__ import annotations From 710a0ff4bcc0108fe5706cce2eba10eb73ea65fe Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:38:40 -0400 Subject: [PATCH 16/27] test: fail suites that skip fixture tests Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- .github/workflows/ci.yml | 15 ++------------- .github/workflows/publish-testpypi.yml | 19 ++----------------- .gitignore | 3 --- tests/conftest.py | 19 +++++++++++++++++++ 4 files changed, 23 insertions(+), 33 deletions(-) create mode 100644 tests/conftest.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b27b5d4..461fd2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,15 +29,7 @@ jobs: with: enable-cache: true - run: uv sync --frozen --no-default-groups --group test --python ${{ matrix.python-version }} - - name: Run tests, failing if any is skipped - run: | - # Defect 1 was a suite that skipped 39 of 40 tests and stayed green. - set -o pipefail - uv run pytest -q | tee summary.txt - if grep -qE '[0-9]+ skipped' summary.txt; then - echo "::error::tests were skipped; the fixture corpus must always run" - exit 1 - fi + - run: uv run pytest -q quality: name: Lint, format and types @@ -67,7 +59,4 @@ jobs: - run: uv sync --frozen --no-default-groups --group codegen --group lint - name: Regenerate models from the vendored schema run: uv run poe generate - - name: Fail if the committed output drifted - run: | - git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ - || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } + - uses: ./.github/actions/check-generated-models diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 3924b04..4ea08de 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -23,14 +23,7 @@ jobs: with: enable-cache: false - run: uv sync --frozen - - name: Run tests, failing if any is skipped - run: | - set -o pipefail - uv run pytest -q | tee summary.txt - if grep -qE '[0-9]+ skipped' summary.txt; then - echo "::error::tests were skipped; the fixture corpus must always run" - exit 1 - fi + - run: uv run pytest -q - run: uv build - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: @@ -62,12 +55,4 @@ jobs: run: | .testpypi-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".testpypi-venv")' - name: Run tests against the TestPyPI package - run: | - set -o pipefail - PYTHONPATH=tests .testpypi-venv/bin/python -m pytest -q \ - tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py \ - tests/test_fixtures.py | tee summary.txt - if grep -qE '[0-9]+ skipped' summary.txt; then - echo "::error::tests were skipped; the fixture corpus must always run" - exit 1 - fi + run: .testpypi-venv/bin/python -m pytest -q tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py tests/test_fixtures.py diff --git a/.gitignore b/.gitignore index 1bd4306..9763f64 100644 --- a/.gitignore +++ b/.gitignore @@ -222,6 +222,3 @@ docs/superpowers/ # Superpowers SDD workspace (scratch, not tracked) .superpowers/ - -# Written to the repo root by CI's no-skipped-tests step (see ci.yml/release.yml) -summary.txt diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4c70bf1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +"""Fail the whole suite if any test is skipped. + +The vendored fixture corpus must always run. A skip means a fixture or the +corpus itself is missing -- the silent-failure class the predecessor shipped +(39 of 40 tests skipped, suite still green). Guarding here, in the suite, +means every `pytest` invocation enforces it with no shell wrapper, no summary +file, and no interpreter-specific one-liner to duplicate per workflow. +""" + +from __future__ import annotations + +import pytest + + +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + reporter = session.config.pluginmanager.get_plugin("terminalreporter") + stats = getattr(reporter, "stats", None) + if isinstance(stats, dict) and stats.get("skipped"): + session.exitstatus = pytest.ExitCode.TESTS_FAILED From bb8e065c23b27d1d5037ed8c0c981e35a71c8ca4 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:38:49 -0400 Subject: [PATCH 17/27] ci: verify generated and released packages Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- .../actions/check-generated-models/action.yml | 10 +++++ .github/workflows/release.yml | 44 ++++++++++++------- 2 files changed, 38 insertions(+), 16 deletions(-) create mode 100644 .github/actions/check-generated-models/action.yml diff --git a/.github/actions/check-generated-models/action.yml b/.github/actions/check-generated-models/action.yml new file mode 100644 index 0000000..bb099ba --- /dev/null +++ b/.github/actions/check-generated-models/action.yml @@ -0,0 +1,10 @@ +name: Check generated models +description: Fail when committed generated models differ from code generation output. + +runs: + using: composite + steps: + - shell: bash + run: | + git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ + || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2972594..d8e5c7b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,29 +23,17 @@ jobs: - run: uv sync --frozen - name: Verify the tag matches the static version run: | - # Defect 3: artifacts must never ship as 0.0.0, and the tag is the - # only thing that should ever disagree with pyproject.toml. + # Artifacts must never ship as 0.0.0, and the tag is the only thing + # that should ever disagree with pyproject.toml. declared="$(uv run python -c 'import tomllib,pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" tagged="${GITHUB_REF_NAME#v}" test "$declared" != "0.0.0" || { echo "::error::version is 0.0.0"; exit 1; } test "$declared" = "$tagged" || { echo "::error::tag $tagged does not match pyproject version $declared"; exit 1; } - - name: Run tests, failing if any is skipped - run: | - # Defect 1 was a suite that skipped 39 of 40 tests and stayed green; - # the release path must not be the one place that regresses on it. - set -o pipefail - uv run pytest -q | tee summary.txt - if grep -qE '[0-9]+ skipped' summary.txt; then - echo "::error::tests were skipped; the fixture corpus must always run" - exit 1 - fi + - run: uv run pytest -q - name: Regenerate models from the vendored schema run: uv run poe generate - - name: Fail if the committed output drifted - run: | - git diff --exit-code src/gemara/v1/_models.py src/gemara/v1/_registry.py \ - || { echo "::error::generated files are stale; run 'uv run poe generate'"; exit 1; } + - uses: ./.github/actions/check-generated-models - run: uv run poe lint - run: uv run ruff format --check . - run: uv run poe typecheck @@ -70,3 +58,27 @@ jobs: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + + verify-published: + name: Test the PyPI package + needs: publish-pypi + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 + with: + enable-cache: false + - name: Wait for PyPI index propagation + run: sleep 60 + - name: Install the published package from PyPI + run: | + version="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + uv venv --clear .pypi-venv + uv pip install --python .pypi-venv/bin/python "gemara-python==$version" pytest + - name: Verify imports use the installed artifact + run: | + .pypi-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".pypi-venv")' + - name: Run tests against the PyPI package + run: .pypi-venv/bin/python -m pytest -q tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py tests/test_fixtures.py From 09e6c5d2db026c53258bec1b5d65c806e673d8f3 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:04 -0400 Subject: [PATCH 18/27] build: share development tooling dependencies Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power # Conflicts: # pyproject.toml --- pyproject.toml | 12 +++++++++--- uv.lock | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c275145..e39eae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,11 +29,17 @@ test = ["pytest>=8.0"] # ruff is pinned exactly because `poe generate` formats generated output with it, # so an unpinned bump would churn committed bytes and fail the drift gate. -lint = ["ruff==0.16.6", "mypy>=2.3", "types-PyYAML>=6.0"] +tooling = ["poethepoet>=0.30", "ruff==0.16.6"] + +lint = [ + {include-group = "tooling"}, + "mypy>=2.3", + "types-PyYAML>=6.0", +] # Needed only to regenerate the models. Pinned exactly for the same reason as # ruff: it decides the committed bytes the drift gate compares against. -codegen = ["datamodel-code-generator==0.76.2", "poethepoet>=0.30"] +codegen = [{include-group = "tooling"}, "datamodel-code-generator==0.76.2"] # IMPORTANT: re-vendoring the schema also needs `cue` on PATH, which is not # a Python dependency and cannot be declared here. See CONTRIBUTING.md. @@ -71,7 +77,7 @@ ignore_errors = true [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra" -pythonpath = ["tests"] +pythonpath = ["tests", "tools"] [tool.poe.tasks] test = "pytest" diff --git a/uv.lock b/uv.lock index 386ec04..13a1629 100644 --- a/uv.lock +++ b/uv.lock @@ -176,6 +176,7 @@ dependencies = [ codegen = [ { name = "datamodel-code-generator" }, { name = "poethepoet" }, + { name = "ruff" }, ] dev = [ { name = "datamodel-code-generator" }, @@ -187,12 +188,17 @@ dev = [ ] lint = [ { name = "mypy" }, + { name = "poethepoet" }, { name = "ruff" }, { name = "types-pyyaml" }, ] test = [ { name = "pytest" }, ] +tooling = [ + { name = "poethepoet" }, + { name = "ruff" }, +] [package.metadata] requires-dist = [ @@ -204,6 +210,7 @@ requires-dist = [ codegen = [ { name = "datamodel-code-generator", specifier = "==0.76.2" }, { name = "poethepoet", specifier = ">=0.30" }, + { name = "ruff", specifier = "==0.16.6" }, ] dev = [ { name = "datamodel-code-generator", specifier = "==0.76.2" }, @@ -215,10 +222,15 @@ dev = [ ] lint = [ { name = "mypy", specifier = ">=2.3" }, + { name = "poethepoet", specifier = ">=0.30" }, { name = "ruff", specifier = "==0.16.6" }, { name = "types-pyyaml", specifier = ">=6.0" }, ] test = [{ name = "pytest", specifier = ">=8.0" }] +tooling = [ + { name = "poethepoet", specifier = ">=0.30" }, + { name = "ruff", specifier = "==0.16.6" }, +] [[package]] name = "genson" From ef038f0704fd87c94f082a75d29f0e60b70c0b4c Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:07 -0400 Subject: [PATCH 19/27] refactor: generate models without temporary files Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- tests/tools/test_generate.py | 17 ++++++------ tools/generate.py | 50 +++++++++++++++++++++--------------- 2 files changed, 38 insertions(+), 29 deletions(-) diff --git a/tests/tools/test_generate.py b/tests/tools/test_generate.py index 6e409ec..48ac7b7 100644 --- a/tests/tools/test_generate.py +++ b/tests/tools/test_generate.py @@ -3,16 +3,11 @@ from __future__ import annotations import json -import sys -from pathlib import Path from typing import Any +import generate import pytest -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) - -import generate # noqa: E402 - def _schema() -> dict[str, Any]: return { @@ -76,7 +71,7 @@ def test_check_document_types_accepts_agreement_with_artifact_type() -> None: def test_check_document_types_rejects_a_missing_discriminator() -> None: - """Defect 4 regression guard: a destroyed discriminator must fail loudly.""" + """A destroyed discriminator must fail loudly at generation time.""" schema = _schema() del schema["$defs"]["Lexicon"]["properties"]["metadata"]["properties"] doc_types = generate.inject_metadata_titles(schema) @@ -181,7 +176,10 @@ def test_public_model_names_reads_classes_from_source() -> None: def test_render_models_excludes_denylisted_names_from_all() -> None: source = generate.render_models( - "from __future__ import annotations\n\nclass Alpha(BaseModel):\n pass\n", + "from __future__ import annotations\n\n" + "class Catalog(BaseModel):\n pass\n\n\n" + "class Log(BaseModel):\n pass\n\n\n" + "class Alpha(BaseModel):\n pass\n", ["Alpha", "Model", "Type"], ["Alpha"], ) @@ -195,8 +193,9 @@ def test_render_models_excludes_denylisted_names_from_all() -> None: def test_render_models_makes_category_bases_document_models() -> None: source = generate.render_models( "from __future__ import annotations\n\nclass Catalog(BaseModel):\n pass\n\n\n" + "class Log(BaseModel):\n pass\n\n\n" "class ControlCatalog(BaseModel):\n pass\n", - ["Catalog", "ControlCatalog"], + ["Catalog", "Log", "ControlCatalog"], ["ControlCatalog"], ) assert "class Catalog(GemaraDocumentModel):" in source diff --git a/tools/generate.py b/tools/generate.py index 3759d72..c57ec0b 100644 --- a/tools/generate.py +++ b/tools/generate.py @@ -13,7 +13,6 @@ import json import subprocess import sys -import tempfile from pathlib import Path from typing import Any, Final @@ -258,10 +257,11 @@ def run_codegen(schema: dict[str, Any]) -> str: """Generate the models from the in-memory schema, in-process. `datamodel_code_generator.generate` takes the schema as a mapping and returns - the source, so nothing is serialised to disk and the CLI need not be on PATH. - `input_filename` sets the name the generated header records; it is fixed - because the drift gate compares committed bytes, and a varying header would - fail it on its own. Verified byte-identical to the equivalent CLI invocation. + the source as a `str` when no `output` path is given, so nothing touches + disk and the CLI need not be on PATH. `input_filename` sets the name the + generated header records; it is fixed because the drift gate compares + committed bytes, and a varying header would fail it on its own. Verified + byte-identical to the equivalent CLI invocation. Imported here rather than at module scope so this module's pure functions -- the ones the unit tests exercise -- stay importable without the codegen @@ -269,20 +269,19 @@ def run_codegen(schema: dict[str, Any]) -> str: """ from datamodel_code_generator import Error, InputFileType, generate - with tempfile.TemporaryDirectory() as tmp: - output = Path(tmp) / "models.py" - try: - generate( - schema, - input_file_type=InputFileType.JsonSchema, - input_filename=CODEGEN_INPUT_NAME, - schema_version="2020-12", - preset=CODEGEN_PRESET, - output=output, - ) - except Error as exc: # datamodel-code-generator's own error type - raise GenerateError(f"codegen failed: {exc}") from exc - return output.read_text(encoding="utf-8") + try: + source = generate( + schema, + input_file_type=InputFileType.JsonSchema, + input_filename=CODEGEN_INPUT_NAME, + schema_version="2020-12", + preset=CODEGEN_PRESET, + ) + except Error as exc: # datamodel-code-generator's own error type + raise GenerateError(f"codegen failed: {exc}") from exc + if not isinstance(source, str): + raise GenerateError("codegen did not return a single module; the schema shape changed") + return source def render_models(body: str, model_names: list[str], document_models: list[str]) -> str: @@ -295,7 +294,10 @@ def render_models(body: str, model_names: list[str], document_models: list[str]) 1, ) for base in ("Catalog", "Log"): - body = body.replace(f"class {base}(BaseModel):", f"class {base}(GemaraDocumentModel):") + declaration = f"class {base}(BaseModel):" + if body.count(declaration) != 1: + raise GenerateError(f"generated models have an unexpected declaration for category base {base!r}") + body = body.replace(declaration, f"class {base}(GemaraDocumentModel):") for model in document_models: declaration = f"class {model}(BaseModel):" if body.count(declaration) != 1: @@ -357,6 +359,14 @@ def ruff_format(*paths: Path) -> None: def main() -> int: + try: + return _generate() + except GenerateError as exc: + print(f"generate: {exc}", file=sys.stderr) + return 1 + + +def _generate() -> int: if not SCHEMA_PATH.exists(): raise GenerateError(f"{SCHEMA_PATH} is missing; run `poe sync-schema` first") From b9b5c10dfea933a9cc85c1876a10c0f22ebb8255 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:09 -0400 Subject: [PATCH 20/27] fix: preserve fixtures when schema sync fails Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- tests/tools/test_sync_schema.py | 48 +++++++++++++++++++++++++++++---- tools/sync_schema.py | 29 ++++++++++++++++---- 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/tests/tools/test_sync_schema.py b/tests/tools/test_sync_schema.py index 68554fe..a29c826 100644 --- a/tests/tools/test_sync_schema.py +++ b/tests/tools/test_sync_schema.py @@ -2,15 +2,14 @@ from __future__ import annotations -import sys +import shutil +import subprocess from pathlib import Path +from subprocess import CompletedProcess from typing import Any import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools")) - -import sync_schema # noqa: E402 +import sync_schema def test_merge_exports_flattens_nested_defs_and_roots() -> None: @@ -57,6 +56,45 @@ def test_merge_exports_rejects_conflicting_nested_defs() -> None: sync_schema.merge_exports(exports) +def test_merge_exports_rejects_conflicting_root_and_nested_defs() -> None: + """A root export must not disagree with a stored nested def of the same name.""" + exports: dict[str, dict[str, Any]] = { + "#A": {"$defs": {"Shared": {"type": "string"}}, "type": "object"}, + "#Shared": {"type": "integer"}, + } + with pytest.raises(sync_schema.SyncError, match="conflicting definition 'Shared'"): + sync_schema.merge_exports(exports) + + +def test_vendor_fixtures_preserves_existing_corpus_when_copy_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + schema_dir = tmp_path / "schemas" + fixture_dir = schema_dir / "fixtures" + fixture_dir.mkdir(parents=True) + existing = fixture_dir / "good-existing.yaml" + existing.write_text("existing", encoding="utf-8") + + def fake_run(args: list[str], **kwargs: Any) -> CompletedProcess[str]: + if args[:2] == ["git", "clone"]: + clone = Path(args[-1]) + source = clone / "test" / "test-data" + source.mkdir(parents=True) + (source / "good-new.yaml").write_text("new", encoding="utf-8") + return CompletedProcess(args, 0, "", "") + return CompletedProcess(args, 0, "commit", "") + + monkeypatch.setattr(sync_schema, "SCHEMA_DIR", schema_dir) + monkeypatch.setattr(sync_schema, "FIXTURE_DIR", fixture_dir) + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr(shutil, "copy2", lambda *_: (_ for _ in ()).throw(OSError("disk full"))) + + with pytest.raises(OSError, match="disk full"): + sync_schema.vendor_fixtures("v1.5.0") + + assert existing.read_text(encoding="utf-8") == "existing" + + def test_document_type_names_reads_the_artifact_type_enum() -> None: schema = {"$defs": {"ArtifactType": {"enum": ["Lexicon", "ControlCatalog"]}}} assert sync_schema.document_type_names(schema) == ["ControlCatalog", "Lexicon"] diff --git a/tools/sync_schema.py b/tools/sync_schema.py index 164d038..0e016c1 100644 --- a/tools/sync_schema.py +++ b/tools/sync_schema.py @@ -136,7 +136,7 @@ def write_json(path: Path, payload: dict[str, Any]) -> None: def vendor_fixtures(ref: str) -> str: """Clone the schema repo at `ref` and copy its good-*/bad-* test data.""" - with tempfile.TemporaryDirectory() as tmp: + with tempfile.TemporaryDirectory(dir=SCHEMA_DIR) as tmp: clone = Path(tmp) / "gemara" cloned = subprocess.run( ["git", "clone", "--depth", "1", "--branch", ref, REPOSITORY, str(clone)], @@ -162,17 +162,29 @@ def vendor_fixtures(ref: str) -> str: if not source.is_dir(): raise SyncError(f"{ref} has no test/test-data directory") - if FIXTURE_DIR.exists(): - shutil.rmtree(FIXTURE_DIR) - FIXTURE_DIR.mkdir(parents=True) + staged_fixtures = Path(tmp) / "fixtures" + staged_fixtures.mkdir() copied = 0 for pattern in FIXTURE_GLOBS: for path in sorted(source.glob(pattern)): - shutil.copy2(path, FIXTURE_DIR / path.name) + shutil.copy2(path, staged_fixtures / path.name) copied += 1 if copied == 0: raise SyncError(f"no fixtures matched {FIXTURE_GLOBS} at {ref}") + + previous_fixtures = Path(tmp) / "previous-fixtures" + if FIXTURE_DIR.exists(): + FIXTURE_DIR.rename(previous_fixtures) + try: + staged_fixtures.rename(FIXTURE_DIR) + except OSError: + if previous_fixtures.exists(): + previous_fixtures.rename(FIXTURE_DIR) + raise + if previous_fixtures.exists(): + shutil.rmtree(previous_fixtures) + print(f" vendored {copied} fixtures") return commit @@ -182,7 +194,14 @@ def main() -> int: parser.add_argument("--ref", default=DEFAULT_REF, help=f"upstream tag (default: {DEFAULT_REF})") args = parser.parse_args() ref: str = args.ref + try: + return _sync(ref) + except SyncError as exc: + print(f"sync: {exc}", file=sys.stderr) + return 1 + +def _sync(ref: str) -> int: print(f"Syncing {CUE_MODULE}@{ref}") print(" Discovering definitions...") From 72f245801f9eba8d2f9be1eb39cbbe6f3db47788 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:16 -0400 Subject: [PATCH 21/27] feat: expose package version and document categories Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- README.md | 5 +++++ src/gemara/v1/__init__.py | 5 +++++ tests/gemara/v1/test_registry.py | 19 +++++++++++++++++-- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dffe816..3bafe49 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,12 @@ text and bytes. Both validate the input as the selected document model. ## Reference - `DOCUMENT_TYPES` contains the supported document models. +- `GemaraDocument` is the union type returned by `load` and `loads`. +- Catalog documents share the `Catalog` base and log documents share `Log`, so + `isinstance(doc, Catalog)` or `isinstance(doc, Log)` narrows a dispatched + document to its category. - `SCHEMA_VERSION` is the Gemara release used to generate the models +- `__version__` is the installed `gemara-python` distribution version. - Invalid input raises `GemaraError`, `UnknownDocumentTypeError`, or `pydantic.ValidationError`. - `load` and `from_file` also propagate filesystem and stream I/O exceptions. diff --git a/src/gemara/v1/__init__.py b/src/gemara/v1/__init__.py index f3796d9..8d0022d 100644 --- a/src/gemara/v1/__init__.py +++ b/src/gemara/v1/__init__.py @@ -2,17 +2,22 @@ from __future__ import annotations +from importlib.metadata import version as _distribution_version + from gemara.v1._loader import GemaraError, UnknownDocumentTypeError, load, loads from gemara.v1._models import * from gemara.v1._models import __all__ as _MODEL_NAMES from gemara.v1._registry import DOCUMENT_TYPES, SCHEMA_VERSION, GemaraDocument +__version__ = _distribution_version("gemara-python") + __all__ = [ "DOCUMENT_TYPES", "SCHEMA_VERSION", "GemaraDocument", "GemaraError", "UnknownDocumentTypeError", + "__version__", "load", "loads", *_MODEL_NAMES, diff --git a/tests/gemara/v1/test_registry.py b/tests/gemara/v1/test_registry.py index 5d8f981..6e09729 100644 --- a/tests/gemara/v1/test_registry.py +++ b/tests/gemara/v1/test_registry.py @@ -4,11 +4,12 @@ import json import typing +from importlib.metadata import version from pathlib import Path from pydantic import BaseModel -from gemara.v1 import DOCUMENT_TYPES, Catalog, GemaraDocument, Log, Metadata +from gemara.v1 import DOCUMENT_TYPES, Catalog, GemaraDocument, Log, Metadata, __version__ from gemara.v1._document import GemaraDocumentModel SCHEMA_DIR = Path(__file__).resolve().parents[3] / "schemas" @@ -24,6 +25,10 @@ def test_registry_has_thirteen_document_types() -> None: assert len(DOCUMENT_TYPES) == 13 +def test_package_version_matches_distribution_metadata() -> None: + assert __version__ == version("gemara-python") + + def test_every_registry_entry_is_a_model() -> None: for name, model in DOCUMENT_TYPES.items(): assert issubclass(model, BaseModel), name @@ -31,7 +36,7 @@ def test_every_registry_entry_is_a_model() -> None: def test_every_model_narrows_its_metadata_type() -> None: - """Defect 4 regression guard: dispatch depends on this narrowing.""" + """Dispatch depends on each model's narrowed metadata type.""" for name, model in DOCUMENT_TYPES.items(): metadata = model.model_fields["metadata"].annotation assert metadata is not None @@ -56,3 +61,13 @@ def test_log_models_share_the_log_runtime_base() -> None: assert "target" in Log.model_fields for name, model in DOCUMENT_TYPES.items(): assert issubclass(model, Log) is name.endswith("Log") + + +def test_documents_narrow_to_their_category_via_isinstance() -> None: + """The tier's point: a consumer narrows any dispatched document by + `isinstance` -- e.g. to gather every catalog's `imports` or every log's + `target` -- instead of re-reading `metadata.type`.""" + for name, model in DOCUMENT_TYPES.items(): + doc = model.model_construct() + assert isinstance(doc, Catalog) is name.endswith("Catalog"), name + assert isinstance(doc, Log) is name.endswith("Log"), name From e9a2a1dae815120e50d4b77f241aac0cacf4bc9a Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:18 -0400 Subject: [PATCH 22/27] refactor: centralize loader text decoding Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- src/gemara/v1/_loader.py | 21 ++++++++------------- tests/gemara/v1/test_loader.py | 6 ++++++ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/gemara/v1/_loader.py b/src/gemara/v1/_loader.py index d726ad5..bd868fc 100644 --- a/src/gemara/v1/_loader.py +++ b/src/gemara/v1/_loader.py @@ -42,6 +42,11 @@ def _decode(data: bytes | bytearray | memoryview) -> str: raise GemaraError(f"document is not valid UTF-8: {exc}") from exc +def _decode_text(text: str | bytes | bytearray | memoryview) -> str: + """Return text unchanged or decode a bytes-like document as UTF-8.""" + return text if isinstance(text, str) else _decode(text) + + def _parse(text: str) -> Any: """Parse JSON or YAML. YAML is a superset of JSON, so one parser covers both.""" try: @@ -53,11 +58,7 @@ def _parse(text: str) -> Any: def _loads_as(model: type[T], text: str | bytes | bytearray | memoryview) -> T: """Parse text and validate it as one explicitly selected document model.""" - if isinstance(text, str): - decoded = text - else: - decoded = _decode(text) - return model.model_validate(_parse(decoded)) + return model.model_validate(_parse(_decode_text(text))) def _read_source(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> str | bytes: @@ -68,6 +69,7 @@ def _read_source(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> str | def _dispatch(raw: Any) -> GemaraDocument: + """Route a parsed mapping to the model selected by `metadata.type`.""" if not isinstance(raw, dict): raise GemaraError(f"a Gemara document must be a mapping, got {type(raw).__name__}") metadata = raw.get("metadata") @@ -100,14 +102,7 @@ def loads(text: str | bytes | bytearray | memoryview) -> GemaraDocument: does not match its model. No other exception type -- in particular no `yaml.YAMLError` -- escapes this function. """ - if isinstance(text, str): - decoded = text - else: - # `isinstance(text, bytes)` is False for `bytearray`/`memoryview`, so a - # narrower check would let those buffers reach here undecoded and fail - # deep inside YAML/JSON with an unhelpful internals error instead. - decoded = _decode(text) - return _dispatch(_parse(decoded)) + return _dispatch(_parse(_decode_text(text))) def load(source: str | os.PathLike[str] | IO[str] | IO[bytes]) -> GemaraDocument: diff --git a/tests/gemara/v1/test_loader.py b/tests/gemara/v1/test_loader.py index 9d8e3ab..18dc827 100644 --- a/tests/gemara/v1/test_loader.py +++ b/tests/gemara/v1/test_loader.py @@ -129,6 +129,12 @@ def test_missing_type_raises_unknown_document_type_with_none() -> None: assert excinfo.value.value is None +def test_missing_metadata_key_raises_unknown_document_type_with_none() -> None: + with pytest.raises(UnknownDocumentTypeError) as excinfo: + loads(json.dumps({})) + assert excinfo.value.value is None + + def test_non_mapping_document_is_rejected() -> None: with pytest.raises(GemaraError, match="mapping"): loads("[1, 2, 3]") From ba9a1b35153267ba398005a83f19df31ec42f9b2 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:20 -0400 Subject: [PATCH 23/27] test: confirm fixtures dispatch to registered models Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- tests/test_fixtures.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index ea34aab..d4ebab4 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -13,7 +13,7 @@ from pydantic import ValidationError from test_helpers import fixture_paths -from gemara.v1 import UnknownDocumentTypeError, load +from gemara.v1 import DOCUMENT_TYPES, UnknownDocumentTypeError, load GOOD = fixture_paths("good-") BAD = fixture_paths("bad-") @@ -63,7 +63,8 @@ def test_the_corpus_is_fully_accounted_for() -> None: @pytest.mark.parametrize("path", GOOD, ids=lambda p: p.stem) def test_good_fixture_validates(path: Path) -> None: - load(path) + document = load(path) + assert document.metadata.type in DOCUMENT_TYPES @pytest.mark.parametrize("path", GOOD, ids=lambda p: p.stem) From 0d2e943f2330f7341b78565b584d876573972611 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:39:22 -0400 Subject: [PATCH 24/27] docs: clarify schema update and validation guidance Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- CONTRIBUTING.md | 10 +++++----- schemas/README.md | 29 ++++------------------------- tests/tools/test_schema.py | 6 +++--- 3 files changed, 12 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4ae277f..f5a87f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,12 +45,12 @@ uv run poe test ``` Review the diff to `schemas/` and `src/gemara/v1/_models.py` together. A field -that got *looser* is the thing to watch for — see the known limitation -documented on `recover_array_allof_element_type` in `tools/generate.py`. +that got *looser* is the thing to watch for: a repair pass in `tools/generate.py` +may drop a constraint it cannot merge, and the only signal is a looser generated +type. -Update `SEMANTIC_GAPS` in `tests/test_fixtures.py` if the corpus changed. Those -entries are asserted to *still parse*, so newly-gained strictness fails the -suite rather than passing unnoticed — that is the point of them. +Update `SEMANTIC_GAPS` in `tests/test_fixtures.py` if the corpus changed. Read +the comment on that set for what it means and when to move an entry. ## Tests diff --git a/schemas/README.md b/schemas/README.md index 9df2713..e322664 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -19,25 +19,8 @@ snapshot. ## Updating the Schema -Install `cue`, ensure it is on `PATH`, and use a checkout with network access. -Then run the sync command for the intended upstream tag: - -```bash -uv run poe sync-schema # optionally --ref vX.Y.Z -uv run poe generate -uv run poe test -``` - -Omit `--ref` to use the script's default Gemara version. The sync command: - -1. Discovers and exports every public CUE definition as JSON Schema. -2. Merges the exports into `gemara-v1.schema.json`. -3. Clones the same upstream tag and replaces `fixtures/` with its `good-*` and - `bad-*` corpus. -4. Writes the exact upstream commit and schema digest to `provenance.json`. - -`poe generate` then produces the Pydantic models from the vendored schema. -Review changes to `schemas/` and `src/gemara/v1/` together before committing. +The complete schema-update procedure, including prerequisites and review steps, +is maintained in [Contributing](../CONTRIBUTING.md#bumping-the-schema-version). ## Fixture Process @@ -45,9 +28,5 @@ Fixtures are copied verbatim from Gemara's `test/test-data` directory. The test suite loads every `good-*` fixture and accounts for every `bad-*` fixture; an empty fixture directory or an unclassified bad fixture fails the tests. -When the upstream corpus changes, update the fixture classification in -`tests/test_fixtures.py`. Fixtures in `STRUCTURALLY_REJECTED` must fail model -validation. Fixtures in `SEMANTIC_GAPS` are known CUE cross-field rules that -JSON Schema cannot currently express and are deliberately asserted to parse. -Move an entry between those sets only when the test result and underlying -validation capability have changed. +When the upstream corpus changes, follow the fixture-classification instructions +in [Contributing](../CONTRIBUTING.md#bumping-the-schema-version). diff --git a/tests/tools/test_schema.py b/tests/tools/test_schema.py index cdbf46d..28e637b 100644 --- a/tests/tools/test_schema.py +++ b/tests/tools/test_schema.py @@ -4,9 +4,9 @@ Hermetic and fast: no cue, no network -- these only read the committed `schemas/gemara-v1.schema.json` and `schemas/provenance.json`. -Commit a21d87e fixed a dangling-`$ref` bug caused by cue's quoted identifiers -(e.g. `#"reference-id"`). The regression guards added there were unit tests on -synthetic inputs in `tests/test_sync_schema.py` -- nothing walked the real, +Cue's quoted identifiers (e.g. `#"reference-id"`) have previously produced +dangling `$ref`s. The regression guards for that bug are unit tests on +synthetic inputs in `tests/tools/test_sync_schema.py` -- nothing walked the real, vendored schema's actual `$ref`s. A future upstream ref with a second quoted shape `sync_schema.py` doesn't anticipate could reproduce that bug with every existing test still green. `test_every_ref_resolves` is the guard that would From 8bc3bdc7103f5b80eb4b91f3aca8f6323ffdb58e Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 19:51:36 -0400 Subject: [PATCH 25/27] test: cover missing vendored fixture failure Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- tests/conftest.py | 19 ------------------- tests/test_fixture_paths.py | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 19 deletions(-) delete mode 100644 tests/conftest.py create mode 100644 tests/test_fixture_paths.py diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 4c70bf1..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Fail the whole suite if any test is skipped. - -The vendored fixture corpus must always run. A skip means a fixture or the -corpus itself is missing -- the silent-failure class the predecessor shipped -(39 of 40 tests skipped, suite still green). Guarding here, in the suite, -means every `pytest` invocation enforces it with no shell wrapper, no summary -file, and no interpreter-specific one-liner to duplicate per workflow. -""" - -from __future__ import annotations - -import pytest - - -def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: - reporter = session.config.pluginmanager.get_plugin("terminalreporter") - stats = getattr(reporter, "stats", None) - if isinstance(stats, dict) and stats.get("skipped"): - session.exitstatus = pytest.ExitCode.TESTS_FAILED diff --git a/tests/test_fixture_paths.py b/tests/test_fixture_paths.py new file mode 100644 index 0000000..a7f9c99 --- /dev/null +++ b/tests/test_fixture_paths.py @@ -0,0 +1,17 @@ +"""Tests for vendored fixture discovery.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from test_helpers import fixture_paths + + +def test_fixture_paths_fails_when_no_matching_fixtures_are_vendored( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr("test_helpers.FIXTURE_DIR", tmp_path) + + with pytest.raises(AssertionError, match=r"no good-\* fixtures vendored"): + fixture_paths("good-") From 5a2fd40a7d0af793ccdb04ffa86d4ce66c71e358 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 20:02:51 -0400 Subject: [PATCH 26/27] ci: share published package verification Assisted-by: OpenCode (OpenAI, GPT 5.6 Terra) Signed-off-by: Jennifer Power --- .../verify-published-package/action.yml | 38 +++++++++++++++++++ .github/workflows/publish-testpypi.yml | 17 ++------- .github/workflows/release.yml | 11 +----- .github/workflows/zizmor.yml | 2 + 4 files changed, 45 insertions(+), 23 deletions(-) create mode 100644 .github/actions/verify-published-package/action.yml diff --git a/.github/actions/verify-published-package/action.yml b/.github/actions/verify-published-package/action.yml new file mode 100644 index 0000000..8f1a075 --- /dev/null +++ b/.github/actions/verify-published-package/action.yml @@ -0,0 +1,38 @@ +name: Verify published package +description: Install the published package from an index and run its package-level tests. + +inputs: + index-url: + description: Package index URL to install from. + required: false + extra-index-url: + description: Additional package index URL to resolve dependencies from. + required: false + +runs: + using: composite + steps: + - name: Install the published package + shell: bash + env: + INDEX_URL: ${{ inputs.index-url }} + EXTRA_INDEX_URL: ${{ inputs.extra-index-url }} + run: | + version="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" + venv=.published-package-venv + index_args=() + if [[ -n "$INDEX_URL" ]]; then + index_args+=(--index-url "$INDEX_URL") + fi + if [[ -n "$EXTRA_INDEX_URL" ]]; then + index_args+=(--extra-index-url "$EXTRA_INDEX_URL") + fi + uv venv --clear "$venv" + uv pip install --python "$venv/bin/python" "${index_args[@]}" "gemara-python==$version" pytest + - name: Verify imports use the installed artifact + shell: bash + run: | + .published-package-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".published-package-venv")' + - name: Run tests against the published package + shell: bash + run: .published-package-venv/bin/python -m pytest -q tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py tests/test_fixtures.py diff --git a/.github/workflows/publish-testpypi.yml b/.github/workflows/publish-testpypi.yml index 4ea08de..20ea956 100644 --- a/.github/workflows/publish-testpypi.yml +++ b/.github/workflows/publish-testpypi.yml @@ -43,16 +43,7 @@ jobs: - uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0 with: enable-cache: false - - name: Install the published package from TestPyPI - run: | - version="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - uv venv --clear .testpypi-venv - uv pip install --python .testpypi-venv/bin/python \ - --index-url https://test.pypi.org/simple \ - --extra-index-url https://pypi.org/simple \ - "gemara-python==$version" pytest - - name: Verify imports use the installed artifact - run: | - .testpypi-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".testpypi-venv")' - - name: Run tests against the TestPyPI package - run: .testpypi-venv/bin/python -m pytest -q tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py tests/test_fixtures.py + - uses: ./.github/actions/verify-published-package + with: + index-url: https://test.pypi.org/simple + extra-index-url: https://pypi.org/simple diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8e5c7b..4bbf7fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,13 +72,4 @@ jobs: enable-cache: false - name: Wait for PyPI index propagation run: sleep 60 - - name: Install the published package from PyPI - run: | - version="$(python -c 'import tomllib, pathlib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')" - uv venv --clear .pypi-venv - uv pip install --python .pypi-venv/bin/python "gemara-python==$version" pytest - - name: Verify imports use the installed artifact - run: | - .pypi-venv/bin/python -c 'import gemara.v1, pathlib; assert pathlib.Path(gemara.v1.__file__).is_relative_to(pathlib.Path.cwd() / ".pypi-venv")' - - name: Run tests against the PyPI package - run: .pypi-venv/bin/python -m pytest -q tests/gemara/v1/test_loader.py tests/gemara/v1/test_registry.py tests/test_fixtures.py + - uses: ./.github/actions/verify-published-package diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 3c0d4a1..1d39d1e 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -5,11 +5,13 @@ on: - main paths: - '.github/workflows/**' # Triggers only when workflow files change + - '.github/actions/**' pull_request: branches: - main paths: - '.github/workflows/**' + - '.github/actions/**' - '.github/dependabot.yml' From 8fdb9b71f5f729cf88e6c8491f360af8a1ae3d92 Mon Sep 17 00:00:00 2001 From: Jennifer Power Date: Wed, 9 Sep 2026 20:10:04 -0400 Subject: [PATCH 27/27] fix: add supression on ruff finding for public API rexport Signed-off-by: Jennifer Power --- src/gemara/v1/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gemara/v1/__init__.py b/src/gemara/v1/__init__.py index 8d0022d..3096aa2 100644 --- a/src/gemara/v1/__init__.py +++ b/src/gemara/v1/__init__.py @@ -5,7 +5,7 @@ from importlib.metadata import version as _distribution_version from gemara.v1._loader import GemaraError, UnknownDocumentTypeError, load, loads -from gemara.v1._models import * +from gemara.v1._models import * # noqa: F403 from gemara.v1._models import __all__ as _MODEL_NAMES from gemara.v1._registry import DOCUMENT_TYPES, SCHEMA_VERSION, GemaraDocument