"""Element module - extracted from the legacy monolith."""
import re
from typing import TYPE_CHECKING, Any, cast
from uuid import UUID, uuid4
from .constants import ARCHI_CATEGORY, JUNCTION_TYPES, NAMED_COLORS
from .enums import ArchiType
from .exceptions import ArchimateConceptTypeError
from .viewpoint_registry import validate_viewpoint_slug
if TYPE_CHECKING:
from .model import Model
def _is_valid_uuid(uuid_to_test, version=4):
"""
Check if uuid_to_test is a valid UUID.
:param uuid_to_test: uuid string
:param version: {1, 2, 3, 4}
:return: True if uuid_to_test is a valid UUID, otherwise `False`.
:rtype: bool
Examples
--------
is_valid_uuid('c9bf9e57-1685-4c89-bafb-ff5af830be8a')
True
is_valid_uuid('c9bf9e58')
False
"""
try:
uuid_obj = UUID(uuid_to_test, version=version)
except ValueError:
return False
return str(uuid_obj) == uuid_to_test
[docs]
def set_id(uuid: str | None = None) -> str:
"""
Function to create an identifier if none exists
:param uuid: a uuid
:cat uuid: str
:return: a formatted identifier
:rtype: str
"""
_id = str(uuid4()) if (uuid is None) else uuid
if _is_valid_uuid(_id):
_id = _id.replace("-", "")
if not _id.startswith("id-"):
_id = "id-" + _id
return _id
def _normalize_color(color: str | None) -> str | None:
"""
Normalize a color to lowercase hex format.
:param color: Color as hex (#RRGGBB) or named color, or None
:return: Normalized hex color (#rrggbb) or None
:raises ValueError: If color format is invalid or color name unknown
"""
if color is None:
return None
color = str(color).strip()
if color.startswith("#"):
if not re.match(r"^#[0-9a-fA-F]{6}$", color):
raise ValueError(f"Invalid hex color: {color} (expected #RRGGBB)")
return color.lower()
named = NAMED_COLORS.get(color.lower())
if named:
return named
raise ValueError(f"Unknown color: {color} (hex or named color expected)")
[docs]
class Element:
"""
Class to manage Element artifacts with visual styling and hierarchy support.
Supports ArchiMate v3.x elements with optional parent-child relationships,
custom visual styles (colors, transparency), and junction type semantics.
:param name: Name of the element
:type name: str
:param elem_type: Archimate concept type of element
:type elem_type: str
:param uuid: element identifier
:type uuid: str
:param desc: description of the element
:type desc: str
:param folder: folder path in which element should be referred to (e.g. /Application/BE)
:type folder: str
:param parent: reference to the parent Model object
:type parent: Model
:param profile: element profile identifier
:type profile: str
**Visual Styling (P3)**:
- Use set_fill_color(color), set_line_color(color) to customize appearance
- Supports hex colors (#RRGGBB) and named colors (e.g., 'red', 'blue')
- Use set_line_width(width) and set_transparency(alpha) for additional styling
- All visual properties are preserved during XML export/import round-trips
**Hierarchy (P3)**:
- Elements can be organized into parent-child relationships via Model.add_child()
- Use get_parent() to retrieve parent, get_siblings() for neighbors
- Junction elements (AND/OR/XOR) use set_junction_type() for semantics
**Junction Types (P3)**:
- Junction elements support type validation: 'and', 'or', 'xor'
- Use set_junction_type(type_str) to set junction semantics
- Junction types are validated on set and preserved in round-trip exports
:raises ArchimateConceptTypeError: Exception raised on elem_type or parent type error
:return: Element object
:rtype: Element
Example::
from pyArchimate import ArchiType
from pyArchimate.model import Model
m = Model('example')
# Create elements
process = m.add(ArchiType.BusinessProcess, 'Order Processing')
func = m.add(ArchiType.BusinessFunction, 'Order Fulfillment')
junction = m.add(ArchiType.Junction, 'Decision Point')
# Build hierarchy
m.add_child(process.uuid, func.uuid)
# Style elements
process.set_fill_color('#ffeb3b')
process.set_transparency(0.9)
# Set junction semantics
junction.set_junction_type('and')
"""
def __init__(self, elem_type=None, name=None, uuid=None, desc=None, folder=None, parent=None, profile=None):
"""Initialize an ArchiMate element with type, name, and parent model."""
# Check validity of arguments according to Archimate standard
if elem_type is None or not hasattr(ArchiType, elem_type):
raise ArchimateConceptTypeError(f"Invalid Element type '{elem_type}'")
if ARCHI_CATEGORY[elem_type] == "Relationship":
raise ArchimateConceptTypeError(f"Element type '{elem_type}' cannot be a Relationship type")
if parent is not None and not hasattr(parent, "elems_dict"):
raise ValueError("Element class parent should be a class Model instance!")
# Attribute and data structure initialization
self._uuid: str = set_id(uuid)
self.parent: Model = cast("Model", parent)
self.model: Model = cast("Model", parent)
self.name: str | None = name
self._type: str | None = elem_type
self.desc: str | None = desc
self.folder: str | None = folder
self._properties: dict[str, object] = {}
self._profile: str | None = profile
self.junction_type: str | None = None
self._viewpoints: list[str] = [] # list of canonical viewpoint slugs
self._parent_uuid: str | None = None
self._visual_style: dict[str, Any] = {}
def _delete_view_refs(self, _id: str) -> None:
for n in self.parent.nodes_dict.copy().values():
if n.ref == _id:
n.delete()
del n
for r in self.parent.rels_dict.copy().values():
if r.source.uuid == _id or r.target.uuid == _id:
r.delete()
del r
def _orphan_children(self, _id: str) -> None:
for child_uuid in self.parent._element_children.get(_id, set()).copy():
child = self.parent.elems_dict.get(child_uuid)
if child:
child._parent_uuid = None
if child_uuid in self.parent._element_hierarchy:
del self.parent._element_hierarchy[child_uuid]
if _id in self.parent._element_children:
del self.parent._element_children[_id]
[docs]
def delete(self) -> None:
"""
Delete the current element from the parent model
Note: it does not delete the instance itself but it remove the data from the model
It also deletes all relationships that have this element as source or target and
it deletes all visual nodes referring to this element (and conns) from views
Children are orphaned rather than cascaded (Phase 2 behavior).
"""
_id = self.uuid
self._delete_view_refs(_id)
# P3 Phase 2: Clean up stale viewpoint references
for vp_id in self._viewpoints:
self.parent._viewpoint_elements.get(vp_id, set()).discard(_id)
self._orphan_children(_id)
# P3 Phase 2: Remove self from parent's children
if self._parent_uuid is not None:
self.parent._element_children.get(self._parent_uuid, set()).discard(_id)
if _id in self.parent._element_hierarchy:
del self.parent._element_hierarchy[_id]
if _id in self.parent.elems_dict:
del self.parent.elems_dict[_id]
@property
def uuid(self) -> str:
"""
Get the identifier of this element
:return: Identifier str
:rtype: str
"""
return self._uuid
@property
def type(self) -> str | None: # noqa: A003 # 'type' is the canonical ArchiMate attribute name; renaming would break public API
"""
Get the Archimate concept type of this element
:return: type str
:rtype: str
"""
return self._type
@type.setter # noqa: A003 # 'type' is the canonical ArchiMate attribute name; renaming would break public API
def type(self, value):
"""
Convert this element to a new Archimate concept type
Note: this is potentially dangerous as existing relationships may become invalid
No check if performed on the relationship validity afet conversion
:param value:
:type value: str
"""
if value is not None:
if value not in ARCHI_CATEGORY or ARCHI_CATEGORY[value] == "Relationship":
raise ValueError("Invalid Archimate element type")
self._type = value
@property
def profile_name(self):
"""
Retrieve the name of the profile associated with the current object. This is done
by checking if the profile attribute exists and matches a profile in the model's
profiles. If no matching profile is found or the profile attribute is None, the
method returns None.
Returns:
str or None: The name of the associated profile if it exists, otherwise None.
"""
pn = [x.name for x in self.model.profiles if x.uuid == self._profile]
if len(pn) == 1:
return pn[0]
else:
return None
@property
def profile_id(self):
"""
Gets the profile ID if the current profile is valid and exists in the associated
model's profiles. Returns None if either there is no current profile or it does
not exist in the model's profiles.
Returns:
int or None: The ID of the current profile if it exists, otherwise None.
"""
pn = [x.uuid for x in self.model.profiles if x.uuid == self._profile]
if len(pn) == 1:
return pn[0]
else:
return None
[docs]
def set_profile(self, profile_name):
"""
Sets the current profile for an instance. If the specified profile name already
exists in the model, it sets the profile to the matching profile. Otherwise,
a new profile is created, added to the model, and set as the current profile.
Parameters:
profile_name (str): The name of the profile to set. If it exists in the
model, it will be used directly. If not, a new profile with this name
will be created and used.
Raises:
No exceptions are explicitly raised in this method.
"""
n = [x.uuid for x in self.model.profiles if x.name == profile_name]
if len(n) == 1:
self._profile = n[0]
else:
# add a new profile to the model
p = self.model.add_profile(name=profile_name, concept=self.type)
self._profile = p.uuid
[docs]
def reset_profile(self) -> None:
"""Clear the profile assignment."""
self._profile = None
@property
def props(self):
"""
Get all element properties as a dictionary
:return: properties
:rtype: dict
"""
return self._properties
[docs]
def prop(self, key, value=None):
"""
Method to get or set an element's property
:param key: Property key
:type key: str
:param value: Property value
:type value: str
:return: an existing element property value str if 'value' argument is None
:rtype: str
"""
if value is None:
return self._properties[key] if key in self._properties else None
else:
self._properties[key] = value
return value
[docs]
def remove_prop(self, key):
"""
Method to remove an element property
:param key:
:type key: str
"""
if key in self._properties:
del self._properties[key]
def _merge_properties_and_desc(self, elem: "Element") -> None:
for key, val in elem.props.items():
if key not in self.props:
self.prop(key, val)
if elem.desc != self.desc:
self.desc = (self.desc or "") + "\n----\n" + (elem.desc or "")
[docs]
def merge(self, elem: "Element", merge_props: bool = False) -> None:
"""
Method to merge another element of the same type with this element
:param elem: the element to merge
:type elem: Element
:param merge_props: flag to merge or not the properties
:type merge_props: bool
"""
# check if merged elem is of the same type or raise an exception
if elem.type != self.type:
raise ValueError("Merged element has not the same type as target")
# merge elem into the current one
if merge_props:
self._merge_properties_and_desc(elem)
# Re-assign othe element related node references to this element (merge target)
for n in [self.model.nodes_dict[x] for x in self.model.nodes_dict if self.model.nodes_dict[x].ref == elem.uuid]:
n.ref = self.uuid
# Re-assign other element inboud and outbound relationship references to this element
for r in self.model.filter_relationships(lambda x: x.target is not None and x.target.uuid == elem.uuid):
r.target = self
for r in self.model.filter_relationships(lambda x: x.source is not None and x.source.uuid == elem.uuid):
r.source = self
# finally delete the merged element
elem.delete()
[docs]
def in_rels(self, rel_type=None):
"""
Method to get a list of the inbound relationships
:param rel_type: relationship type to filter
:type rel_type: str
:return: [Relationship]
:rtype: list
"""
if rel_type is None:
return self.model.filter_relationships(lambda x: x.target is not None and x.target.uuid == self.uuid)
else:
return self.model.filter_relationships(
lambda x: x.target is not None and x.target.uuid == self.uuid and x.type == rel_type
)
[docs]
def out_rels(self, rel_type=None):
"""
Method to get a list of the outbound relationships
:param rel_type: relationship type to filter
:type rel_type: str
:return: [Relationship]
:rtype: list
"""
if rel_type is None:
return self.model.filter_relationships(lambda x: x.source is not None and x.source.uuid == self.uuid)
else:
return self.model.filter_relationships(
lambda x: x.source is not None and x.source.uuid == self.uuid and x.type == rel_type
)
[docs]
def rels(self, rel_type=None):
"""
Method to get a list of the inbound and outbound relationships
:param rel_type: relationship type to filter
:type rel_type: str
:return: [Relationship]
:rtype: list
"""
if rel_type is None:
return self.model.filter_relationships(
lambda x: (
(x.target is not None and x.target.uuid == self.uuid)
or (x.source is not None and x.source.uuid == self.uuid)
)
)
else:
return self.model.filter_relationships(
lambda x: (
(
(x.target is not None and x.target.uuid == self.uuid)
or (x.source is not None and x.source.uuid == self.uuid)
)
and x.type == rel_type
)
)
[docs]
def remove_folder(self):
"""
Method to remove this element from the given folder path
"""
self.folder = None
@property
def viewpoints(self) -> list[str]:
"""Return the list of assigned viewpoint slugs.
:return: list of canonical viewpoint slug strings
:rtype: list[str]
"""
return list(self._viewpoints)
[docs]
def assign_viewpoint(self, viewpoint_id: str) -> None:
"""Assign a standard ArchiMate 3.x viewpoint slug to this element.
:param viewpoint_id: canonical viewpoint slug (e.g. 'stakeholder')
:type viewpoint_id: str
:raises ValueError: if viewpoint_id is not a recognised slug
"""
validate_viewpoint_slug(viewpoint_id)
if viewpoint_id not in self._viewpoints:
self._viewpoints.append(viewpoint_id)
if self.parent is not None:
vp_elems = self.parent._viewpoint_elements.setdefault(viewpoint_id, set())
vp_elems.add(self._uuid)
[docs]
def remove_viewpoint(self, viewpoint_id: str) -> None:
"""Remove a viewpoint slug assignment; silently ignores unknown slugs.
:param viewpoint_id: canonical viewpoint slug to remove
:type viewpoint_id: str
"""
if viewpoint_id in self._viewpoints:
self._viewpoints.remove(viewpoint_id)
if self.parent is not None:
self.parent._viewpoint_elements.get(viewpoint_id, set()).discard(self._uuid)
@property
def parent_uuid(self) -> str | None:
"""Get the parent element UUID (for hierarchical grouping).
:return: Parent element UUID or None if this is a root element
:rtype: Optional[str]
"""
return self._parent_uuid
[docs]
def set_fill_color(self, color: str | None) -> None:
"""Set the fill color of this element.
:param color: Hex color (#RRGGBB), named color, or None to use default
:type color: Optional[str]
:raises ValueError: If color format is invalid
"""
if color is None:
self._visual_style.pop("fillColor", None)
else:
self._visual_style["fillColor"] = _normalize_color(color)
[docs]
def set_line_color(self, color: str | None) -> None:
"""Set the line/border color of this element.
:param color: Hex color (#RRGGBB), named color, or None to use default
:type color: Optional[str]
:raises ValueError: If color format is invalid
"""
if color is None:
self._visual_style.pop("lineColor", None)
else:
self._visual_style["lineColor"] = _normalize_color(color)
[docs]
def set_line_width(self, width: float | None) -> None:
"""Set the line/border width of this element.
:param width: Width in pixels (≥ 0), or None to use default
:type width: Optional[float]
:raises ValueError: If width is negative
:raises TypeError: If width is not numeric
"""
if width is None:
self._visual_style.pop("lineWidth", None)
else:
if not isinstance(width, (int, float)):
raise TypeError(f"Line width must be number, got {type(width).__name__}")
if width < 0:
raise ValueError(f"Line width must be non-negative number, got {width}")
self._visual_style["lineWidth"] = float(width)
[docs]
def set_transparency(self, alpha: float | None) -> None:
"""Set the transparency/opacity of this element.
:param alpha: Opacity 0.0 (transparent) to 1.0 (opaque), or None to use default
:type alpha: Optional[float]
:raises ValueError: If alpha is out of range
:raises TypeError: If alpha is not numeric
"""
if alpha is None:
self._visual_style.pop("transparency", None)
else:
if not isinstance(alpha, (int, float)):
raise TypeError(f"Transparency must be number, got {type(alpha).__name__}")
if alpha < 0.0 or alpha > 1.0:
raise ValueError(f"Transparency must be 0.0-1.0, got {alpha}")
self._visual_style["transparency"] = float(alpha)
[docs]
def set_visual_style(
self,
fill_color: str | None = None,
line_color: str | None = None,
line_width: float | None = None,
transparency: float | None = None,
) -> None:
"""Set multiple visual style properties at once.
:param fill_color: Fill color (hex or named)
:param line_color: Line color (hex or named)
:param line_width: Line width in pixels (≥ 0)
:param transparency: Opacity 0.0-1.0
:raises ValueError: If any property is invalid
"""
if fill_color is not None:
self.set_fill_color(fill_color)
if line_color is not None:
self.set_line_color(line_color)
if line_width is not None:
self.set_line_width(line_width)
if transparency is not None:
self.set_transparency(transparency)
[docs]
def get_fill_color(self) -> str | None:
"""Get the fill color of this element.
:return: Hex color (#rrggbb) or None if not set (use default)
:rtype: Optional[str]
"""
return self._visual_style.get("fillColor")
[docs]
def get_line_color(self) -> str | None:
"""Get the line/border color of this element.
:return: Hex color (#rrggbb) or None if not set (use default)
:rtype: Optional[str]
"""
return self._visual_style.get("lineColor")
[docs]
def get_line_width(self) -> float | None:
"""Get the line/border width of this element.
:return: Width in pixels or None if not set (use default)
:rtype: Optional[float]
"""
return self._visual_style.get("lineWidth")
[docs]
def get_transparency(self) -> float | None:
"""Get the transparency/opacity of this element.
:return: Opacity 0.0-1.0 or None if not set (use default)
:rtype: Optional[float]
"""
return self._visual_style.get("transparency")
[docs]
def get_visual_style(self) -> dict[str, Any]:
"""Get all visual style properties as a dictionary.
:return: Dictionary with fillColor, lineColor, lineWidth, transparency (only set values)
:rtype: dict[str, Any]
"""
return dict(self._visual_style)
[docs]
def reset_visual_style(self) -> None:
"""Reset all custom visual styles to defaults.
:return: None
"""
self._visual_style.clear()
[docs]
def set_junction_type(self, junction_type: str | None) -> None:
"""
Set the junction type for a Junction element.
:param junction_type: Junction type ('and', 'or', 'xor') or None
:type junction_type: Optional[str]
:raises ValueError: If junction_type is not valid
"""
if junction_type is None:
self.junction_type = None
return
junction_type_lower = str(junction_type).lower().strip()
if junction_type_lower not in JUNCTION_TYPES:
raise ValueError(f"Invalid junction type: {junction_type}. Must be one of {JUNCTION_TYPES}")
self.junction_type = junction_type_lower
[docs]
def get_junction_type(self) -> str | None:
"""
Get the junction type for a Junction element.
:return: Junction type ('and', 'or', 'xor') or None if not set
:rtype: Optional[str]
"""
return self.junction_type
__all__ = ["Element"]