Source code for pyArchimate.writers.archimateWriter

"""Writer for ArchiMate Exchange format (.archimate).

Exports pyArchimate models and views to exchange-compatible .archimate files.
Supports ArchiMate 3.x specification and standard exchange metadata.
"""

# ruff: noqa: N999  # legacy module name preserved for API compatibility
import os
import sys
from collections import defaultdict
from collections.abc import Callable
from typing import Any
from typing import cast as _cast

from lxml import etree as et
from lxml.etree import _Element

try:
    from ..constants import (
        ARCHI_CATEGORY as archi_category,  # noqa: N811  # alias matches public API export and fallback import
    )
    from ..constants import (
        DEFAULT_THEME as default_theme,  # noqa: N811  # alias matches public API export and fallback import
    )
    from ..constants import RGBA
    from ..enums import ArchiType
    from ..helpers.logging import log
    from ..model import Model, default_color
    from ..view import Node
except ImportError:
    sys.path.insert(0, "..")
    from pyArchimate import (  # type: ignore[no-redef,attr-defined]  # noqa: E401
        RGBA,
        ArchiType,
        Model,
        Node,
        archi_category,
        default_color,
        default_theme,
        log,
    )

__mod__ = __name__.split(".")[len(__name__.split(".")) - 1]
__location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__)))

_NS_ITEM = "ns:item"

_GetPropId = Callable[[str], str]


def _get_prop_def_id(model: Model, k: str) -> str:
    id_list = [x for x, y in model.pdefs.items() if y == k]
    if len(id_list) == 0:
        prop_id: str = "propid-" + str(len(model.pdefs) + 1)
        model.pdefs[prop_id] = k
        return prop_id
    return str(id_list[0])


def _write_properties(parent: _Element, props: dict[str, object], model: Model) -> None:
    pp = et.SubElement(parent, "properties")
    for k, v in props.items():
        prop_id = _get_prop_def_id(model, k)
        p = et.SubElement(pp, "property", propertyDefinitionRef=prop_id)
        pv = et.SubElement(p, "value")
        pv.text = str(v)


def _write_elem_name_doc(elem: _Element, e: Any) -> None:
    if e.name is None:
        e.name = e.type
    if e.name is not None:
        e_name = et.SubElement(elem, "name")
        e_name.text = e.name
    if e.desc is not None and e.desc != "":
        e_desc = et.SubElement(elem, "documentation")
        e_desc.text = e.desc


def _write_elem_viewpoints(elem: _Element, e: Any, model: Model) -> None:
    for slug in getattr(e, "viewpoints", []):
        vp_prop_id = _get_prop_def_id(model, "viewpoint")
        pp = elem.find("properties")
        if pp is None:
            pp = et.SubElement(elem, "properties")
        p = et.SubElement(pp, "property", propertyDefinitionRef=vp_prop_id)
        pv = et.SubElement(p, "value")
        pv.text = slug


def _write_elem_visual_style(elem: _Element, e: Any, model: Model) -> None:
    visual_style = getattr(e, "_visual_style", {})
    if not visual_style:
        return
    pp = elem.find("properties")
    if pp is None:
        pp = et.SubElement(elem, "properties")
    for key in ["fillColor", "lineColor", "lineWidth", "transparency"]:
        if key in visual_style:
            prop_id = _get_prop_def_id(model, key)
            p = et.SubElement(pp, "property", propertyDefinitionRef=prop_id)
            pv = et.SubElement(p, "value")
            pv.text = str(visual_style[key])


def _write_elem_junction_type(elem: _Element, e: Any, model: Model) -> None:
    # Junction semantics are encoded in the element's xsi:type (OrJunction /
    # AndJunction) written by _write_elements, so a redundant junctionType
    # property is only written when the type is the plain 'Junction' fallback.
    junction_type = getattr(e, "junction_type", None)
    if junction_type and getattr(e, "type", None) == "Junction":
        pp = elem.find("properties")
        if pp is None:
            pp = et.SubElement(elem, "properties")
        prop_id = _get_prop_def_id(model, "junctionType")
        p = et.SubElement(pp, "property", propertyDefinitionRef=prop_id)
        pv = et.SubElement(p, "value")
        pv.text = junction_type


