"""
Optional builder/fluent API for constructing ODM documents.
The builder provides a chainable interface for constructing complex
documents with less boilerplate. It wraps the standard odmlib model
classes and is purely additive — all existing APIs continue to work
unchanged.
Example::
from odmlib.builder import ODMBuilder
odm = (ODMBuilder("odm_1_3_2")
.set_file(FileOID="F.001", FileType="Snapshot",
CreationDateTime="2024-01-01T00:00:00")
.add_study(OID="S.001",
study_name="My Study",
study_description="A study",
protocol_name="PROT-001")
.add_metadata_version(OID="MDV.001", Name="Version 1")
.add_item_group_def(OID="IG.DM", Name="Demographics", Repeating="No")
.add_item_ref(ItemOID="IT.SUBJID", Mandatory="Yes", OrderNumber=1)
.add_item_ref(ItemOID="IT.AGE", Mandatory="No", OrderNumber=2)
.build())
"""
from __future__ import annotations
from typing import Any, Optional
import importlib
import odmlib.odm_element as OE
import odmlib.typed as T
[docs]
class ODMBuilder:
"""Fluent builder for constructing ODM documents.
Maintains state for the currently active Study, MetaDataVersion,
and ItemGroupDef so that ``add_*`` calls automatically append to
the right parent.
Works with ``odm_1_3_2``, ``define_2_0``, ``define_2_1`` and
``odm_2_0``. Model-shape differences are handled automatically —
notably the ODM 2.0 ``Study`` (scalar ``StudyName`` / ``ProtocolName``,
no ``GlobalVariables``) and its object-valued ``Description``. Note
that ODM 2.0 is structurally flatter: ``StudyEventDef`` references
``ItemGroupRef`` directly (there is no ``FormDef`` / ``FormRef``), so
the form-level helpers do not apply to ``odm_2_0``.
:param model_package: The odmlib model package to use.
Defaults to ``"odm_1_3_2"``.
"""
def __init__(self, model_package: str = "odm_1_3_2") -> None:
self._model = importlib.import_module(f"odmlib.{model_package}.model")
self._file_attrs: dict[str, Any] = {}
self._studies: list = []
self._current_study: Any = None
self._current_mdv: Any = None
self._current_igd: Any = None
self._current_item_def: Any = None
self._current_form_def: Any = None
self._current_sed: Any = None
# ------------------------------------------------------------------
# Model-shape detection
# ------------------------------------------------------------------
def _uses_global_variables(self) -> bool:
"""Whether the active model uses the ODM 1.3.2 study structure.
ODM 1.3.2 and the Define-XML models nest study identification under
a ``GlobalVariables`` element (with ``StudyName`` /
``StudyDescription`` / ``ProtocolName`` child elements) and model
``MetaDataVersion.Description`` as a plain string. ODM 2.0 has a
flatter shape: scalar ``StudyName`` / ``ProtocolName`` attributes on
``Study``, an object-valued ``Study.Description``, and an
object-valued ``MetaDataVersion.Description``. Presence of the
``GlobalVariables`` class is a precise proxy for that distinction.
"""
return hasattr(self._model, "GlobalVariables")
def _translated_text(self, **kw: Any) -> Any:
"""Construct a model ``TranslatedText``.
ODM 2.0 makes ``TranslatedText/@Type`` required (a free-text media
type per the XSD). For the ODM 2.0 model shape this helper defaults
``Type="text/plain"`` so builder output is schema- and model-valid;
an explicit ``Type`` in ``kw`` always wins. For ODM 1.3.2 / Define
(which have a ``GlobalVariables`` element and no required ``Type``)
the kwargs are passed through unchanged, so output is byte-identical
to before.
"""
M = self._model
if not self._uses_global_variables() and "Type" not in kw:
kw["Type"] = "text/plain"
return M.TranslatedText(**kw)
def _formal_expression(self, context: str, expression: str) -> Any:
"""Construct a model ``FormalExpression`` carrying *expression*.
ODM 1.3.2 / Define-XML hold the expression as element text
(``_content``). ODM 2.0 made FormalExpression element-based: the
XSD requires a choice of ``Code`` (inline source) or
``ExternalCodeLib``, so the expression is wrapped in a ``Code``
child. Callers pass plain text either way.
.. versionadded:: 0.2.1
"""
M = self._model
if self._uses_global_variables():
return M.FormalExpression(Context=context, _content=expression)
return M.FormalExpression(Context=context, Code=M.Code(_content=expression))
# ------------------------------------------------------------------
# File-level
# ------------------------------------------------------------------
[docs]
def set_file(self, **kwargs: Any) -> ODMBuilder:
"""Set ODM file-level attributes (FileOID, FileType, CreationDateTime, …).
These are passed directly to the ``ODM`` constructor when
:meth:`build` is called.
:returns: self, for chaining.
"""
self._file_attrs = kwargs
return self
# ------------------------------------------------------------------
# Study
# ------------------------------------------------------------------
[docs]
def add_study(self, OID: str, study_name: str,
study_description: str, protocol_name: str,
**kwargs: Any) -> ODMBuilder:
"""Add a Study element.
The Study is constructed in whichever shape the active model uses:
- **ODM 1.3.2 / Define-XML:** a ``GlobalVariables`` element wrapping
``StudyName`` / ``StudyDescription`` / ``ProtocolName`` children.
- **ODM 2.0:** scalar ``StudyName`` / ``ProtocolName`` attributes on
``Study`` plus an object-valued ``Description`` (there is no
``GlobalVariables`` element in ODM 2.0).
:param OID: Study OID.
:param study_name: Study name (StudyName content/attribute).
:param study_description: Study description.
:param protocol_name: Protocol name (ProtocolName content/attribute).
:param kwargs: Additional Study attributes.
:returns: self, for chaining.
"""
M = self._model
if self._uses_global_variables():
sn = M.StudyName(_content=study_name)
sd = M.StudyDescription(_content=study_description)
pn = M.ProtocolName(_content=protocol_name)
gv = M.GlobalVariables(StudyName=sn, StudyDescription=sd,
ProtocolName=pn)
self._current_study = M.Study(OID=OID, GlobalVariables=gv, **kwargs)
else:
# ODM 2.0: StudyName/ProtocolName are scalar attributes; the
# description is an object-valued Description element.
desc = M.Description(TranslatedText=[
self._translated_text(_content=study_description, lang="en")])
self._current_study = M.Study(
OID=OID, StudyName=study_name, ProtocolName=protocol_name,
Description=desc, **kwargs)
self._studies.append(self._current_study)
# a new Study starts a new scope: stale pointers from a previous
# study must not receive later with_*/attach_to_current() calls
self._current_mdv = None
self._current_igd = None
self._current_item_def = None
self._current_sed = None
self._current_form_def = None
return self
# ------------------------------------------------------------------
# MetaDataVersion
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# ItemGroupDef
# ------------------------------------------------------------------
[docs]
def add_item_group_def(self, **kwargs: Any) -> ODMBuilder:
"""Add an ItemGroupDef to the current MetaDataVersion.
:param kwargs: ItemGroupDef attributes (OID, Name, Repeating, …).
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_item_group_def()")
M = self._model
self._current_igd = M.ItemGroupDef(**kwargs)
self._current_mdv.ItemGroupDef.append(self._current_igd)
# the new ItemGroupDef is now the "most recent element": clear the
# previous ItemDef so with_description()/with_alias() target this one
self._current_item_def = None
return self
# ------------------------------------------------------------------
# ItemRef
# ------------------------------------------------------------------
[docs]
def add_item_ref(self, **kwargs: Any) -> ODMBuilder:
"""Add an ItemRef to the current ItemGroupDef.
:param kwargs: ItemRef attributes (ItemOID, Mandatory, OrderNumber, …).
:returns: self, for chaining.
:raises RuntimeError: if no ItemGroupDef has been added yet.
"""
if self._current_igd is None:
raise RuntimeError("Call add_item_group_def() before add_item_ref()")
M = self._model
self._current_igd.ItemRef.append(M.ItemRef(**kwargs))
return self
# ------------------------------------------------------------------
# ItemDef
# ------------------------------------------------------------------
[docs]
def add_item_def(self, **kwargs: Any) -> ODMBuilder:
"""Add an ItemDef to the current MetaDataVersion.
:param kwargs: ItemDef attributes (OID, Name, DataType, …).
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_item_def()")
M = self._model
self._current_item_def = M.ItemDef(**kwargs)
self._current_mdv.ItemDef.append(self._current_item_def)
return self
# ------------------------------------------------------------------
# CodeList
# ------------------------------------------------------------------
[docs]
def add_code_list(self, OID: str, Name: str, DataType: str,
items: Optional[list] = None,
**kwargs: Any) -> ODMBuilder:
"""Add a CodeList to the current MetaDataVersion.
:param OID: CodeList OID.
:param Name: CodeList name.
:param DataType: Coded value data type.
:param items: Optional list of dicts. Each dict must have a
``"CodedValue"`` key and may have a ``"Decode"`` key
(a plain English label string). If ``"Decode"`` is present
a CodeListItem is created; otherwise an EnumeratedItem.
:param kwargs: Additional CodeList attributes.
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_code_list()")
M = self._model
cl = M.CodeList(OID=OID, Name=Name, DataType=DataType, **kwargs)
if items:
for item in items:
coded_value = item["CodedValue"]
decode_text = item.get("Decode")
if decode_text:
decode = M.Decode(TranslatedText=[
self._translated_text(_content=decode_text, lang="en")])
cl.CodeListItem.append(
M.CodeListItem(CodedValue=coded_value, Decode=decode))
else:
cl.EnumeratedItem.append(
M.EnumeratedItem(CodedValue=coded_value))
self._current_mdv.CodeList.append(cl)
return self
# ------------------------------------------------------------------
# Description helper
# ------------------------------------------------------------------
[docs]
def with_description(self, text: str, lang: str = "en") -> ODMBuilder:
"""Attach a Description to the most recently created element.
Applies to the most recently created ItemGroupDef or ItemDef.
Falls back to the current MetaDataVersion if neither is set.
:param text: Description text.
:param lang: Language code (default ``"en"``).
:returns: self, for chaining.
"""
M = self._model
desc = M.Description(TranslatedText=[
self._translated_text(_content=text, lang=lang)])
if self._current_item_def is not None:
self._current_item_def.Description = desc
elif self._current_igd is not None:
self._current_igd.Description = desc
elif self._current_mdv is not None:
if self._uses_global_variables():
# ODM 1.3.2 / Define: MetaDataVersion.Description is a String.
self._current_mdv.Description = text
else:
# ODM 2.0: MetaDataVersion.Description is a Description object.
self._current_mdv.Description = desc
return self
# ------------------------------------------------------------------
# Protocol / StudyEventDef / StudyEventRef
# ------------------------------------------------------------------
def _require_v1(self, feature: str) -> None:
"""Raise if the active model is ODM 2.0 (which lacks ``feature``)."""
if not self._uses_global_variables():
raise RuntimeError(
f"{feature} is not part of ODM 2.0. In ODM 2.0 a "
f"StudyEventDef references ItemGroupRef directly — use "
f"add_study_event_def() then add_item_group_ref().")
[docs]
def add_study_event_def(self, **kwargs: Any) -> ODMBuilder:
"""Add a StudyEventDef to the current MetaDataVersion.
The StudyEventDef is appended to ``MetaDataVersion.StudyEventDef``
and becomes the *current* study event so that subsequent
:meth:`add_form_ref` (ODM 1.3.2) or :meth:`add_item_group_ref`
(ODM 2.0) calls attach to it. This does *not* create a Protocol
reference — use :meth:`add_study_event_ref` for that.
:param kwargs: StudyEventDef attributes (OID, Name, Repeating, Type, …).
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_study_event_def()")
M = self._model
self._current_sed = M.StudyEventDef(**kwargs)
self._current_form_def = None
self._current_mdv.StudyEventDef.append(self._current_sed)
return self
[docs]
def add_study_event_ref(self, **kwargs: Any) -> ODMBuilder:
"""Add a StudyEventRef to the current MetaDataVersion's Protocol.
The Protocol element is created automatically if it does not exist
yet.
**ODM 1.3.2 / Define-XML only.** The ODM 2.0 XSD has no
``Protocol/StudyEventRef``: a Protocol references
``StudyEventGroupRef``, and ``StudyEventRef`` lives one level down on
``StudyEventGroupDef``. Calling this under ``odm_2_0`` therefore
raises rather than emitting schema-invalid markup — build the
StudyEventGroupDef yourself and graft it on with :meth:`attach`.
.. versionchanged:: 0.2.1
Raises under ``odm_2_0`` instead of silently producing an invalid
``Protocol/StudyEventRef``.
:param kwargs: StudyEventRef attributes (StudyEventOID, Mandatory,
OrderNumber, …).
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet, or
if the active model is ODM 2.0.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_study_event_ref()")
M = self._model
if not self._uses_global_variables():
raise RuntimeError(
"Protocol/StudyEventRef is not part of ODM 2.0. A Protocol "
"references StudyEventGroupRef, and StudyEventRef belongs to "
"StudyEventGroupDef — build the StudyEventGroupDef and its "
"refs directly and attach() them to the MetaDataVersion.")
if self._current_mdv.Protocol is None:
self._current_mdv.Protocol = M.Protocol()
self._current_mdv.Protocol.StudyEventRef.append(M.StudyEventRef(**kwargs))
return self
# ------------------------------------------------------------------
# FormDef / FormRef (ODM 1.3.2 / Define-XML only)
# ------------------------------------------------------------------
[docs]
def add_item_group_ref(self, **kwargs: Any) -> ODMBuilder:
"""Add an ItemGroupRef to the appropriate parent.
In ODM 1.3.2 / Define-XML the ItemGroupRef is added to the current
FormDef (call :meth:`add_form_def` first). In ODM 2.0 — which has
no FormDef — it is added to the current StudyEventDef (call
:meth:`add_study_event_def` first).
:param kwargs: ItemGroupRef attributes (ItemGroupOID, Mandatory, …).
:returns: self, for chaining.
:raises RuntimeError: if the required parent has not been added yet.
"""
M = self._model
if self._uses_global_variables():
if self._current_form_def is None:
raise RuntimeError("Call add_form_def() before add_item_group_ref()")
self._current_form_def.ItemGroupRef.append(M.ItemGroupRef(**kwargs))
else:
if self._current_sed is None:
raise RuntimeError(
"Call add_study_event_def() before add_item_group_ref()")
self._current_sed.ItemGroupRef.append(M.ItemGroupRef(**kwargs))
return self
# ------------------------------------------------------------------
# MethodDef / ConditionDef
# ------------------------------------------------------------------
[docs]
def add_method_def(self, OID: str, Name: str, Type: str, description: str,
formal_expression: Optional[str] = None,
expression_context: str = "Python",
lang: str = "en", **kwargs: Any) -> ODMBuilder:
"""Add a MethodDef to the current MetaDataVersion.
MethodDef requires a Description; it is built from ``description``.
An optional FormalExpression is added when ``formal_expression`` is
given.
``formal_expression`` is plain text for every model. Under
``odm_2_0`` it is wrapped in the ``Code`` child the XSD requires;
under ODM 1.3.2 / Define-XML it stays element text.
Under ``odm_2_0`` the XSD also makes ``MethodSignature`` a required
child of MethodDef, so an empty one is emitted for you (both of its
children are optional, so ``<MethodSignature/>`` is valid); populate
it via :meth:`attach` if the method takes parameters. ODM 1.3.2
adds no MethodSignature.
.. versionchanged:: 0.2.1
``formal_expression`` is now valid under ``odm_2_0``, and a
MethodSignature is emitted there, so the output is schema-valid.
:param OID: Method OID.
:param Name: Method name.
:param Type: Method type (e.g. "Computation", "Imputation").
:param description: Human-readable description text.
:param formal_expression: Optional expression source code.
:param expression_context: FormalExpression Context (e.g. "Python",
"SAS"); used only when ``formal_expression`` is given.
:param lang: Language code for the Description (default ``"en"``).
:param kwargs: Additional MethodDef attributes.
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_method_def()")
M = self._model
desc = M.Description(TranslatedText=[
self._translated_text(_content=description, lang=lang)])
md = M.MethodDef(OID=OID, Name=Name, Type=Type, Description=desc,
**kwargs)
if not self._uses_global_variables():
md.MethodSignature = M.MethodSignature()
if formal_expression is not None:
md.FormalExpression.append(
self._formal_expression(expression_context, formal_expression))
self._current_mdv.MethodDef.append(md)
return self
[docs]
def add_condition_def(self, OID: str, Name: str,
description: Optional[str] = None,
formal_expression: Optional[str] = None,
expression_context: str = "Python",
lang: str = "en", **kwargs: Any) -> ODMBuilder:
"""Add a ConditionDef to the current MetaDataVersion.
Under ``odm_2_0`` the XSD makes both ``Description`` and
``MethodSignature`` required children, so ``description`` is
mandatory there and an empty ``MethodSignature`` is emitted for you
(both of its children are optional, so ``<MethodSignature/>`` is
valid). Populate it further via :meth:`attach` if the condition's
expressions take parameters. ODM 1.3.2 is unchanged: ``description``
stays optional and no MethodSignature is added.
``formal_expression`` is plain text for every model; under
``odm_2_0`` it is wrapped in a ``Code`` child (see
:meth:`add_method_def`).
.. versionchanged:: 0.2.1
Produces schema-valid output under ``odm_2_0``.
:param OID: Condition OID.
:param Name: Condition name.
:param description: Description text. Required under ``odm_2_0``,
optional for ODM 1.3.2.
:param formal_expression: Optional expression source code.
:param expression_context: FormalExpression Context; used only when
``formal_expression`` is given.
:param lang: Language code for the Description (default ``"en"``).
:param kwargs: Additional ConditionDef attributes.
:returns: self, for chaining.
:raises RuntimeError: if no MetaDataVersion has been added yet, or
if ``description`` is omitted under ``odm_2_0``.
"""
if self._current_mdv is None:
raise RuntimeError("Call add_metadata_version() before add_condition_def()")
M = self._model
is_v2 = not self._uses_global_variables()
if is_v2 and description is None:
raise RuntimeError(
"ConditionDef requires a Description in ODM 2.0 — pass "
"description= to add_condition_def().")
cd = M.ConditionDef(OID=OID, Name=Name, **kwargs)
if description is not None:
cd.Description = M.Description(TranslatedText=[
self._translated_text(_content=description, lang=lang)])
if is_v2:
cd.MethodSignature = M.MethodSignature()
if formal_expression is not None:
cd.FormalExpression.append(
self._formal_expression(expression_context, formal_expression))
self._current_mdv.ConditionDef.append(cd)
return self
# ------------------------------------------------------------------
# MeasurementUnit (ODM 1.3.2 / Define-XML only)
# ------------------------------------------------------------------
[docs]
def add_measurement_unit(self, OID: str, Name: str, symbol: str,
lang: str = "en", **kwargs: Any) -> ODMBuilder:
"""Add a MeasurementUnit to the Study's BasicDefinitions.
BasicDefinitions is created automatically if absent. Not available
in ODM 2.0 (which has no BasicDefinitions/MeasurementUnit).
:param OID: MeasurementUnit OID.
:param Name: MeasurementUnit name.
:param symbol: Symbol text (wrapped in Symbol/TranslatedText).
:param lang: Language code for the Symbol (default ``"en"``).
:param kwargs: Additional MeasurementUnit attributes.
:returns: self, for chaining.
:raises RuntimeError: if no Study yet, or model is ODM 2.0.
"""
if self._current_study is None:
raise RuntimeError("Call add_study() before add_measurement_unit()")
self._require_v1("BasicDefinitions/MeasurementUnit")
M = self._model
if self._current_study.BasicDefinitions is None:
self._current_study.BasicDefinitions = M.BasicDefinitions()
sym = M.Symbol(TranslatedText=[
self._translated_text(_content=symbol, lang=lang)])
self._current_study.BasicDefinitions.MeasurementUnit.append(
M.MeasurementUnit(OID=OID, Name=Name, Symbol=sym, **kwargs))
return self
# ------------------------------------------------------------------
# ItemDef enrichers (operate on the most recent ItemDef)
# ------------------------------------------------------------------
def _require_item_def(self, method: str) -> Any:
if self._current_item_def is None:
raise RuntimeError(f"Call add_item_def() before {method}()")
return self._current_item_def
[docs]
def with_question(self, text: str, lang: str = "en") -> ODMBuilder:
"""Set the Question on the most recently created ItemDef.
:param text: Question text.
:param lang: Language code (default ``"en"``).
:returns: self, for chaining.
:raises RuntimeError: if no ItemDef has been added yet.
"""
item = self._require_item_def("with_question")
M = self._model
item.Question = M.Question(TranslatedText=[
self._translated_text(_content=text, lang=lang)])
return self
[docs]
def with_codelist_ref(self, codelist_oid: str) -> ODMBuilder:
"""Attach a CodeListRef to the most recently created ItemDef.
:param codelist_oid: OID of the referenced CodeList.
:returns: self, for chaining.
:raises RuntimeError: if no ItemDef has been added yet.
"""
item = self._require_item_def("with_codelist_ref")
item.CodeListRef = self._model.CodeListRef(CodeListOID=codelist_oid)
return self
[docs]
def with_measurement_unit_ref(self, mu_oid: str) -> ODMBuilder:
"""Add a MeasurementUnitRef to the most recent ItemDef (ODM 1.3.2 only).
:param mu_oid: OID of the referenced MeasurementUnit.
:returns: self, for chaining.
:raises RuntimeError: if no ItemDef yet, or model is ODM 2.0.
"""
item = self._require_item_def("with_measurement_unit_ref")
self._require_v1("MeasurementUnitRef")
item.MeasurementUnitRef.append(
self._model.MeasurementUnitRef(MeasurementUnitOID=mu_oid))
return self
[docs]
def with_range_check(self, comparator: Optional[str], check_values: list,
soft_hard: str = "Soft",
**kwargs: Any) -> ODMBuilder:
"""Add a RangeCheck to the most recently created ItemDef.
:param comparator: Comparator (e.g. "GE", "LE", "IN"); may be None.
:param check_values: List of check values (rendered as CheckValue).
:param soft_hard: SoftHard flag (default ``"Soft"``).
:param kwargs: Additional RangeCheck attributes.
:returns: self, for chaining.
:raises RuntimeError: if no ItemDef has been added yet.
"""
item = self._require_item_def("with_range_check")
M = self._model
rc_kwargs: dict[str, Any] = {"SoftHard": soft_hard}
if comparator is not None:
rc_kwargs["Comparator"] = comparator
rc_kwargs["CheckValue"] = [
M.CheckValue(_content=str(v)) for v in check_values]
rc_kwargs.update(kwargs)
item.RangeCheck.append(M.RangeCheck(**rc_kwargs))
return self
[docs]
def with_alias(self, context: str, name: str) -> ODMBuilder:
"""Add an Alias to the most recent ItemDef (or ItemGroupDef).
Applies to the most recently created ItemDef; falls back to the
current ItemGroupDef.
:param context: Alias Context.
:param name: Alias Name.
:returns: self, for chaining.
:raises RuntimeError: if neither an ItemDef nor ItemGroupDef exists.
"""
M = self._model
alias = M.Alias(Context=context, Name=name)
if self._current_item_def is not None:
self._current_item_def.Alias.append(alias)
elif self._current_igd is not None:
self._current_igd.Alias.append(alias)
else:
raise RuntimeError(
"Call add_item_def() or add_item_group_def() before with_alias()")
return self
# ------------------------------------------------------------------
# Generic escape hatch
# ------------------------------------------------------------------
def _find_elem_field(self, parent: Any, element: Any):
"""Return ``(field, descriptor)`` on *parent* that accepts *element*.
Prefers an exact class match, falling back to a subclass match
(so a Define-XML subclass element still resolves). Returns
``None`` if *parent* has no child field for *element*'s type.
"""
elems = type(parent)._elems
fallback = None
for field, descr in elems.items():
ec = getattr(descr, "element_class", None)
if ec is None:
continue
if type(element) is ec:
return field, descr
if fallback is None and isinstance(element, ec):
fallback = (field, descr)
return fallback
def _attach(self, parent: Any, element: Any) -> None:
if not isinstance(element, OE.ODMElement):
raise TypeError(
f"attach() expects an ODMElement, got "
f"{type(element).__name__}")
match = self._find_elem_field(parent, element)
if match is None:
accepted = sorted({
d.element_class.__name__
for d in type(parent)._elems.values()
if getattr(d, "element_class", None) is not None})
raise TypeError(
f"{type(parent).__name__} cannot hold "
f"{type(element).__name__}. Accepted child elements: "
f"{', '.join(accepted)}")
field, descr = match
if isinstance(descr, T.ODMListObject):
getattr(parent, field).append(element)
else:
setattr(parent, field, element)
[docs]
def attach(self, parent: Any, element: Any) -> ODMBuilder:
"""Attach a pre-built ODMElement onto an explicit parent element.
Escape hatch for any model element that has no dedicated
``add_*`` method. The target child field is resolved from the
parent's model definition — appended if it is a list element,
assigned otherwise.
:param parent: An ODMElement already in the tree being built
(e.g. a value returned via :attr:`current`).
:param element: A pre-built ODMElement to attach.
:returns: self, for chaining.
:raises TypeError: if *element* is not an ODMElement or *parent*
has no child field able to hold it.
"""
self._attach(parent, element)
return self
[docs]
def attach_to_current(self, element: Any) -> ODMBuilder:
"""Attach a pre-built ODMElement to the most specific current parent.
Tries the active ItemDef, FormDef, StudyEventDef, ItemGroupDef,
MetaDataVersion and Study in turn, attaching to the first that
accepts *element*'s type.
:param element: A pre-built ODMElement to attach.
:returns: self, for chaining.
:raises TypeError: if no active element can hold *element*.
"""
for parent in (self._current_item_def, self._current_form_def,
self._current_sed, self._current_igd,
self._current_mdv, self._current_study):
if parent is not None and \
self._find_elem_field(parent, element) is not None:
self._attach(parent, element)
return self
raise TypeError(
f"No active element can hold {type(element).__name__}; "
f"use attach(parent, element) with an explicit parent.")
@property
def current(self) -> dict:
"""The active context elements, for use with :meth:`attach`.
Keys: ``study``, ``mdv``, ``item_group_def``, ``item_def``,
``form_def``, ``study_event_def`` (value is ``None`` if not set).
"""
return {
"study": self._current_study,
"mdv": self._current_mdv,
"item_group_def": self._current_igd,
"item_def": self._current_item_def,
"form_def": self._current_form_def,
"study_event_def": self._current_sed,
}
# ------------------------------------------------------------------
# Build
# ------------------------------------------------------------------
[docs]
def build(self) -> Any:
"""Construct and return the top-level ODM object.
:returns: An ``ODM`` instance containing all added elements.
"""
M = self._model
return M.ODM(Study=self._studies, **self._file_attrs)