Skip to content

Serialization

etcion.serialization.xml

XML serialization for the ArchiMate Exchange Format.

Reference: ADR-031.

serialize_element(elem)

Serialize a single Element to an lxml element node.

Source code in src/etcion/serialization/xml.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def serialize_element(elem: Element) -> etree._Element:
    """Serialize a single Element to an lxml element node."""
    desc = TYPE_REGISTRY[type(elem)]
    el = etree.Element(f"{{{ARCHIMATE_NS}}}element", nsmap=NSMAP)
    el.set("identifier", _to_exchange_id(elem.id))
    el.set(f"{{{XSI_NS}}}type", desc.xml_tag)

    name_el = etree.SubElement(el, f"{{{ARCHIMATE_NS}}}name")
    name_el.set(f"{{{XML_NS}}}lang", DEFAULT_LANG)
    name_el.text = elem.name

    if elem.description:
        doc_el = etree.SubElement(el, f"{{{ARCHIMATE_NS}}}documentation")
        doc_el.set(f"{{{XML_NS}}}lang", DEFAULT_LANG)
        doc_el.text = elem.description

    return el

serialize_relationship(rel)

Serialize a single Relationship to an lxml element node.

Source code in src/etcion/serialization/xml.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def serialize_relationship(rel: Relationship) -> etree._Element:
    """Serialize a single Relationship to an lxml element node."""
    desc = TYPE_REGISTRY[type(rel)]
    el = etree.Element(f"{{{ARCHIMATE_NS}}}relationship", nsmap=NSMAP)
    el.set("identifier", _to_exchange_id(rel.id))
    el.set("source", _to_exchange_id(rel.source.id))
    el.set("target", _to_exchange_id(rel.target.id))
    el.set(f"{{{XSI_NS}}}type", desc.xml_tag)

    if rel.name:
        name_el = etree.SubElement(el, f"{{{ARCHIMATE_NS}}}name")
        name_el.set(f"{{{XML_NS}}}lang", DEFAULT_LANG)
        name_el.text = rel.name

    for attr_name, extractor in desc.extra_attrs.items():
        val = extractor(rel)
        if val is not None:
            el.set(attr_name, str(val))

    return el

serialize_model(model, *, model_name='Untitled Model')

Serialize a Model to a complete Exchange Format ElementTree.

Source code in src/etcion/serialization/xml.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def serialize_model(model: Model, *, model_name: str = "Untitled Model") -> etree._ElementTree:
    """Serialize a Model to a complete Exchange Format ElementTree."""
    root = etree.Element(f"{{{ARCHIMATE_NS}}}model", nsmap=NSMAP)
    root.set("identifier", "id-model-root")
    root.set(f"{{{XSI_NS}}}schemaLocation", ARCHIMATE_SCHEMA_LOCATION)

    name_el = etree.SubElement(root, f"{{{ARCHIMATE_NS}}}name")
    name_el.set(f"{{{XML_NS}}}lang", DEFAULT_LANG)
    name_el.text = model_name

    elements_container = etree.SubElement(root, f"{{{ARCHIMATE_NS}}}elements")
    for elem in model.elements:
        elements_container.append(serialize_element(elem))

    rels_container = etree.SubElement(root, f"{{{ARCHIMATE_NS}}}relationships")
    for rel in model.relationships:
        rels_container.append(serialize_relationship(rel))

    opaque = getattr(model, "_opaque_xml", [])
    for node in opaque:
        root.append(node)

    return etree.ElementTree(root)

write_model(model, path, *, model_name='Untitled Model')

Write a Model to an XML file in Exchange Format.

Source code in src/etcion/serialization/xml.py
107
108
109
110
111
112
113
114
115
116
def write_model(model: Model, path: str | Path, *, model_name: str = "Untitled Model") -> None:
    """Write a Model to an XML file in Exchange Format."""
    tree = serialize_model(model, model_name=model_name)
    etree.indent(tree, space="  ")
    tree.write(
        str(path),
        xml_declaration=True,
        encoding="UTF-8",
        pretty_print=True,
    )

deserialize_model(tree)

Deserialize an Exchange Format :class:lxml.etree._ElementTree into a :class:~etcion.metamodel.model.Model.

Uses a two-phase approach:

  1. Parse all <element> nodes and build an id_map keyed by the exchange-format identifier (id-<uuid>).
  2. Parse all <relationship> nodes, resolving source/target references against id_map.