def _get_elem_xsi_type(e: Any) -> str:
    if e.type != "Junction":
        return e.type  # type: ignore[no-any-return]
    junction_type = getattr(e, "junction_type", None)
    if junction_type == "or":
        return "OrJunction"
    return "AndJunction"


def _ensure_folder(e: Any) -> None:
    if e.folder is None:
        cat = archi_category[e.type].split("-")[0]
        if cat == "Junction":
            cat = "Other"
        elif cat == "Physical":
            cat = "Technology"
        e.folder = "/" + cat


def _write_elements(root: _Element, model: Model, xsi: et.QName) -> None:
    elems = et.SubElement(root, "elements")
    for e in model.elements:
        _ensure_folder(e)
        elem_xsi_type = _get_elem_xsi_type(e)
        elem_attrs = {"identifier": e.uuid, str(xsi): elem_xsi_type}
        parent_uuid = getattr(e, "_parent_uuid", None)
        if parent_uuid:
            elem_attrs["parentId"] = parent_uuid
        elem = et.SubElement(elems, "element", elem_attrs)
        _write_elem_name_doc(elem, e)
        if e.props:
            _write_properties(elem, e.props, model)
        _write_elem_visual_style(elem, e, model)
        _write_elem_junction_type(elem, e, model)
        _write_elem_viewpoints(elem, e, model)


def _write_rel_attrs(elem: _Element, e: Any, model: Model) -> None:
    if e.access_type is not None and e.type == ArchiType.Access:
        elem.set("accessType", e.access_type)
    if e.is_directed is not None and e.type == ArchiType.Association:
        elem.set("isDirected", "true")
    if e.name is not None:
        e_name = et.SubElement(elem, "name")
        e_name.text = e.name
    if e.desc is not None:
        e_desc = et.SubElement(elem, "documentation")
        e_desc.text = e.desc
    if e.props:
        _write_properties(elem, e.props, model)
    if e.influence_strength is not None and e.type == ArchiType.Influence:
        pp = elem.find("properties")
        if pp is None:
            pp = et.SubElement(elem, "properties")
        prop_id = _get_prop_def_id(model, "influenceStrength")
        p = et.SubElement(pp, "property", propertyDefinitionRef=prop_id)
        pv = et.SubElement(p, "value")
        pv.text = e.influence_strength


def _write_relationships(root: _Element, model: Model, xsi: et.QName) -> None:
    rels = et.SubElement(root, "relationships")
    for e in model.relationships:
        assert e.source is not None and e.target is not None
        elem = et.SubElement(
            rels,
            "relationship",
            {"identifier": e.uuid, "source": e.source.uuid, "target": e.target.uuid, str(xsi): e.type},
        )
        _write_rel_attrs(elem, e, model)


def _collect_orgs_dict(model: Model) -> dict[str, list[str]]:
    orgs_dict: dict[str, list[str]] = defaultdict(list)
    for e in model.elements:
        if e.folder is not None:
            orgs_dict[e.folder].append(e.uuid)
    for r in model.relationships:
        if r.folder is not None:
            orgs_dict[r.folder].append(r.uuid)
    for v in model.views:
        if v.folder is not None:
            orgs_dict[v.folder].append(v.uuid)
    return orgs_dict


