Skip to main content

oscal-bindings (Python)

Typed Python data bindings for OSCAL (Open Security Controls Assessment Language) v1.2.3, generated from the official NIST JSON Schema using datamodel-code-generator (Pydantic v2).

Installation

pip install oscal-bindings
# or
uv add oscal-bindings

The distribution is oscal-bindings; the import package is oscal_bindings.

To install from a local checkout (e.g., as a uv workspace path dependency):

uv add ../oscal-bindings-python
# or
pip install -e ../oscal-bindings-python

To install directly from a Git tag, branch, or commit:

pip install "git+https://github.com/cairn-proofs/oscal-bindings-python.git@main"

Known issue: Invalid OSCAL JSON keys can be silently lost

Verified on pydantic 2.13.5. What happens:

  • parse_oscal, parse_oscal_file, the typed parse_* functions, and model_validate_json accept the document with the field unset.
  • validate_oscal returns True, so it can't catch the mistake.
  • A missing required field is reported only as missing at its OSCAL name, which hides the real cause.
  • If both spellings are present, the OSCAL-name value wins and the other key is ignored.
doc = parse_oscal('{"catalog": {..., "back_matter": {"resources": [...]}}}')
doc.root.catalog.back_matter    # None: the back-matter is gone

You're most likely to hit this with hand-written JSON, or with output from model_dump(by_alias=False) that's fed back in. Unknown keys (bogus, back-matterr) are still rejected, and dict input is rejected on every supported pydantic version.

Workaround: if your input might not come from a conforming OSCAL tool, validate it as a dict. Dict validation rejects field-name keys with extra_forbidden:

import json
from oscal_bindings import CatalogDocument, OscalDocument

CatalogDocument.model_validate(json.loads(text))   # known type: clearest errors
OscalDocument.model_validate(json.loads(text))     # any of the 8 document types

This raises pydantic.ValidationError, not OscalParseError. Pinning pydantic<2.13 probably avoids the bug too, but that hasn't been verified.

This is an upstream pydantic-core bug in its new 2.13 JSON validation path. The fix is pydantic/pydantic#13275, which hasn't merged yet. Once a fixed release ships, this library will raise its pydantic floor to that release.

Import paths

The bindings live under a namespace scoped to the OSCAL major version:

from oscal_bindings.v1 import parse_oscal, OscalDoc, Catalog

The top-level package re-exports the current major version, so from oscal_bindings import parse_oscal (and oscal_bindings.models, oscal_bindings.parser, oscal_bindings.extensions) resolves to the same objects:

import oscal_bindings
import oscal_bindings.v1

oscal_bindings.Catalog is oscal_bindings.v1.models.Catalog   # True

Examples below use the shorter top-level path. Importing from oscal_bindings.v1 is equivalent and states which OSCAL major version your code is written against.

OSCAL is backward-compatible within a major version, so a routine 1.x schema refresh regenerates v1 in place and never changes your imports. Only OSCAL 2.0 would introduce a second namespace.

Which OSCAL release?

__oscal_schema_version__ reports the exact release the models were generated from:

from oscal_bindings import __oscal_schema_version__

__oscal_schema_version__      # '1.2.3'

Worth checking, because models are strict about unknown fields: a document from a newer 1.x release than the vendored one can fail validation on a field these bindings have never seen. The constant makes that gap visible rather than a mystery.

Usage

Parsing

from oscal_bindings import parse_oscal, parse_oscal_file, parse_catalog, parse_profile

# Parse any OSCAL document
doc = parse_oscal(json_string)

# Parse with type-specific convenience functions
catalog = parse_catalog(json_string)    # returns CatalogDocument
profile = parse_profile(json_string)    # returns ProfileDocument

# Parse from a file
doc = parse_oscal_file("catalog.json")

parse_oscal and the per-type parsers accept either str or bytes. Passing bytes directly skips a UTF-8 decode step, which avoids one document-size string allocation — preferable when the document comes from a byte-source (HTTP response body, S3 GET, container layer pull):

import urllib.request