Source code in src/etcion/serialization/xml.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def deserialize_model(tree: etree._ElementTree) -> Model:
    """Deserialize an Exchange Format :class:`lxml.etree._ElementTree` into a
    :class:`~etcion.metamodel.model.Model`.

    Uses a two-phase approach:

    1. Parse all ``<element>`` nodes and build an ``id_map`` keyed by the
       exchange-format identifier (``id-<uuid>``).
    2. Parse all ``<relationship>`` nodes, resolving ``source``/``target``
       references against ``id_map``.
    """
    root = tree.getroot()
    model = Model()

    # Phase 1: elements
    id_map: dict[str, Concept] = {}
    elements_node = root.find(f"{{{ARCHIMATE_NS}}}elements")
    if elements_node is not None:
        for el_node in elements_node:
            concept = _deserialize_element(el_node)
            if concept is not None:
                # Key by the EXCHANGE FORMAT id (with id- prefix) so that
                # relationship source/target refs resolve correctly.
                id_map[el_node.get("identifier", "")] = concept
                model.add(concept)

    # Phase 2: relationships
    rels_node = root.find(f"{{{ARCHIMATE_NS}}}relationships")
    if rels_node is not None:
        for rel_node in rels_node:
            rel = _deserialize_relationship(rel_node, id_map)
            if rel is not None:
                model.add(rel)

    # Capture opaque children (e.g. <views>) for lossless round-trip.
    opaque: list[etree._Element] = []
    for child in root:
        tag_local = etree.QName(child.tag).localname
        if tag_local not in ("elements", "relationships", "name", "documentation"):
            opaque.append(child)
    model._opaque_xml = opaque  # type: ignore[attr-defined]

    return model

read_model(path)

Parse an Exchange Format XML file from path and return a :class:~etcion.metamodel.model.Model.

Source code in src/etcion/serialization/xml.py
223
224
225
226
227
228
def read_model(path: str | Path) -> Model:
    """Parse an Exchange Format XML file from *path* and return a
    :class:`~etcion.metamodel.model.Model`.
    """
    tree = etree.parse(str(path))
    return deserialize_model(tree)

validate_exchange_format(tree)

Validate a serialized Exchange Format tree against the bundled XSD.

Returns a list of validation error strings (empty list means valid). Raises :exc:FileNotFoundError if the XSD has not been bundled yet.

Source code in src/etcion/serialization/xml.py
238
239
240
241
242
243
244
245
246
247
248
249
def validate_exchange_format(tree: etree._ElementTree) -> list[str]:
    """Validate a serialized Exchange Format tree against the bundled XSD.

    Returns a list of validation error strings (empty list means valid).
    Raises :exc:`FileNotFoundError` if the XSD has not been bundled yet.
    """
    if not _XSD_PATH.exists():
        raise FileNotFoundError(f"XSD not found at {_XSD_PATH}")
    schema = etree.XMLSchema(etree.parse(str(_XSD_PATH)))
    if schema.validate(tree):
        return []
    return [str(e) for e in schema.error_log]

etcion.serialization.json

JSON serialization for etcion models.

Reference: ADR-031 Decision 9.

model_to_dict(model)

Serialize a :class:~etcion.metamodel.model.Model to a JSON-compatible dictionary.

The returned structure is::

{
    "elements": [<element dict>, ...],
    "relationships": [<relationship dict>, ...],
}

Every entry carries a _type key whose value is the ArchiMate type name string (e.g. "BusinessActor"), used as a discriminator during deserialization.

Source code in src/etcion/serialization/json.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def model_to_dict(model: Model) -> dict[str, Any]:
    """Serialize a :class:`~etcion.metamodel.model.Model` to a JSON-compatible dictionary.

    The returned structure is::

        {
            "elements": [<element dict>, ...],
            "relationships": [<relationship dict>, ...],
        }

    Every entry carries a ``_type`` key whose value is the ArchiMate
    type name string (e.g. ``"BusinessActor"``), used as a discriminator
    during deserialization.
    """
    return {
        "elements": [_serialize_concept(e) for e in model.elements],
        "relationships": [_serialize_concept(r) for r in model.relationships],
    }

model_from_dict(data)

Reconstruct a :class:~etcion.metamodel.model.Model from a JSON-compatible dictionary.

