Source code for pyArchimate.relationship

"""Relationship module - extracted from the legacy monolith."""

from typing import TYPE_CHECKING, Any, Optional, cast
from uuid import UUID, uuid4

from .constants import ALLOWED_RELATIONSHIPS, ARCHI_CATEGORY, RELATIONSHIP_KEYS
from .enums import ArchiType
from .exceptions import ArchimateConceptTypeError, ArchimateRelationshipError
from .logger import log

if TYPE_CHECKING:
    from .element import Element
    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
    """
    try:
        uuid_obj = UUID(uuid_to_test, version=version)
    except ValueError:
        return False
    return str(uuid_obj) == uuid_to_test


def set_id(uuid=None):
    """
    Function to create an identifier if none exists

    :param uuid: a uuid
    :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 _report(exc: Exception, raise_flg: bool) -> None:
    if raise_flg:
        raise exc
    log.error(exc)


[docs] def check_valid_relationship(rel_type, source_type, target_type, raise_flg=False): """ Check if a relationship is used according to Archimate language or raise an exception :param rel_type: relationship type :type rel_type: str :param source_type: source concept type :type source_type: str :param target_type: target concept type :type target_type: str :param raise_flg: Throw an exception instead of logging an error """ if not hasattr(ArchiType, rel_type) or ARCHI_CATEGORY[rel_type] != "Relationship": _report(ArchimateConceptTypeError(f"Invalid Archimate Relationship Concept type '{rel_type}'"), raise_flg) if not hasattr(ArchiType, source_type): _report(ArchimateConceptTypeError(f"Invalid Archimate Source Concept type '{source_type}'"), raise_flg) if not hasattr(ArchiType, target_type): _report(ArchimateConceptTypeError(f"Invalid Archimate Target Concept type '{target_type}'"), raise_flg) if ARCHI_CATEGORY[source_type] == "Relationship": source_type = "Relationship" if ARCHI_CATEGORY[target_type] == "Relationship": target_type = "Relationship" if "Junction" in rel_type: rel_type = "Junction" if "Junction" in source_type: source_type = "Junction" if "Junction" in target_type: target_type = "Junction" if RELATIONSHIP_KEYS[rel_type] not in ALLOWED_RELATIONSHIPS[source_type][target_type]: _report( ArchimateRelationshipError( f"Invalid Relationship type '{rel_type}' from '{source_type}' and '{target_type}' " ), raise_flg, )
def _resolve_and_validate_ref(ref: Any, elems_dict: dict[str, Any], rels_dict: dict[str, Any], arg_name: str) -> str: """Resolve an element/relationship arg to its UUID and verify it exists in the model.""" if isinstance(ref, str): uid: str = ref else: from .element import Element # noqa: PLC0415 # circular: element↔relationship init cycle if not (isinstance(ref, Element) or isinstance(ref, Relationship) or hasattr(ref, "uuid")): raise ValueError(f"'{arg_name}' argument is not an instance of 'Element or Relationship' class.") uid = cast(str, ref.uuid) if uid not in elems_dict and uid not in rels_dict: raise ValueError(f'Invalid {arg_name} reference "{uid}') return uid def _get_concept_type(uid: str, elems_dict: dict[str, Any], rels_dict: dict[str, Any]) -> str: if uid in elems_dict: return cast(str, elems_dict[uid].type) return cast(str, rels_dict[uid].type)
[docs] def get_default_rel_type(source_type, target_type): """Return the default valid relationship type between two element types.""" if not hasattr(ArchiType, source_type) or ARCHI_CATEGORY[source_type] == "Relationship": raise ArchimateConceptTypeError(f"Invalid Archimate Source Concept type '{source_type}'") if not hasattr(ArchiType, target_type) or ARCHI_CATEGORY[target_type] == "Relationship": raise ArchimateConceptTypeError(f"Invalid Archimate Target Concept type '{target_type}'") rels = ALLOWED_RELATIONSHIPS[source_type][target_type] if len(rels) > 0: t: str = rels[0] for preferred in ("g", "r", "s", "a", "c", "o", "v"): if preferred in rels: t = preferred break return [k for k, v in RELATIONSHIP_KEYS.items() if v == t][0]
[docs] class Relationship: """ Class to manage the relationship between two elements of the model :param rel_type: Archimate relationship type :type rel_type: str :param source: element source either an element identifier, or an element object :type source: [str|Element] :param target: element target either an element identifier, or an element object :type target: [str|Element] :param uuid: identifier of the relationship :type uuid: str :param name: optional name of the relationship :type name: str :param access_type: optional parameter for access relationship ('Read', 'ReadWrite', 'Write', 'Access') :type access_type: str :param influence_strength: optional influence strength for Influence relationships. Canonical field preserved across export/import cycles. Values: numeric (0-10), '+', '++', '-', '--'. Supports round-trip fidelity with both .archimate and OpenGroup formats. On import, automatically maps legacy 'modifier' field to influenceStrength. :type influence_strength: str :param desc: description/documentation text of the relationship. Stored as <documentation> element in .archimate format. Preserved during round-trip export/import cycles with support for Unicode characters, special XML characters, and arbitrary length text. :type desc: str :param is_directed: boolean flag for association relationship :type is_directed: bool :param profile: relationship profile identifier :type profile: str :param parent: parent Model object :type parent: Model """ def __init__( self, rel_type="", source=None, target=None, uuid=None, name=None, access_type=None, influence_strength=None, desc=None, is_directed=None, profile=None, parent=None, ): """Initialize a relationship between two elements with type and optional properties.""" if parent is not None: # Accept any model-like object exposing relationship storage to # remain compatible with legacy and modular Model instances. if not hasattr(parent, "rels_dict"): raise ValueError("Relationship class parent should be a class Model instance!") self.parent: Model = cast("Model", parent) self.model: Model = cast("Model", parent) self._source = _resolve_and_validate_ref(source, self.parent.elems_dict, self.parent.rels_dict, "source") self._target = _resolve_and_validate_ref(target, self.parent.elems_dict, self.parent.rels_dict, "target") self._uuid = set_id(uuid) self._type = rel_type self.name = name self.desc = desc self._properties = {} self.folder: str | None = None self._profile = profile self._access_type = access_type self._influence_strength = influence_strength self._is_directed = is_directed # check relationship validity (it also tests concept type validity) or raise exception src_type = _get_concept_type(self._source, self.model.elems_dict, self.model.rels_dict) dst_type = _get_concept_type(self._target, self.model.elems_dict, self.model.rels_dict) check_valid_relationship(self.type, src_type, dst_type) # Add the new relationship object in model's dictionaries self.parent.rels_dict[self.uuid] = self
[docs] def delete(self): """ Method to delete this relationship from the model structure Also take care to remove visual conns from the views """ _id = self._uuid # remove related conns for c in self.parent.conns_dict.copy().values(): if c.ref == _id: c.delete() del c # remove from parent dictionaries if _id in self.parent.rels_dict: del self.parent.rels_dict[_id]
@property def uuid(self): """ Get this relationship identifier :return: Identifier :rtype: str """ return self._uuid @property def source(self): """ Get the source object :return: Source object :rtype: [Element | None] """ _id = self._source if _id in self.parent.elems_dict: return self.parent.elems_dict[_id] if _id in self.parent.rels_dict: return self.parent.rels_dict[_id] return None @source.setter def source(self, src): """ Set the reference to a new source object :param src: an Element identifier or an Element object :type src: [str | Element] :raises ArchimateConceptTypeError: wrong type exception """ if isinstance(src, str): self._source = src elif not isinstance(src, type(None)): from .element import Element # noqa: PLC0415 # circular: element↔relationship init cycle if not isinstance(src, Element): raise ArchimateConceptTypeError("'source' argument is not an instance of 'Element' class.") else: self._source = src.uuid @property def target(self) -> Optional["Element"]: """ Get the target object :return: Element object :rtype: Element """ _id = self._target if _id in self.parent.elems_dict: return cast("Element", self.parent.elems_dict[_id]) if _id in self.parent.rels_dict: return cast("Element", self.parent.rels_dict[_id]) return None @target.setter def target(self, dst): """ Set the reference to a new target object :param dst: an Element identifier or an Element object :type dst: [str | Element] :raises ArchimateConceptTypeError: wrong type exception """ if isinstance(dst, str): self._target = dst elif not isinstance(dst, type(None)): from .element import Element # noqa: PLC0415 # circular: element↔relationship init cycle if not isinstance(dst, Element): raise ArchimateConceptTypeError("'target' argument is not an instance of 'Element' class.") else: self._target = dst.uuid @property def type(self): # noqa: A003 # 'type' is the canonical ArchiMate attribute name; renaming would break public API """ Get the Archimate type of the relationship :return: Archimate type :rtype: str """ return self._type @type.setter # noqa: A003 # 'type' is the canonical ArchiMate attribute name; renaming would break public API def type(self, new_type): """ Set a new type for the relationship :param new_type: :type new_type: str """ if new_type not in ARCHI_CATEGORY or ARCHI_CATEGORY[new_type] != "Relationship": raise ValueError("Invalid Archimate relationship type") # Raise an exception is the new relationship type is not compatible with the source & target ones check_valid_relationship( new_type, self.source.type if self.source else "", self.target.type if self.target else "" ) # noqa: E501 self._type = new_type @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 or updates the profile for the current instance based on the provided profile name. If the profile name exists in the model's profile collection, it is set as the current profile. Otherwise, a new profile is created and added to the model, and its unique identifier is set for the current profile. Args: profile_name (str): The name of the profile to set. If it does not exist, a new profile is created with this name. Raises: ValueError: If the profile name is invalid or cannot be processed. """ n = [x.name 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): """ Return the properties of this relationship :return: properties :rtype: dict """ return self._properties
[docs] def prop(self, key, value=None): """ Method to set a property given by its key if a value is provided or to return of value of a property if the value is None :param key: :type key: str :param value: :type value: str :return: value of the property defined by the key or 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): """ Methode to remove a property by key :param key: :type key: str """ if key in self._properties: del self._properties[key]
@property def access_type(self): """ Get the access type of an Access relationship :return: type :rtype: str """ return self._access_type @access_type.setter def access_type(self, val): """ Set the access type of an Access relationship :param val: :type val: str """ self._access_type = val if val is not None and self.type == ArchiType.Access: self._access_type = val @property def is_directed(self): """ Get the direction of an Association relationship :return: direction flag :rtype: boolean """ return self._is_directed @is_directed.setter def is_directed(self, val: bool) -> None: """ Set the direction of an Association relationship :param val: :type val: bool """ if val is not None and self.type == ArchiType.Association: self._is_directed = "true" if val else "false" @property def influence_strength(self): """ Get the influence strength of an Influence relationship :return: influence strength :rtype: str """ return self._influence_strength @influence_strength.setter def influence_strength(self, strength): """ Set the influence strength of an Influence relationship [0-10 | '+' | '++' | '-' | '--'] :param strength: strength value :type strength: str """ if strength is not None and self.type == ArchiType.Influence: self._influence_strength = str(strength)
[docs] def remove_folder(self): """ Method to remove this element from the given folder path """ self.folder = None
__all__ = ["Relationship"]