with urllib.request.urlopen("https://example.com/catalog.json") as resp:
    doc = parse_oscal(resp.read())     # bytes → parsed model, no .decode() needed

Invalid UTF-8 bytes surface as OscalParseError, the same exception type used for JSON and schema validation failures.

Accessing Parsed Data

catalog = parse_catalog(json_string)
print(catalog.catalog.metadata.title)       # plain str
print(catalog.catalog.metadata.version)

Accessors

OscalDoc is a uniform facade over any of the 8 parsed wrappers (CatalogDocument, ProfileDocument, SystemSecurityPlanDocument, …). It exposes the structural fields shared across every OSCAL top-level document — metadata, oscal_version, uuid — without making the caller dispatch on body type.

from oscal_bindings import OscalDoc

doc = OscalDoc.from_file("ssp.json")
doc.oscal_version          # '1.2.0'
doc.uuid                   # body-level UUID, regardless of document type
doc.metadata.title
doc.body                   # the underlying Catalog | Profile | SSP | ...

Wrap an already-parsed document directly:

from oscal_bindings import OscalDoc, parse_oscal_file

doc = OscalDoc(parse_oscal_file("ssp.json"))

For Assessment Plans and Assessment Results, assessment_period() returns the (start, end) date range:

from oscal_bindings import OscalAccessError

doc = OscalDoc.from_file("assessment-results.json")
start, end = doc.assessment_period()       # (date(2025, 1, 5), date(2025, 1, 30))

try:
    OscalDoc.from_file("catalog.json").assessment_period()
except OscalAccessError as e:
    print(e)                               # only defined for AP / AR

Building back-matter resources

from oscal_bindings import make_hash, make_resource, make_rlink

digest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
resource = make_resource(
    title="Evidence: signed audit report",
    description="Q1 audit deliverable, two equivalent mirrors.",
    rlinks=[
        make_rlink("https://example.com/audit-q1.pdf", media_type="application/pdf",
                   hashes=[make_hash(digest)]),
        make_rlink("s3://evidence-bucket/audit-q1.pdf",
                   hashes=[make_hash(digest)]),
    ],
)
# resource.uuid is a freshly generated UUID4

Serialization

from oscal_bindings import serialize_oscal

json_str = serialize_oscal(doc)                # pretty-printed (indent: 2)
json_str = serialize_oscal(doc, indent=None)   # compact

Every model serializes to valid OSCAL by default: OSCAL key names (media-type, not media_type) and no null for unset fields, on every path. That covers model_dump(), model_dump(mode="json"), model_dump_json(), pydantic_core.to_json(...), and TypeAdapter(...).dump_python(...), including models nested in plain dicts and lists. Framework serializers that never pass kwargs (e.g. MCP or agent tool results) get valid OSCAL too. serialize_oscal is still the recommended call for whole documents.

from oscal_bindings import make_rlink

rlink = make_rlink("https://example.com/a.pdf", media_type="application/pdf")
rlink.model_dump_json()                 # '{"href":"https://example.com/a.pdf","media-type":"application/pdf"}'
rlink.model_dump(by_alias=False)        # {'href': '...', 'media_type': 'application/pdf'}

Opt-outs:

  • by_alias=False emits Python field names, still without null.
  • exclude_none=False has no effect. OSCAL forbids null, so None-valued fields are always omitted. To tell whether a field was set, use attribute access (rlink.hashes is None) or model_fields_set.

Field names vs. OSCAL names

Python code can use field names; OSCAL data must use OSCAL names.

  • Constructor keyword arguments accept either: Rlink(href=..., media_type=...) and Rlink(href=..., **{"media-type": ...}) build the same model. Plain dicts nested inside the kwargs are OSCAL data and must use OSCAL names.
  • Dict input (Model.model_validate(...), validate_element) rejects a field name at any depth with extra_forbidden.
  • JSON input (parse_oscal, parse_oscal_file, validate_oscal, the typed parse_* functions, model_validate_json) never populates a field from a field name key.

On pydantic 2.13.x, JSON input drops a field-name key without an error instead of rejecting it. See Known issue: Invalid OSCAL JSON keys can be silently lost.