def _write_org_path(orgs: _Element, k: str, orgs_dict: dict[str, list[str]], ns_find: dict[str, str]) -> None:
    labels = k.split("/")
    item = orgs
    for label in labels[1:-1]:
        if item.find(_NS_ITEM, ns_find) is None:
            item = et.SubElement(item, "item")
        else:
            item = _cast(_Element, item.find(_NS_ITEM, ns_find))
        lbl = et.SubElement(item, "label")
        lbl.text = label
    if item.find(_NS_ITEM, ns_find) is None:
        item = et.SubElement(item, "item")
    else:
        item = _cast(_Element, item.find(_NS_ITEM, ns_find))
    lbl = et.SubElement(item, "label")
    lbl.text = labels[-1]
    for i in orgs_dict[k]:
        et.SubElement(item, "item", identifierRef=i)


def _write_organizations(root: _Element, model: Model, ns_find: dict[str, str]) -> None:
    orgs_dict = _collect_orgs_dict(model)
    orgs = et.SubElement(root, "organizations")
    for k in sorted(orgs_dict.keys()):
        _write_org_path(orgs, k, orgs_dict, ns_find)


def _write_node_style(n_elem: _Element, n: Node) -> None:
    style = et.SubElement(n_elem, "style")
    if n.line_color is not None:
        lc = et.SubElement(style, "lineColor")
        rgb = RGBA()
        rgb.color = n.line_color
        lc.set("r", str(rgb.r))
        lc.set("g", str(rgb.r))
        lc.set("b", str(rgb.b))
        lc.set("a", "100" if n.opacity is None else str(n.lc_opacity))
    if n.fill_color is not None:
        if n.fill_color != default_color(n.type or "", default_theme):
            fc = et.SubElement(style, "fillColor")
            rgb = RGBA()
            rgb.color = n.fill_color
            fc.set("r", str(rgb.r))
            fc.set("g", str(rgb.g))
            fc.set("b", str(rgb.b))
            fc.set("a", "100" if n.opacity is None else str(n.opacity))
    if n.font_name is not None:
        ft = et.SubElement(style, "font", attrib={"name": n.font_name, "size": str(n.font_size)})
        rgb = RGBA()
        ftc = et.SubElement(ft, "color")
        rgb.color = n.font_color
        ftc.set("r", str(rgb.r))
        ftc.set("g", str(rgb.g))
        ftc.set("b", str(rgb.b))


def _add_node(parent: _Element, n: Node, xsi: et.QName) -> None:
    if n.cat == "Element":
        n_elem = et.SubElement(
            parent,
            "node",
            attrib={
                "identifier": n.uuid,
                "elementRef": n.ref or "",
                str(xsi): n.cat,
                "x": str(n.x),
                "y": str(n.y),
                "w": str(n.w),
                "h": str(n.h),
            },
        )
    else:
        n_elem = et.SubElement(
            parent,
            "node",
            attrib={"identifier": n.uuid, str(xsi): n.cat, "x": str(n.x), "y": str(n.y), "w": str(n.w), "h": str(n.h)},
        )
        lbl = et.SubElement(n_elem, "label")
        lbl.text = n.label
    _write_node_style(n_elem, n)
    if n.cat == "Model":
        et.SubElement(n_elem, "viewRef", ref=n.ref or "")
        n_elem.set(str(xsi), "Label")
    for sub_n in n.nodes:
        _add_node(n_elem, sub_n, xsi)


def _write_conn_style(c_elem: _Element, c: Any) -> None:
    style = et.SubElement(c_elem, "style")
    if c.line_width is not None:
        style.set("lineWidth", str(c.line_width))
    if c.line_color is not None:
        if c.line_color != default_color(c.type, default_theme):
            lc = et.SubElement(style, "lineColor")
            rgb = RGBA()
            rgb.color = c.line_color
            lc.set("r", str(rgb.r))
            lc.set("g", str(rgb.g))
            lc.set("b", str(rgb.b))
    if c.font_name is not None:
        ft = et.SubElement(style, "font", attrib={"name": c.font_name, "size": str(c.font_size)})
        rgb = RGBA()
        ftc = et.SubElement(ft, "color")
        rgb.color = c.font_color
        ftc.set("r", str(rgb.r))
        ftc.set("g", str(rgb.g))
        ftc.set("b", str(rgb.b))


