From a584e6f8c1590f1b91e183eb529d39bc82674f9c Mon Sep 17 00:00:00 2001 From: huyenngn Date: Sat, 9 Nov 2024 20:12:10 +0100 Subject: [PATCH 01/22] feat: Implement custom_diagram --- capellambse_context_diagrams/__init__.py | 10 + .../collectors/custom.py | 232 ++++++++++++++++++ capellambse_context_diagrams/context.py | 73 ++++++ 3 files changed, 315 insertions(+) create mode 100644 capellambse_context_diagrams/collectors/custom.py diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index c3d947e..b9a3fa8 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -69,6 +69,7 @@ def init() -> None: register_realization_view() register_data_flow_view() register_cable_tree_view() + register_custom_diagram() # register_functional_context() XXX: Future @@ -313,3 +314,12 @@ def register_cable_tree_view() -> None: {}, ), ) + + +def register_custom_diagram() -> None: + """Add the `custom_diagram` attribute to `ModelObject`s.""" + m.set_accessor( + sa.SystemFunction, + "custom_diagram", + context.CustomContextAccessor(DiagramType.SAB.value, {}), + ) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py new file mode 100644 index 0000000..76e2252 --- /dev/null +++ b/capellambse_context_diagrams/collectors/custom.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB InfraGO AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""This module defines the collector for the CustomDiagram.""" +from __future__ import annotations + +import collections.abc as cabc +import typing as t + +import capellambse.model as m + +from .. import _elkjs, context +from . import generic, makers + + +class CustomCollector: + """Collect the context for a custom diagram.""" + + def __init__( + self, + diagram: context.ContextDiagram, + params: dict[str, t.Any], + ) -> None: + self.diagram = diagram + self.obj: m.ModelElement = self.diagram.target + self.data = makers.make_diagram(diagram) + self.params = params + self.instructions = self.diagram._collect + self.boxes: dict[str, _elkjs.ELKInputChild] = {} + self.edges: dict[str, _elkjs.ELKInputEdge] = {} + self.ports: dict[str, _elkjs.ELKInputPort] = {} + self.boxes_to_delete: set[str] = set() + if self.diagram._display_parent_relation: + self.diagram_target_owners = list( + generic.get_all_owners(self.diagram.target) + ) + self.common_owners: set[str] = set() + if self.diagram._unify_edge_direction: + self.dicrections: dict[str, bool] = {} + + def __call__(self) -> _elkjs.ELKInputData: + self._make_target(self.obj) + if not self.instructions: + return self._get_data() + self._perform_get(self.obj, self.instructions) + if self.diagram._display_parent_relation and self.obj.owner: + current = self.obj.owner + while ( + current + and self.common_owners + and hasattr(current, "owner") + and not isinstance(current.owner, generic.PackageTypes) + ): + current = self._make_owner_box( + current, + ) + self.common_owners.discard(current.uuid) + for uuid in self.boxes_to_delete: + del self.boxes[uuid] + return self._get_data() + + def _get_data(self) -> t.Any: + self.data.children = list(self.boxes.values()) + self.data.edges = list(self.edges.values()) + return self.data + + def _matches_filters( + self, obj: m.ModelElement, filters: dict[str, t.Any] + ) -> bool: + for key, value in filters.items(): + if getattr(obj, key) != value: + return False + return True + + def _perform_get( + self, obj: m.ModelElement, instructions: dict[str, t.Any] + ) -> None: + if insts := instructions.get("get"): + create = False + elif insts := instructions.get("include"): + create = True + if not insts: + return + if isinstance(insts, dict): + insts = [insts] + assert isinstance(insts, list) + for i in insts: + attr = i.get("name") + assert attr, "Attribute name is required." + target = getattr(obj, attr, None) + if isinstance(target, cabc.Iterable): + filters = i.get("filter", {}) + for item in target: + if not self._matches_filters(item, filters): + continue + if create: + self._make_target(item) + self._perform_get(item, i) + elif isinstance(target, m.ModelElement): + if create: + self._make_target(target) + self._perform_get(target, i) + + def _make_target( + self, obj: m.ModelElement + ) -> _elkjs.ELKInputChild | _elkjs.ELKInputEdge | None: + if _is_edge(obj): + return self._make_edge_and_ports(obj) + return self._make_box(obj, slim_width=self.diagram._slim_center_box) + + def _make_box( + self, + obj: m.ModelElement, + **kwargs: t.Any, + ) -> _elkjs.ELKInputChild: + box = makers.make_box( + obj, + no_symbol=self.diagram._display_symbols_as_boxes, + **kwargs, + ) + self.boxes[obj.uuid] = box + if self.diagram._display_unused_ports: + for attr in generic.DIAGRAM_TYPE_TO_CONNECTOR_NAMES[ + self.diagram.type + ]: + for port in getattr(obj, attr, []): + self._make_port_and_owner(port) + if self.diagram._display_parent_relation: + current = obj + while ( + current + and current.uuid not in self.diagram_target_owners + and getattr(current, "owner", None) is not None + and not isinstance(current.owner, generic.PackageTypes) + ): + current = self._make_owner_box(current) + self.common_owners.add(current.uuid) + return box + + def _make_owner_box( + self, + obj: t.Any, + ) -> t.Any: + if not (parent_box := self.boxes.get(obj.owner.uuid)): + parent_box = self._make_box( + obj.owner, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) + assert (obj_box := self.boxes.get(obj.uuid)) + for box in (children := parent_box.children): + if box.id == obj.uuid: + box = obj_box + break + else: + children.append(obj_box) + for label in parent_box.labels: + label.layoutOptions = makers.DEFAULT_LABEL_LAYOUT_OPTIONS + self.boxes_to_delete.add(obj.uuid) + return obj.owner + + def _make_edge_and_ports( + self, + edge_obj: m.ModelElement, + ) -> _elkjs.ELKInputEdge | None: + src_obj = edge_obj.source + tgt_obj = edge_obj.target + src_owner = src_obj.owner + tgt_owner = tgt_obj.owner + if self.diagram._hide_direct_children: + if ( + getattr(src_owner, "owner", None) == self.obj + or getattr(tgt_owner, "owner", None) == self.obj + ): + return None + if self.diagram._unify_edge_direction: + src_dir = self.dicrections.get(src_owner.uuid) + tgt_dir = self.dicrections.get(tgt_owner.uuid) + if (src_dir is None) and (tgt_dir is None): + self.dicrections[src_owner.uuid] = False + self.dicrections[tgt_owner.uuid] = True + elif src_dir is None: + self.dicrections[src_owner.uuid] = not tgt_dir + elif tgt_dir is None: + self.dicrections[tgt_owner.uuid] = not src_dir + if self.dicrections[src_owner.uuid]: + src_obj, tgt_obj = tgt_obj, src_obj + self._make_port_and_owner(src_obj) + self._make_port_and_owner(tgt_obj) + edge = _elkjs.ELKInputEdge( + id=edge_obj.uuid, + sources=[src_obj.uuid], + targets=[tgt_obj.uuid], + labels=makers.make_label( + edge_obj.name, + ), + ) + self.edges[edge_obj.uuid] = edge + return edge + + def _make_port_and_owner( + self, port_obj: m.ModelElement + ) -> _elkjs.ELKInputPort: + owner_obj = port_obj.owner + if not (box := self.boxes.get(owner_obj.uuid)): + box = self._make_box( + owner_obj, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) + if port := self.ports.get(port_obj.uuid): + return port + port = makers.make_port(port_obj.uuid) + if self.diagram._display_port_labels: + text = port_obj.name or "UNKNOWN" + port.labels = makers.make_label(text) + box.ports.append(port) + self.ports[port_obj.uuid] = port + return port + + +def _is_edge(obj: m.ModelElement) -> bool: + styleclass = obj.xtype.rsplit(":", 1)[-1] + for sub_str in ("Link", "Exchange"): + if sub_str in styleclass: + return True + return False + + +def collector( + diagram: context.ContextDiagram, params: dict[str, t.Any] +) -> _elkjs.ELKInputData: + """Collect data for rendering a custom diagram.""" + return CustomCollector(diagram, params)() diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index d82eddc..25bd1b5 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -20,6 +20,7 @@ from . import _elkjs, filters, serializers, styling from .collectors import ( cable_tree, + custom, dataflow_view, exchanges, get_elkdata, @@ -205,6 +206,22 @@ def __get__( # type: ignore return self._get(obj, CableTreeViewDiagram) +class CustomContextAccessor(ContextAccessor): + """Provides access to the custom context diagrams.""" + + def __get__( # type: ignore + self, + obj: m.T | None, + objtype: type | None = None, + ) -> m.Accessor | ContextDiagram: + """Make a CustomDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, m.ModelElement) + return self._get(obj, CustomDiagram) + + class ContextDiagram(m.AbstractDiagram): """An automatically generated context diagram. @@ -254,6 +271,7 @@ class ContextDiagram(m.AbstractDiagram): * display_unused_ports - Display ports that are not connected to an edge. """ + _collect: dict[str, t.Any] _display_symbols_as_boxes: bool _display_parent_relation: bool _hide_direct_children: bool @@ -263,6 +281,7 @@ class ContextDiagram(m.AbstractDiagram): _port_label_position: str _transparent_background: bool _display_unused_ports: bool + _unify_edge_direction: bool def __init__( self, @@ -282,6 +301,7 @@ def __init__( self._elk_input_data: CollectorOutputData | None = None self.__filters: cabc.MutableSet[str] = self.FilterSet(self) self._default_render_parameters = { + "collect": {}, "display_symbols_as_boxes": False, "display_parent_relation": False, "hide_direct_children": False, @@ -291,6 +311,7 @@ def __init__( "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, "display_unused_ports": False, "transparent_background": False, + "unify_edge_direction": False, } | default_render_parameters if standard_filter := STANDARD_FILTERS.get(class_): @@ -857,6 +878,58 @@ def name(self) -> str: # type: ignore return f"Cable Tree View of {self.target.name}" +class CustomDiagram(ContextDiagram): + """An automatically generated CustomDiagram Diagram.""" + + _collect: dict[str, t.Any] + _display_symbols_as_boxes: bool + _display_parent_relation: bool + _hide_direct_children: bool + _slim_center_box: bool + _display_port_labels: bool + _port_label_position: str + _transparent_background: bool + _display_unused_ports: bool + _unify_edge_direction: bool + + def __init__( + self, + class_: str, + obj: m.ModelElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "collect": {}, + "display_symbols_as_boxes": False, + "display_parent_relation": False, + "hide_direct_children": False, + "slim_center_box": True, + "display_port_labels": False, + "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, + "transparent_background": False, + "display_unused_ports": False, + "unify_edge_direction": False, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) + self.collector = custom.collector + + @property + def uuid(self) -> str: # type: ignore + """Returns the UUID of the diagram.""" + return f"{self.target.uuid}_custom_diagram" + + @property + def name(self) -> str: # type: ignore + return f"Custom Diagram of {self.target.name}" + + def try_to_layout(data: _elkjs.ELKInputData) -> _elkjs.ELKOutputData: """Try calling elkjs, raise a JSONDecodeError if it fails.""" try: From c65647a3893466c21564fbd8268a11499d17da6e Mon Sep 17 00:00:00 2001 From: huyenngn Date: Sat, 9 Nov 2024 21:37:48 +0100 Subject: [PATCH 02/22] fix: Diagram target dictates unified edge direction --- capellambse_context_diagrams/__init__.py | 5 +++++ .../collectors/custom.py | 21 ++++++++----------- capellambse_context_diagrams/context.py | 4 ++-- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index b9a3fa8..4ba8a53 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -323,3 +323,8 @@ def register_custom_diagram() -> None: "custom_diagram", context.CustomContextAccessor(DiagramType.SAB.value, {}), ) + m.set_accessor( + cs.PhysicalLink, + "custom_diagram", + context.CustomContextAccessor(DiagramType.PAB.value, {}), + ) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 76e2252..74e7dfb 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -40,15 +40,20 @@ def __init__( def __call__(self) -> _elkjs.ELKInputData: self._make_target(self.obj) + if self.diagram._unify_edge_direction: + if len(self.boxes) > 0: + self.dicrections[self.obj.uuid] = False + else: + self.dicrections[self.obj.source.owner.uuid] = False if not self.instructions: return self._get_data() self._perform_get(self.obj, self.instructions) - if self.diagram._display_parent_relation and self.obj.owner: - current = self.obj.owner + if self.diagram._display_parent_relation: + current = self.obj while ( current and self.common_owners - and hasattr(current, "owner") + and getattr(current, "owner", None) is not None and not isinstance(current.owner, generic.PackageTypes) ): current = self._make_owner_box( @@ -104,7 +109,7 @@ def _perform_get( def _make_target( self, obj: m.ModelElement ) -> _elkjs.ELKInputChild | _elkjs.ELKInputEdge | None: - if _is_edge(obj): + if hasattr(obj, "source") and hasattr(obj, "target"): return self._make_edge_and_ports(obj) return self._make_box(obj, slim_width=self.diagram._slim_center_box) @@ -217,14 +222,6 @@ def _make_port_and_owner( return port -def _is_edge(obj: m.ModelElement) -> bool: - styleclass = obj.xtype.rsplit(":", 1)[-1] - for sub_str in ("Link", "Exchange"): - if sub_str in styleclass: - return True - return False - - def collector( diagram: context.ContextDiagram, params: dict[str, t.Any] ) -> _elkjs.ELKInputData: diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 25bd1b5..bb6846e 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -902,10 +902,10 @@ def __init__( ) -> None: default_render_parameters = { "collect": {}, - "display_symbols_as_boxes": False, + "display_symbols_as_boxes": True, "display_parent_relation": False, "hide_direct_children": False, - "slim_center_box": True, + "slim_center_box": False, "display_port_labels": False, "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, "transparent_background": False, From cf6fdca92b3a4b2fcaa8d23b5016aef00b59d622 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Sun, 10 Nov 2024 14:12:11 +0100 Subject: [PATCH 03/22] feat(custom_diagram): Add recursion option --- capellambse_context_diagrams/__init__.py | 23 +++--- .../collectors/custom.py | 74 ++++++++++++++++--- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 4ba8a53..e15081d 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -318,13 +318,16 @@ def register_cable_tree_view() -> None: def register_custom_diagram() -> None: """Add the `custom_diagram` attribute to `ModelObject`s.""" - m.set_accessor( - sa.SystemFunction, - "custom_diagram", - context.CustomContextAccessor(DiagramType.SAB.value, {}), - ) - m.set_accessor( - cs.PhysicalLink, - "custom_diagram", - context.CustomContextAccessor(DiagramType.PAB.value, {}), - ) + supported_classes: list[tuple[type[m.ModelElement], DiagramType]] = [ + (sa.SystemFunction, DiagramType.SAB), + (cs.PhysicalLink, DiagramType.PAB), + (la.LogicalFunction, DiagramType.LAB), + (pa.PhysicalFunction, DiagramType.PAB), + (fa.ComponentExchange, DiagramType.SAB), + ] + for class_, dgcls in supported_classes: + m.set_accessor( + class_, + "custom_diagram", + context.CustomContextAccessor(dgcls.value, {}), + ) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 74e7dfb..c8afc3b 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -13,6 +13,12 @@ from . import generic, makers +def _is_edge(obj: m.ModelElement) -> bool: + if hasattr(obj, "source") and hasattr(obj, "target"): + return True + return False + + class CustomCollector: """Collect the context for a custom diagram.""" @@ -22,34 +28,40 @@ def __init__( params: dict[str, t.Any], ) -> None: self.diagram = diagram - self.obj: m.ModelElement = self.diagram.target + self.target: m.ModelElement = self.diagram.target + self.boxable_target = ( + self.target.source.owner if _is_edge(self.target) else self.target + ) self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect + self.repeat_instructions: dict[str, t.Any] | None = None + self.visited: set[str] = set() self.boxes: dict[str, _elkjs.ELKInputChild] = {} self.edges: dict[str, _elkjs.ELKInputEdge] = {} self.ports: dict[str, _elkjs.ELKInputPort] = {} self.boxes_to_delete: set[str] = set() if self.diagram._display_parent_relation: self.diagram_target_owners = list( - generic.get_all_owners(self.diagram.target) + generic.get_all_owners(self.boxable_target) ) self.common_owners: set[str] = set() if self.diagram._unify_edge_direction: self.dicrections: dict[str, bool] = {} + self.min_heights: dict[str, dict[str, float]] = {} def __call__(self) -> _elkjs.ELKInputData: - self._make_target(self.obj) + self._make_target(self.target) if self.diagram._unify_edge_direction: if len(self.boxes) > 0: - self.dicrections[self.obj.uuid] = False + self.dicrections[self.target.uuid] = False else: - self.dicrections[self.obj.source.owner.uuid] = False + self.dicrections[self.target.source.owner.uuid] = False if not self.instructions: return self._get_data() - self._perform_get(self.obj, self.instructions) + self._perform_get(self.target, self.instructions) if self.diagram._display_parent_relation: - current = self.obj + current = self.boxable_target while ( current and self.common_owners @@ -60,6 +72,7 @@ def __call__(self) -> _elkjs.ELKInputData: current, ) self.common_owners.discard(current.uuid) + self._fix_box_heights() for uuid in self.boxes_to_delete: del self.boxes[uuid] return self._get_data() @@ -69,6 +82,16 @@ def _get_data(self) -> t.Any: self.data.edges = list(self.edges.values()) return self.data + def _fix_box_heights(self) -> None: + if self.diagram._unify_edge_direction: + for uuid, min_heights in self.min_heights.items(): + box = self.boxes[uuid] + box.height = max(box.height, sum(min_heights.values())) + else: + for uuid, min_heights in self.min_heights.items(): + box = self.boxes[uuid] + box.height = max([box.height] + list(min_heights.values())) + def _matches_filters( self, obj: m.ModelElement, filters: dict[str, t.Any] ) -> bool: @@ -80,11 +103,15 @@ def _matches_filters( def _perform_get( self, obj: m.ModelElement, instructions: dict[str, t.Any] ) -> None: + if instructions.pop("repeat", False): + self.repeat_instructions = instructions if insts := instructions.get("get"): create = False elif insts := instructions.get("include"): create = True if not insts: + if self.repeat_instructions: + self._perform_get(obj, self.repeat_instructions) return if isinstance(insts, dict): insts = [insts] @@ -96,12 +123,18 @@ def _perform_get( if isinstance(target, cabc.Iterable): filters = i.get("filter", {}) for item in target: + if item.uuid in self.visited: + continue + self.visited.add(item.uuid) if not self._matches_filters(item, filters): continue if create: self._make_target(item) self._perform_get(item, i) elif isinstance(target, m.ModelElement): + if target.uuid in self.visited: + continue + self.visited.add(target.uuid) if create: self._make_target(target) self._perform_get(target, i) @@ -109,7 +142,7 @@ def _perform_get( def _make_target( self, obj: m.ModelElement ) -> _elkjs.ELKInputChild | _elkjs.ELKInputEdge | None: - if hasattr(obj, "source") and hasattr(obj, "target"): + if _is_edge(obj): return self._make_edge_and_ports(obj) return self._make_box(obj, slim_width=self.diagram._slim_center_box) @@ -173,8 +206,8 @@ def _make_edge_and_ports( tgt_owner = tgt_obj.owner if self.diagram._hide_direct_children: if ( - getattr(src_owner, "owner", None) == self.obj - or getattr(tgt_owner, "owner", None) == self.obj + getattr(src_owner, "owner", None) == self.boxable_target + or getattr(tgt_owner, "owner", None) == self.boxable_target ): return None if self.diagram._unify_edge_direction: @@ -189,8 +222,24 @@ def _make_edge_and_ports( self.dicrections[tgt_owner.uuid] = not src_dir if self.dicrections[src_owner.uuid]: src_obj, tgt_obj = tgt_obj, src_obj - self._make_port_and_owner(src_obj) - self._make_port_and_owner(tgt_obj) + + if not self.ports.get(src_obj.uuid): + port = self._make_port_and_owner(src_obj) + self.min_heights.setdefault( + src_owner.uuid, {"left": 0.0, "right": 0.0} + )["right"] += makers.PORT_SIZE + max( + 2 * makers.PORT_PADDING, + sum(label.height for label in port.labels), + ) + if not self.ports.get(tgt_obj.uuid): + port = self._make_port_and_owner(tgt_obj) + self.min_heights.setdefault( + tgt_owner.uuid, {"left": 0.0, "right": 0.0} + )["left"] += makers.PORT_SIZE + max( + 2 * makers.PORT_PADDING, + sum(label.height for label in port.labels), + ) + edge = _elkjs.ELKInputEdge( id=edge_obj.uuid, sources=[src_obj.uuid], @@ -217,6 +266,7 @@ def _make_port_and_owner( if self.diagram._display_port_labels: text = port_obj.name or "UNKNOWN" port.labels = makers.make_label(text) + box.ports.append(port) self.ports[port_obj.uuid] = port return port From 40c009c18152ae7fc525d3b52528804454f8fbab Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 11 Nov 2024 11:59:21 +0100 Subject: [PATCH 04/22] fix: Move edges to owners --- .../collectors/custom.py | 85 ++++++++++++++----- capellambse_context_diagrams/context.py | 10 +-- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index c8afc3b..3ac0aa5 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -42,23 +42,21 @@ def __init__( self.ports: dict[str, _elkjs.ELKInputPort] = {} self.boxes_to_delete: set[str] = set() if self.diagram._display_parent_relation: + self.edge_owners: dict[str, str] = {} self.diagram_target_owners = list( generic.get_all_owners(self.boxable_target) ) self.common_owners: set[str] = set() - if self.diagram._unify_edge_direction: - self.dicrections: dict[str, bool] = {} + if self.diagram._unify_edge_direction != "NONE": + self.directions: dict[str, bool] = {} self.min_heights: dict[str, dict[str, float]] = {} def __call__(self) -> _elkjs.ELKInputData: self._make_target(self.target) - if self.diagram._unify_edge_direction: - if len(self.boxes) > 0: - self.dicrections[self.target.uuid] = False - else: - self.dicrections[self.target.source.owner.uuid] = False if not self.instructions: return self._get_data() + if self.diagram._unify_edge_direction == "UNIFORM": + self.directions[self.boxable_target.uuid] = False self._perform_get(self.target, self.instructions) if self.diagram._display_parent_relation: current = self.boxable_target @@ -72,6 +70,10 @@ def __call__(self) -> _elkjs.ELKInputData: current, ) self.common_owners.discard(current.uuid) + for edge_uuid, box_uuid in self.edge_owners.items(): + if box := self.boxes.get(box_uuid): + box.edges.append(self.edges.pop(edge_uuid)) + self._fix_box_heights() for uuid in self.boxes_to_delete: del self.boxes[uuid] @@ -83,7 +85,7 @@ def _get_data(self) -> t.Any: return self.data def _fix_box_heights(self) -> None: - if self.diagram._unify_edge_direction: + if self.diagram._unify_edge_direction != "NONE": for uuid, min_heights in self.min_heights.items(): box = self.boxes[uuid] box.height = max(box.height, sum(min_heights.values())) @@ -210,18 +212,22 @@ def _make_edge_and_ports( or getattr(tgt_owner, "owner", None) == self.boxable_target ): return None - if self.diagram._unify_edge_direction: - src_dir = self.dicrections.get(src_owner.uuid) - tgt_dir = self.dicrections.get(tgt_owner.uuid) - if (src_dir is None) and (tgt_dir is None): - self.dicrections[src_owner.uuid] = False - self.dicrections[tgt_owner.uuid] = True - elif src_dir is None: - self.dicrections[src_owner.uuid] = not tgt_dir - elif tgt_dir is None: - self.dicrections[tgt_owner.uuid] = not src_dir - if self.dicrections[src_owner.uuid]: - src_obj, tgt_obj = tgt_obj, src_obj + src_owners = list(generic.get_all_owners(src_obj)) + tgt_owners = list(generic.get_all_owners(tgt_obj)) + if self.diagram._display_parent_relation: + common_owner = None + for owner in src_owners: + if owner in tgt_owners: + common_owner = owner + break + if common_owner: + self.edge_owners[edge_obj.uuid] = common_owner + + if self._need_switch( + src_owners, tgt_owners, src_owner.uuid, tgt_owner.uuid + ): + src_obj, tgt_obj = tgt_obj, src_obj + src_owner, tgt_owner = tgt_owner, src_owner if not self.ports.get(src_obj.uuid): port = self._make_port_and_owner(src_obj) @@ -249,8 +255,47 @@ def _make_edge_and_ports( ), ) self.edges[edge_obj.uuid] = edge + return edge + def _need_switch( + self, + src_owners: list[str], + tgt_owners: list[str], + src_uuid: str, + tgt_uuid: str, + ) -> bool: + if self.diagram._unify_edge_direction == "SMART": + if src_uuid != self.boxable_target.uuid: + src_uncommon = [ + owner for owner in src_owners if owner not in tgt_owners + ][-1] + src_dir = self.directions.setdefault(src_uncommon, False) + else: + src_dir = None + if tgt_uuid != self.boxable_target.uuid: + tgt_uncommon = [ + owner for owner in tgt_owners if owner not in src_owners + ][-1] + tgt_dir = self.directions.setdefault(tgt_uncommon, True) + else: + tgt_dir = None + if (src_dir is True) or (tgt_dir is False): + return True + elif self.diagram._unify_edge_direction == "UNIFORM": + src_dir = self.directions.get(src_uuid) + tgt_dir = self.directions.get(tgt_uuid) + if (src_dir is None) and (tgt_dir is None): + self.directions[src_uuid] = False + self.directions[tgt_uuid] = True + elif src_dir is None: + self.directions[src_uuid] = not tgt_dir + elif tgt_dir is None: + self.directions[tgt_uuid] = not src_dir + if self.directions[src_uuid]: + return True + return False + def _make_port_and_owner( self, port_obj: m.ModelElement ) -> _elkjs.ELKInputPort: diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index bb6846e..8311da0 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -281,7 +281,7 @@ class ContextDiagram(m.AbstractDiagram): _port_label_position: str _transparent_background: bool _display_unused_ports: bool - _unify_edge_direction: bool + _unify_edge_direction: str def __init__( self, @@ -311,7 +311,7 @@ def __init__( "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, "display_unused_ports": False, "transparent_background": False, - "unify_edge_direction": False, + "unify_edge_direction": "NONE", } | default_render_parameters if standard_filter := STANDARD_FILTERS.get(class_): @@ -890,7 +890,7 @@ class CustomDiagram(ContextDiagram): _port_label_position: str _transparent_background: bool _display_unused_ports: bool - _unify_edge_direction: bool + _unify_edge_direction: str def __init__( self, @@ -902,7 +902,7 @@ def __init__( ) -> None: default_render_parameters = { "collect": {}, - "display_symbols_as_boxes": True, + "display_symbols_as_boxes": False, "display_parent_relation": False, "hide_direct_children": False, "slim_center_box": False, @@ -910,7 +910,7 @@ def __init__( "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, "transparent_background": False, "display_unused_ports": False, - "unify_edge_direction": False, + "unify_edge_direction": str, } | default_render_parameters super().__init__( class_, From bfa43bcab33a2d6b3d55a88cb1bb99e304cca41b Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 11 Nov 2024 13:24:37 +0100 Subject: [PATCH 05/22] docs: Add docs for custom diagram --- docs/custom_diagram.md | 62 ++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 ++ 2 files changed, 64 insertions(+) create mode 100644 docs/custom_diagram.md diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md new file mode 100644 index 0000000..c235b89 --- /dev/null +++ b/docs/custom_diagram.md @@ -0,0 +1,62 @@ + + +# Custom Diagram + +`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using a YAML-based declarative language. +You can access `.custom_diagram` on any supported model element. + +## Example + +Here is an example YAML file that declares a context diagram: + +```yaml +get: + - name: inputs + include: + - name: exchanges + - name: links + - name: outputs + include: + - name: exchanges + - name: links + - name: ports + include: + - name: exchanges + - name: links +``` + +## Collector definition + +- `get` element at attribute defined in `name` +- `include` element at attribute defined in `name` +- `filter` elements if element is a list + +## API Usage + +```python +import capellambse +import yaml + +my_model = capellambse.MelodyModel(...) +my_element = my_model.by_uuid(...) +my_yaml = "..." + +my_element.custom_diagram(collect=yaml.safe_load(my_yaml)).render("svgdiagram").save(pretty=True) +``` + +## Supported Elements + +- [`sa.SystemFunction`][capellambse.metamodel.sa.SystemFunction] +- [`cs.PhysicalLink`][capellambse.metamodel.cs.PhysicalFunction] +- [`la.LogicalFunction`][capellambse.metamodel.la.LogicalFunction] +- [`pa.PhysicalFunction`][capellambse.metamodel.pa.PhysicalFunction] +- [`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange] + +## Check out the code + +To understand the collection have a look into the +[`cable_tree`][capellambse_context_diagrams.collectors.cable_tree] +module. diff --git a/mkdocs.yml b/mkdocs.yml index 2221b70..99671cb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -98,6 +98,8 @@ nav: - Overview: data_flow_view.md - Cable Tree View: - Overview: cable_tree.md + - Custom Diagram: + - Overview: custom_diagram.md - Extras: - Filters: extras/filters.md - Styling: extras/styling.md From be8a5b881cf7b44309b2ce75d08dcff9cbfec027 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 12 Nov 2024 07:35:36 +0100 Subject: [PATCH 06/22] feat(custom_diagram): Add nested recursion and recursion depth --- .../collectors/custom.py | 23 ++++++++++++------- capellambse_context_diagrams/context.py | 4 ---- docs/custom_diagram.md | 3 ++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 3ac0aa5..e11a45f 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -35,7 +35,8 @@ def __init__( self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect - self.repeat_instructions: dict[str, t.Any] | None = None + self.repeat_instructions: list[dict[str, t.Any]] = [] + self.repeat_depth: list[int] = [] self.visited: set[str] = set() self.boxes: dict[str, _elkjs.ELKInputChild] = {} self.edges: dict[str, _elkjs.ELKInputEdge] = {} @@ -105,15 +106,21 @@ def _matches_filters( def _perform_get( self, obj: m.ModelElement, instructions: dict[str, t.Any] ) -> None: - if instructions.pop("repeat", False): - self.repeat_instructions = instructions + if max_depth := instructions.get("repeat", None): + if self.repeat_instructions: + self.repeat_depth[-1] -= 1 + if self.repeat_depth[-1] == 0: + self.repeat_instructions.pop() + else: + self.repeat_instructions.append(instructions) + self.repeat_depth.append(max_depth) if insts := instructions.get("get"): create = False elif insts := instructions.get("include"): create = True if not insts: if self.repeat_instructions: - self._perform_get(obj, self.repeat_instructions) + self._perform_get(obj, self.repeat_instructions[-1]) return if isinstance(insts, dict): insts = [insts] @@ -206,14 +213,14 @@ def _make_edge_and_ports( tgt_obj = edge_obj.target src_owner = src_obj.owner tgt_owner = tgt_obj.owner + src_owners = list(generic.get_all_owners(src_obj)) + tgt_owners = list(generic.get_all_owners(tgt_obj)) if self.diagram._hide_direct_children: if ( - getattr(src_owner, "owner", None) == self.boxable_target - or getattr(tgt_owner, "owner", None) == self.boxable_target + self.boxable_target.uuid in src_owners + or self.boxable_target.uuid in tgt_owners ): return None - src_owners = list(generic.get_all_owners(src_obj)) - tgt_owners = list(generic.get_all_owners(tgt_obj)) if self.diagram._display_parent_relation: common_owner = None for owner in src_owners: diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 8311da0..60ab1d5 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -271,7 +271,6 @@ class ContextDiagram(m.AbstractDiagram): * display_unused_ports - Display ports that are not connected to an edge. """ - _collect: dict[str, t.Any] _display_symbols_as_boxes: bool _display_parent_relation: bool _hide_direct_children: bool @@ -281,7 +280,6 @@ class ContextDiagram(m.AbstractDiagram): _port_label_position: str _transparent_background: bool _display_unused_ports: bool - _unify_edge_direction: str def __init__( self, @@ -301,7 +299,6 @@ def __init__( self._elk_input_data: CollectorOutputData | None = None self.__filters: cabc.MutableSet[str] = self.FilterSet(self) self._default_render_parameters = { - "collect": {}, "display_symbols_as_boxes": False, "display_parent_relation": False, "hide_direct_children": False, @@ -311,7 +308,6 @@ def __init__( "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, "display_unused_ports": False, "transparent_background": False, - "unify_edge_direction": "NONE", } | default_render_parameters if standard_filter := STANDARD_FILTERS.get(class_): diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index c235b89..e1014e9 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -31,8 +31,9 @@ get: ## Collector definition - `get` element at attribute defined in `name` -- `include` element at attribute defined in `name` +- `include` element at attribute defined in `name` or all elements if element is a list as boxes or edges - `filter` elements if element is a list +- `repeat` elements if for n times ## API Usage From 810ca663bc7e25665f8b22752218ad8bdf30492c Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 12 Nov 2024 10:26:45 +0100 Subject: [PATCH 07/22] docs: Add expamles for custom_diagram --- docs/custom_diagram.md | 55 ++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index e1014e9..4e2cd2d 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -5,28 +5,51 @@ # Custom Diagram -`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using a YAML-based declarative language. +`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using a dictionary. You can access `.custom_diagram` on any supported model element. ## Example -Here is an example YAML file that declares a context diagram: +Here are example collection definitions in YAML format for different diagrams: -```yaml -get: - - name: inputs - include: - - name: exchanges - - name: links - - name: outputs - include: - - name: exchanges - - name: links - - name: ports +??? example "Custom diagram for context" + + ```yaml + get: + - name: inputs + include: + - name: exchanges + - name: links + - name: outputs + include: + - name: exchanges + - name: links + - name: ports + include: + - name: exchanges + - name: links + ``` + +??? example "Custom diagram for cable tree" + + ```yaml + repeat: -1 + get: + - name: source + include: + name: links + - name: target + include: + name: links + ``` + +??? example "Custom diagram for exchanges" + + ```yaml include: - - name: exchanges - - name: links -``` + - name: allocated_functional_exchanges + - name: allocated_interactions + ``` ## Collector definition From 8e6baea315f78b512f656fb8d5d2b178bb4e5f94 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 18 Nov 2024 17:57:45 +0100 Subject: [PATCH 08/22] fix: Fix recursion depth --- .../collectors/custom.py | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index e11a45f..8b66be2 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -35,8 +35,8 @@ def __init__( self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect - self.repeat_instructions: list[dict[str, t.Any]] = [] - self.repeat_depth: list[int] = [] + self.repeat_instructions: dict[str, t.Any] = {} + self.repeat_depth: int = 0 self.visited: set[str] = set() self.boxes: dict[str, _elkjs.ELKInputChild] = {} self.edges: dict[str, _elkjs.ELKInputEdge] = {} @@ -58,7 +58,7 @@ def __call__(self) -> _elkjs.ELKInputData: return self._get_data() if self.diagram._unify_edge_direction == "UNIFORM": self.directions[self.boxable_target.uuid] = False - self._perform_get(self.target, self.instructions) + self._perform_instructions(self.target, self.instructions) if self.diagram._display_parent_relation: current = self.boxable_target while ( @@ -103,29 +103,33 @@ def _matches_filters( return False return True - def _perform_get( + def _perform_instructions( self, obj: m.ModelElement, instructions: dict[str, t.Any] ) -> None: - if max_depth := instructions.get("repeat", None): - if self.repeat_instructions: - self.repeat_depth[-1] -= 1 - if self.repeat_depth[-1] == 0: - self.repeat_instructions.pop() - else: - self.repeat_instructions.append(instructions) - self.repeat_depth.append(max_depth) - if insts := instructions.get("get"): - create = False - elif insts := instructions.get("include"): - create = True - if not insts: - if self.repeat_instructions: - self._perform_get(obj, self.repeat_instructions[-1]) - return - if isinstance(insts, dict): - insts = [insts] - assert isinstance(insts, list) - for i in insts: + if max_depth := instructions.pop("repeat", None): + self.repeat_instructions = instructions + self.repeat_depth = max_depth + if get_targets := instructions.get("get"): + self._perform_get_or_include(obj, get_targets, False) + elif include_targets := instructions.get("include"): + self._perform_get_or_include(obj, include_targets, True) + if not get_targets and not include_targets: + if self.repeat_depth != 0: + self.repeat_depth -= 1 + self._perform_instructions(obj, self.repeat_instructions) + + def _perform_get_or_include( + self, + obj: m.ModelElement, + targets: dict[str, t.Any] | list[dict[str, t.Any]], + create: bool, + ) -> None: + if isinstance(targets, dict): + targets = [targets] + assert isinstance(targets, list) + if self.repeat_depth > 0: + self.repeat_depth += len(targets) + for i in targets: attr = i.get("name") assert attr, "Attribute name is required." target = getattr(obj, attr, None) @@ -139,14 +143,14 @@ def _perform_get( continue if create: self._make_target(item) - self._perform_get(item, i) + self._perform_instructions(item, i) elif isinstance(target, m.ModelElement): if target.uuid in self.visited: continue self.visited.add(target.uuid) if create: self._make_target(target) - self._perform_get(target, i) + self._perform_instructions(target, i) def _make_target( self, obj: m.ModelElement From d7e94b9ec2d55b327825490eea8182739188eb90 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 18 Nov 2024 19:06:04 +0100 Subject: [PATCH 09/22] fix: Straighten target edge --- capellambse_context_diagrams/collectors/custom.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 8b66be2..1c97519 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -5,6 +5,7 @@ from __future__ import annotations import collections.abc as cabc +import copy import typing as t import capellambse.model as m @@ -54,6 +55,10 @@ def __init__( def __call__(self) -> _elkjs.ELKInputData: self._make_target(self.target) + if target_edge := self.edges.get(self.target.uuid): + target_edge.layoutOptions = copy.deepcopy( + _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS + ) if not self.instructions: return self._get_data() if self.diagram._unify_edge_direction == "UNIFORM": From d6592a0a915e72f6abf7f51b09ef2d331a5a9bf1 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 19 Nov 2024 14:34:14 +0100 Subject: [PATCH 10/22] feat(context-diagram): Add support for PhysicalPorts --- capellambse_context_diagrams/__init__.py | 14 +++++ .../collectors/custom.py | 33 ++++++++++-- capellambse_context_diagrams/context.py | 54 ++++++++++++++++--- tests/test_context_diagrams.py | 7 ++- 4 files changed, 95 insertions(+), 13 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index e15081d..c5fa031 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -65,6 +65,7 @@ def init() -> None: """Initialize the extension.""" register_classes() register_interface_context() + register_physical_port_context() register_tree_view() register_realization_view() register_data_flow_view() @@ -248,6 +249,18 @@ def register_functional_context() -> None: ) +def register_physical_port_context() -> None: + """Add the `context_diagram` attribute to `PhysicalPort`s.""" + m.set_accessor( + cs.PhysicalPort, + ATTR_NAME, + context.PhysicalPortContextAccessor( + DiagramType.PAB.value, + {}, + ), + ) + + def register_tree_view() -> None: """Add the ``tree_view`` attribute to ``Class``es.""" m.set_accessor( @@ -324,6 +337,7 @@ def register_custom_diagram() -> None: (la.LogicalFunction, DiagramType.LAB), (pa.PhysicalFunction, DiagramType.PAB), (fa.ComponentExchange, DiagramType.SAB), + (cs.PhysicalPort, DiagramType.PAB), ] for class_, dgcls in supported_classes: m.set_accessor( diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 1c97519..8bab521 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -20,6 +20,12 @@ def _is_edge(obj: m.ModelElement) -> bool: return False +def _is_port(obj: m.ModelElement) -> bool: + if obj.xtype.endswith("Port"): + return True + return False + + class CustomCollector: """Collect the context for a custom diagram.""" @@ -30,9 +36,12 @@ def __init__( ) -> None: self.diagram = diagram self.target: m.ModelElement = self.diagram.target - self.boxable_target = ( - self.target.source.owner if _is_edge(self.target) else self.target - ) + if _is_port(self.target): + self.boxable_target = self.target.owner + elif _is_edge(self.target): + self.boxable_target = self.target.source.owner + else: + self.boxable_target = self.target self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect @@ -54,7 +63,10 @@ def __init__( self.min_heights: dict[str, dict[str, float]] = {} def __call__(self) -> _elkjs.ELKInputData: - self._make_target(self.target) + if _is_port(self.target): + self._make_port_and_owner(self.target) + else: + self._make_target(self.target) if target_edge := self.edges.get(self.target.uuid): target_edge.layoutOptions = copy.deepcopy( _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS @@ -116,7 +128,7 @@ def _perform_instructions( self.repeat_depth = max_depth if get_targets := instructions.get("get"): self._perform_get_or_include(obj, get_targets, False) - elif include_targets := instructions.get("include"): + if include_targets := instructions.get("include"): self._perform_get_or_include(obj, include_targets, True) if not get_targets and not include_targets: if self.repeat_depth != 0: @@ -310,6 +322,17 @@ def _need_switch( self.directions[tgt_uuid] = not src_dir if self.directions[src_uuid]: return True + elif self.diagram._unify_edge_direction == "TREE": + src_dir = self.directions.get(src_uuid) + tgt_dir = self.directions.get(tgt_uuid) + if (src_dir is None) and (tgt_dir is None): + self.directions[src_uuid] = True + self.directions[tgt_uuid] = True + elif src_dir is None: + self.directions[src_uuid] = True + return True + elif tgt_dir is None: + self.directions[tgt_uuid] = True return False def _make_port_and_owner( diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 60ab1d5..697d32e 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -123,6 +123,20 @@ def __get__( # type: ignore return self._get(obj, FunctionalContextDiagram) +class PhysicalPortContextAccessor(ContextAccessor): + def __get__( # type: ignore + self, + obj: m.T | None, + objtype: type | None = None, + ) -> m.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, m.ModelElement) + return self._get(obj, PhysicalPortContextDiagram) + + class ClassTreeAccessor(ContextAccessor): """Provides access to the tree view diagrams.""" @@ -916,14 +930,40 @@ def __init__( ) self.collector = custom.collector - @property - def uuid(self) -> str: # type: ignore - """Returns the UUID of the diagram.""" - return f"{self.target.uuid}_custom_diagram" - @property - def name(self) -> str: # type: ignore - return f"Custom Diagram of {self.target.name}" +class PhysicalPortContextDiagram(ContextDiagram): + """An automatically generated Context Diagram exclusively for + PhysicalPorts. + """ + + def __init__( + self, + class_: str, + obj: m.ModelElement, + *, + render_styles: dict[str, styling.Styler] | None = None, + default_render_parameters: dict[str, t.Any], + ) -> None: + default_render_parameters = { + "collect": { + "repeat": -1, + "include": { + "name": "links", + "get": [{"name": "source"}, {"name": "target"}], + }, + }, + "display_parent_relation": True, + "unify_edge_direction": "TREE", + "display_port_labels": True, + "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, + } | default_render_parameters + super().__init__( + class_, + obj, + render_styles=render_styles, + default_render_parameters=default_render_parameters, + ) + self.collector = custom.collector def try_to_layout(data: _elkjs.ELKInputData) -> _elkjs.ELKOutputData: diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index 6c3d0fa..a644c85 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -26,6 +26,7 @@ TEST_ENTITY_UUID = "e37510b9-3166-4f80-a919-dfaac9b696c7" TEST_SYS_FNC_UUID = "a5642060-c9cc-4d49-af09-defaa3024bae" TEST_DERIVATION_UUID = "4ec45aec-0d6a-411a-80ee-ebd3c1a53d2c" +TEST_PHYSICAL_PORT_UUID = "c403d4f4-9633-42a2-a5d6-9e1df2655146" @pytest.mark.parametrize( @@ -53,13 +54,17 @@ "c78b5d7c-be0c-4ed4-9d12-d447cb39304e", id="PhysicalBehaviorComponent", ), + pytest.param( + TEST_PHYSICAL_PORT_UUID, + id="PhysicalPort", + ), ], ) def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: obj = model.by_uuid(uuid) diag = obj.context_diagram - diag.render(None, display_parent_relation=True) + diag.render("svgdiagram", display_parent_relation=True).save(pretty=True) diag.render(None, display_parent_relation=False) assert diag.nodes From 261ea062b0cae42bd3539f911921cb9988de778c Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 19 Nov 2024 15:57:41 +0100 Subject: [PATCH 11/22] docs: Add PhysicalPort to docs --- capellambse_context_diagrams/context.py | 2 +- docs/custom_diagram.md | 1 + docs/gen_images.py | 1 + docs/index.md | 14 ++++++++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 697d32e..d6225da 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -953,7 +953,7 @@ def __init__( }, }, "display_parent_relation": True, - "unify_edge_direction": "TREE", + "unify_edge_direction": "UNIFORM", "display_port_labels": True, "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, } | default_render_parameters diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 4e2cd2d..834c361 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -78,6 +78,7 @@ my_element.custom_diagram(collect=yaml.safe_load(my_yaml)).render("svgdiagram"). - [`la.LogicalFunction`][capellambse.metamodel.la.LogicalFunction] - [`pa.PhysicalFunction`][capellambse.metamodel.pa.PhysicalFunction] - [`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange] +- [`cs.PhysicalPort`][capellambse.metamodel.cs.PhysicalPort] ## Check out the code diff --git a/docs/gen_images.py b/docs/gen_images.py index 580203e..335a041 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -30,6 +30,7 @@ "Physical Node": "fdb34c92-7c49-491d-bf11-dd139930786e", "Physical Behavior": "313f48f4-fb7e-47a8-b28a-76440932fcb9", "Maintain": "ee745644-07d7-40b9-ad7a-910dc8cbb805", + "Physical Port": "c403d4f4-9633-42a2-a5d6-9e1df2655146", } interface_context_diagram_uuids: dict[str, str] = { "Left to right": "3ef23099-ce9a-4f7d-812f-935f47e7938d", diff --git a/docs/index.md b/docs/index.md index ec46073..8f6f3a2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -192,6 +192,20 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.ModelObj
Context of Maintain Switch Firmware [PDFB]
+- ??? example "[`pa.PhysicalPort`][capellambse.metamodel.pa.PhysicalPort] (PAB)" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("c403d4f4-9633-42a2-a5d6-9e1df2655146").context_diagram + diag.render("svgdiagram").save(pretty=True) + ``` +
+ +
Context of PP 1 [PAB]
+
+ #### Hierarchy in diagrams Hierarchical diagrams are diagrams where boxes have child boxes and edges From 2a5f96661d25541ce44bf54673fbd91dd7e05fc5 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 25 Nov 2024 15:47:45 +0100 Subject: [PATCH 12/22] fix: Apply code review suggestions --- .../collectors/custom.py | 11 ++- docs/custom_diagram.md | 98 ++++++++++--------- 2 files changed, 62 insertions(+), 47 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 8bab521..273e3ba 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -36,12 +36,15 @@ def __init__( ) -> None: self.diagram = diagram self.target: m.ModelElement = self.diagram.target + + self.boxable_target: m.ModelElement if _is_port(self.target): self.boxable_target = self.target.owner elif _is_edge(self.target): self.boxable_target = self.target.source.owner else: self.boxable_target = self.target + self.data = makers.make_diagram(diagram) self.params = params self.instructions = self.diagram._collect @@ -52,12 +55,14 @@ def __init__( self.edges: dict[str, _elkjs.ELKInputEdge] = {} self.ports: dict[str, _elkjs.ELKInputPort] = {} self.boxes_to_delete: set[str] = set() + if self.diagram._display_parent_relation: self.edge_owners: dict[str, str] = {} self.diagram_target_owners = list( generic.get_all_owners(self.boxable_target) ) self.common_owners: set[str] = set() + if self.diagram._unify_edge_direction != "NONE": self.directions: dict[str, bool] = {} self.min_heights: dict[str, dict[str, float]] = {} @@ -350,7 +355,11 @@ def _make_port_and_owner( if self.diagram._display_port_labels: text = port_obj.name or "UNKNOWN" port.labels = makers.make_label(text) - + _plp = self.diagram._port_label_position + if not (plp := getattr(_elkjs.PORT_LABEL_POSITION, _plp, None)): + raise ValueError(f"Invalid port label position '{_plp}'.") + assert isinstance(plp, _elkjs.PORT_LABEL_POSITION) + box.layoutOptions["portLabels.placement"] = plp.name box.ports.append(port) self.ports[port_obj.uuid] = port return port diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 834c361..62ffdb3 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -8,55 +8,61 @@ `Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using a dictionary. You can access `.custom_diagram` on any supported model element. -## Example - -Here are example collection definitions in YAML format for different diagrams: - -??? example "Custom diagram for context" - - ```yaml - get: - - name: inputs - include: - - name: exchanges - - name: links - - name: outputs - include: - - name: exchanges - - name: links - - name: ports - include: - - name: exchanges - - name: links - ``` - -??? example "Custom diagram for cable tree" - - ```yaml - repeat: -1 - get: - - name: source - include: - name: links - - name: target - include: - name: links - ``` - -??? example "Custom diagram for exchanges" - - ```yaml +## Collector definition + +### `get` and `include` + +At every step of the collection, you can either `get` or `include` elements. `get` will simply get the element and `include` will include the element in the collection. `name` is the attribute name. + +```yaml +get: + - name: inputs + include: + - name: exchanges + - name: links + - name: outputs include: - - name: allocated_functional_exchanges - - name: allocated_interactions - ``` + - name: exchanges + - name: links + - name: ports + include: + - name: exchanges + - name: links +``` -## Collector definition +In the example above, we first `get` all the inputs of our target element and iterate over them. For each input, we include all the exchanges and links in the resulting diagram. We do the same for outputs and ports. Note that `get` does not include the element in the diagram, it just gets the element, but calling `include` on an edge will also include the edge's source and target ports. + +### `filter` + +Whenever you have a list of elements and you want to filter them, you can use the `filter` keyword. The `filter` keyword takes a dictionary as an argument. The dictionary should have the key as the attribute name and the value as the value you want to filter on. + +```yaml +get: + - name: inputs + include: + - name: exchanges + filter: + kind: "FunctionalExchange" +``` + +In the example above, we get all the inputs of our target element and include all the exchanges that are of kind `FunctionalExchange` in the resulting diagram. + +### `repeat` + +With the `repeat` keyword, you can repeat the collection. The value of `repeat` should be an integer. If the value is -1, the collection will repeat until no new elements are found. If the value is 0, the collection will not repeat. If the value is 1, the collection will repeat once and so on. + +```yaml +repeat: -1 +get: + - name: source + include: + name: links + - name: target + include: + name: links +``` -- `get` element at attribute defined in `name` -- `include` element at attribute defined in `name` or all elements if element is a list as boxes or edges -- `filter` elements if element is a list -- `repeat` elements if for n times +In the example above, we get the source and target of our target element and include all the links in the resulting diagram. For each link we again get the source and target and include all the links in the resulting diagram. This will repeat until no new elements are found. ## API Usage From 63050088277d8c43c83f6d6dc0c76298dcbd3d87 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <48179958+huyenngn@users.noreply.github.com> Date: Mon, 25 Nov 2024 15:51:26 +0100 Subject: [PATCH 13/22] fix: Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ernst Würger --- capellambse_context_diagrams/__init__.py | 5 +---- .../collectors/custom.py | 7 ++----- capellambse_context_diagrams/context.py | 18 +----------------- tests/test_context_diagrams.py | 1 - 4 files changed, 4 insertions(+), 27 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index c5fa031..460c356 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -254,10 +254,7 @@ def register_physical_port_context() -> None: m.set_accessor( cs.PhysicalPort, ATTR_NAME, - context.PhysicalPortContextAccessor( - DiagramType.PAB.value, - {}, - ), + context.PhysicalPortContextAccessor(DiagramType.PAB.value, {}), ) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 273e3ba..ffbc576 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -31,7 +31,7 @@ class CustomCollector: def __init__( self, - diagram: context.ContextDiagram, + diagram: context.CustomDiagram, params: dict[str, t.Any], ) -> None: self.diagram = diagram @@ -89,9 +89,7 @@ def __call__(self) -> _elkjs.ELKInputData: and getattr(current, "owner", None) is not None and not isinstance(current.owner, generic.PackageTypes) ): - current = self._make_owner_box( - current, - ) + current = self._make_owner_box(current) self.common_owners.discard(current.uuid) for edge_uuid, box_uuid in self.edge_owners.items(): if box := self.boxes.get(box_uuid): @@ -288,7 +286,6 @@ def _make_edge_and_ports( ), ) self.edges[edge_obj.uuid] = edge - return edge def _need_switch( diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index d6225da..daad184 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -892,14 +892,6 @@ class CustomDiagram(ContextDiagram): """An automatically generated CustomDiagram Diagram.""" _collect: dict[str, t.Any] - _display_symbols_as_boxes: bool - _display_parent_relation: bool - _hide_direct_children: bool - _slim_center_box: bool - _display_port_labels: bool - _port_label_position: str - _transparent_background: bool - _display_unused_ports: bool _unify_edge_direction: str def __init__( @@ -912,14 +904,7 @@ def __init__( ) -> None: default_render_parameters = { "collect": {}, - "display_symbols_as_boxes": False, - "display_parent_relation": False, - "hide_direct_children": False, "slim_center_box": False, - "display_port_labels": False, - "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, - "transparent_background": False, - "display_unused_ports": False, "unify_edge_direction": str, } | default_render_parameters super().__init__( @@ -931,7 +916,7 @@ def __init__( self.collector = custom.collector -class PhysicalPortContextDiagram(ContextDiagram): +class PhysicalPortContextDiagram(CustomDiagram): """An automatically generated Context Diagram exclusively for PhysicalPorts. """ @@ -963,7 +948,6 @@ def __init__( render_styles=render_styles, default_render_parameters=default_render_parameters, ) - self.collector = custom.collector def try_to_layout(data: _elkjs.ELKInputData) -> _elkjs.ELKOutputData: diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index a644c85..a9e62a1 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -64,7 +64,6 @@ def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: obj = model.by_uuid(uuid) diag = obj.context_diagram - diag.render("svgdiagram", display_parent_relation=True).save(pretty=True) diag.render(None, display_parent_relation=False) assert diag.nodes From e4dfd0dbe095b26b181292d3aede11b5804816e4 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 26 Nov 2024 12:44:32 +0100 Subject: [PATCH 14/22] fix: Fix minimum size calculation of boxes --- capellambse_context_diagrams/__init__.py | 13 +++++++++++-- capellambse_context_diagrams/collectors/custom.py | 5 +++++ docs/custom_diagram.md | 11 +---------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 460c356..5a307e2 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -329,12 +329,21 @@ def register_cable_tree_view() -> None: def register_custom_diagram() -> None: """Add the `custom_diagram` attribute to `ModelObject`s.""" supported_classes: list[tuple[type[m.ModelElement], DiagramType]] = [ + (oa.Entity, DiagramType.OAB), + (oa.OperationalActivity, DiagramType.OAB), + (oa.OperationalCapability, DiagramType.OCB), + (oa.CommunicationMean, DiagramType.OAB), + (sa.Mission, DiagramType.MCB), + (sa.Capability, DiagramType.MCB), + (sa.SystemComponent, DiagramType.SAB), (sa.SystemFunction, DiagramType.SAB), - (cs.PhysicalLink, DiagramType.PAB), + (la.LogicalComponent, DiagramType.LAB), (la.LogicalFunction, DiagramType.LAB), + (pa.PhysicalComponent, DiagramType.PAB), (pa.PhysicalFunction, DiagramType.PAB), - (fa.ComponentExchange, DiagramType.SAB), + (cs.PhysicalLink, DiagramType.PAB), (cs.PhysicalPort, DiagramType.PAB), + (fa.ComponentExchange, DiagramType.SAB), ] for class_, dgcls in supported_classes: m.set_accessor( diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index ffbc576..f7ddf9f 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -89,6 +89,7 @@ def __call__(self) -> _elkjs.ELKInputData: and getattr(current, "owner", None) is not None and not isinstance(current.owner, generic.PackageTypes) ): + self.common_owners.discard(current.uuid) current = self._make_owner_box(current) self.common_owners.discard(current.uuid) for edge_uuid, box_uuid in self.edge_owners.items(): @@ -224,6 +225,10 @@ def _make_owner_box( break else: children.append(obj_box) + obj_box.width = max( + obj_box.width, + parent_box.width, + ) for label in parent_box.labels: label.layoutOptions = makers.DEFAULT_LABEL_LAYOUT_OPTIONS self.boxes_to_delete.add(obj.uuid) diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 62ffdb3..3c175a1 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -74,18 +74,9 @@ my_model = capellambse.MelodyModel(...) my_element = my_model.by_uuid(...) my_yaml = "..." -my_element.custom_diagram(collect=yaml.safe_load(my_yaml)).render("svgdiagram").save(pretty=True) +my_element.custom_diagram.render("svgdiagram", collect=yaml.safe_load(my_yaml)).save(pretty=True) ``` -## Supported Elements - -- [`sa.SystemFunction`][capellambse.metamodel.sa.SystemFunction] -- [`cs.PhysicalLink`][capellambse.metamodel.cs.PhysicalFunction] -- [`la.LogicalFunction`][capellambse.metamodel.la.LogicalFunction] -- [`pa.PhysicalFunction`][capellambse.metamodel.pa.PhysicalFunction] -- [`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange] -- [`cs.PhysicalPort`][capellambse.metamodel.cs.PhysicalPort] - ## Check out the code To understand the collection have a look into the From f091a65e7ad9927eecec33dba7918d89a2562fb5 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Tue, 26 Nov 2024 14:02:03 +0100 Subject: [PATCH 15/22] refactor: Implement generic make_owner_boxes function --- .../collectors/custom.py | 14 +++++--------- .../collectors/default.py | 18 ++++++------------ .../collectors/generic.py | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index f7ddf9f..9f0314a 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -198,15 +198,11 @@ def _make_box( for port in getattr(obj, attr, []): self._make_port_and_owner(port) if self.diagram._display_parent_relation: - current = obj - while ( - current - and current.uuid not in self.diagram_target_owners - and getattr(current, "owner", None) is not None - and not isinstance(current.owner, generic.PackageTypes) - ): - current = self._make_owner_box(current) - self.common_owners.add(current.uuid) + self.common_owners.add( + generic.make_owner_boxes( + obj, self.diagram_target_owners, self._make_owner_box + ) + ) return box def _make_owner_box( diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index c311e0e..316cd2f 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -84,7 +84,6 @@ def process_context(self): and not isinstance(current.owner, generic.PackageTypes) ): current = self._make_owner_box( - self.diagram, current, ) self.common_owners.discard(current.uuid) @@ -256,15 +255,11 @@ def _process_ports(self) -> None: box.layoutOptions["portLabels.placement"] = "OUTSIDE" if self.diagram._display_parent_relation: - current = owner - while ( - current - and current.uuid not in self.diagram_target_owners - and getattr(current, "owner", None) is not None - and not isinstance(current.owner, generic.PackageTypes) - ): - current = self._make_owner_box(self.diagram, current) - self.common_owners.add(current.uuid) + self.common_owners.add( + generic.make_owner_boxes( + owner, self.diagram_target_owners, self._make_owner_box + ) + ) def _make_port( self, port_obj: t.Any @@ -295,13 +290,12 @@ def _make_box( def _make_owner_box( self, - diagram: context.ContextDiagram, obj: t.Any, ) -> t.Any: if not (parent_box := self.global_boxes.get(obj.owner.uuid)): parent_box = self._make_box( obj.owner, - no_symbol=diagram._display_symbols_as_boxes, + no_symbol=self.diagram._display_symbols_as_boxes, layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) assert (obj_box := self.global_boxes.get(obj.uuid)) diff --git a/capellambse_context_diagrams/collectors/generic.py b/capellambse_context_diagrams/collectors/generic.py index cbbe21b..faf8512 100644 --- a/capellambse_context_diagrams/collectors/generic.py +++ b/capellambse_context_diagrams/collectors/generic.py @@ -267,3 +267,18 @@ def get_all_owners(obj: m.ModelElement) -> cabc.Iterator[str]: while current is not None: yield current.uuid current = getattr(current, "owner", None) + + +def make_owner_boxes( + obj: m.ModelElement, excluded: list[str], make_func: t.Callable +) -> str: + """Create owner boxes for all owners of ``obj``.""" + current = obj + while ( + current + and current.uuid not in excluded + and getattr(current, "owner", None) is not None + and not isinstance(current.owner, PackageTypes) + ): + current = make_func(current) + return current.uuid From bed6a30769b5926f25eccb0c879c34d7a0a8235e Mon Sep 17 00:00:00 2001 From: huyenngn Date: Wed, 27 Nov 2024 01:16:21 +0100 Subject: [PATCH 16/22] fix: Fix mutability bug --- .../collectors/custom.py | 47 ++++++++++--------- .../collectors/default.py | 14 +++--- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 9f0314a..7787445 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -185,6 +185,8 @@ def _make_box( obj: m.ModelElement, **kwargs: t.Any, ) -> _elkjs.ELKInputChild: + if box := self.boxes.get(obj.uuid): + return box box = makers.make_box( obj, no_symbol=self.diagram._display_symbols_as_boxes, @@ -209,15 +211,13 @@ def _make_owner_box( self, obj: t.Any, ) -> t.Any: - if not (parent_box := self.boxes.get(obj.owner.uuid)): - parent_box = self._make_box( - obj.owner, - layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, - ) + parent_box = self._make_box( + obj.owner, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) assert (obj_box := self.boxes.get(obj.uuid)) for box in (children := parent_box.children): if box.id == obj.uuid: - box = obj_box break else: children.append(obj_box) @@ -234,6 +234,8 @@ def _make_edge_and_ports( self, edge_obj: m.ModelElement, ) -> _elkjs.ELKInputEdge | None: + if self.edges.get(edge_obj.uuid): + return None src_obj = edge_obj.source tgt_obj = edge_obj.target src_owner = src_obj.owner @@ -263,20 +265,10 @@ def _make_edge_and_ports( if not self.ports.get(src_obj.uuid): port = self._make_port_and_owner(src_obj) - self.min_heights.setdefault( - src_owner.uuid, {"left": 0.0, "right": 0.0} - )["right"] += makers.PORT_SIZE + max( - 2 * makers.PORT_PADDING, - sum(label.height for label in port.labels), - ) + self._update_min_heights(src_owner.uuid, "right", port) if not self.ports.get(tgt_obj.uuid): port = self._make_port_and_owner(tgt_obj) - self.min_heights.setdefault( - tgt_owner.uuid, {"left": 0.0, "right": 0.0} - )["left"] += makers.PORT_SIZE + max( - 2 * makers.PORT_PADDING, - sum(label.height for label in port.labels), - ) + self._update_min_heights(tgt_owner.uuid, "left", port) edge = _elkjs.ELKInputEdge( id=edge_obj.uuid, @@ -289,6 +281,16 @@ def _make_edge_and_ports( self.edges[edge_obj.uuid] = edge return edge + def _update_min_heights( + self, owner_uuid: str, side: str, port: _elkjs.ELKInputPort + ) -> None: + self.min_heights.setdefault(owner_uuid, {"left": 0.0, "right": 0.0})[ + side + ] += makers.PORT_SIZE + max( + 2 * makers.PORT_PADDING, + sum(label.height for label in port.labels), + ) + def _need_switch( self, src_owners: list[str], @@ -342,11 +344,10 @@ def _make_port_and_owner( self, port_obj: m.ModelElement ) -> _elkjs.ELKInputPort: owner_obj = port_obj.owner - if not (box := self.boxes.get(owner_obj.uuid)): - box = self._make_box( - owner_obj, - layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, - ) + box = self._make_box( + owner_obj, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) if port := self.ports.get(port_obj.uuid): return port port = makers.make_port(port_obj.uuid) diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index 316cd2f..edf4f4d 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -280,6 +280,8 @@ def _make_box( obj: t.Any, **kwargs: t.Any, ) -> _elkjs.ELKInputChild: + if box := self.global_boxes.get(obj.uuid): + return box box = makers.make_box( obj, **kwargs, @@ -292,16 +294,14 @@ def _make_owner_box( self, obj: t.Any, ) -> t.Any: - if not (parent_box := self.global_boxes.get(obj.owner.uuid)): - parent_box = self._make_box( - obj.owner, - no_symbol=self.diagram._display_symbols_as_boxes, - layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, - ) + parent_box = self._make_box( + obj.owner, + no_symbol=self.diagram._display_symbols_as_boxes, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) assert (obj_box := self.global_boxes.get(obj.uuid)) for box in (children := parent_box.children): if box.id == obj.uuid: - box = obj_box break else: children.append(obj_box) From c744a6be7676b417b3a053f4447005ee9b06c9f4 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Wed, 27 Nov 2024 10:42:41 +0100 Subject: [PATCH 17/22] feat: Add support for python lambda filter --- .../collectors/custom.py | 93 ++++++++++++++++++- docs/custom_diagram.md | 14 ++- 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 7787445..06d2e14 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -4,10 +4,12 @@ """This module defines the collector for the CustomDiagram.""" from __future__ import annotations +import builtins import collections.abc as cabc import copy import typing as t +import capellambse import capellambse.model as m from .. import _elkjs, context @@ -116,9 +118,98 @@ def _fix_box_heights(self) -> None: box = self.boxes[uuid] box.height = max([box.height] + list(min_heights.values())) + def _safely_eval_filter(self, obj: m.ModelElement, filter: str) -> bool: + if not filter.startswith("lambda"): + raise ValueError(f"Filter '{filter}' is not a lambda expression.") + + safe_builtins = { + "abs", + "all", + "any", + "ascii", + "bin", + "bool", + "bytearray", + "bytes", + "callable", + "chr", + "classmethod", + "complex", + "dict", + "divmod", + "enumerate", + "filter", + "float", + "format", + "frozenset", + "getattr", + "hasattr", + "hash", + "hex", + "id", + "int", + "isinstance", + "issubclass", + "iter", + "len", + "list", + "map", + "max", + "memoryview", + "min", + "next", + "object", + "oct", + "ord", + "pow", + "print", + "property", + "range", + "repr", + "reversed", + "round", + "set", + "slice", + "sorted", + "staticmethod", + "str", + "sum", + "tuple", + "type", + "vars", + "zip", + } + allowed_builtins = { + name: getattr(builtins, name) for name in safe_builtins + } + allowed_builtins.update( + { + "True": True, + "False": False, + "capellambse": capellambse, + } + ) + + try: + # pylint: disable=eval-used + result = eval(filter, {"__builtins__": allowed_builtins})(obj) + except Exception as e: + raise ValueError( + f"Filter '{filter}' raised an exception: {e}" + ) from e + + if not isinstance(result, bool): + raise ValueError( + f"Filter '{filter}' did not return a boolean value." + ) + + return result + def _matches_filters( - self, obj: m.ModelElement, filters: dict[str, t.Any] + self, obj: m.ModelElement, filters: dict[str, t.Any] | str ) -> bool: + if isinstance(filters, str): + return self._safely_eval_filter(obj, filters) for key, value in filters.items(): if getattr(obj, key) != value: return False diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 3c175a1..0a49da5 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -34,7 +34,7 @@ In the example above, we first `get` all the inputs of our target element and it ### `filter` -Whenever you have a list of elements and you want to filter them, you can use the `filter` keyword. The `filter` keyword takes a dictionary as an argument. The dictionary should have the key as the attribute name and the value as the value you want to filter on. +Whenever you have a list of elements and you want to filter them, you can use the `filter` keyword. The `filter` keyword takes a dictionary or a string as an argument. The dictionary should have the key as the attribute name and the value as the value you want to filter on. ```yaml get: @@ -47,6 +47,18 @@ get: In the example above, we get all the inputs of our target element and include all the exchanges that are of kind `FunctionalExchange` in the resulting diagram. +For a string, the filter should be a lambda expression that takes the element as an argument and returns a boolean. + +```yaml +get: + - name: inputs + filter: "lambda x: isinstance(x, capellambse.metamodel.fa.FunctionPort)" + include: + - name: exchanges +``` + +In the example above, we get all the inputs that are of type `FunctionPort` and include all it's exchanges in the resulting diagram. + ### `repeat` With the `repeat` keyword, you can repeat the collection. The value of `repeat` should be an integer. If the value is -1, the collection will repeat until no new elements are found. If the value is 0, the collection will not repeat. If the value is 1, the collection will repeat once and so on. From 3f99c27216c58df910bb90575d286ffa66078046 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 2 Dec 2024 09:39:59 +0100 Subject: [PATCH 18/22] fix: Add custom_diagram attribute to Class elements --- capellambse_context_diagrams/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 5a307e2..b66c57c 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -344,6 +344,7 @@ def register_custom_diagram() -> None: (cs.PhysicalLink, DiagramType.PAB), (cs.PhysicalPort, DiagramType.PAB), (fa.ComponentExchange, DiagramType.SAB), + (information.Class, DiagramType.CDB), ] for class_, dgcls in supported_classes: m.set_accessor( From 5398b91826352afac8e2cf7956b02f901c45aefe Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 9 Dec 2024 10:39:36 +0100 Subject: [PATCH 19/22] refactor: Add generic make_owner_box method --- .../collectors/custom.py | 33 +++++------------ .../collectors/default.py | 35 ++++++------------ .../collectors/generic.py | 36 +++++++++++++++++-- docs/custom_diagram.md | 18 +++++----- docs/index.md | 2 +- tests/test_context_diagrams.py | 4 --- 6 files changed, 62 insertions(+), 66 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index 06d2e14..f4d35c7 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -92,7 +92,9 @@ def __call__(self) -> _elkjs.ELKInputData: and not isinstance(current.owner, generic.PackageTypes) ): self.common_owners.discard(current.uuid) - current = self._make_owner_box(current) + current = generic.make_owner_box( + current, self._make_box, self.boxes, self.boxes_to_delete + ) self.common_owners.discard(current.uuid) for edge_uuid, box_uuid in self.edge_owners.items(): if box := self.boxes.get(box_uuid): @@ -293,34 +295,15 @@ def _make_box( if self.diagram._display_parent_relation: self.common_owners.add( generic.make_owner_boxes( - obj, self.diagram_target_owners, self._make_owner_box + obj, + self.diagram_target_owners, + self._make_box, + self.boxes, + self.boxes_to_delete, ) ) return box - def _make_owner_box( - self, - obj: t.Any, - ) -> t.Any: - parent_box = self._make_box( - obj.owner, - layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, - ) - assert (obj_box := self.boxes.get(obj.uuid)) - for box in (children := parent_box.children): - if box.id == obj.uuid: - break - else: - children.append(obj_box) - obj_box.width = max( - obj_box.width, - parent_box.width, - ) - for label in parent_box.labels: - label.layoutOptions = makers.DEFAULT_LABEL_LAYOUT_OPTIONS - self.boxes_to_delete.add(obj.uuid) - return obj.owner - def _make_edge_and_ports( self, edge_obj: m.ModelElement, diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index edf4f4d..53874af 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -67,7 +67,6 @@ def process_context(self): ): box = self._make_box( self.diagram.target.owner, - no_symbol=self.diagram._display_symbols_as_boxes, layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) box.children = [self.centerbox] @@ -83,8 +82,11 @@ def process_context(self): and hasattr(current, "owner") and not isinstance(current.owner, generic.PackageTypes) ): - current = self._make_owner_box( + current = generic.make_owner_box( current, + self._make_box, + self.global_boxes, + self.boxes_to_delete, ) self.common_owners.discard(current.uuid) @@ -248,7 +250,6 @@ def _process_ports(self) -> None: box = self._make_box( owner, height=height, - no_symbol=self.diagram._display_symbols_as_boxes, ) box.ports = local_port_objs @@ -257,7 +258,11 @@ def _process_ports(self) -> None: if self.diagram._display_parent_relation: self.common_owners.add( generic.make_owner_boxes( - owner, self.diagram_target_owners, self._make_owner_box + owner, + self.diagram_target_owners, + self._make_box, + self.global_boxes, + self.boxes_to_delete, ) ) @@ -284,33 +289,13 @@ def _make_box( return box box = makers.make_box( obj, + no_symbol=self.diagram._display_symbols_as_boxes, **kwargs, ) self.global_boxes[obj.uuid] = box self.made_boxes[obj.uuid] = box return box - def _make_owner_box( - self, - obj: t.Any, - ) -> t.Any: - parent_box = self._make_box( - obj.owner, - no_symbol=self.diagram._display_symbols_as_boxes, - layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, - ) - assert (obj_box := self.global_boxes.get(obj.uuid)) - for box in (children := parent_box.children): - if box.id == obj.uuid: - break - else: - children.append(obj_box) - for label in parent_box.labels: - label.layoutOptions = makers.DEFAULT_LABEL_LAYOUT_OPTIONS - - self.boxes_to_delete.add(obj.uuid) - return obj.owner - def collector( diagram: context.ContextDiagram, params: dict[str, t.Any] | None = None diff --git a/capellambse_context_diagrams/collectors/generic.py b/capellambse_context_diagrams/collectors/generic.py index faf8512..f6cc992 100644 --- a/capellambse_context_diagrams/collectors/generic.py +++ b/capellambse_context_diagrams/collectors/generic.py @@ -269,8 +269,38 @@ def get_all_owners(obj: m.ModelElement) -> cabc.Iterator[str]: current = getattr(current, "owner", None) +def make_owner_box( + obj: t.Any, + make_box_func: t.Callable, + boxes: dict[str, _elkjs.ELKInputChild], + boxes_to_delete: set[str], +) -> t.Any: + parent_box = make_box_func( + obj.owner, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) + assert (obj_box := boxes.get(obj.uuid)) + for box in (children := parent_box.children): + if box.id == obj.uuid: + break + else: + children.append(obj_box) + obj_box.width = max( + obj_box.width, + parent_box.width, + ) + for label in parent_box.labels: + label.layoutOptions = makers.DEFAULT_LABEL_LAYOUT_OPTIONS + boxes_to_delete.add(obj.uuid) + return obj.owner + + def make_owner_boxes( - obj: m.ModelElement, excluded: list[str], make_func: t.Callable + obj: m.ModelElement, + excluded: list[str], + make_box_func: t.Callable, + boxes: dict[str, _elkjs.ELKInputChild], + boxes_to_delete: set[str], ) -> str: """Create owner boxes for all owners of ``obj``.""" current = obj @@ -280,5 +310,7 @@ def make_owner_boxes( and getattr(current, "owner", None) is not None and not isinstance(current.owner, PackageTypes) ): - current = make_func(current) + current = make_owner_box( + current, make_box_func, boxes, boxes_to_delete + ) return current.uuid diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 0a49da5..43193ae 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -17,17 +17,17 @@ At every step of the collection, you can either `get` or `include` elements. `ge ```yaml get: - name: inputs - include: - - name: exchanges - - name: links + include: + - name: exchanges + - name: links - name: outputs - include: - - name: exchanges - - name: links + include: + - name: exchanges + - name: links - name: ports - include: - - name: exchanges - - name: links + include: + - name: exchanges + - name: links ``` In the example above, we first `get` all the inputs of our target element and iterate over them. For each input, we include all the exchanges and links in the resulting diagram. We do the same for outputs and ports. Note that `get` does not include the element in the diagram, it just gets the element, but calling `include` on an edge will also include the edge's source and target ports. diff --git a/docs/index.md b/docs/index.md index 8f6f3a2..af85294 100644 --- a/docs/index.md +++ b/docs/index.md @@ -202,7 +202,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.ModelObj diag.render("svgdiagram").save(pretty=True) ```
- +
Context of PP 1 [PAB]
diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index a9e62a1..9209a95 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -300,10 +300,6 @@ def test_context_diagram_display_unused_ports( obj = model.by_uuid("446d3f9f-644d-41ee-bd57-8ae0f7662db2") unused_port_uuid = "5cbc4d2d-1b9c-4e10-914e-44d4526e4a2f" - obj.context_diagram.render("svgdiagram", display_unused_ports=True).save( - pretty=True - ) - adiag = obj.context_diagram.render(None, display_unused_ports=False) bdiag = obj.context_diagram.render(None, display_unused_ports=True) From ad39f9920058911be0ecee609b9fba7a33bd388b Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 9 Dec 2024 22:21:05 +0100 Subject: [PATCH 20/22] refactor(custom_diagram): Take iterable as collection --- .../collectors/custom.py | 160 +----------------- capellambse_context_diagrams/context.py | 27 ++- docs/custom_diagram.md | 83 +-------- 3 files changed, 26 insertions(+), 244 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index f4d35c7..fde8399 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -49,10 +49,7 @@ def __init__( self.data = makers.make_diagram(diagram) self.params = params - self.instructions = self.diagram._collect - self.repeat_instructions: dict[str, t.Any] = {} - self.repeat_depth: int = 0 - self.visited: set[str] = set() + self.collection = self.diagram._collect self.boxes: dict[str, _elkjs.ELKInputChild] = {} self.edges: dict[str, _elkjs.ELKInputEdge] = {} self.ports: dict[str, _elkjs.ELKInputPort] = {} @@ -74,15 +71,18 @@ def __call__(self) -> _elkjs.ELKInputData: self._make_port_and_owner(self.target) else: self._make_target(self.target) + if target_edge := self.edges.get(self.target.uuid): target_edge.layoutOptions = copy.deepcopy( _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS ) - if not self.instructions: - return self._get_data() + if self.diagram._unify_edge_direction == "UNIFORM": self.directions[self.boxable_target.uuid] = False - self._perform_instructions(self.target, self.instructions) + + for elem in self.collection: + self._make_target(elem) + if self.diagram._display_parent_relation: current = self.boxable_target while ( @@ -120,152 +120,6 @@ def _fix_box_heights(self) -> None: box = self.boxes[uuid] box.height = max([box.height] + list(min_heights.values())) - def _safely_eval_filter(self, obj: m.ModelElement, filter: str) -> bool: - if not filter.startswith("lambda"): - raise ValueError(f"Filter '{filter}' is not a lambda expression.") - - safe_builtins = { - "abs", - "all", - "any", - "ascii", - "bin", - "bool", - "bytearray", - "bytes", - "callable", - "chr", - "classmethod", - "complex", - "dict", - "divmod", - "enumerate", - "filter", - "float", - "format", - "frozenset", - "getattr", - "hasattr", - "hash", - "hex", - "id", - "int", - "isinstance", - "issubclass", - "iter", - "len", - "list", - "map", - "max", - "memoryview", - "min", - "next", - "object", - "oct", - "ord", - "pow", - "print", - "property", - "range", - "repr", - "reversed", - "round", - "set", - "slice", - "sorted", - "staticmethod", - "str", - "sum", - "tuple", - "type", - "vars", - "zip", - } - allowed_builtins = { - name: getattr(builtins, name) for name in safe_builtins - } - allowed_builtins.update( - { - "True": True, - "False": False, - "capellambse": capellambse, - } - ) - - try: - # pylint: disable=eval-used - result = eval(filter, {"__builtins__": allowed_builtins})(obj) - except Exception as e: - raise ValueError( - f"Filter '{filter}' raised an exception: {e}" - ) from e - - if not isinstance(result, bool): - raise ValueError( - f"Filter '{filter}' did not return a boolean value." - ) - - return result - - def _matches_filters( - self, obj: m.ModelElement, filters: dict[str, t.Any] | str - ) -> bool: - if isinstance(filters, str): - return self._safely_eval_filter(obj, filters) - for key, value in filters.items(): - if getattr(obj, key) != value: - return False - return True - - def _perform_instructions( - self, obj: m.ModelElement, instructions: dict[str, t.Any] - ) -> None: - if max_depth := instructions.pop("repeat", None): - self.repeat_instructions = instructions - self.repeat_depth = max_depth - if get_targets := instructions.get("get"): - self._perform_get_or_include(obj, get_targets, False) - if include_targets := instructions.get("include"): - self._perform_get_or_include(obj, include_targets, True) - if not get_targets and not include_targets: - if self.repeat_depth != 0: - self.repeat_depth -= 1 - self._perform_instructions(obj, self.repeat_instructions) - - def _perform_get_or_include( - self, - obj: m.ModelElement, - targets: dict[str, t.Any] | list[dict[str, t.Any]], - create: bool, - ) -> None: - if isinstance(targets, dict): - targets = [targets] - assert isinstance(targets, list) - if self.repeat_depth > 0: - self.repeat_depth += len(targets) - for i in targets: - attr = i.get("name") - assert attr, "Attribute name is required." - target = getattr(obj, attr, None) - if isinstance(target, cabc.Iterable): - filters = i.get("filter", {}) - for item in target: - if item.uuid in self.visited: - continue - self.visited.add(item.uuid) - if not self._matches_filters(item, filters): - continue - if create: - self._make_target(item) - self._perform_instructions(item, i) - elif isinstance(target, m.ModelElement): - if target.uuid in self.visited: - continue - self.visited.add(target.uuid) - if create: - self._make_target(target) - self._perform_instructions(target, i) - def _make_target( self, obj: m.ModelElement ) -> _elkjs.ELKInputChild | _elkjs.ELKInputEdge | None: diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index daad184..ce5d86b 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -891,7 +891,7 @@ def name(self) -> str: # type: ignore class CustomDiagram(ContextDiagram): """An automatically generated CustomDiagram Diagram.""" - _collect: dict[str, t.Any] + _collect: cabc.Iterator[m.ModelElement] _unify_edge_direction: str def __init__( @@ -903,7 +903,7 @@ def __init__( default_render_parameters: dict[str, t.Any], ) -> None: default_render_parameters = { - "collect": {}, + "collect": [], "slim_center_box": False, "unify_edge_direction": str, } | default_render_parameters @@ -929,19 +929,28 @@ def __init__( render_styles: dict[str, styling.Styler] | None = None, default_render_parameters: dict[str, t.Any], ) -> None: + + visited = set() + + def _collector( + target: m.ModelElement, + ) -> cabc.Iterator[m.ModelElement]: + if target.uuid in visited: + return + visited.add(target.uuid) + for link in target.links: + yield link + yield from _collector(link.source) + yield from _collector(link.target) + default_render_parameters = { - "collect": { - "repeat": -1, - "include": { - "name": "links", - "get": [{"name": "source"}, {"name": "target"}], - }, - }, + "collect": _collector(obj), "display_parent_relation": True, "unify_edge_direction": "UNIFORM", "display_port_labels": True, "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, } | default_render_parameters + super().__init__( class_, obj, diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 43193ae..66d0da7 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -5,90 +5,9 @@ # Custom Diagram -`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using a dictionary. +`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using an iterable and `Custom diagram` takes care of the rest. You can access `.custom_diagram` on any supported model element. -## Collector definition - -### `get` and `include` - -At every step of the collection, you can either `get` or `include` elements. `get` will simply get the element and `include` will include the element in the collection. `name` is the attribute name. - -```yaml -get: - - name: inputs - include: - - name: exchanges - - name: links - - name: outputs - include: - - name: exchanges - - name: links - - name: ports - include: - - name: exchanges - - name: links -``` - -In the example above, we first `get` all the inputs of our target element and iterate over them. For each input, we include all the exchanges and links in the resulting diagram. We do the same for outputs and ports. Note that `get` does not include the element in the diagram, it just gets the element, but calling `include` on an edge will also include the edge's source and target ports. - -### `filter` - -Whenever you have a list of elements and you want to filter them, you can use the `filter` keyword. The `filter` keyword takes a dictionary or a string as an argument. The dictionary should have the key as the attribute name and the value as the value you want to filter on. - -```yaml -get: - - name: inputs - include: - - name: exchanges - filter: - kind: "FunctionalExchange" -``` - -In the example above, we get all the inputs of our target element and include all the exchanges that are of kind `FunctionalExchange` in the resulting diagram. - -For a string, the filter should be a lambda expression that takes the element as an argument and returns a boolean. - -```yaml -get: - - name: inputs - filter: "lambda x: isinstance(x, capellambse.metamodel.fa.FunctionPort)" - include: - - name: exchanges -``` - -In the example above, we get all the inputs that are of type `FunctionPort` and include all it's exchanges in the resulting diagram. - -### `repeat` - -With the `repeat` keyword, you can repeat the collection. The value of `repeat` should be an integer. If the value is -1, the collection will repeat until no new elements are found. If the value is 0, the collection will not repeat. If the value is 1, the collection will repeat once and so on. - -```yaml -repeat: -1 -get: - - name: source - include: - name: links - - name: target - include: - name: links -``` - -In the example above, we get the source and target of our target element and include all the links in the resulting diagram. For each link we again get the source and target and include all the links in the resulting diagram. This will repeat until no new elements are found. - -## API Usage - -```python -import capellambse -import yaml - -my_model = capellambse.MelodyModel(...) -my_element = my_model.by_uuid(...) -my_yaml = "..." - -my_element.custom_diagram.render("svgdiagram", collect=yaml.safe_load(my_yaml)).save(pretty=True) -``` - ## Check out the code To understand the collection have a look into the From d2162d4a10066ff90ff3cf4f7ec2c9a3c62a8c1a Mon Sep 17 00:00:00 2001 From: huyenngn Date: Mon, 9 Dec 2024 22:28:23 +0100 Subject: [PATCH 21/22] docs(custom_diagram): Add example to docs --- docs/custom_diagram.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 66d0da7..3f4f7de 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -8,8 +8,37 @@ `Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using an iterable and `Custom diagram` takes care of the rest. You can access `.custom_diagram` on any supported model element. +??? example "Custom Diagram of `PP 1 `" + + ``` py + import capellambse + + visited = set() + + def _collector( + target: m.ModelElement, + ) -> cabc.Iterator[m.ModelElement]: + if target.uuid in visited: + return + visited.add(target.uuid) + for link in target.links: + yield link + yield from _collector(link.source) + yield from _collector(link.target) + + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + obj = model.by_uuid("c403d4f4-9633-42a2-a5d6-9e1df2655146") + diag = obj.context_diagram + diag.render("svgdiagram", collect=_collector(obj)).save(pretty=True) + ``` +
+ +
Context of PP 1 [PAB]
+
+ ## Check out the code To understand the collection have a look into the -[`cable_tree`][capellambse_context_diagrams.collectors.cable_tree] +[`custom`][capellambse_context_diagrams.collectors.custom] module. From 51772a881c87da778c9ee0beabce820e2b247bc2 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <48179958+huyenngn@users.noreply.github.com> Date: Mon, 16 Dec 2024 21:58:58 +0100 Subject: [PATCH 22/22] docs: Update custom_diagram docs Co-authored-by: Martin Lehmann --- .../collectors/custom.py | 5 +--- docs/custom_diagram.md | 25 +++++++++++-------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/capellambse_context_diagrams/collectors/custom.py b/capellambse_context_diagrams/collectors/custom.py index fde8399..6169ea1 100644 --- a/capellambse_context_diagrams/collectors/custom.py +++ b/capellambse_context_diagrams/collectors/custom.py @@ -4,12 +4,9 @@ """This module defines the collector for the CustomDiagram.""" from __future__ import annotations -import builtins -import collections.abc as cabc import copy import typing as t -import capellambse import capellambse.model as m from .. import _elkjs, context @@ -33,7 +30,7 @@ class CustomCollector: def __init__( self, - diagram: context.CustomDiagram, + diagram: context.ContextDiagram, params: dict[str, t.Any], ) -> None: self.diagram = diagram diff --git a/docs/custom_diagram.md b/docs/custom_diagram.md index 3f4f7de..98d1c1c 100644 --- a/docs/custom_diagram.md +++ b/docs/custom_diagram.md @@ -5,7 +5,8 @@ # Custom Diagram -`Custom diagram`s let's you create custom diagrams based on the data in the model. You define the data collection using an iterable and `Custom diagram` takes care of the rest. +`Custom diagram`s let you create custom diagrams based on the data in the model. You define the data collection using an iterable, and `Custom diagram` takes care of the rest. + You can access `.custom_diagram` on any supported model element. ??? example "Custom Diagram of `PP 1 `" @@ -13,19 +14,21 @@ You can access `.custom_diagram` on any supported model element. ``` py import capellambse - visited = set() - def _collector( target: m.ModelElement, ) -> cabc.Iterator[m.ModelElement]: - if target.uuid in visited: - return - visited.add(target.uuid) - for link in target.links: - yield link - yield from _collector(link.source) - yield from _collector(link.target) - + visited = set() + def collector( + target: m.ModelElement, + ) -> cabc.Iterator[m.ModelElement]: + if target.uuid in visited: + return + visited.add(target.uuid) + for link in target.links: + yield link + yield from collector(link.source) + yield from collector(link.target) + yield from collector(target) model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") obj = model.by_uuid("c403d4f4-9633-42a2-a5d6-9e1df2655146")