Validation

from oscal_bindings import validate_oscal

if validate_oscal(json_string):
    print("Valid OSCAL document")

Error Handling

from oscal_bindings import parse_oscal, OscalParseError

try:
    parse_oscal(invalid_json)
except OscalParseError as e:
    print(e)          # Human-readable message
    print(e.errors)   # Pydantic validation error details

Typed Parser Functions

Each OSCAL document type has a dedicated parser:

  • parse_catalog(json) → CatalogDocument
  • parse_profile(json) → ProfileDocument
  • parse_component_definition(json) → ComponentDefinitionDocument
  • parse_system_security_plan(json) → SystemSecurityPlanDocument
  • parse_assessment_plan(json) → AssessmentPlanDocument
  • parse_assessment_results(json) → AssessmentResultsDocument
  • parse_plan_of_action_and_milestones(json) → PlanOfActionAndMilestonesDocument
  • parse_mapping_collection(json) → MappingCollectionDocument

Building

hatch build
hatch run release  # generate + lint + typing + security + test + coverage + docs

Architecture

  • Version package (src/oscal_bindings/v1/) — everything below, scoped to the OSCAL major version. Exposes __oscal_schema_version__.
  • Generated models (src/oscal_bindings/v1/models.py) — Pydantic v2 classes with Annotated field constraints, produced by datamodel-code-generator from the OSCAL JSON Schema.
  • Model base (src/oscal_bindings/v1/_base.py) — hand-written OscalBaseModel, which every generated object model inherits via --base-class: OSCAL keys and no nulls by default, field-name constructor kwargs.
  • Post-processing (scripts/postprocess_models.py) — renames classes from schema namespace paths to clean short names and names each document wrapper after its root key (e.g. CatalogDocument).
  • Vendored schemas (schemas/<release>/) — one directory per OSCAL release; the active bundle is chosen by explicit path.
  • Runtime (src/oscal_bindings/v1/parser.py) — parse / serialize / validate utilities and per-type typed parsers.
  • Extensions (src/oscal_bindings/v1/extensions/) — hand-written helpers layered on top of the generated models:
    • document.py — OscalDoc facade (uniform metadata / uuid / oscal_version / assessment_period() accessors).
    • builders.py — make_hash, make_rlink, make_resource constructors for back-matter elements.
    • validate_element.py — element-level schema validation utilities.
  • Top-level package (src/oscal_bindings/__init__.py and the models / parser / extensions alias modules) — pure re-exports of the current version package.

Public symbols are re-exported from oscal_bindings.v1 and from the top-level oscal_bindings package, so callers don't need to know which submodule a name lives in.

Requirements

  • Python 3.11+ (tested on 3.11–3.14)
  • Pydantic 2.11+

License

Apache License 2.0; see LICENSE. The vendored OSCAL schemas are from NIST and are in the public domain; see NOTICE.

Metadata

Release files for oscal-bindings 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for oscal-bindings 0.2.1
File Size Uploaded
oscal_bindings-0.2.1.tar.gz 382.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for oscal-bindings 0.2.1
File Interpreter ABI Platform
oscal_bindings-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 434.4 kB

Release files / oscal_bindings-0.2.1.tar.gz

Download URL oscal_bindings-0.2.1.tar.gz
Size 382.2 kB
Tags Source
SHA-256 checksum
How to use checksums
3a25e6558a40a00245626e995b82aa6e2ceeee088a643941bf6a8cd7299245e4
BLAKE2b-256 checksum
How to use checksums
a3948c1c003607feead62834b3f6c62c16188ba5e025686fe70c10ab5c479a66
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release files / oscal_bindings-0.2.1-py3-none-any.whl

Download URL oscal_bindings-0.2.1-py3-none-any.whl
Size 52.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
c403c59fc4f3fc82bdd81f8d43ef3c30fee82e723646ec65bf68d2951f8fa708
BLAKE2b-256 checksum
How to use checksums
9cd2a938a9a355d58c8ba2a0f6154dfd3509dd7efc472d12e39fb1a5c03ec2fb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 11, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page