module documentation

Net-effect change reporting for DataGraphs schemas.

This module is the pure, read-only consumer of a ~datagraphs.schema.Schema's tracking state. Given the baseline snapshot, the recorded op-log, and the current schema dict, it computes a deterministic, semantically-annotated changelog.

It has no dependency on the Schema class: every function operates on plain dicts and lists, so it can be tested and reasoned about in isolation. Schema owns producing the op-log (the cross-cutting tracking concern woven into its mutating methods); this module owns interpreting it. The single public entry point is build_change_report; everything else is module-internal pipeline detail.

Class Change Represents a single net-effect change detected by the structural diff.
Class ChangeRenderer Strategy that renders an ordered list of Change instances.
Class RecordChangeRenderer Render Changes as structured record dicts (the records format).
Class RenameMap Identity correspondence derived by event-sourced op-log replay.
Class TextChangeRenderer Render Changes as a human-readable plain-text changelog (the text format).
Function build_change_report Compute a net-effect changelog from a schema's tracking state.
Function _annotate Layer semantic intent from the op-log onto identity-aware structural Changes.
Function _bool_str Render a boolean as lowercase 'true'/'false'; pass anything else through.
Function _change_class_name Derive the owning class name from a Change's target.
Function _diff Compute the net-effect structural delta between two schema dicts.
Function _diff_class_fields Compute per-field changes between two class definitions.
Function _diff_properties Diff two ordered property lists for a single class, by identity.
Function _diff_property_fields Compute the per-field changes between two property definitions.
Function _format_field Render one field change as 'field: before -> after' (booleans lowercased).
Function _normalise_description Return the plain-text rendering of a description value.
Function _replay_identities Replay the op-log over the baseline to derive identity correspondence.
Function _sort_changes Return a copy of changes sorted into a deterministic rendering order.
Constant _CLASS_FIELDS Per-class fields that are diffed (description is handled separately).
Constant _CREATE_CLASS_OPS Undocumented
Constant _DATE_FIELDS Top-level date fields excluded from diff everywhere.
Constant _KIND_RANK kind_rank: metadata(0) < class(1) < property(2)
Constant _METADATA_EXCLUDED_KEYS Top-level metadata keys that are server-internal / structural plumbing and must NOT surface as user-facing change-report entries. name and the classes list are handled explicitly; dates are excluded above; everything else listed here (identifiers, JSON-LD context, type tags) is server-owned and a change to it is not a domain-model edit the report should claim.
Constant _OP_RANK op_rank: a fixed stable order shared by both renderers
Constant _PROPERTY_FIELDS Per-property fields diffed (description is handled separately).
Constant _RENDERERS Output-format registry: maps a REPORT_FORMAT to its renderer strategy. Adding a format is one entry here plus its ChangeRenderer subclass — no branching in build_change_report, which coerces the requested format to the enum and selects the strategy by lookup.
def build_change_report(baseline: dict, change_log: list[dict], current: dict, fmt: REPORT_FORMAT = REPORT_FORMAT.TEXT) -> str | list[dict]:

Compute a net-effect changelog from a schema's tracking state.

This is the single public entry point of the report module: it runs the full pipeline (identity replay -> structural diff -> semantic annotation -> sentinel pruning -> ordering -> rendering) and is invoked by datagraphs.schema.Schema.change_report. It is strictly read-only: it never mutates baseline, change_log, or current.

Parameters
baseline:dictThe schema dict as captured at construction (Schema._baseline).
change_log:list[dict]The recorded op-log (Schema._change_log).
current:dictThe live schema dict (Schema._schema).
fmt:REPORT_FORMATA REPORT_FORMAT (or an equivalent string, since the enum is a ~enum.StrEnum): REPORT_FORMAT.TEXT for the best-effort human-readable changelog, or REPORT_FORMAT.RECORDS for the guaranteed structured list[dict].
Returns
str | list[dict]A str for REPORT_FORMAT.TEXT; a list[dict] for REPORT_FORMAT.RECORDS. Empty ("" / []) when nothing changed.
Raises
ValueErrorIf fmt is not a member (or value) of REPORT_FORMAT.
def _annotate(changes: list[Change], change_log: list[dict], baseline: dict, current: dict, rename_map: RenameMap | None = None) -> list[Change]:

