"""Core metaclass, writer, and base element class for odmlib ODM models.
This module provides three central components:
- :class:`ODMMeta` -- metaclass that processes descriptor-based class definitions
- :class:`ODMWriter` -- utility class for writing ElementTree XML to file
- :class:`ODMElement` -- base class for all ODM element model objects
All model classes (e.g., ``Study``, ``ItemDef``, ``MetaDataVersion``) inherit
from :class:`ODMElement` and use descriptors from :mod:`odmlib.typed` to define
their attributes and child elements.
"""
from __future__ import annotations
from typing import Any, Optional, List, cast
import odmlib.descriptor as DESC
import odmlib.typed as T
import odmlib.ns_registry as NS
import odmlib.oid_index as IDX
from collections import OrderedDict
import json
import warnings
import xml.etree.ElementTree as ET
from odmlib.exceptions import (
OdmlibError,
OdmlibTypeError,
OdmlibRequiredAttributeError,
OdmlibConformanceError,
OdmlibElementOrderError,
OdmlibErrorLimitError,
OdmlibWarning,
ErrorCollector,
is_collecting_checker,
_ErrorLimitReached,
)
import odmlib.mode as _mode
# ---------------------------------------------------------------------------
# Validation helpers (module-level; shared by the fail-fast and collect paths)
# ---------------------------------------------------------------------------
def _raise_error(error, element=None):
"""Reporter for fail-fast traversals: re-raise immediately.
The *element* argument is accepted and ignored so fail-fast and collecting
reporters share one signature.
"""
raise error
def _element_label(element):
"""Short identifier for an element, safe on partially-built objects.
Reads ``__dict__`` directly — going through the descriptors would raise
:class:`~odmlib.exceptions.OdmlibRequiredAttributeError` on an element
loaded in permissive mode.
"""
oid = element.__dict__.get("OID") or element.__dict__.get("ID")
name = type(element).__name__
return f"{name}(OID={oid})" if oid else name
def _collecting_reporter(collector):
"""Build a reporter that records errors instead of raising.
Stamps ``element_path`` *after* construction so ``str(error)`` (built by
``_format()`` during ``__init__``) is byte-identical to fail-fast mode,
while still letting callers tell apart N errors carrying the same message.
"""
def report(error, element=None):
if element is not None:
error.element_path = _element_label(element)
collector.add_error(error)
return report
def _run_validation_layer(collector, layer):
"""Run one validation layer, absorbing the ``max_errors`` sentinel.
A layer that reports through the collector may hit the cap mid-walk; the
sentinel unwinds it. A layer that still *raises* an ``OdmlibError`` — a
checker without the collecting protocol, or an error we cannot expand —
contributes that single error.
"""
try:
layer()
except _ErrorLimitReached:
return
except OdmlibError as e:
try:
collector.add_error(e)
except _ErrorLimitReached:
return
def _warn_if_checker_dirty(oid_checker):
"""Warn when an OID checker still holds state from a previous run."""
if getattr(oid_checker, "oid", None) or getattr(oid_checker, "unique_oids", None):
warnings.warn(
"oid_checker already holds OID definitions from a previous run; every "
"OID will be reported as a duplicate. Call reset() on the checker, or "
"create a new one with create_oid_checker().",
OdmlibWarning,
stacklevel=3,
)
[docs]
class ODMWriter:
"""Writes an odmlib ElementTree hierarchy to an XML file.
Handles namespace registration and XML declaration.
"""
[docs]
@staticmethod
def write_odm(odm_file, odm_elem, ns_snapshot=None):
"""
after converting ODMLIB to ElementTree, write the ElementTree to an ODM file
:param odm_file: path and file to write the ODM XML
:param odm_elem: Element object to write to ODM (presumably an ODM root)
:param ns_snapshot: optional per-document namespace snapshot (as returned
by NamespaceRegistry.snapshot()); when omitted, the shared registry
state is used
"""
tree = ET.ElementTree(odm_elem)
root = tree.getroot()
nsr = NS.NamespaceRegistry()
if ns_snapshot:
nsr.set_odm_namespace_attributes(
root, namespaces=ns_snapshot["namespaces"], default=ns_snapshot["default"])
else:
nsr.set_odm_namespace_attributes(root)
# Write through a binary handle. Handed a *filename*, ElementTree opens it in
# text mode, so Windows translates every \n to \r\n and the file no longer
# matches to_xml_string() byte for byte. Handed a binary file object it uses
# TextIOWrapper(newline="\n"), which is identical on every platform.
with open(odm_file, "wb") as fh:
tree.write(fh, xml_declaration=True, encoding='UTF-8', method='xml',
short_empty_elements=True)
[docs]
class ODMElement(metaclass=ODMMeta):
"""Base class for all ODM element model objects.
Provides XML and JSON serialization, OID validation, conformance
checking, and element ordering verification. All model classes
(e.g., Study, ItemDef, MetaDataVersion) inherit from this class.
Serialization:
- :meth:`to_xml_string` -- XML as a string (self-contained; declares its own xmlns)
- :meth:`to_element` -- namespace-resolved ElementTree Element
- :meth:`to_dict` -- Python dict (namespace info stripped)
- :meth:`to_json` -- JSON string
- :meth:`write_xml` -- write XML to file
- :meth:`write_json` -- write JSON to file
- :meth:`to_xml` -- internal tree builder; no xmlns (prefer :meth:`to_element`)
Validation:
- :meth:`verify_oids` -- OID uniqueness and ref/def integrity
- :meth:`verify_conformance` -- Cerberus schema conformance
- :meth:`verify_order` -- element order per ODM spec
- :meth:`validate` -- combined validation with error collection
Finding elements:
- :meth:`find` -- find first child element by attribute value
- :meth:`find_all` -- find all matching child elements
- :meth:`find_by` -- find by multiple attribute criteria
"""
[docs]
def __init__(self, **kwargs: Any) -> None:
"""Initialize an ODM element with keyword arguments.
Sets attributes and child elements on the instance using the
provided keyword arguments. Validates that all required attributes
are present after construction.
Args:
**kwargs: Attribute names and values for this element.
Names must match declared descriptors on the class.
Raises:
OdmlibTypeError: If an unknown keyword argument is provided.
OdmlibRequiredAttributeError: If a required attribute is missing.
"""
for name, val in kwargs.items():
if name not in self._fields and name not in self.__class__.__dict__:
# strip out non-default elementtree namespaces from the XML to work with just the name e.g. xml:lang
if "}" in name:
name = name[name.find('}') + 1:]
else:
if not _mode.is_permissive(_mode.ValidationMode.SKIP_TYPE):
raise OdmlibTypeError(
f"Unknown keyword argument {name} in {self.__class__.__name__}",
attribute=name,
element_type=self.__class__.__name__,
hint=f"Valid attributes for {self.__class__.__name__}: "
f"{', '.join(k for k in self._fields if not k.startswith('_'))}",
)
continue
setattr(self, name, val)
if not _mode.is_permissive(_mode.ValidationMode.SKIP_REQUIRED):
for attr, obj in self._attrs.items():
if isinstance(obj, DESC.Descriptor) and (not isinstance(obj, T.ODMObject)) and (attr not in self.__dict__) and obj.required:
raise OdmlibRequiredAttributeError(
f"Missing required keyword argument {attr} in {self.__class__.__name__}",
attribute=attr,
element_type=self.__class__.__name__,
hint=f"Attribute '{attr}' is required when constructing {self.__class__.__name__}",
)
def __setattr__(self, key, value):
"""Set an attribute, validating it belongs to this class.
Delegates to the descriptor protocol after confirming ``key``
is a declared field on this class.
Args:
key (str): Attribute name.
value: Value to assign.
Raises:
OdmlibTypeError: If ``key`` is not a declared attribute on this class.
"""
""" ensure the object being added is a type that belongs to the class """
if key in self._fields:
super().__setattr__(key, value)
return
# non-descriptor class attributes keep the historical MRO lookup, but
# a descriptor deliberately dropped by a restricted subclass (e.g. the
# Define-XML models omitting ODM-only children) must not be assignable
# — previously such assignments succeeded and then serialized
# inconsistently or not at all
if any(key in cls.__dict__ for cls in type(self).__mro__) \
and not isinstance(getattr(type(self), key, None), DESC.Descriptor):
super().__setattr__(key, value)
return
if not _mode.is_permissive(_mode.ValidationMode.SKIP_TYPE):
raise OdmlibTypeError(
f"Assignment error: {self.__class__.__name__} does not have a defined attribute {key}",
attribute=key,
element_type=self.__class__.__name__,
)
else:
self.__dict__[key] = value
[docs]
def to_json(self) -> str:
"""
transforms odmlib hierarchy into a dict and converts that to JSON and returns it
:return: JSON representation of odmlib hierarchy
"""
return json.dumps(self.to_dict())
[docs]
def to_xml(self, parent_elem: Optional[ET.Element] = None, top_elem: Optional[ET.Element] = None) -> Optional[ET.Element]:
"""Build odmlib's internal ElementTree serialization buffer.
**Prefer :meth:`to_element` if you want a tree to work with.** The Element
returned here carries **no** xmlns declarations and uses prefix-literal tags
(``def:leaf``) rather than Clark notation, so on its own it cannot be re-parsed
for Define-XML (``ParseError: unbound prefix``), lands in no namespace for ODM,
fails ``ET.canonicalize()``, and does not support namespace-aware ``find()``.
Declarations are attached downstream by :meth:`to_xml_string` and
:class:`ODMWriter`. This method exists as the shared tree builder for those two.
Children are emitted in the model declaration order recorded in
``_elems`` by :class:`ODMMeta` (i.e. the order in which descriptors
appear in the class body of the relevant model module). Attribute
insertion order on the instance does not affect the output, so users
do not need to call :meth:`reorder_object` before serializing.
Optional children that were never assigned are silently skipped.
:param parent_elem: (obj) Element that is the parent of the element to be created
:param top_elem: (obj) Topmost element that has been created
:return: top_elem: (obj) Topmost element created during the recursive calls
"""
# create attributes
attrs = {}
for attr, obj in self.__dict__.items():
if not isinstance(obj, (ODMElement, list)) and attr != "_content" and obj is not None:
# add namespace if not the default namespace
if attr in self.__class__.__dict__["_attr_ns"]:
attrs[self.__class__.__dict__["_attr_ns"][attr] + ":" + attr] = str(obj)
else:
attrs[attr] = str(obj)
# create element
if isinstance(parent_elem, ET.Element):
odm_elem = ET.SubElement(parent_elem, self.__class__.__name__ if self.namespace == "odm" else self.namespace + ":" + self.__class__.__name__, attrs)
else:
odm_elem = ET.Element(self.__class__.__name__ if self.namespace == "odm" else self.namespace + ":" + self.__class__.__name__, attrs)
top_elem = odm_elem
# add text to element if it exists
if "_content" in self.__dict__:
odm_elem.text = self.__dict__["_content"]
# emit children in model declaration order (driven by _elems), not __dict__ insertion order,
# so the schema's required element ordering is preserved regardless of how the user assigned attributes.
for name in self._elems:
obj = self.__dict__.get(name)
if isinstance(obj, list) and obj:
for o in obj:
o.to_xml(odm_elem, top_elem)
elif isinstance(obj, ODMElement) and not DESC.is_pristine_auto_created(obj):
obj.to_xml(odm_elem, top_elem)
return top_elem
[docs]
def to_xml_string(self, *, xml_declaration: bool = False) -> str:
"""Convert this element to an XML string.
The string includes xmlns declarations for the default namespace and
any prefixes used in the serialized tree, so the result is
namespace-well-formed and can be re-parsed on its own.
The two serialization paths line up exactly, which makes a string and a
file interchangeable for comparison purposes:
* ``xml_declaration=False`` (the default) returns precisely the bytes
:meth:`write_xml` produces *after* its ``<?xml ...?>`` line.
* ``xml_declaration=True`` returns precisely what :meth:`write_xml`
writes, declaration included.
The default stays ``False``: this string is the documented input to
``ODMLoader.load_odm_string()``, and 0.2.1 shipped it declaration-free.
Args:
xml_declaration: When True, prepend
``<?xml version='1.0' encoding='UTF-8'?>`` and a newline.
Keyword-only.
Returns:
str: UTF-8 XML representation of this element.
"""
# a top-level to_xml() always returns the root it created; the Optional in its
# signature is for the recursive child calls, which return top_elem instead
elem = cast(ET.Element, self.to_xml())
nsr = NS.NamespaceRegistry()
snapshot = NS.get_document_namespaces(self)
if snapshot:
nsr.set_odm_namespace_attributes(
elem, namespaces=snapshot["namespaces"], default=snapshot["default"])
else:
nsr.set_odm_namespace_attributes(elem)
# xml_declaration= overrides ElementTree's encoding heuristic, which otherwise
# suppresses the declaration because 'UTF-8'.lower() is a recognized default.
xml_bytes: bytes = ET.tostring(elem, encoding='UTF-8', method='xml',
xml_declaration=xml_declaration)
return xml_bytes.decode("utf-8")
[docs]
def to_element(self) -> ET.Element:
"""Return this element as a standard, namespace-resolved ElementTree Element.
Unlike :meth:`to_xml`, which builds odmlib's internal serialization buffer with
prefix-literal tags (``def:leaf``) and no declarations, this returns a tree parsed
from :meth:`to_xml_string`. Tags and attributes use Clark notation
(``{http://www.cdisc.org/ns/def/v2.1}leaf``), so namespace-aware ``find()``,
``ET.canonicalize()``, ``ET.indent()`` pretty-printing, and grafting the result
into a host document you are assembling yourself all behave correctly.
This is the method to reach for when you want an ``Element``; ``to_xml()`` is the
internal tree builder and its output is not a self-contained document.
Costs one serialize + reparse (~8 ms for a 166 KB Define-XML document).
Note that re-serializing the result with ``ET.tostring()`` picks prefixes from
ElementTree's process-global ``register_namespace()`` map, so the prefix spelling
may differ from the source document (``odm:ODM`` rather than a default ``xmlns``).
The namespaces themselves are identical. Use :meth:`to_xml_string` or
:meth:`write_xml` when you need odmlib's exact output.
:return: a namespace-resolved ``ET.Element``
:raises OdmlibNamespaceError: if no default namespace is registered
"""
return ET.fromstring(self.to_xml_string())
[docs]
def to_dict(self) -> dict:
"""
transform odmlib object hierarchy into a Python dictionary and return it
:return: dictionary serialization of odmlib hierarchy
"""
# Note: namespaces used in the XML serialization are not part of the dictionary or json serializations
property_dict = {}
for attr, obj in self.__dict__.items():
if isinstance(obj, ODMElement):
if not DESC.is_pristine_auto_created(obj):
property_dict[attr] = obj.to_dict() # element
elif isinstance(obj, list):
property_dict[attr] = [o.to_dict() for o in obj] # list of ELEMENTS
elif obj is not None:
property_dict[attr] = obj # attributes
return property_dict
def __repr__(self):
"""Return a developer-readable representation of this element.
Returns:
str: Class name with declared field names listed.
"""
args = ", ".join(name for name in self._fields)
return type(self).__name__ + "(" + args + ")"
def __str__(self):
"""Return a human-readable representation of this element.
Returns the element's text content if present, or its OID value
if it has one, otherwise falls back to a field name listing.
Returns:
str: Human-readable string for this element.
"""
if "_content" in self._fields and self._content:
return self._content
elif "OID" in self._fields and self.OID:
return type(self).__name__ + "(OID=" + self.OID + ")"
else:
args = ", ".join(name for name in self._fields)
return type(self).__name__ + "(" + args + ")"
[docs]
def find(self, obj_name: str, attr: str, val: Any) -> Optional[ODMElement]:
"""Return the first odmlib object where attribute ``attr`` equals ``val``.
Searches within the child element(s) named ``obj_name`` on this element.
For list elements (ODMListObject), returns the first match.
For single elements (ODMObject), returns the element if it matches.
:param obj_name: text name of the ODM Element (case sensitive)
:param attr: text name of the ODM Element Attribute (case sensitive)
:param val: attribute value to search for
:return: first matching odmlib object, or None if not found
"""
obj_list = getattr(self, obj_name)
if isinstance(obj_list, list):
for o in obj_list:
if o.__dict__.get(attr) == val:
return o
return None
elif isinstance(obj_list, ODMElement):
if obj_list.__dict__.get(attr) == val:
return obj_list
return None
else:
# unset single element (None) or a scalar attribute name
return None
[docs]
def find_all(self, obj_name: str, attr: str, val: Any) -> List[ODMElement]:
"""Return all odmlib objects where attribute ``attr`` equals ``val``.
Like find(), but returns all matches instead of just the first.
:param obj_name: text name of the ODM Element (case sensitive)
:param attr: text name of the ODM Element Attribute (case sensitive)
:param val: attribute value to search for
:return: list of matching odmlib objects (empty list if no matches)
"""
obj_list = getattr(self, obj_name)
if isinstance(obj_list, list):
return [o for o in obj_list if o.__dict__.get(attr) == val]
elif isinstance(obj_list, ODMElement):
if obj_list.__dict__.get(attr) == val:
return [obj_list]
return []
else:
# unset single element (None) or a scalar attribute name
return []
[docs]
def find_by(self, obj_name: str, **kwargs: Any) -> Optional[ODMElement]:
"""Return the first odmlib object matching all keyword criteria.
A more ergonomic alternative to find() for multi-attribute lookups.
:param obj_name: text name of the ODM Element (case sensitive)
:param kwargs: attribute name=value pairs to match
:return: first matching odmlib object, or None
"""
obj_list = getattr(self, obj_name)
if not isinstance(obj_list, list):
obj_list = [obj_list] if isinstance(obj_list, ODMElement) else []
for o in obj_list:
if all(o.__dict__.get(k) == v for k, v in kwargs.items()):
return o
return None
[docs]
def write_xml(self, odm_file: str, odm_writer: type = ODMWriter) -> None:
"""
write the odmlib hierarchy as an XML file
:param odm_file: string ODM filename and path
:param odm_writer: object used to write the elementree XML to a file
"""
odm_elem = self.to_xml()
writer_cls = odm_writer
odm_writer = odm_writer()
if writer_cls is ODMWriter:
# use the namespaces this document was loaded under, if known,
# so later loads of other documents cannot change its xmlns
odm_writer.write_odm(odm_file, odm_elem,
ns_snapshot=NS.get_document_namespaces(self))
else:
odm_writer.write_odm(odm_file, odm_elem)
[docs]
def write_json(self, odm_file: str) -> None:
"""
write the odmlib hierarchy as a JSON file
:param odm_file: string ODM filename and path
"""
with open(odm_file, 'w', encoding='utf-8') as outfile:
json.dump(self.to_dict(), outfile)
[docs]
def build_oid_index(self) -> IDX.OIDIndex:
"""Build an OID lookup index for this element and all descendants.
Traverses the entire object tree and creates an index mapping
OID attribute values to their containing odmlib objects.
Returns:
OIDIndex: Index supporting ``find_all(oid)`` lookups.
"""
idx = IDX.OIDIndex()
self._init_oid_index(idx)
return idx
def _init_oid_index(self, idx):
"""
for odmlib object, loads all OIDs into a dict that functions as an OID index
:return oid_index: object that provices a dictionary lookup based on OID
"""
for attr, obj in self.__dict__.items():
if isinstance(obj, ODMElement):
obj._init_oid_index(idx) # element
elif isinstance(obj, list):
for o in obj:
o._init_oid_index(idx) # list of ELEMENTS
else:
if attr == "OID" or "OID" in attr:
idx.add_oid(obj, self)
return
[docs]
def verify_oids(self, oid_checker: Any) -> bool:
"""
checks all the OIDs for uniqueness and Def/Ref integrity; oid_checker throws a ValueError on failure
:param oid_checker: object that performs that checks OID uniqueness and Def/Ref checks
"""
self._init_oid_check(oid_checker)
return oid_checker.check_oid_refs()
[docs]
def unreferenced_oids(self, oid_checker: Any) -> dict:
"""Find OID definitions that are not referenced anywhere.
Calls :meth:`verify_oids` first if OIDs have not yet been verified.
Args:
oid_checker: An ``OIDRef`` instance (from ``rules/oid_ref.py``)
or a ``DynamicOIDRef`` instance.
Returns:
dict: Mapping of ``oid_value -> ref_attr_name`` for each OID that
is defined but never referenced. An empty dict means every
definition is used.
Note:
Pass a checker that has been through a *collect-mode*
:meth:`validate` (or an otherwise complete
:meth:`verify_oids`). After a fail-fast ``verify_oids()`` that
raised, the checker is only half-populated and this method can
report a misleading duplicate naming an OID that is not in fact
duplicated.
"""
if not oid_checker.is_oids_verified():
self.verify_oids(oid_checker)
return oid_checker.check_unreferenced_oids()
def _init_oid_check(self, oid_checker):
"""
for odmlib object, loads all OIDs and checks them for uniqueness; throws an error if uniqueness check fails
:param oid_checker: object used to check OIDs for uniqueness and Def/Ref check
"""
for attr, obj in self.__dict__.items():
if isinstance(obj, ODMElement):
obj._init_oid_check(oid_checker) # element
elif isinstance(obj, list):
for o in obj:
o._init_oid_check(oid_checker) # list of ELEMENTS
else:
# assumes consistency in OID naming. Exceptions: FileOID and PriorFileOID in ODM.
# Document references are ID-based rather than OID-named:
# leaf/@ID (Define-XML) and Leaf/@ID (ODM 2.0) are the definitions,
# referenced by leafID, LeafID and def:ArchiveLocationID.
if attr == "OID":
oid_checker.add_oid(obj, self.__class__.__name__)
elif attr == "ID" and self.__class__.__name__ in ("leaf", "Leaf"):
oid_checker.add_oid(obj, self.__class__.__name__)
elif "OID" in attr or attr in ("leafID", "LeafID", "ArchiveLocationID"):
oid_checker.add_oid_ref(obj, attr)
return
[docs]
def verify_order(self) -> bool:
"""Verify that child elements are in the model declaration order.
Recursively checks this element and all descendants — calling at
the odmlib root covers the whole document. Works on any
:class:`ODMElement` subclass; not applicable to
:class:`xml.etree.ElementTree.Element` (load through a loader
first if you only have an ElementTree).
Limitations:
* Each class's expected order comes from descriptors declared in
its own class body. Custom extensions that subclass a model
class must redeclare base children in schema order — otherwise
inherited children are flagged as unexpected.
* In permissive mode (:class:`~odmlib.mode.ValidationMode.SKIP_TYPE`)
unknown attributes can land in ``__dict__``; they will trip
this check until cleaned up.
* Only the order of *different* child element kinds is checked.
Order within a single list (e.g. multiple ``ItemRef`` entries)
is not validated — ODM relies on ``OrderNumber`` attributes
for that.
* Completeness is not checked. Missing required children must be
caught via :meth:`verify_conformance` or :meth:`validate`.
Note:
:meth:`to_xml` / :meth:`write_xml` already emit children in
``_elems`` order, so ``verify_order()`` is **not** required
before XML serialization. It is still useful before
:meth:`to_dict` / :meth:`to_json`, which walk ``__dict__``
and therefore reflect instance assignment order.
Returns:
bool: True if ordering is correct.
Raises:
OdmlibElementOrderError: If any element has children out of
order. Use :meth:`reorder_object` to fix automatically.
See also:
:meth:`validate` with ``collect_errors=True`` reports *every*
misordered element in one pass instead of just the first.
"""
self._verify_order(_raise_error)
return True
def _verify_order(self, report) -> None:
"""Walk the tree, reporting every element whose children are misordered.
Args:
report: Callable ``(error, element)``. :func:`_raise_error` gives
fail-fast semantics; :func:`_collecting_reporter` accumulates.
Recursion continues into the children of a misordered element:
ordering is a per-element property and :meth:`reorder_object` is not
recursive, so a caller in collect mode needs the complete list to fix
a document in one round. Fail-fast never reaches that code, so its
behaviour is unchanged.
"""
obj_list = [key for key in list(self.__dict__.keys()) if key != "_content" and key not in self._attrs]
elem_list = [elem for elem in self._elems if elem in obj_list]
if obj_list != elem_list:
report(OdmlibElementOrderError(
f"The order of elements in {self.__class__.__name__} should be "
f"{', '.join(key for key in self._elems.keys())}",
element_type=self.__class__.__name__,
hint="Use reorder_object() to fix element ordering automatically",
), self)
for attr, obj in self.__dict__.items():
if isinstance(obj, ODMElement):
obj._verify_order(report)
elif isinstance(obj, list):
for o in obj:
o._verify_order(report)
[docs]
def reorder_object(self) -> None:
"""Reorder this element's children to match model declaration order.
Modifies ``__dict__`` in-place. Issues an :class:`OdmlibWarning`
to indicate that reordering was needed.
Note:
Recursion is not automatic — call on each element that needs
reordering, or call :meth:`verify_order` first to identify
which elements need reordering.
"""
warnings.warn(
f"{self.__class__.__name__} elements are being reordered to match the ODM specification. "
"Use verify_order() before reorder_object() to understand the ordering issue.",
OdmlibWarning,
stacklevel=2,
)
ordered_obj = OrderedDict()
for model_elem_name, model_elem_obj in self._elems.items():
if model_elem_name in self.__dict__:
obj = self.__dict__.pop(model_elem_name)
ordered_obj[model_elem_name] = obj
for name, elem in ordered_obj.items():
self.__dict__[name] = elem
[docs]
def validate(self, collect_errors=False, oid_checker=None,
conformance_checker=None, max_errors=None):
"""Validate this element and all children.
Runs up to three layers: element order, OID uniqueness and ref/def
integrity, and Cerberus conformance. In collect mode each layer
enumerates *every* problem it can find rather than stopping at its
first.
Args:
collect_errors: If True, accumulate every error each layer can
find and return them as a list instead of raising on the first
failure. Defaults to False (fail-fast, existing behaviour).
oid_checker: Optional OID checker — a
:class:`~odmlib.oid_generator.DynamicOIDRef` from
:func:`~odmlib.oid_generator.create_oid_checker`, or a
deprecated ``rules.oid_ref.OIDRef``. Checkers that do not
implement the collecting protocol (see
:func:`~odmlib.exceptions.is_collecting_checker`) still work,
but contribute at most one error to the list.
conformance_checker: Optional ``MetadataSchema`` instance. In
collect mode the bundled Cerberus result is expanded into one
error per failing field; in fail-fast mode the single bundled
:class:`~odmlib.exceptions.OdmlibConformanceError` is raised
unchanged.
max_errors: Optional cap on collected errors. Collection stops the
moment the cap is reached and a final
:class:`~odmlib.exceptions.OdmlibErrorLimitError` is appended,
so the list holds at most ``max_errors + 1`` entries. ``None``
(the default) collects everything. Ignored when
``collect_errors`` is False. ``max_errors=0`` checks nothing
and returns just the limit marker.
Returns:
If ``collect_errors=False``: ``True`` (or raises on first error).
If ``collect_errors=True``: list of
:class:`~odmlib.exceptions.OdmlibError` instances (an empty list
means valid).
Note:
OID checkers are stateful. Use one checker instance per document,
or call ``reset()`` on a ``DynamicOIDRef`` between runs —
otherwise the second pass reports every OID as a duplicate.
Only :class:`~odmlib.exceptions.OdmlibError` subclasses are
collected. Any other exception (a bug in a custom checker, an
``AttributeError`` from a malformed permissively-loaded tree)
propagates in both modes. Note that
:class:`~odmlib.exceptions.OdmlibTypeError` *is* an
``OdmlibError`` and is therefore collected.
Example::
errors = odm.validate(collect_errors=True, oid_checker=checker,
max_errors=100)
for err in errors:
print(err)
.. versionchanged:: 0.2.1
Each layer now reports every problem it finds. Previously
``collect_errors=True`` returned at most one error per layer
(three in total), so ``len(errors)`` may be larger than before.
Added ``max_errors``.
"""
if not collect_errors:
# Fail-fast — preserves existing behaviour exactly
self.verify_order()
if oid_checker:
self.verify_oids(oid_checker)
if conformance_checker:
self.verify_conformance(conformance_checker)
return True
collector = ErrorCollector(max_errors=max_errors)
def _order_layer():
self._verify_order(_collecting_reporter(collector))
def _oid_layer():
if is_collecting_checker(oid_checker):
with oid_checker.collecting(collector):
self._init_oid_check(oid_checker)
oid_checker.check_oid_refs()
else:
# Deprecated or duck-typed checker without the collecting
# protocol: fail-fast, one error for the whole layer.
self.verify_oids(oid_checker)
def _conformance_layer():
try:
self.verify_conformance(conformance_checker)
except OdmlibConformanceError as e:
for expanded in e.expand():
collector.add_error(expanded)
layers = [_order_layer]
if oid_checker:
_warn_if_checker_dirty(oid_checker)
layers.append(_oid_layer)
if conformance_checker:
layers.append(_conformance_layer)
for layer in layers:
if collector.is_full:
collector.truncated = True # stopped early for any reason
break
_run_validation_layer(collector, layer)
if collector.truncated:
collector.errors.append(OdmlibErrorLimitError(
f"Validation stopped after {max_errors} errors "
f"(max_errors={max_errors}); additional problems may exist.",
hint="Fix the reported errors and re-run validate(), or raise "
"max_errors (max_errors=None collects every error).",
))
return collector.errors