"""Dynamic OID ref/def check generation from model introspection.
Inspects odmlib model class definitions to automatically derive:
- Which classes are OID definitions (have an attribute named ``OID``).
- Which attributes are OID references (any attribute whose name contains
``"OID"`` but is not the definition attribute itself).
- The mapping from reference attribute names to definition element classes.
- The reverse mapping from definition element classes to reference attributes.
This replaces the manually-coded dictionaries in the ``rules/oid_ref.py``
files for each model package. The dynamic system is derived directly from
the model classes, so it stays in sync automatically when the models evolve.
Usage::
from odmlib.oid_generator import create_oid_checker
checker = create_oid_checker("odm_1_3_2")
odm.verify_oids(checker)
.. versionadded:: 0.2.0
"""
from __future__ import annotations
import importlib
import warnings
from typing import Any, Optional
from odmlib.exceptions import ErrorReporting, OdmlibOIDError
from odmlib.odm_element import ODMElement
import odmlib.typed as T
# ---------------------------------------------------------------------------
# Override Mappings
# ---------------------------------------------------------------------------
# These handle naming irregularities where the convention
# (strip "OID" suffix → try "<base>Def", then "<base>") doesn't resolve
# to a valid definition class in a given model package.
#
# Convention example:
# "FormOID" → "Form" → "FormDef" → found in model → OK
# "CodeListOID" → "CodeList" → "CodeListDef" → NOT found → "CodeList" → found → OK
#
# Override example:
# "CollectionExceptionConditionOID" → "CollectionExceptionCondition"
# → "CollectionExceptionConditionDef" NOT found → "CollectionExceptionCondition"
# NOT found → falls to this override → "ConditionDef"
#
# An override is only applied when the target class EXISTS in the model
# being introspected (checked via model_classes lookup). This means the
# same override dict works correctly across all model packages.
_REF_DEF_OVERRIDES: dict[str, str] = {
# ODM 1.3.2 -- classes whose names diverge from the <strip>Def / <strip>
# convention.
"CollectionExceptionConditionOID": "ConditionDef",
# ODM 2.0 -- workflow and timing constraint OIDs whose targets cannot be
# inferred from the attribute name alone.
"StartConditionOID": "ConditionDef",
"EndConditionOID": "ConditionDef",
"ConditionOID": "ConditionDef",
"ArmOID": "Arm",
"EpochOID": "Epoch",
"WorkflowOID": "WorkflowDef",
"StudyEventGroupOID": "StudyEventGroupDef",
"TransitionOID": "Transition",
# RoleCodeListOID (found in ItemRef) references a CodeList but the
# naming convention doesn't resolve (RoleCodeListDef / RoleCodeList
# don't exist as model classes).
"RoleCodeListOID": "CodeList",
}
# Document references are ID-based rather than OID-named: leaf/@ID
# (Define-XML) and Leaf/@ID (ODM 2.0) are the definitions, referenced by
# DocumentRef/@leafID, DocumentRef/@LeafID and
# ItemGroupDef/@def:ArchiveLocationID. These pairs are surfaced by
# ODMElement._init_oid_check alongside the OID-named attributes; they
# are only wired up when the target class exists in the model package.
_ID_REF_DEF_PAIRS: dict[str, str] = {
"leafID": "leaf",
"ArchiveLocationID": "leaf",
# ODM 2.0 spells the element Leaf and the reference LeafID.
"LeafID": "Leaf",
}
# ---------------------------------------------------------------------------
# Discovery helpers
# ---------------------------------------------------------------------------
[docs]
def discover_model_classes(model_module: Any) -> dict[str, type]:
"""Return all ODMElement subclasses found in *model_module*.
Args:
model_module: An imported odmlib model module, e.g.
``odmlib.odm_1_3_2.model``.
Returns:
Mapping of class name → class object for every
:class:`~odmlib.odm_element.ODMElement` subclass exposed at module
level.
"""
classes: dict[str, type] = {}
for name in dir(model_module):
obj = getattr(model_module, name)
if (
isinstance(obj, type)
and issubclass(obj, ODMElement)
and obj is not ODMElement
):
classes[name] = obj
return classes
[docs]
def discover_oid_definitions(model_classes: dict[str, type]) -> list[str]:
"""Find all model classes that define an OID.
A class is a "definition" element when it has an attribute literally
named ``"OID"`` in its ``_attrs`` dict (regardless of descriptor type).
This mirrors the runtime behaviour in
:meth:`~odmlib.odm_element.ODMElement._init_oid_check`, which registers
any ``attr == "OID"`` as a definition.
Args:
model_classes: Mapping returned by :func:`discover_model_classes`.
Returns:
List of class names that have an ``OID`` attribute.
"""
oid_defs: list[str] = []
for name, cls in model_classes.items():
attrs = getattr(cls, "_attrs", {})
if "OID" in attrs:
oid_defs.append(name)
elif "ID" in attrs and name in _ID_REF_DEF_PAIRS.values():
oid_defs.append(name)
return oid_defs
[docs]
def discover_oid_references(model_classes: dict[str, type]) -> dict[str, list[str]]:
"""Find all OID reference attributes across all model classes.
An attribute is an OID reference when its name contains the substring
``"OID"`` but is NOT itself named exactly ``"OID"`` (which would be a
definition). This mirrors the runtime check
``elif "OID" in attr: oid_checker.add_oid_ref(obj, attr)`` in
:meth:`~odmlib.odm_element.ODMElement._init_oid_check`.
Args:
model_classes: Mapping returned by :func:`discover_model_classes`.
Returns:
Mapping of ``ref_attr_name → [class_names …]`` listing every class
that carries each reference attribute.
"""
refs: dict[str, list[str]] = {}
for cls_name, cls in model_classes.items():
attrs = getattr(cls, "_attrs", {})
for attr_name in attrs:
if ("OID" in attr_name and attr_name != "OID") or attr_name in _ID_REF_DEF_PAIRS:
if attr_name not in refs:
refs[attr_name] = []
refs[attr_name].append(cls_name)
return refs
[docs]
def build_ref_def_mapping(
ref_attrs: dict[str, list[str]],
oid_defs: list[str],
model_classes: dict[str, type],
) -> dict[str, str]:
"""Build the mapping from reference attribute names to definition classes.
Resolution order for each reference attribute:
1. Check :data:`_REF_DEF_OVERRIDES`; only apply the override if its
target class exists in *model_classes* (so the same override dict
works for all model packages).
2. Strip the ``"OID"`` suffix from the attribute name and try
``<base>Def`` first, then ``<base>`` as the definition class name.
If neither step resolves to a known definition class, the attribute is
not added to the mapping and will not be validated.
Args:
ref_attrs: Mapping returned by :func:`discover_oid_references`.
oid_defs: List returned by :func:`discover_oid_definitions`.
model_classes: Mapping returned by :func:`discover_model_classes`.
Returns:
Mapping of ``ref_attr_name → definition_class_name``.
"""
ref_def: dict[str, str] = {}
oid_def_set = set(oid_defs)
for attr_name in ref_attrs:
# --- Step 0: ID-based ref/def pairs (Define-XML leaf references) -------
if attr_name in _ID_REF_DEF_PAIRS:
target = _ID_REF_DEF_PAIRS[attr_name]
if target in model_classes:
ref_def[attr_name] = target
continue
# --- Step 1: check overrides ------------------------------------------
if attr_name in _REF_DEF_OVERRIDES:
target = _REF_DEF_OVERRIDES[attr_name]
# Only use the override when the target class exists in this model.
if target in model_classes:
ref_def[attr_name] = target
# Either way, do not fall through to the convention for
# override-registered names.
continue
# --- Step 2: naming convention ----------------------------------------
if attr_name.endswith("OID"):
base = attr_name[:-3] # strip "OID" suffix
if base + "Def" in oid_def_set:
ref_def[attr_name] = base + "Def"
elif base in oid_def_set:
ref_def[attr_name] = base
# else: unresolvable — silently skip; the attr will not be
# validated but will not cause false positives either.
return ref_def
[docs]
def build_def_ref_mapping(
ref_def: dict[str, str],
) -> dict[str, list[str]]:
"""Build the reverse mapping from definition classes to reference attributes.
This is used by :meth:`DynamicOIDRef.check_unreferenced_oids` to find
which reference attributes (if any) point to each definition class.
Args:
ref_def: Mapping returned by :func:`build_ref_def_mapping`.
Returns:
Mapping of ``definition_class_name → [ref_attr_names …]``.
"""
def_ref: dict[str, list[str]] = {}
for attr_name, def_class in ref_def.items():
if def_class not in def_ref:
def_ref[def_class] = []
if attr_name not in def_ref[def_class]:
def_ref[def_class].append(attr_name)
return def_ref
# ---------------------------------------------------------------------------
# Main dynamic checker
# ---------------------------------------------------------------------------
[docs]
class DynamicOIDRef(ErrorReporting):
"""Dynamically-generated OID ref/def checker.
Drop-in replacement for the manually-coded ``OIDRef`` classes in the
``rules/oid_ref.py`` files. Introspects the model module at
instantiation time to build the ref/def mappings automatically.
Implements the same interface as the manual ``OIDRef`` classes so it can
be passed directly to
:meth:`~odmlib.odm_element.ODMElement.verify_oids`.
Via the :class:`~odmlib.exceptions.ErrorReporting` mixin it also supports
the collecting-checker protocol, so
:meth:`~odmlib.odm_element.ODMElement.validate` with
``collect_errors=True`` reports *every* duplicate and every bad reference
rather than only the first. The deprecated manual ``OIDRef`` classes do
not, and contribute at most one error.
Note:
A checker accumulates every OID it sees, so it is single-use per
document. Call :meth:`reset` before validating a second document (or
re-validating the same one), otherwise every OID is reported as a
duplicate.
Args:
model_package: Model package name, e.g. ``"odm_1_3_2"`` or
``"define_2_1"``.
skip_attrs: Attribute names to ignore during validation (e.g.
``["FileOID", "PriorFileOID"]``).
skip_elems: Element class names to ignore when registering OID
definitions (e.g. ``["ODM"]``).
Example::
from odmlib.oid_generator import create_oid_checker
checker = create_oid_checker("odm_1_3_2")
odm.verify_oids(checker)
.. versionadded:: 0.2.0
"""
def __init__(
self,
model_package: str,
skip_attrs: Optional[list[str]] = None,
skip_elems: Optional[list[str]] = None,
) -> None:
self.model_package = model_package
self.model = importlib.import_module(f"odmlib.{model_package}.model")
# --- Model introspection ---
self.model_classes = discover_model_classes(self.model)
self.oid_defs = discover_oid_definitions(self.model_classes)
self.ref_attrs = discover_oid_references(self.model_classes)
# --- Static mappings ---
self.ref_def = build_ref_def_mapping(
self.ref_attrs, self.oid_defs, self.model_classes
)
self.def_ref = build_def_ref_mapping(self.ref_def)
# --- Runtime state (populated during verify_oids traversal) ---
# self.oid: OID value → element class name (reference targets only;
# excludes skip_elem element types)
self.oid: dict[str, str] = {}
# self.unique_oids: OID value → element class name for EVERY
# definition, including skip_elem types — skip_elem only exempts an
# element from reference-target checking, not from uniqueness
self.unique_oids: dict[str, str] = {}
# self.oid_ref: attr name → set of referenced OID values
self.oid_ref: dict[str, set] = {attr: set() for attr in self.ref_def}
self.is_verified: bool = False
# --- Skip lists ---
self.skip_attr: list[str] = list(skip_attrs) if skip_attrs else []
self.skip_elem: list[str] = list(skip_elems) if skip_elems else []
# ------------------------------------------------------------------
# Runtime population (called by ODMElement._init_oid_check)
# ------------------------------------------------------------------
[docs]
def add_oid(self, oid: str, element: str) -> None:
"""Register an OID definition.
Called automatically during
:meth:`~odmlib.odm_element.ODMElement._init_oid_check` traversal.
Args:
oid: The OID value to register.
element: The class name of the element that owns this OID.
Raises:
OdmlibOIDError: If *oid* is already registered (duplicate OID).
Inside a :meth:`~odmlib.exceptions.ErrorReporting.collecting`
block the error goes to the sink instead and the traversal
continues, so the reference checks that follow still run.
Note:
On a duplicate, the **first** definition is kept — that is what
the error message asserts, and it keeps :attr:`oid` stable so the
reference pass gives a deterministic verdict. A corner case worth
knowing: if the first definition is a ``skip_elem`` type it lands
only in :attr:`unique_oids`, so the reference pass then reports
"not found" for everything pointing at it.
"""
if oid in self.unique_oids:
self.report(OdmlibOIDError(
f"OID {oid} is not unique - element {element}",
attribute="OID",
hint=(
f"Each OID must be unique within a MetaDataVersion. "
f"OID '{oid}' is already defined in a {self.unique_oids[oid]} element."
),
))
return # first definition wins
self.unique_oids[oid] = element
if element in self.skip_elem:
return
self.oid[oid] = element
[docs]
def add_oid_ref(self, oid: str, attr: str) -> None:
"""Register an OID reference.
Called automatically during
:meth:`~odmlib.odm_element.ODMElement._init_oid_check` traversal.
Args:
oid: The referenced OID value.
attr: The attribute name (e.g. ``"FormOID"``, ``"ItemOID"``).
"""
if attr in self.skip_attr:
return
if attr in self.oid_ref:
self.oid_ref[attr].add(oid)
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
[docs]
def is_oids_verified(self) -> bool:
"""Return ``True`` if :meth:`check_oid_refs` has been called."""
return bool(self.oid and self.is_verified)
[docs]
def check_oid_refs(self) -> bool:
"""Validate that all OID references point to valid definitions.
For each collected OID reference, checks:
1. The referenced OID exists in the OID definitions.
2. The definition element type matches what the reference attribute
expects (per :attr:`ref_def`).
Returns:
``True`` if all references are valid.
Raises:
OdmlibOIDError: On the first invalid or mismatched reference.
Inside a :meth:`~odmlib.exceptions.ErrorReporting.collecting`
block every bad reference is reported to the sink instead.
"""
self.is_verified = True
for attr, oid_set in self.oid_ref.items():
if attr in self.skip_attr:
continue
# oid_set is a set, so iteration order varies with PYTHONHASHSEED.
# Sort for stable, reproducible error ordering. key=str guards
# against a non-string OID from a permissive load.
for oid in sorted(oid_set, key=str):
if oid not in self.oid:
self.report(OdmlibOIDError(
f"OID {oid} referenced in attribute {attr} is not found.",
attribute=attr,
hint=(
f"Define an element with OID '{oid}' before "
f"referencing it via {attr}."
),
))
continue # one error per bad ref, not two
expected_type = self.ref_def.get(attr)
actual_type = self.oid.get(oid)
if expected_type and actual_type and expected_type != actual_type:
self.report(OdmlibOIDError(
f"OID reference for attribute {attr} element types do "
f"not match: {expected_type} and {actual_type}",
attribute=attr,
hint=(
f"Attribute '{attr}' should reference a "
f"{expected_type}, but OID '{oid}' is defined on "
f"a {actual_type}."
),
))
return True
[docs]
def reset(self) -> None:
"""Clear all state collected by a previous verification pass.
A checker accumulates every OID it sees, so reusing one instance for a
second document — or a second ``validate()`` call on the same document
— reports every OID as a duplicate. Call ``reset()`` between runs, or
build a fresh checker with
:func:`~odmlib.oid_generator.create_oid_checker`.
"""
self.oid.clear()
self.unique_oids.clear()
for oid_set in self.oid_ref.values():
oid_set.clear()
self.is_verified = False
[docs]
def check_unreferenced_oids(self) -> dict[str, str]:
"""Find OID definitions that are not referenced anywhere.
Returns:
Mapping of ``oid_value → ref_attr_name`` for each OID that is
defined but never referenced. An empty dict means every
definition is used.
"""
all_referenced: set[str] = set()
for oid_set in self.oid_ref.values():
all_referenced.update(oid_set)
orphans: dict[str, str] = {}
for oid, element_type in self.oid.items():
if element_type in self.skip_elem:
continue
if oid not in all_referenced:
# Look up which ref_attr is associated with this element type.
ref_attr = next(
(
attr
for attr, def_class in self.ref_def.items()
if def_class == element_type
),
element_type, # fallback: use class name itself
)
orphans[oid] = ref_attr
return orphans
# ---------------------------------------------------------------------------
# Factory function (public API)
# ---------------------------------------------------------------------------
[docs]
def create_oid_checker(
model_package: str,
extra_skip_attrs: Optional[list[str]] = None,
extra_skip_elems: Optional[list[str]] = None,
) -> DynamicOIDRef:
"""Create a :class:`DynamicOIDRef` checker for a model package.
This is the primary public API for creating OID checkers. It loads
the per-model skip configuration from
:mod:`odmlib.oid_generator_config` and merges any caller-provided
extras before constructing the checker.
Args:
model_package: Model package name, e.g. ``"odm_1_3_2"``,
``"define_2_1"``, ``"odm_2_0"``.
extra_skip_attrs: Additional attribute names to skip during
validation (merged with model-level defaults).
extra_skip_elems: Additional element class names to skip
(merged with model-level defaults).
Returns:
A configured :class:`DynamicOIDRef` instance ready for use with
:meth:`~odmlib.odm_element.ODMElement.verify_oids`.
Example::
from odmlib.oid_generator import create_oid_checker
# Replace manual OIDRef usage:
# from odmlib.odm_1_3_2.rules.oid_ref import OIDRef
# checker = OIDRef()
# With:
checker = create_oid_checker("odm_1_3_2")
odm.verify_oids(checker)
.. versionadded:: 0.2.0
"""
from odmlib.oid_generator_config import get_skip_config
skip_attrs, skip_elems = get_skip_config(model_package)
if extra_skip_attrs:
skip_attrs = list(skip_attrs) + extra_skip_attrs
if extra_skip_elems:
skip_elems = list(skip_elems) + extra_skip_elems
return DynamicOIDRef(
model_package,
skip_attrs=skip_attrs,
skip_elems=skip_elems,
)