From 31e7c7c4330a6a7cc7976a402d212de6e01ddf76 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 30 Apr 2024 17:08:43 +0200 Subject: [PATCH 1/7] test(context-diagram): Add derived collectors tests --- tests/data/ContextDiagram.aird | 515 ++++++++++++++++++++++++++++++ tests/data/ContextDiagram.capella | 85 +++++ tests/test_context_diagrams.py | 14 + 3 files changed, 614 insertions(+) diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index 1ab52215..61b5b7b3 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -94,6 +94,14 @@ + + +
+
+ + + + @@ -8936,4 +8944,511 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella index dd452ea4..a20b61b3 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -2944,6 +2944,33 @@ The predator is far away + + + + + + + + + + + + + + + + @@ -3031,6 +3058,15 @@ The predator is far away + + + name="Multiport" abstractType="#b3888dad-a870-4b8b-97d4-0ddb83ef9251"/> + + + name="Right 1" abstractType="#c999f0f0-49d0-4b01-b260-f69dc63abbcb"/> + + + @@ -3764,6 +3813,30 @@ The predator is far away id="b557cfc6-ca9a-40f5-b6f7-14e61523bd9a" name="CP 1" orientation="IN" kind="FLOW"/> + + + + + + + + + + + id="3e0a3791-cc99-4af5-b789-5b33451ca743" name="CP 1" orientation="OUT" kind="FLOW"/> + + + + + + None: + obj = model.by_uuid(TEST_DERIVED_UUID) + + diag = obj.context_diagram + diag.display_derived_interfaces = True + + diag.render("svgdiagram").save(pretty=True) + + assert len(diag.nodes) > 5 From 747d43a8086c1b8270b6fd009898ea733609f913 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 30 Apr 2024 17:10:34 +0200 Subject: [PATCH 2/7] feat(context-diagram): Implement derived collectors --- capellambse_context_diagrams/__init__.py | 37 +++++++++ .../collectors/default.py | 76 +++++++++++++++++++ capellambse_context_diagrams/context.py | 5 ++ capellambse_context_diagrams/serializers.py | 54 ++++++++----- 4 files changed, 152 insertions(+), 20 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index e3786726..706b15dd 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -128,6 +128,43 @@ def patch_styles(classes: cabc.Iterable[SupportedClass]) -> None: for _, dt, _ in classes: capstyle.STYLES[dt.value]["Circle.FunctionalExchange"] = circle_style + for dt in (DiagramType.SAB, DiagramType.LAB, DiagramType.PAB): + text_fill = COLORS["black"] + if dt == DiagramType.SAB: + fill = [ + COLORS["_CAP_Component_Blue_min"], + COLORS["_CAP_Component_Blue"], + ] + stroke = COLORS["_CAP_Component_Border_Blue"] + elif dt == DiagramType.LAB: + fill = [ + COLORS["_CAP_Component_Blue_min"], + COLORS["_CAP_Component_Blue"], + ] + text_fill = COLORS["_CAP_Component_Label_Blue"] + elif dt == DiagramType.PAB: + fill = [ + COLORS["_CAP_Unset_Gray_min"], + COLORS["_CAP_Unset_Gray"], + ] + stroke = COLORS["_CAP_Lifeline_Gray"] + + capstyle.STYLES[dt.value].update( + { + "Box.DerivedBox": { + "fill": fill, + "stroke": stroke, + "stroke-dasharray": "4", + "text_fill": text_fill, + }, + "Edge.DerivedComponentExchange": { + "stroke": COLORS["_CAP_Component_Border_Blue"], + "stroke-width": 2, + "stroke-dasharray": "4", + }, + } + ) + def register_interface_context() -> None: """Add the `context_diagram` property to interface model objects.""" diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index fbe4de4b..b134dfe1 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -12,6 +12,7 @@ from capellambse import helpers from capellambse.model import common from capellambse.model.crosslayer import cs, fa +from capellambse.model.layers import ctx, la from capellambse.model.modeltypes import DiagramType as DT from .. import _elkjs, context @@ -119,6 +120,9 @@ def collector( ) centerbox["height"] = max(centerbox["height"], *stack_heights.values()) + if diagram.display_derived_interfaces: + add_derived_components_and_interfaces(diagram, data) + return data @@ -259,3 +263,75 @@ def port_context_collector( info.ports.append(port) return iter(ctx.values()) + + +def add_derived_components_and_interfaces( + diagram: context.ContextDiagram, data: _elkjs.ELKInputData +) -> None: + """Add hidden Boxes and Exchanges to ``obj``'s context. + + The derived exchanges are displayed with a dashed line. + """ + if not (derivator := DERIVATORS.get(type(diagram.target))): + return + + derivator(diagram, data) + + +def _derive_from_functions( + diagram: context.ContextDiagram, data: _elkjs.ELKInputData +): + assert isinstance(diagram.target, cs.Component) + ports = [] + for fnc in diagram.target.allocated_functions: + ports.extend(port_collector(fnc, diagram.type)) + + components: dict[str, cs.Component] = {} + for port in ports: + for fex in port.exchanges: + if isinstance(port, fa.FunctionOutputPort): + attr = "target" + else: + attr = "source" + + try: + derived_comp = getattr(fex, attr).owner.owner + if derived_comp.uuid not in components: + components[derived_comp.uuid] = derived_comp + except AttributeError: + ... + + # TODO: Even out derived interfaces on each side + + for i, (uuid, derived_component) in enumerate(components.items()): + box = makers.make_box( + derived_component, + no_symbol=diagram.display_symbols_as_boxes, + ) + box["id"] = comp_uuid = f"__DerivedBox_{uuid}" + data["children"].append(box) + if i % 2 == 0: + source_id = comp_uuid + target_id = diagram.target.uuid + else: + source_id = diagram.target.uuid + target_id = comp_uuid + + data["edges"].append( + { + "id": f"__DerivedComponentExchange_{i}", + "sources": [source_id], + "targets": [target_id], + } + ) + + data["children"][0]["height"] += ( + makers.PORT_PADDING + + (makers.PORT_SIZE + makers.PORT_PADDING) * len(components) // 2 + ) + + +DERIVATORS = { + la.LogicalComponent: _derive_from_functions, + ctx.SystemComponent: _derive_from_functions, +} diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 174c6d77..419574cb 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -232,6 +232,9 @@ class ContextDiagram(diagram.AbstractDiagram): display_parent_relation Display objects with a parent relationship to the object of interest as the parent box. + display_derived_interfaces + Display derived objects collected from additional collectors + beside the main collector for building the context. slim_center_box Minimal width for the center box, containing just the icon and the label. This is False if hierarchy was identified. @@ -255,6 +258,7 @@ def __init__( render_styles: dict[str, styling.Styler] | None = None, display_symbols_as_boxes: bool = False, display_parent_relation: bool = False, + display_derived_interfaces: bool = False, include_inner_objects: bool = False, slim_center_box: bool = True, ) -> None: @@ -267,6 +271,7 @@ def __init__( self.__filters: cabc.MutableSet[str] = self.FilterSet(self) self.display_symbols_as_boxes = display_symbols_as_boxes self.display_parent_relation = display_parent_relation + self.display_derived_interfaces = display_derived_interfaces self.include_inner_objects = include_inner_objects self.slim_center_box = slim_center_box diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index c7b0a3a2..8f37d2e8 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -112,21 +112,23 @@ def deserialize_child( [`diagram.Diagram`][capellambse.diagram.Diagram] : Diagram class type that stores all previously named classes. """ + uuid: str + styleclass: str | None if child["id"].startswith("__"): - styleclass: str | None = child["id"][2:].split("_", 1)[0] + styleclass, uuid = child["id"][2:].split("_", 1) else: styleclass = self.get_styleclass(child["id"]) + uuid = child["id"] + element: diagram.Box | diagram.Edge | diagram.Circle if child["type"] in {"node", "port"}: assert parent is None or isinstance(parent, diagram.Box) has_symbol_cls = False try: - obj = self.model.by_uuid(child["id"]) + obj = self.model.by_uuid(uuid) has_symbol_cls = collectors.makers.is_symbol(obj) except KeyError: - logger.error( - "ModelObject could not be found: '%s'", child["id"] - ) + logger.error("ModelObject could not be found: '%s'", uuid) is_port = child["type"] == "port" box_type = ("box", "symbol")[ @@ -145,48 +147,58 @@ class type that stores all previously named classes. element = diagram.Box( ref, size, - uuid=child["id"], + uuid=uuid, parent=parent, port=is_port, styleclass=styleclass, - styleoverrides=self.get_styleoverrides(child), + styleoverrides=self.get_styleoverrides(uuid, child), features=features, context=child.get("context"), ) element.JSON_TYPE = box_type self.diagram.add_element(element) - self._cache[child["id"]] = element + self._cache[uuid] = element elif child["type"] == "edge": styleclass = child.get("styleclass", styleclass) # type: ignore[assignment] styleclass = REMAP_STYLECLASS.get(styleclass, styleclass) # type: ignore[arg-type] EDGE_HANDLER.get(styleclass, lambda c: c)(child) + source_id = child["sourceId"] + if source_id.startswith("__"): + source_id = source_id[2:].split("_", 1)[-1] + + target_id = child["targetId"] + if target_id.startswith("__"): + target_id = target_id[2:].split("_", 1)[-1] + if child["routingPoints"]: refpoints = [ ref + (point["x"], point["y"]) for point in child["routingPoints"] ] else: - source = self._cache[child["sourceId"]] - target = self._cache[child["targetId"]] + source = self._cache[source_id] + target = self._cache[target_id] refpoints = route_shortest_connection(source, target) element = diagram.Edge( refpoints, - uuid=child["id"], - source=self.diagram[child["sourceId"]], - target=self.diagram[child["targetId"]], + uuid=uuid, + source=self.diagram[source_id], + target=self.diagram[target_id], styleclass=styleclass, - styleoverrides=self.get_styleoverrides(child), + styleoverrides=self.get_styleoverrides(uuid, child), context=child.get("context"), ) self.diagram.add_element(element) - self._cache[child["id"]] = element + self._cache[uuid] = element elif child["type"] == "label": assert parent is not None if not parent.port: if parent.JSON_TYPE != "symbol": - parent.styleoverrides |= self.get_styleoverrides(child) + parent.styleoverrides |= self.get_styleoverrides( + uuid, child + ) if isinstance(parent, diagram.Box): attr_name = "floating_labels" @@ -211,7 +223,9 @@ class type that stores all previously named classes. + (child["position"]["x"], child["position"]["y"]), (child["size"]["width"], child["size"]["height"]), label=child["text"], - styleoverrides=self.get_styleoverrides(child), + styleoverrides=self.get_styleoverrides( + uuid, child + ), ) ) @@ -228,7 +242,7 @@ class type that stores all previously named classes. 5, uuid=child["id"], styleclass=self.get_styleclass(uuid), - styleoverrides=self.get_styleoverrides(child), + styleoverrides=self.get_styleoverrides(uuid, child), context=child.get("context"), ) self.diagram.add_element(element) @@ -265,7 +279,7 @@ def get_styleclass(self, uuid: str) -> str | None: return diagram.get_styleclass(melodyobj) def get_styleoverrides( - self, child: _elkjs.ELKOutputChild + self, uuid: str, child: _elkjs.ELKOutputChild ) -> diagram.StyleOverrides: """Return [`styling.CSSStyles`][capellambse_context_diagrams.styling.CSSStyles] @@ -276,7 +290,7 @@ def get_styleoverrides( styleoverrides: dict[str, t.Any] = {} if style_condition is not None: if child["type"] != "junction": - obj = self._diagram._model.by_uuid(child["id"]) + obj = self._diagram._model.by_uuid(uuid) else: obj = None From 8dff1183296b0ee8d954720a8b3f09ef31e2b51a Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 30 Apr 2024 17:11:49 +0200 Subject: [PATCH 3/7] SQUASH THIS --- tests/test_context_diagrams.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index 42f882df..fd4454e1 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -159,6 +159,4 @@ def test_context_diagram_with_derived_interfaces( diag = obj.context_diagram diag.display_derived_interfaces = True - diag.render("svgdiagram").save(pretty=True) - assert len(diag.nodes) > 5 From b5c1dd9b8236ad2e829323fef3554e2513accb37 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Thu, 2 May 2024 09:32:39 +0200 Subject: [PATCH 4/7] SQUASH IT --- capellambse_context_diagrams/__init__.py | 38 +------------------ .../collectors/default.py | 12 +++++- capellambse_context_diagrams/serializers.py | 26 +++++++------ tests/test_context_diagrams.py | 2 + 4 files changed, 28 insertions(+), 50 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 706b15dd..25ed39ac 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -28,6 +28,7 @@ from capellambse.model.crosslayer import fa, information from capellambse.model.layers import ctx, la, oa, pa from capellambse.model.modeltypes import DiagramType +from capellambse.svg import decorations from . import context, styling @@ -128,43 +129,6 @@ def patch_styles(classes: cabc.Iterable[SupportedClass]) -> None: for _, dt, _ in classes: capstyle.STYLES[dt.value]["Circle.FunctionalExchange"] = circle_style - for dt in (DiagramType.SAB, DiagramType.LAB, DiagramType.PAB): - text_fill = COLORS["black"] - if dt == DiagramType.SAB: - fill = [ - COLORS["_CAP_Component_Blue_min"], - COLORS["_CAP_Component_Blue"], - ] - stroke = COLORS["_CAP_Component_Border_Blue"] - elif dt == DiagramType.LAB: - fill = [ - COLORS["_CAP_Component_Blue_min"], - COLORS["_CAP_Component_Blue"], - ] - text_fill = COLORS["_CAP_Component_Label_Blue"] - elif dt == DiagramType.PAB: - fill = [ - COLORS["_CAP_Unset_Gray_min"], - COLORS["_CAP_Unset_Gray"], - ] - stroke = COLORS["_CAP_Lifeline_Gray"] - - capstyle.STYLES[dt.value].update( - { - "Box.DerivedBox": { - "fill": fill, - "stroke": stroke, - "stroke-dasharray": "4", - "text_fill": text_fill, - }, - "Edge.DerivedComponentExchange": { - "stroke": COLORS["_CAP_Component_Border_Blue"], - "stroke-width": 2, - "stroke-dasharray": "4", - }, - } - ) - def register_interface_context() -> None: """Add the `context_diagram` property to interface model objects.""" diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index b134dfe1..e686baff 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -286,6 +286,7 @@ def _derive_from_functions( for fnc in diagram.target.allocated_functions: ports.extend(port_collector(fnc, diagram.type)) + context_box_ids = {child["id"] for child in data["children"]} components: dict[str, cs.Component] = {} for port in ports: for fex in port.exchanges: @@ -296,6 +297,12 @@ def _derive_from_functions( try: derived_comp = getattr(fex, attr).owner.owner + if ( + derived_comp == diagram.target + or derived_comp.uuid in context_box_ids + ): + continue + if derived_comp.uuid not in components: components[derived_comp.uuid] = derived_comp except AttributeError: @@ -308,7 +315,8 @@ def _derive_from_functions( derived_component, no_symbol=diagram.display_symbols_as_boxes, ) - box["id"] = comp_uuid = f"__DerivedBox_{uuid}" + class_ = type(derived_comp).__name__ + box["id"] = comp_uuid = f"__Derived-{class_}_{uuid}" data["children"].append(box) if i % 2 == 0: source_id = comp_uuid @@ -319,7 +327,7 @@ def _derive_from_functions( data["edges"].append( { - "id": f"__DerivedComponentExchange_{i}", + "id": f"__Derived-ComponentExchange_{i}", "sources": [source_id], "targets": [target_id], } diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index 8f37d2e8..69dfde3f 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -114,12 +114,17 @@ class type that stores all previously named classes. """ uuid: str styleclass: str | None + derived = False if child["id"].startswith("__"): styleclass, uuid = child["id"][2:].split("_", 1) + if styleclass.startswith("Derived-"): + styleclass = styleclass.removeprefix("Derived-") + derived = True else: styleclass = self.get_styleclass(child["id"]) uuid = child["id"] + styleoverrides = self.get_styleoverrides(uuid, child, derived=derived) element: diagram.Box | diagram.Edge | diagram.Circle if child["type"] in {"node", "port"}: assert parent is None or isinstance(parent, diagram.Box) @@ -151,7 +156,7 @@ class type that stores all previously named classes. parent=parent, port=is_port, styleclass=styleclass, - styleoverrides=self.get_styleoverrides(uuid, child), + styleoverrides=styleoverrides, features=features, context=child.get("context"), ) @@ -183,11 +188,11 @@ class type that stores all previously named classes. element = diagram.Edge( refpoints, - uuid=uuid, + uuid=child["id"], source=self.diagram[source_id], target=self.diagram[target_id], styleclass=styleclass, - styleoverrides=self.get_styleoverrides(uuid, child), + styleoverrides=styleoverrides, context=child.get("context"), ) self.diagram.add_element(element) @@ -196,9 +201,7 @@ class type that stores all previously named classes. assert parent is not None if not parent.port: if parent.JSON_TYPE != "symbol": - parent.styleoverrides |= self.get_styleoverrides( - uuid, child - ) + parent.styleoverrides |= styleoverrides if isinstance(parent, diagram.Box): attr_name = "floating_labels" @@ -223,9 +226,7 @@ class type that stores all previously named classes. + (child["position"]["x"], child["position"]["y"]), (child["size"]["width"], child["size"]["height"]), label=child["text"], - styleoverrides=self.get_styleoverrides( - uuid, child - ), + styleoverrides=styleoverrides, ) ) @@ -242,7 +243,7 @@ class type that stores all previously named classes. 5, uuid=child["id"], styleclass=self.get_styleclass(uuid), - styleoverrides=self.get_styleoverrides(uuid, child), + styleoverrides=styleoverrides, context=child.get("context"), ) self.diagram.add_element(element) @@ -279,7 +280,7 @@ def get_styleclass(self, uuid: str) -> str | None: return diagram.get_styleclass(melodyobj) def get_styleoverrides( - self, uuid: str, child: _elkjs.ELKOutputChild + self, uuid: str, child: _elkjs.ELKOutputChild, *, derived: bool = False ) -> diagram.StyleOverrides: """Return [`styling.CSSStyles`][capellambse_context_diagrams.styling.CSSStyles] @@ -296,6 +297,9 @@ def get_styleoverrides( styleoverrides = style_condition(obj, self) or {} + if derived: + styleoverrides["stroke-dasharray"] = "4" + style: dict[str, t.Any] if style := child.get("style", {}): styleoverrides |= style diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index fd4454e1..42f882df 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -159,4 +159,6 @@ def test_context_diagram_with_derived_interfaces( diag = obj.context_diagram diag.display_derived_interfaces = True + diag.render("svgdiagram").save(pretty=True) + assert len(diag.nodes) > 5 From 1733ff1274596d3d30fcf206d904a9f52f037f96 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 7 May 2024 16:40:56 +0200 Subject: [PATCH 5/7] feat: Add non-existent `ComponentPort`s --- .../collectors/default.py | 25 +++++++++++-------- .../collectors/makers.py | 18 ++++++++----- capellambse_context_diagrams/serializers.py | 21 ++++++---------- tests/test_context_diagrams.py | 5 ++-- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index e686baff..9994e2aa 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -12,7 +12,8 @@ from capellambse import helpers from capellambse.model import common from capellambse.model.crosslayer import cs, fa -from capellambse.model.layers import ctx, la +from capellambse.model.layers import ctx as sa +from capellambse.model.layers import la from capellambse.model.modeltypes import DiagramType as DT from .. import _elkjs, context @@ -23,6 +24,9 @@ def collector( diagram: context.ContextDiagram, params: dict[str, t.Any] | None = None ) -> _elkjs.ELKInputData: """Collect context data from ports of centric box.""" + diagram.display_derived_interfaces = (params or {}).pop( + "display_derived_interfaces", diagram.display_derived_interfaces + ) data = generic.collector(diagram, no_symbol=True) ports = port_collector(diagram.target, diagram.type) centerbox = data["children"][0] @@ -310,24 +314,25 @@ def _derive_from_functions( # TODO: Even out derived interfaces on each side - for i, (uuid, derived_component) in enumerate(components.items()): + centerbox = data["children"][0] + for i, (uuid, derived_component) in enumerate(components.items(), 1): box = makers.make_box( derived_component, no_symbol=diagram.display_symbols_as_boxes, ) class_ = type(derived_comp).__name__ - box["id"] = comp_uuid = f"__Derived-{class_}_{uuid}" + box["id"] = f"__Derived-{class_}:{uuid}" data["children"].append(box) + source_id = f"__Derived-CP_INOUT:{i}" + target_id = f"__Derived-CP_INOUT:{-i}" + box.setdefault("ports", []).append(makers.make_port(source_id)) + centerbox.setdefault("ports", []).append(makers.make_port(target_id)) if i % 2 == 0: - source_id = comp_uuid - target_id = diagram.target.uuid - else: - source_id = diagram.target.uuid - target_id = comp_uuid + source_id, target_id = target_id, source_id data["edges"].append( { - "id": f"__Derived-ComponentExchange_{i}", + "id": f"__Derived-ComponentExchange:{i}", "sources": [source_id], "targets": [target_id], } @@ -341,5 +346,5 @@ def _derive_from_functions( DERIVATORS = { la.LogicalComponent: _derive_from_functions, - ctx.SystemComponent: _derive_from_functions, + sa.SystemComponent: _derive_from_functions, } diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py index 672c3869..fdea3a12 100644 --- a/capellambse_context_diagrams/collectors/makers.py +++ b/capellambse_context_diagrams/collectors/makers.py @@ -42,10 +42,12 @@ FAULT_PAD = 10 """Height adjustment for labels.""" BOX_TO_SYMBOL = ( - layers.ctx.Capability, - layers.oa.OperationalCapability, - layers.ctx.Mission, - layers.ctx.SystemComponent, + layers.ctx.Capability.__name__, + layers.oa.OperationalCapability.__name__, + layers.ctx.Mission.__name__, + layers.ctx.SystemComponent.__name__, + "SystemHumanActor", + "SystemActor", ) """ Types that need to be converted to symbols during serialization if @@ -189,9 +191,13 @@ def calculate_height_and_width( return width, max(height, _height) -def is_symbol(obj: common.GenericElement) -> bool: +def is_symbol(obj: str | common.GenericElement | None) -> bool: """Check if given `obj` is rendered as a Symbol instead of a Box.""" - return isinstance(obj, BOX_TO_SYMBOL) + if obj is None: + return False + elif isinstance(obj, str): + return obj in BOX_TO_SYMBOL + return type(obj).__name__ in BOX_TO_SYMBOL def make_port(uuid: str) -> _elkjs.ELKInputPort: diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index 69dfde3f..760a43b7 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -17,7 +17,8 @@ from capellambse import diagram from capellambse.svg import decorations -from . import _elkjs, collectors, context +from . import _elkjs, context +from .collectors import makers logger = logging.getLogger(__name__) @@ -116,7 +117,7 @@ class type that stores all previously named classes. styleclass: str | None derived = False if child["id"].startswith("__"): - styleclass, uuid = child["id"][2:].split("_", 1) + styleclass, uuid = child["id"][2:].split(":", 1) if styleclass.startswith("Derived-"): styleclass = styleclass.removeprefix("Derived-") derived = True @@ -128,18 +129,12 @@ class type that stores all previously named classes. element: diagram.Box | diagram.Edge | diagram.Circle if child["type"] in {"node", "port"}: assert parent is None or isinstance(parent, diagram.Box) - has_symbol_cls = False - try: - obj = self.model.by_uuid(uuid) - has_symbol_cls = collectors.makers.is_symbol(obj) - except KeyError: - logger.error("ModelObject could not be found: '%s'", uuid) - + has_symbol_cls = makers.is_symbol(styleclass) is_port = child["type"] == "port" box_type = ("box", "symbol")[ is_port or has_symbol_cls - and not self._diagram.target == obj + and not self._diagram.target.uuid == uuid and not self._diagram.display_symbols_as_boxes ] @@ -170,11 +165,11 @@ class type that stores all previously named classes. source_id = child["sourceId"] if source_id.startswith("__"): - source_id = source_id[2:].split("_", 1)[-1] + source_id = source_id[2:].split(":", 1)[-1] target_id = child["targetId"] if target_id.startswith("__"): - target_id = target_id[2:].split("_", 1)[-1] + target_id = target_id[2:].split(":", 1)[-1] if child["routingPoints"]: refpoints = [ @@ -275,7 +270,7 @@ def get_styleclass(self, uuid: str) -> str | None: except KeyError: if not uuid.startswith("__"): return None - return uuid[2:].split("_", 1)[0] + return uuid[2:].split(":", 1)[0] else: return diagram.get_styleclass(melodyobj) diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index 42f882df..da9121d0 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -158,7 +158,6 @@ def test_context_diagram_with_derived_interfaces( diag = obj.context_diagram diag.display_derived_interfaces = True + diagram = diag.render(None) - diag.render("svgdiagram").save(pretty=True) - - assert len(diag.nodes) > 5 + assert len(diagram) > 5 From 5c59199a0fab63de1c59a68ea83f6a00ed20a262 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 7 May 2024 17:14:55 +0200 Subject: [PATCH 6/7] docs: Document the derived context feature --- .../collectors/default.py | 15 ++++++-- docs/extras/derived.md | 37 +++++++++++++++++++ docs/gen_images.py | 14 +++++++ mkdocs.yml | 3 +- tests/test_context_diagrams.py | 3 +- 5 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 docs/extras/derived.md diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index 9994e2aa..d87f4064 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -282,9 +282,15 @@ def add_derived_components_and_interfaces( derivator(diagram, data) -def _derive_from_functions( +def derive_from_functions( diagram: context.ContextDiagram, data: _elkjs.ELKInputData -): +) -> None: + """Derive Components from allocated functions of the context target. + + A Component, a ComponentExchange and two ComponentPorts are added + to ``data``. These elements are prefixed with ``Derived-`` to + receive special styling in the serialization step. + """ assert isinstance(diagram.target, cs.Component) ports = [] for fnc in diagram.target.allocated_functions: @@ -345,6 +351,7 @@ def _derive_from_functions( DERIVATORS = { - la.LogicalComponent: _derive_from_functions, - sa.SystemComponent: _derive_from_functions, + la.LogicalComponent: derive_from_functions, + sa.SystemComponent: derive_from_functions, } +"""Objects to build derived contexts.""" diff --git a/docs/extras/derived.md b/docs/extras/derived.md new file mode 100644 index 00000000..796833f4 --- /dev/null +++ b/docs/extras/derived.md @@ -0,0 +1,37 @@ + + +# Derived diagram elements + +With capellambse-context-diagrams +[`v0.2.36`](https://github.com/DSD-DBS/capellambse-context-diagrams/releases/tag/v0.2.36) +a separate context is built. The elements are derived from the diagram target, +i.e. the system of interest on which `context_diagram` was called on. The +render parameter to enable this feature is called `display_derived_interfaces` +and is available on: + +- `LogicalComponent`s and +- `SystemComponent`s + +!!! example "Context Diagram with derived elements" + + ```py + from capellambse import MelodyModel + + lost = model.by_uuid("0d18f31b-9a13-4c54-9e63-a13dbf619a69") + diag = obj.context_diagram + diag.render( + "svgdiagram", display_derived_interfaces=True + ).save(pretty=True) + ``` +
+ +
Context diagram of Center with derived context
+
+ +See [`the derivator +functions`][capellambse_context_diagrams.collectors.default.DERIVATORS] to gain +an overview over all supported capellambse types and the logic to derive +elements. diff --git a/docs/gen_images.py b/docs/gen_images.py index c09bfe8a..b07f7488 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -36,6 +36,7 @@ realization_fnc_uuid = "beaf5ba4-8fa9-4342-911f-0266bb29be45" realization_comp_uuid = "b9f9a83c-fb02-44f7-9123-9d86326de5f1" data_flow_uuid = "3b83b4ba-671a-4de8-9c07-a5c6b1d3c422" +derived_uuid = "0d18f31b-9a13-4c54-9e63-a13dbf619a69" def generate_index_images() -> None: @@ -153,6 +154,18 @@ def generate_data_flow_image() -> None: print(diag.render("svg", transparent_background=False), file=fd) +def generate_derived_image() -> None: + diag: context.ContextDiagram = model.by_uuid(derived_uuid).context_diagram + params = { + "display_derived_interfaces": True, + "transparent_background": False, + } + with mkdocs_gen_files.open( + f"{str(dest / diag.name)}-derived.svg", "w" + ) as fd: + print(diag.render("svg", **params), file=fd) + + generate_index_images() generate_hierarchy_image() generate_no_symbol_images() @@ -177,3 +190,4 @@ def generate_data_flow_image() -> None: generate_class_tree_images() generate_realization_view_images() generate_data_flow_image() +generate_derived_image() diff --git a/mkdocs.yml b/mkdocs.yml index 11b56c02..7ff20180 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,11 +89,12 @@ nav: - Extras: - Filters: extras/filters.md - Styling: extras/styling.md + - 🔥 Derived 🔥: extras/derived.md - Tree View: - Overview: tree_view.md - Realization View: - Overview: realization_view.md - - 🔥 DataFlow View 🔥: + - DataFlow View: - Overview: data_flow_view.md - Code Reference: reference/ diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index da9121d0..71e6decc 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -157,7 +157,6 @@ def test_context_diagram_with_derived_interfaces( obj = model.by_uuid(TEST_DERIVED_UUID) diag = obj.context_diagram - diag.display_derived_interfaces = True - diagram = diag.render(None) + diagram = diag.render(None, display_derived_interfaces=True) assert len(diagram) > 5 From e4eded1dcbe4072aa968562206187159975aa997 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Wed, 15 May 2024 09:23:22 +0200 Subject: [PATCH 7/7] refactor(derived): Apply changes from code review --- .../collectors/default.py | 26 ++++++++++--------- .../collectors/tree_view.py | 4 ++- tests/test_context_diagrams.py | 8 +++--- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index d87f4064..8d351c2e 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -19,6 +19,8 @@ from .. import _elkjs, context from . import exchanges, generic, makers +STYLECLASS_PREFIX = "__Derived" + def collector( diagram: context.ContextDiagram, params: dict[str, t.Any] | None = None @@ -276,10 +278,8 @@ def add_derived_components_and_interfaces( The derived exchanges are displayed with a dashed line. """ - if not (derivator := DERIVATORS.get(type(diagram.target))): - return - - derivator(diagram, data) + if derivator := DERIVATORS.get(type(diagram.target)): + derivator(diagram, data) def derive_from_functions( @@ -315,10 +315,12 @@ def derive_from_functions( if derived_comp.uuid not in components: components[derived_comp.uuid] = derived_comp - except AttributeError: - ... + except AttributeError: # No owner of owner. + pass - # TODO: Even out derived interfaces on each side + # Idea: Include flow direction of derived interfaces from all functional + # exchanges. Mixed means bidirectional. Just even out bidirectional + # interfaces and keep flow direction of others. centerbox = data["children"][0] for i, (uuid, derived_component) in enumerate(components.items(), 1): @@ -327,10 +329,10 @@ def derive_from_functions( no_symbol=diagram.display_symbols_as_boxes, ) class_ = type(derived_comp).__name__ - box["id"] = f"__Derived-{class_}:{uuid}" + box["id"] = f"{STYLECLASS_PREFIX}-{class_}:{uuid}" data["children"].append(box) - source_id = f"__Derived-CP_INOUT:{i}" - target_id = f"__Derived-CP_INOUT:{-i}" + source_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{i}" + target_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{-i}" box.setdefault("ports", []).append(makers.make_port(source_id)) centerbox.setdefault("ports", []).append(makers.make_port(target_id)) if i % 2 == 0: @@ -338,7 +340,7 @@ def derive_from_functions( data["edges"].append( { - "id": f"__Derived-ComponentExchange:{i}", + "id": f"{STYLECLASS_PREFIX}-ComponentExchange:{i}", "sources": [source_id], "targets": [target_id], } @@ -354,4 +356,4 @@ def derive_from_functions( la.LogicalComponent: derive_from_functions, sa.SystemComponent: derive_from_functions, } -"""Objects to build derived contexts.""" +"""Supported objects to build derived contexts for.""" diff --git a/capellambse_context_diagrams/collectors/tree_view.py b/capellambse_context_diagrams/collectors/tree_view.py index 081d1eec..d9d1698c 100644 --- a/capellambse_context_diagrams/collectors/tree_view.py +++ b/capellambse_context_diagrams/collectors/tree_view.py @@ -16,6 +16,7 @@ from . import generic, makers logger = logging.getLogger(__name__) + DATA_TYPE_LABEL_LAYOUT_OPTIONS: _elkjs.LayoutOptions = { "nodeLabels.placement": "INSIDE, V_CENTER, H_CENTER" } @@ -25,6 +26,7 @@ "elk.direction": "DOWN", "edgeRouting": "ORTHOGONAL", } +ASSOC_STYLECLASS = "__Association" class ClassProcessor: @@ -58,7 +60,7 @@ def process_class(self, cls: ClassInfo, params: dict[str, t.Any]): if len(edges) == 1: edge_id = edges[0].uuid else: - edge_id = f"__Association_{self.edge_counter}" + edge_id = f"{ASSOC_STYLECLASS}_{self.edge_counter}" self.edge_counter += 1 if edge_id not in self.made_edges: self.made_edges.add(edge_id) diff --git a/tests/test_context_diagrams.py b/tests/test_context_diagrams.py index 71e6decc..8bad10d4 100644 --- a/tests/test_context_diagrams.py +++ b/tests/test_context_diagrams.py @@ -156,7 +156,9 @@ def test_context_diagram_with_derived_interfaces( ) -> None: obj = model.by_uuid(TEST_DERIVED_UUID) - diag = obj.context_diagram - diagram = diag.render(None, display_derived_interfaces=True) + context_diagram = obj.context_diagram + derived_diagram = context_diagram.render( + None, display_derived_interfaces=True + ) - assert len(diagram) > 5 + assert len(derived_diagram) > 5