Layer semantic intent from the op-log onto identity-aware structural Changes.

Runs after _diff + rename folding. Walks the op-log and, for the intent-bearing ops, upgrades the corresponding raw Changes into a single semantic record. Changes with no matching op-log entry are returned unchanged (ADR 0001 graceful degradation).

The following upgrades are applied:

  • create_subclass — the diff reports the new subclass as a single op="added" class Change (an added class does not expand into per-property adds). It is replaced by one op="subclass_created" Change with detail={"parent": <parent>, "inherited": N} where N is the number of properties on the newly-created subclass (its full inherited property set, mirroring the parent).
  • create_property / update_property with apply_to_subclasses — the op-log records the apply_to_subclasses intent flag. When the flag is set and the parent has at least one current subclass, the parent property Change is referenced from every current subclass via detail={"applied_to_subclasses": [<subclass current name>, ...]}, derived from issued INTENT (the op-log + the current subclass set), NOT from a per-subclass diff effect — so the annotation appears even when a subclass already held the value and produced no structural Change (ADR 0001/0002: the op-log is the authoritative intent source). The per-subclass structural Changes (where they exist) are kept as ordinary records. No annotation is added when the flag is unset or the parent has no subclasses.
  • assign_label_property — the diff reports a class labelProperty field flip plus the designated property's isOptional/isLangString flips. The two are fused: the class Change's labelProperty field entry is dropped (if it was the only changed field the whole class Change is removed), and the property Change gains detail={"label_property": <prop current name>} marking it as the coherent label-property designation rather than two unrelated flips.
  • assign_property_orders — driven directly from the op-log entry (the authoritative intent, ADR 0001). For each class in the entry the relative order of the properties present in BOTH baseline and current is compared; if it differs, an op="reordered" Change (target=<Class current name>, detail={"order": [<current order>]}) is emitted, INDEPENDENT of any concurrent property add/remove (which suppresses the diff's exact-set __order__ candidate). Any structural __order__ reorder candidate for the class is consumed: replaced by the reordered Change on a genuine reorder, or dropped when the surviving properties are unchanged in order (so the internal sentinel never surfaces on a tracked no-op). An untracked reorder (no op-log entry) is left untouched — documented degradation (ADR 0001).
  • delete_class(cascade_to_subclasses=True) — the stripped subClassOf on former subclasses is genuine structural truth and is left exactly as the diff reported it (an op="modified" class Change whose fields carries subClassOf: <parent> -> None). Pinned by tests as documented behaviour.
Parameters
changes:list[Change]The identity-aware Changes from _diff.
change_log:list[dict]The Schema._change_log op-log entries.
baseline:dictThe baseline schema dict (for parent/inheritance lookups).
current:dictThe current schema dict (for subclass / property lookups).
rename_map:RenameMap | NoneIdentity correspondence (from _replay_identities), or None. Used to resolve op-log call-time names to current names.
Returns
list[Change]A new list of annotated Changes in the original diff order.
def _bool_str(v: Any) -> str:

Render a boolean as lowercase 'true'/'false'; pass anything else through.

def _change_class_name(ch: Change) -> str:

Derive the owning class name from a Change's target.

For "Class.prop" targets this returns "Class". For bare class or metadata targets the target itself is returned so the sort stays total.

Parameters
ch:ChangeThe change to inspect.
Returns
strThe class-level sort key string.
def _diff(baseline: dict, current: dict, rename_map: RenameMap | None = None) -> list[Change]:

Compute the net-effect structural delta between two schema dicts.

This is the authoritative source of structural truth. It never touches createdDate or lastModifiedDate. Descriptions are normalised to plain text. No-op sequences (create-then-delete, modify-then-revert) produce no entry.

When a rename_map is supplied, classes and properties are matched by canonical/baseline identity rather than by current name (ADR 0003): a baseline class whose identity maps to a differently-named current class is reported as a single op="renamed" Change (from_/to) with any field-level modifications merged into that same record, and its properties are diffed scoped to the class's baseline identity. A name that maps back to itself carries no rename label; an untracked rename (no op-log event) has no map entry and degrades to remove+add.

Parameters
baseline:dictDeep-copied baseline schema dict (from Schema._baseline).
current:dictLive current schema dict (from Schema._schema).
rename_map:RenameMap | NoneIdentity correspondence (from _replay_identities), or None for a pure name-keyed diff.
Returns
list[Change]List of Change instances in traversal order.
def _diff_class_fields(b_cls: dict, c_cls: dict) -> list[dict]:

Compute per-field changes between two class definitions.

Only fields that actually differ are included. Date fields are excluded. Descriptions are normalised to plain text before comparison.

Parameters
b_cls:dictBaseline class dict.
c_cls:dictCurrent class dict.
Returns
list[dict]List of {"field": str, "before": Any, "after": Any} entries.
def _diff_properties(b_props: list[dict], c_props: list[dict], class_name: str, rename_map: RenameMap | None = None, current_class_name: str | None = None) -> list[Change]:

Diff two ordered property lists for a single class, by identity.

Properties are matched by stable identity through the rename_map's per-baseline-entity fate (ADR 0003): each baseline property's fate is looked up by its baseline identity (class_name, b_name) — its final current name, or None if its identity was deleted during the session. A deleted identity is removed and never matches a current property of the same name (which, if present, is a recycled identity reported added). A surviving property whose final name differs is one op="renamed" Change with field modifications merged in; a current property matched by no surviving baseline identity is added. An untracked rename (no op-log event) leaves the identity's name unchanged in the replay and so degrades to remove+add — exactly as ADR 0001 documents.

A reorder candidate signal is attached as detail={"reorder_candidate": True} on a synthetic op="modified" Change with target "<ClassName>.__order__" when the property-name SET (after rename reconciliation) is unchanged but the ordered SEQUENCE differs. Phase 5 reconciles this against the op-log.

Parameters
b_props:list[dict]Baseline property list.
c_props:list[dict]Current property list.
class_name:strOwning class's baseline name (used to scope rename lookups and to build target).
rename_map:RenameMap | NoneIdentity correspondence, or None for a pure name-keyed diff.
current_class_name:str | NoneThe class's current name (for building target on the current side). Defaults to class_name when unchanged.
Returns
list[Change]List of Change instances.
def _diff_property_fields(b_p: dict, c_p: dict) -> list[dict]:

Compute the per-field changes between two property definitions.

Only genuinely changed fields are included. Descriptions are normalised to plain text before comparison; date fields are never compared.

Parameters
b_p:dictBaseline property dict.
c_p:dictCurrent property dict.
Returns
list[dict]List of {"field": str, "before": Any, "after": Any} entries.
def _format_field(f: dict) -> str:

Render one field change as 'field: before -> after' (booleans lowercased).

The shared atom behind every field-change line: class-level field lines, the [renamed] property tail, and the modified property suffix all compose this single rendering — they differ only in the surrounding prefix and join, not in how an individual field is shown.

Parameters
f:dictA {"field": str, "before": Any, "after": Any} change entry.
Returns
strThe "field: before -> after" rendering.
def _normalise_description(desc: Any) -> str:

Return the plain-text rendering of a description value.

Descriptions are stored as {"en": "...", "@none": "..."} dicts. Plain strings are passed through unchanged; None maps to "".

Parameters
desc:AnyRaw description value from a schema dict.
Returns
strPlain text string.
def _replay_identities(baseline: dict, change_log: list[dict]) -> RenameMap:

Replay the op-log over the baseline to derive identity correspondence.

Each baseline class is seeded with a fresh class-identity and each baseline property with a fresh property-identity scoped to its class-identity. The op-log is then replayed in order, maintaining a live current name -> identity map for classes (and, per class-identity, for its properties):

  • create (create_class/create_subclass/create_property) — MINT a brand-new identity (no baseline name) bound to that name, severing the name from any prior occupant. A recycled name therefore binds to a different identity than the original — so a real rename of the original is never stolen, and the net-new entity is reported added, not renamed.
  • rename (update_class new_name / rename_property) — REBIND the existing identity's current name (never mint).
  • delete (delete_class/delete_property) — END the identity (drop it from the live map; its final name becomes None).

Cascade self-calls are absent from the op-log (recorded only at the outermost public boundary, ADR 0002), so the cascade's per-subclass property creations are invisible here and correctly fall to the diff's "added" path.

Parameters
baseline:dictThe baseline schema dict (Schema._baseline).
change_log:list[dict]The Schema._change_log op-log entries.
Returns
RenameMapA RenameMap carrying the baseline<->current name maps and the per-op-log-position call-time-name resolutions.
def _sort_changes(changes: list[Change]) -> list[Change]:

Return a copy of changes sorted into a deterministic rendering order.

Sort key: (kind_rank, class_name, op_rank, target)

  • kind_rank: metadata first (0), then classes (1), then properties (2).
  • class_name: alphabetical — groups property changes under their owning class and keeps class-level changes immediately before their properties.
  • op_rank: stable across ops — added < subclass_created < renamed < modified < reordered < removed.
  • target: alphabetical tie-break within the same class and op.
  • a final fully-serialised tie-break (from_/to/fields/detail) so the order is a TOTAL order: two Changes sharing all four primary components (e.g. a recycled name's renamed + removed share kind/class/op rank only partially, but a defensive total order must not fall back to insertion order) still sort deterministically, independent of dict insertion order (ADR 0001 determinism requirement).

Implementation note (performance): the json tie-break is expensive (json.dumps of fields/detail, where a cascade op's detail carries an O(C) applied_to_subclasses list). Under an apply_to_subclasses fan-out there are O(L*C) changes, so serialising the tie-break key for every change is wasteful — the primary 4-tuple already disambiguates the overwhelming majority of changes. This sort is therefore two-phase: sort on the primary tuple first, then serialise the json tie-break only within runs that tie on all four primary components. This yields a total order byte-identical to a single composite key (within each primary-equal run the json key is the sole remaining discriminator, and the primary sort below is stable), while removing the json serialisation from the common, collision-free path.

Parameters
changes:list[Change]The annotated list of Change instances.
Returns
list[Change]A new list sorted into rendering order; changes is not mutated.
_CLASS_FIELDS: tuple[str, ...] =

Per-class fields that are diffed (description is handled separately).

Value
('subClassOf', 'labelProperty', 'isAbstract', 'identifierProperty')
_CREATE_CLASS_OPS: frozenset[str] =

Undocumented

Value
frozenset(set(['create_class', 'create_subclass']))
_DATE_FIELDS: frozenset[str] =

Top-level date fields excluded from diff everywhere.

Value
frozenset(set(['createdDate', 'lastModifiedDate']))
_KIND_RANK: dict[str, int] =

kind_rank: metadata(0) < class(1) < property(2)

Value
{'metadata': 0, 'class': 1, 'property': 2}
_METADATA_EXCLUDED_KEYS: frozenset[str] =

Top-level metadata keys that are server-internal / structural plumbing and must NOT surface as user-facing change-report entries. name and the classes list are handled explicitly; dates are excluded above; everything else listed here (identifiers, JSON-LD context, type tags) is server-owned and a change to it is not a domain-model edit the report should claim.

Value
frozenset(set(['name',
               'classes',
               'guid',
               'id',
               '@id',
               '@context',
               '@type',
...
_OP_RANK: dict[str, int] =

op_rank: a fixed stable order shared by both renderers

Value
{'added': 0,
 'subclass_created': 1,
 'renamed': 2,
 'modified': 3,
 'reordered': 4,
 'removed': 5}
_PROPERTY_FIELDS: tuple[str, ...] =

Per-property fields diffed (description is handled separately).

Value
('isOptional',
 'isArray',
 'range',
 'type',
 'isLangString',
 'isLabelSynonym',
 'isFilterable',
...
_RENDERERS: dict[REPORT_FORMAT, type[ChangeRenderer]] =

Output-format registry: maps a REPORT_FORMAT to its renderer strategy. Adding a format is one entry here plus its ChangeRenderer subclass — no branching in build_change_report, which coerces the requested format to the enum and selects the strategy by lookup.

Value
{REPORT_FORMAT.TEXT: TextChangeRenderer,
 REPORT_FORMAT.RECORDS: RecordChangeRenderer}