Deserialization is two-phase:

  1. Elements — each element dict is validated into its correct concrete class and added to the model; an id_map is built for cross-reference resolution.
  2. Relationships — the source and target bare ID strings are resolved to the corresponding :class:~etcion.metamodel.concepts.Concept instances before the relationship is validated and added.

:raises KeyError: if a _type value is not present in :data:_NAME_TO_TYPE or a relationship references an unknown element ID.

Source code in src/etcion/serialization/json.py
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def model_from_dict(data: dict[str, Any]) -> Model:
    """Reconstruct a :class:`~etcion.metamodel.model.Model` from a JSON-compatible dictionary.

    Deserialization is two-phase:

    1. **Elements** — each element dict is validated into its correct
       concrete class and added to the model; an ``id_map`` is built
       for cross-reference resolution.
    2. **Relationships** — the ``source`` and ``target`` bare ID strings
       are resolved to the corresponding :class:`~etcion.metamodel.concepts.Concept`
       instances before the relationship is validated and added.

    :raises KeyError: if a ``_type`` value is not present in
        :data:`_NAME_TO_TYPE` or a relationship references an unknown element ID.
    """
    model = Model()
    id_map: dict[str, Concept] = {}

    for elem_data in data.get("elements", []):
        elem_data = dict(elem_data)  # copy so we don't mutate the caller's dict
        type_name = elem_data.pop("_type")
        cls = _NAME_TO_TYPE[type_name]
        elem = cls.model_validate(elem_data)
        id_map[elem.id] = elem
        model.add(elem)

    for rel_data in data.get("relationships", []):
        rel_data = dict(rel_data)  # copy so we don't mutate the caller's dict
        type_name = rel_data.pop("_type")
        cls = _NAME_TO_TYPE[type_name]
        rel_data["source"] = id_map[rel_data["source"]]
        rel_data["target"] = id_map[rel_data["target"]]
        rel = cls.model_validate(rel_data)
        model.add(rel)

    return model

etcion.serialization.registry

External TypeRegistry mapping Concept subclasses to XML descriptors.

Reference: ADR-031 Decision 3.

TypeDescriptor(xml_tag, extra_attrs=dict()) dataclass

Maps a Concept subclass to its Exchange Format XML representation.

register_element_type(cls, xml_tag, extra_attrs=None)

Register a custom Element subclass with the serialization layer.

.. warning:: Models containing custom types are not portable to other ArchiMate tools. Prefer Profiles for spec-compliant extension.

:param cls: A concrete Element subclass (must define _type_name). :param xml_tag: The XML tag name used in Exchange Format serialization. :param extra_attrs: Optional dict mapping XML attribute names to callables that extract the attribute value from an instance. :raises TypeError: If cls is not a concrete Element subclass. :raises ValueError: If cls is already registered.

Source code in src/etcion/serialization/registry.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
def register_element_type(
    cls: type[Concept],
    xml_tag: str,
    extra_attrs: dict[str, Callable[[Any], str | None]] | None = None,
) -> None:
    """Register a custom Element subclass with the serialization layer.

    .. warning::
        Models containing custom types are **not portable** to other
        ArchiMate tools. Prefer Profiles for spec-compliant extension.

    :param cls: A concrete Element subclass (must define ``_type_name``).
    :param xml_tag: The XML tag name used in Exchange Format serialization.
    :param extra_attrs: Optional dict mapping XML attribute names to
        callables that extract the attribute value from an instance.
    :raises TypeError: If *cls* is not a concrete Element subclass.
    :raises ValueError: If *cls* is already registered.
    """
    import warnings

    from etcion.metamodel.concepts import Element as _Element
    from etcion.validation import permissions as _perm

    if not (isinstance(cls, type) and issubclass(cls, _Element)):
        raise TypeError(f"Expected a concrete Element subclass, got {cls!r}")
    if "_type_name" not in cls.__dict__ and not any(
        "_type_name" in c.__dict__ for c in cls.__mro__ if c is not Concept
    ):
        raise TypeError(f"{cls.__name__} is abstract (no _type_name)")
    if cls in TYPE_REGISTRY:
        raise ValueError(f"{cls.__name__} is already registered")

    TYPE_REGISTRY[cls] = TypeDescriptor(
        xml_tag=xml_tag,
        extra_attrs=extra_attrs or {},
    )
    _perm._cache = None

    warnings.warn(
        f"Custom type '{cls.__name__}' registered. Models containing this "
        f"type are NOT portable to other ArchiMate tools.",
        UserWarning,
        stacklevel=2,
    )