def _write_connections(view_elem: _Element, _v: object, xsi: et.QName) -> None:
    for c in _v.conns:  # type: ignore[attr-defined]
        if c.source is None or c.target is None:
            log.debug(f"Skipping connection {c.uuid}: missing source or target node")
            continue
        # Do NOT skip connections between embedded nodes — the OpenGroup format
        # must preserve all explicit connections regardless of visual containment.
        c_elem = et.SubElement(
            view_elem,
            "connection",
            attrib={
                "identifier": c.uuid,
                "relationshipRef": c.ref,
                str(xsi): "Relationship",
                "source": c.source.uuid,
                "target": c.target.uuid,
            },
        )
        _write_conn_style(c_elem, c)
        for bp in c.get_all_bendpoints():
            et.SubElement(c_elem, "bendpoint", x=str(int(round(bp.x))), y=str(int(round(bp.y))))


def _write_views(root: _Element, model: Model, xsi: et.QName) -> None:
    if not model.views:
        return
    views = et.SubElement(root, "views")
    diag = et.SubElement(views, "diagrams")
    for _v in model.views:
        view_attrib: dict[str, str] = {"identifier": _v.uuid, str(xsi): "Diagram"}
        # Write primary viewpoint as XML attribute on the view element
        primary_vp = getattr(_v, "primary_viewpoint", None)
        if primary_vp is not None:
            view_attrib["viewpoint"] = primary_vp
        view_elem = et.SubElement(diag, "view", attrib=view_attrib)
        if _v.name is not None:
            v_name = et.SubElement(view_elem, "name")
            v_name.text = _v.name
        if _v.desc is not None:
            doc = et.SubElement(view_elem, "documentation")
            doc.text = _v.desc
        if _v.props:
            _write_properties(view_elem, _v.props, model)
        for _n in _v.nodes:
            _add_node(view_elem, _n, xsi)
        _write_connections(view_elem, _v, xsi)


[docs] def archimate_writer(model: Model, file_path: str | None = None) -> str: """ Method to generate an Archimate XML Open Exchange File format structure as a string object Used by Model.write(filepath) method """ xml = b"""<?xml version="1.0" encoding="UTF-8"?> <model xmlns="http://www.opengroup.org/xsd/archimate/3.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.opengroup.org/xsd/archimate/3.0/ http://www.opengroup.org/xsd/archimate/3.0/archimate3.xsd" identifier="id-a84d2455d48c44a2847b3407e270599f"> </model> """ root = et.fromstring(xml) nsp_url = "http://www.opengroup.org/xsd/archimate/3.0/" # NOSONAR xsi_url = "http://www.w3.org/2001/XMLSchema-instance" # NOSONAR xsi = et.QName(xsi_url, "type") ns_find: dict[str, str] = {"ns": nsp_url} name = et.SubElement(root, "name") name.text = model.name if model.name is not None else "Archimate Model" if model.desc is not None: doc = et.SubElement(root, "documentation") doc.text = model.desc if model.props: _write_properties(root, model.props, model) _write_elements(root, model, xsi) _write_relationships(root, model, xsi) _write_organizations(root, model, ns_find) pd = et.SubElement(root, "propertyDefinitions") for k, v in model.pdefs.items(): p = et.SubElement(pd, "propertyDefinition", identifier=k, type="string") p_name = et.SubElement(p, "name") p_name.text = str(v) _write_views(root, model, xsi) pd_check = root.find("propertyDefinitions") if pd_check is not None and pd_check.find("propertyDefinition") is None: root.remove(pd_check) xml_str = et.tostring(root, encoding="UTF-8", pretty_print=True) if file_path is not None: try: with open(file_path, "wb") as fd: fd.write(xml_str) except OSError: log.error(f'{__mod__}.write: Cannot write to file "{file_path}') return xml_str.decode()