celine.governance — the library
celine.governance is the one implementation of governance.yaml parsing, merging
and validation. Every CELINE component that reads a governance file imports it:
dataset-api, ds, celine-superset and the pipeline runner in this repository.
This page is for consumers importing the package. For the file format itself see the format reference; for the CLI see CLI.
The dependency contract
Runtime dependencies are
pydantic,pyyamlandjsonschema. Nothing else, ever.
This is the feature, not a style preference. The package exists because
celine-utils once required dbt, Meltano, Prefect and Keycloak in order to parse a
YAML file — so no API service could import it, four teams wrote their own parser
instead, and the four then disagreed about what the same file meant.
celine.governance must not import celine.utils, SQLAlchemy, OpenLineage, or
anything from an optional extra. If a governance module needs something from the fat
side, the dependency goes the other way round: celine.utils may import
celine.governance, never the reverse.
A CI job asserts this rather than trusting review — adding one import line is easy and nothing in a diff makes it look expensive. See ADR-0001.
Installing just the core
uv add celine-utils # core only — three dependencies
The extras are opt-in and none of them are needed to read governance:
| Extra | Brings | For |
|---|---|---|
| (none) | pydantic, pyyaml, jsonschema | parsing governance |
openlineage |
openlineage-python |
the typed GovernanceDatasetFacet class |
pipelines |
dbt, Meltano, Prefect, pandas, SQLAlchemy, … | running pipelines |
all |
everything above | pipelines plus the typed facet |
There was an admin extra until 2.3.0. It is gone with the command tree it served —
requesting it now installs successfully and warns does not have an extra named
'admin', so check the name rather than trusting the install to complain.
build_facet returns a plain dict and needs none of them, including
openlineage.
Import path
The package ships from the celine-utils wheel today and is expected to become its
own distribution. The import path celine.governance was chosen so that move is a
directory rename with no consumer editing an import. Do not refer to it as
celine.utils.governance — that path does not exist, and creating it would make the
eventual split a breaking change for every downstream repository.
Reading a file
from pathlib import Path
from celine.governance import GovernanceResolver
resolver = GovernanceResolver.from_file(Path("governance.yaml"))
rule = resolver.resolve("datasets.ds_prod_gold.weather_hourly")
rule.access_level # 'restricted'
rule.tags # ['gold', 'weather']
A missing file is an error: from_file raises FileNotFoundError. Handing over a
path is already a statement about which file you mean, so absence is what you asked
for is not there — not nothing was asked for, which is auto_discover's case and
still returns an empty configuration.
It used to warn and return that empty configuration instead. A connector whose
governance/ directory had been renamed then started clean, served an empty dataset
list and empty sharing offers, and said nothing while the files sat on disk; the first
symptom was an unrelated end-to-end test failing on a fixture that was right there in
the file. Catch FileNotFoundError if a missing file is a state your caller supports.
Constructors
| Call | Use |
|---|---|
GovernanceResolver.from_file(path) |
a known path |
GovernanceResolver.from_dict(raw) |
a document already parsed, e.g. from an API response |
GovernanceResolver.from_file_with_override(base, overlay_name=None, *, infer_from_dir=False) |
base plus a deployer overlay beside it |
GovernanceResolver.auto_discover(app_name=None, project_dir=None) |
convention-based lookup — see discovery order |
infer_from_dir is opt-in rather than default because the two callers this
consolidated genuinely disagreed: one inferred the overlay name from the parent
directory, the other returned the base unchanged. Neither default would have been
correct for both, so each passes what it means.
A missing overlay is not an error, unlike a missing base: the overlay is located by convention rather than named by the caller, so its absence is the "nothing was asked for" case.
parse_rule
parse_rule(block) builds a single GovernanceRule from one raw block, accepting
either a bare block or one nested under a governance: key. parse_rule(block, MyRule)
builds a subclass of one — see extending the models.
It goes through model_validate on a dict containing only the keys the block
actually declared. That is load-bearing. Pydantic records those keys in
model_fields_set, and the whole merge layer reads it to tell unset from set to a
falsy value. Constructing a GovernanceRule with keyword arguments marks every
field as set, which silently degrades merging to "override always wins" and makes
expose: false inexpressible. Build rules with parse_rule or model_validate,
never with kwargs, if the result will be merged.
Merging
from celine.governance import merge_rules, merge_configs
resolved = merge_rules(base_rule, override_rule)
config = merge_configs(base_config, overlay_config)
| Function | Combines |
|---|---|
merge_rules(base, override, *, model_cls=None) |
two GovernanceRules, with the per-field rules below |
merge_configs(base, override, *, model_cls=None) |
two whole documents — defaults with defaults, sources rule-wise, overlay-only sources added as-is |
merge_dataspace(base, override, *, model_cls=None) |
two DataspaceConfigs |
merge_models(base, override, cls) |
the generic exclude_unset overlay |
The overlay uses exclude_unset — not exclude_defaults, and never truthiness.
All three drop a field the source never mentioned; they differ on a field the source
did mention whose value equals the default. exclude_defaults drops it, so it
cannot tell silent from said no. Truthiness cannot express off at all once the
base says on.
The full per-field table is in the format reference.
Validation
from pathlib import Path
from celine.governance import validate_file, validate, GovernanceValidationError
unknown = validate_file(Path("governance.yaml")) # warns
unknown = validate_file(Path("governance.yaml"), strict=True) # raises
| Function | Behaviour |
|---|---|
validate(data, *, source, strict=False) |
schema violations raise; unknown keys warn, or raise under strict. Returns the unknown keys |
validate_file(path, *, strict=False) |
the same, loading YAML from a path |
schema_errors(data, schema_name) |
every violation as a list of strings, sorted by document position. Raises nothing |
unknown_keys(data) |
keys the grammar does not define, per block |
load_schema(name) |
a packaged JSON Schema as a dict |
validate_owners(data) / validate_owners_file(path) |
strict always — see owners |
schema_errors reports every error rather than the first: a governance file is
edited by hand, and one problem per run turns a five-minute fix into five round
trips.
Why unknown keys warn by default. Seventeen governance files had never been schema-checked when validation arrived. Making unknown keys fatal on day one would have turned adopting this package into a seventeen-file cleanup discovered at import time — in an exporter, in CI, at the worst possible moment. Warn, fix, then flip the default.
Schemas are read from the installed package via importlib.resources, never by
walking from __file__, so validation works from a wheel and inside a container.
Exposure gates
from celine.governance import effective_expose, dataspace_expose, exposure_conflict
effective_expose(rule) # listed in the catalogue and served by the API?
dataspace_expose(rule) # offered into the dataspace?
if (why := exposure_conflict(rule)) is not None:
log.error("%s: %s", name, why)
effective_expose falls back to dataspace.expose when expose is unstated;
dataspace_expose has no fallback in either direction. exposure_conflict returns a
sentence or None — it is reported rather than raised so a caller can collect
every conflict in a run instead of failing on the first.
Never read rule.expose directly to decide catalogue visibility: it is tri-state,
and None does not mean False. See
the two gates.
Building the OpenLineage facet
from celine.governance import build_facet, is_empty, SCHEMA_URL
if not is_empty(rule):
facet = build_facet(rule, producer="https://github.com/celine-eu/celine-utils")
Returns a plain dict with camelCase keys and imports nothing from OpenLineage, so a
consumer gets the projection without the dependency. Absent values are omitted rather
than emitted as null.
include_dataspace defaults to True, which matches the catalogue — the consumer
that reads those fields back out. The lineage extractors historically did not
project the dataspace block, so events already in Marquez carry no dataspace fields;
an extractor adopting this function should pass include_dataspace=False to keep
emitting what it emits today. Widening the payload is a deliberate change, not a side
effect of deduplication.
SCHEMA_URL is a contract, not a configuration. It is embedded in every
OpenLineage event already sitting in Marquez, so changing it does not break a build —
it silently invalidates historical lineage.
Levels
from celine.governance import normalize_access_level, normalize_classification
normalize_access_level("SECRET") # -> 'restricted'
normalize_classification("PII") # -> 'pii'
Both accept None and return None. Both raise ValueError on a value outside the
enum. secret is folded to restricted for compatibility with deployed files; the
enums are GovernanceAccessLevel (open / internal / restricted), DataClassification
(green / yellow / red / pii) and AccessRequirement (all / partner / contract).
Models
GovernanceRule is the resolved block; GovernanceConfig holds defaults and
sources. Sub-models: DcatConfig, OntologyConfig, DataspaceConfig,
TemporalCoverage, GovernanceOwner.
Every model sets extra="ignore", so a file carrying fields this package does not
know — the EDC sub-objects ds adds, a new field from a newer release — parses
rather than failing. Unknown keys at block level are preserved in rule.extra.
KNOWN_KEYS is the frozenset stating which keys the base grammar defines. It is what
validate checks a block against when reporting unknown keys.
It is no longer what decides the split. parse_rule reads that off the model class
it is given, so a consumer's subclass keeps its own fields instead of watching them land
in extra. For GovernanceRule the two sets are identical, and a contract test holds
them there.
The difference matters when they do drift: a field added to the model and forgotten in
KNOWN_KEYS now parses correctly and is reported as an unknown key by validate —
noisy and wrong, rather than the silent permanent absence it used to be. That silence is
how the ontology block failed on introduction; it can no longer happen that way.
Extending the models
ds extends GovernanceRule with ODRL policy and a richer dataspace spec. Subclass
rather than adding fields here: what belongs in this package is the surface every
consumer shares. A field only one component reads is a field the other three carry
without meaning.
class SpecRule(GovernanceRule):
policy: dict | None = None
base = parse_rule(base_block, SpecRule)
override = parse_rule(override_block, SpecRule)
merged = merge_rules(base, override) # -> SpecRule, policy and all
parse_rule, merge_rules, merge_dataspace and merge_configs all follow the class
they are given: parse_rule from its model_cls argument, the merges from their
operands. Nothing needs model_cls in the ordinary case.
Both operands must be the same class. Mixing them raises TypeError rather than
picking one, because picking the more derived class invents fields the other operand
never had and picking the base drops the ones it did — either way a silent decision
about which half of the input survives. Validate both into one class first, or pass
model_cls to say deliberately what the result is:
merge_rules(base, override, model_cls=GovernanceRule) # narrow on purpose
Until 3.0 these functions named their class instead. A subclass handed to any of them came back as a base-class instance with the subclass's fields dropped — no error, just a smaller object.
The package ships a PEP 561 py.typed marker, so a subclass is type-checkable rather
than a subclass of Any.