Python API
The entry point is PolicyRuntime. It loads policies, evaluates a context against them,
and looks up license data.
from ospac import PolicyRuntime, License, Policy, ComplianceResult
Those four names, plus license_aliases, license_ambiguous, license_never_resolve,
resolve_license and data_version below, are ospac’s public surface. ospac.__all__ is the authoritative list and also
carries DataVersion, the return type of data_version(), and the
DATA_SCHEMA_VERSION constant. Anything under ospac.pipeline is dataset-generation
machinery, not an API to build on.
PolicyRuntime
from ospac import PolicyRuntime
runtime = PolicyRuntime() # bundled default enterprise policy
runtime = PolicyRuntime("./policy.yaml") # your policy
runtime = PolicyRuntime(skip_default=True) # no policy at all
PolicyRuntime._using_default tells you whether the default policy was loaded. It is
underscore-prefixed but it is what the CLI checks to emit its warning, and it is worth
asserting on in automation. See
Policies.
runtime = PolicyRuntime("./policy.yaml")
assert not runtime._using_default, "custom policy failed to load"
evaluate
result = runtime.evaluate(context)
context is a dict, and the keys must be the ones policy rules match on. The CLI builds
this shape, and replicating it exactly is the difference between rules firing and rules
being silently skipped:
context = {
"licenses": ["GPL-3.0"],
"licenses_found": ["GPL-3.0"], # both keys are read; set both
"distribution_type": "mobile",
"distribution": "mobile",
"context": "general",
"linking_type": None, # or "static_linking" / "dynamic_linking"
}
result = runtime.evaluate(context)
print(result.action) # ActionType.DENY
print(result.message) # the winning rule's own reason
print(result.to_dict())
{'rule_id': 'aggregate', 'action': 'deny', 'severity': 'error',
'message': 'GPL licenses not allowed in commercial products due to viral copyleft requirements',
'requirements': [],
'remediation': 'Replace with MIT, Apache-2.0, or BSD licensed alternative'}
Returns a PolicyResult. action is an ActionType enum, so compare against the enum or
call to_dict() for the plain string:
from ospac.models.compliance import ActionType
if result.action == ActionType.DENY:
raise SystemExit(result.to_dict()["remediation"])
A context missing a field that a rule matches on causes that rule to be skipped, not to
error. If evaluation returns flag_for_review with "No policy rule matched" when you
expected a denial, the rule was skipped: check the context keys first.
evaluate_licenses
result, per_license = runtime.evaluate_licenses(
["MIT", "MPL-2.0"],
{"distribution_type": "commercial", "distribution": "commercial",
"context": "general", "linking_type": None},
)
The per-license entry point, and the one the CLI uses. Each license is evaluated
independently, with its own license_type resolved from the dataset, and the verdicts
aggregate with most-restrictive-wins. evaluate() judges the context as a single unit, so
a rule matched by one license produces a result and the no-match fail-safe never runs for
the others: with evaluate(), one permissive license in the list can answer for a license
that matched nothing. Use evaluate_licenses whenever the input is a list of licenses.
per_license maps each license id to its own PolicyResult, so you can report which
license drove the aggregate verdict.
check_compatibility
result = runtime.check_compatibility("GPL-2.0", "Apache-2.0")
result = runtime.check_compatibility("MIT", "GPL-3.0", context="static_linking")
Returns a ComplianceResult:
>>> r = runtime.check_compatibility("GPL-2.0", "Apache-2.0")
>>> r.is_compliant
False
>>> r.violations
[{'rule_id': 'aggregate', 'message': 'GPL-2.0 and Apache-2.0 are incompatible due to patent clause conflicts', 'severity': 'error'}]
>>> r.required_actions
['Use GPL-3.0 (compatible with Apache-2.0) or choose different licenses']
Note that licenses_checked comes back empty here even though two licenses were compared;
read the licenses from your own call arguments rather than from the result.
lookup_license_data
data = runtime.lookup_license_data("MIT")
Returns the raw record from the dataset, or None if the license is unknown. The record is
wrapped in a license key, matching the file on disk:
>>> runtime.lookup_license_data("MIT").keys()
dict_keys(['license'])
>>> runtime.lookup_license_data("MIT")["license"]["type"]
'permissive'
Unwrap before use. Field meanings are in The dataset.
resolve_data_dir
>>> runtime.resolve_data_dir()
'/usr/lib/python3.12/site-packages/ospac/data'
Where ospac reads license data from. Pass a path to override.
get_obligations
PolicyRuntime.get_obligations(["MIT"])returns{}for every license. It readslookup_license_data()["obligations"], but obligations live at["license"]["obligations"], so the lookup always misses. The CLI’sospac obligationsis unaffected: it uses a separate code path.Read obligations directly until this is fixed:
record = runtime.lookup_license_data("MIT") obligations = record["license"]["obligations"] # ['Retain copyright notices', 'Include license text']
license_aliases, license_ambiguous and license_never_resolve
import ospac
ospac.license_aliases() # {"expat": "MIT", "apache2": "Apache-2.0", ...}
ospac.license_ambiguous() # {"gplv3": ["GPL-3.0-only", "GPL-3.0-or-later"], ...}
ospac.license_never_resolve() # {"gpl", "lgpl", "agpl", "bsd", "apache", "public domain"}
Three answers a declared license string can get, and every key is lowercased, so lowercase your input before looking it up. No string appears in more than one of them.
The alias map is lowercased alias to SPDX id, flattened from the records.
The ambiguous map is text that identifies a license but not which id, mapped to the ids it
could mean. gnu lesser general public license v2.1 states the license and the version and
is still not an identifier: only versus or-later is the copyright holder’s grant. Report the
missing distinction rather than picking one, which is why these are absent from
license_aliases().
The never-resolve set is family names that must not map to any one license and have no candidates to offer, because resolving them fabricates a version the document never stated.
Tools normalizing declared licenses should consume all three instead of curating their own
tables. ospac data aliases prints the same three from the command line. See
The dataset.
resolve_license
One declared license string, and what the shipped data can make of it.
import ospac
ospac.resolve_license("Apache 2.0")
# LicenseResolution(text='Apache 2.0', license_id='Apache-2.0', candidates=[], status='normalized')
ospac.resolve_license("GNU Affero General Public License v3")
# LicenseResolution(text=..., license_id=None,
# candidates=['AGPL-3.0-only', 'AGPL-3.0-or-later'], status='ambiguous')
status is the part to branch on:
| status | Meaning | license_id |
candidates |
|---|---|---|---|
exact |
already a canonical SPDX identifier | the same string | empty |
normalized |
resolved through the alias map | the identifier | empty |
ambiguous |
names a license, not which identifier | None |
the readings |
unresolved |
not in the data, or a family name that must never resolve | None |
empty |
This is the three tables above applied in the order that keeps them from contradicting each other, so prefer it to looking up in each yourself. It case-folds the input; it does not otherwise normalize spelling, so collapsing whitespace and punctuation is still yours.
PolicyRuntime.evaluate_licenses runs it on every declared string before matching, and
PolicyRuntime.resolve_licenses(licenses) returns the resolutions keyed by the string you
passed, so you can show what a verdict was actually about. The evaluate command reports
the same thing under resolved_licenses.
data_version
What the bundled dataset says about itself, without parsing index.json by hand.
import ospac
info = ospac.data_version()
info.schema_version # '1.1.0' shape of the shipped files
info.schema_version_info # (1, 1, 0) compare on this, not on the string
info.spdx_list_version # 'e4c1f27' upstream SPDX revision
info.generated # '2026-08-12T09:45:09.769222'
info.total_licenses # 733
The three version-ish fields answer different questions and are not interchangeable.
schema_version says whether the shape is one you understand, generated says how stale
your copy is, and spdx_list_version says which upstream produced it. A monthly refresh
moves the second and third and leaves the first alone.
Gate on the major version at import, so an incompatible dataset fails loudly instead of normalizing quietly wrong:
major, _, _ = ospac.data_version().schema_version_info
if major != 1:
raise RuntimeError(f"ospac data schema v{major} is not supported here")
ospac.DATA_SCHEMA_VERSION is the same string as a module constant, for a build-time pin.
What a bump means, and which files and fields carry a promise at all, is on
Data contract.
License
A dataclass over one license record.
from ospac import License
record = runtime.lookup_license_data("MIT")
lic = License.from_dict(record["license"])
lic.id # 'MIT'
lic.name # 'MIT License'
lic.type # 'permissive'
lic.properties # {'commercial_use': True, ...}
lic.requirements # {'include_license': True, ...}
lic.compatibility # per-linking-context rules
from_dict expects the unwrapped record, so pass record["license"] rather than record.
lic.is_compatible_with(other, context="static_linking") # -> bool
lic.get_obligations() # -> list[str]
License.get_obligations() recomputes obligation strings from the boolean fields rather
than returning the dataset’s obligations list, so the wording differs slightly:
>>> lic.get_obligations()
['Include license text', 'Include copyright notice']
>>> record["license"]["obligations"]
['Retain copyright notices', 'Include license text']
Both describe the same duties. Use the dataset’s list when you want the same text the CLI prints.
is_compatible_with is a model-level convenience that reads the license’s own
compatibility block. It does not consult your policy. Use runtime.check_compatibility()
when the answer should respect policy.
ComplianceResult and PolicyResult
PolicyResult is what evaluate returns; ComplianceResult is what
check_compatibility returns and the richer of the two.
result.is_compliant # property: no violations
result.needs_review # property: flagged rather than denied
result.violations # list of dicts
result.warnings
result.obligations
result.required_actions
result.metadata
result.to_dict() # JSON-serializable
from ospac import ComplianceResult
ComplianceResult.from_policy_result(policy_result) # convert
PolicyResult.aggregate([...]) merges several results, taking the most severe action.
This is what produces the rule_id: "aggregate" you see in CLI output.
A CI gate
Putting the pieces together, including the two assertions that keep the check from failing open:
import sys
from ospac import PolicyRuntime
from ospac.models.compliance import ActionType
DISTRIBUTION = "mobile"
LICENSES = ["MIT", "Apache-2.0", "GPL-3.0"]
runtime = PolicyRuntime("./compliance-policy.yaml")
if runtime._using_default:
sys.exit("policy did not load; refusing to pass on the default policy")
result = runtime.evaluate({
"licenses": LICENSES,
"licenses_found": LICENSES,
"distribution_type": DISTRIBUTION,
"distribution": DISTRIBUTION,
"context": "general",
"linking_type": None,
})
detail = result.to_dict()
if result.action == ActionType.DENY:
sys.exit(f"denied: {detail['message']}\nremediation: {detail['remediation']}")
print(f"ok: {detail['action']}")
Pair it with a case that must be denied, so the gate is proven to still bite:
gpl = runtime.evaluate({**base_context, "licenses": ["GPL-3.0"],
"licenses_found": ["GPL-3.0"]})
assert gpl.action == ActionType.DENY, "policy stopped denying GPL, rules are inert"