From f5d708ed06271078b11dc16acdb3ddb82182856c Mon Sep 17 00:00:00 2001 From: ewuerger Date: Tue, 17 Sep 2024 18:05:16 +0200 Subject: [PATCH 1/4] feat(context-diagram): Implement for `PhysicalLink` --- capellambse_context_diagrams/__init__.py | 13 +- capellambse_context_diagrams/_elkjs.py | 12 + .../collectors/exchanges.py | 80 +++++++ capellambse_context_diagrams/context.py | 16 +- capellambse_context_diagrams/serializers.py | 16 ++ tests/data/ContextDiagram.aird | 212 +++++++++++++++++- tests/data/ContextDiagram.capella | 31 ++- tests/test_interface_diagrams.py | 29 ++- 8 files changed, 400 insertions(+), 9 deletions(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index fb8e4605..7fbbf4ec 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -25,7 +25,7 @@ import capellambse.model as m from capellambse.diagram import COLORS, CSSdef, capstyle -from capellambse.metamodel import fa, information, la, oa, pa, sa +from capellambse.metamodel import cs, fa, information, la, oa, pa, sa from capellambse.model import DiagramType from . import _elkjs, context, styling @@ -193,6 +193,17 @@ def register_interface_context() -> None: {"include_interface": True}, ), ) + m.set_accessor( + cs.PhysicalLink, + ATTR_NAME, + context.InterfaceContextAccessor( + { + pa.PhysicalComponentPkg: DiagramType.PAB.value, + pa.PhysicalComponent: DiagramType.PAB.value, + }, + {"include_interface": True, "display_port_labels": True}, + ), + ) def register_functional_context() -> None: diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py index d4b930dd..c63229b9 100644 --- a/capellambse_context_diagrams/_elkjs.py +++ b/capellambse_context_diagrams/_elkjs.py @@ -10,6 +10,7 @@ import collections.abc as cabc import copy +import enum import json import logging import os @@ -94,6 +95,17 @@ """Options for increasing the edge straightness priority.""" +class PORT_LABEL_POSITION(enum.Enum): + """Position of port labels.""" + + OUTSIDE = enum.auto() + INSIDE = enum.auto() + NEXT_TO_PORT_IF_POSSIBLE = enum.auto() + ALWAYS_SAME_SIDE = enum.auto() + ALWAYS_OTHER_SAME_SIDE = enum.auto() + SPACE_EFFICIENT = enum.auto() + + class BaseELKModel(pydantic.BaseModel): """Base class for ELK models.""" diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py index f08f3733..d3b1cd70 100644 --- a/capellambse_context_diagrams/collectors/exchanges.py +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -307,6 +307,86 @@ def collect(self) -> None: pass +class PhysicalLinkContextCollector(ExchangeCollector): + """Collect necessary + [`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData] + for building the ``PhysicalLink`` context. + """ + + left: _elkjs.ELKInputChild | None + """Left partner of the interface.""" + right: _elkjs.ELKInputChild | None + """Right partner of the interface.""" + + def __init__( + self, + diagram: context.InterfaceContextDiagram, + data: _elkjs.ELKInputData, + params: dict[str, t.Any], + ) -> None: + self.left: _elkjs.ELKInputChild | None = None + self.right: _elkjs.ELKInputChild | None = None + + super().__init__(diagram, data, params) + + def get_left_and_right(self) -> None: + self.left = makers.make_box(self.get_source(self.obj), no_symbol=True) + self.right = makers.make_box(self.get_target(self.obj), no_symbol=True) + self.data.children.extend([self.left, self.right]) + + def add_interface(self) -> None: + """Add the ComponentExchange (interface) to the collected data.""" + ex_data = generic.ExchangeData( + self.obj, + self.data, + self.diagram.filters, + self.params, + is_hierarchical=False, + ) + src, tgt = generic.exchange_data_collector(ex_data) + self.data.edges[-1].layoutOptions = copy.deepcopy( + _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS + ) + assert self.right is not None + assert self.left is not None + left_port, right_port = self.get_source_and_target_ports(src, tgt) + self.left.ports.append(left_port) + self.right.ports.append(right_port) + + def get_source_and_target_ports( + self, src: m.ModelElement, tgt: m.ModelElement + ) -> tuple[_elkjs.ELKInputPort, _elkjs.ELKInputPort]: + """Return the source and target ports of the interface.""" + left_port = makers.make_port(src.uuid) + right_port = makers.make_port(tgt.uuid) + if self.diagram._display_port_labels: + left_port.labels = makers.make_label(src.name) + right_port.labels = makers.make_label(tgt.name) + + _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) + port_label_position = plp.name + + assert self.left is not None + self.left.layoutOptions["portLabels.placement"] = ( + port_label_position + ) + assert self.right is not None + self.right.layoutOptions["portLabels.placement"] = ( + port_label_position + ) + return left_port, right_port + + def collect(self) -> None: + """Collect all allocated `PhysicalLink`s in the context.""" + self.get_left_and_right() + if self.diagram._include_interface: + self.add_interface() + + class FunctionalContextCollector(ExchangeCollector): def __init__( self, diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 5cbe959a..e569ec01 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -15,6 +15,7 @@ from capellambse import diagram as cdiagram from capellambse import helpers from capellambse import model as m +from capellambse.metamodel import cs from . import _elkjs, filters, serializers, styling from .collectors import ( @@ -358,6 +359,9 @@ class InterfaceContextDiagram(ContextDiagram): context diagram target: The interface ComponentExchange. * hide_functions — Boolean flag to enable white box view: Only displaying Components or Entities. + * display_port_labels — Display port labels on the diagram. + * port_label_position — Position of the port labels. See + [`PORT_LABEL_POSITION`][capellambse_context_diagrams.context._elkjs.PORT_LABEL_POSITION]. In addition to all other render parameters of [`ContextDiagram`][capellambse_context_diagrams.context.ContextDiagram]. @@ -365,6 +369,8 @@ class InterfaceContextDiagram(ContextDiagram): _include_interface: bool _hide_functions: bool + _display_port_labels: bool + _port_label_position: str def __init__( self, @@ -378,6 +384,8 @@ def __init__( "include_interface": False, "hide_functions": False, "display_symbols_as_boxes": True, + "display_port_labels": False, + "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, } | default_render_parameters super().__init__( class_, @@ -396,8 +404,14 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: for param_name in self._default_render_parameters: setattr(self, f"_{param_name}", params.pop(param_name)) + collector: t.Type[exchanges.ExchangeCollector] + if isinstance(self.target, cs.PhysicalLink): + collector = exchanges.PhysicalLinkContextCollector + else: + collector = exchanges.InterfaceContextCollector + super_params["elkdata"] = exchanges.get_elkdata_for_exchanges( - self, exchanges.InterfaceContextCollector, params + self, collector, params ) return super()._create_diagram(super_params) diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index b4a78a27..3ee78cc9 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -225,6 +225,13 @@ class type that stores all previously named classes. else: attr_name = "labels" + if ( + parent.port + and self._diagram._port_label_position + == _elkjs.PORT_LABEL_POSITION.OUTSIDE.name + ): + bring_labels_closer_to_port(child) + if labels := getattr(parent, attr_name): label_box = labels[-1] label_box.label += " " + child.text @@ -396,6 +403,15 @@ def reverse_edge_refpoints(child: _elkjs.ELKOutputEdge) -> None: child.routingPoints = child.routingPoints[::-1] +def bring_labels_closer_to_port(child: _elkjs.ELKOutputLabel) -> None: + """Move labels closer to the port.""" + if child.position.x > 1: + child.position.x = -5 + + if child.position.x < -11: + child.position.x += 18 + + EDGE_HANDLER: dict[str | None, cabc.Callable[[_elkjs.ELKOutputEdge], None]] = { "Generalization": reverse_edge_refpoints } diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index e0515cdb..c32b098d 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -15,7 +15,7 @@ - +
@@ -13153,6 +13153,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -13202,6 +13262,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -13211,7 +13319,7 @@ - + @@ -13220,7 +13328,7 @@ - + @@ -13252,7 +13360,7 @@ - + @@ -13354,6 +13462,102 @@ + + + + + + + + + + + + + + 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 fe7ed768..6a32b948 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -4783,6 +4783,12 @@ The predator is far away name="Node Software" abstractType="#2de65884-8c89-4c55-8941-4aa1a07b8d86"/> + + + @@ -4812,6 +4818,12 @@ The predator is far away + + + + id="682edffb-6b44-48c0-826b-32eb217eb81c" name="Cable 1 Target"/> @@ -5191,6 +5203,21 @@ The predator is far away id="3438195c-9a99-4328-8c56-6f6a2cb37cb7" targetElement="#02a8c8c8-5d86-4559-81da-76c5d37d5e74" sourceElement="#643069e4-aad0-4477-832d-d11913d87730"/> + + + + + + + + + id="fdb34c92-7c49-491d-bf11-dd139930786e" name="Physical Component" nature="NODE"> + id="f3722f0a-c7de-421d-b4aa-fea6f5278672" name="Cable 1 Source"/> diff --git a/tests/test_interface_diagrams.py b/tests/test_interface_diagrams.py index f6fd69d0..fb839dbd 100644 --- a/tests/test_interface_diagrams.py +++ b/tests/test_interface_diagrams.py @@ -5,14 +5,17 @@ import pytest TEST_INTERFACE_UUID = "2f8ed849-fbda-4902-82ec-cbf8104ae686" +TEST_PA_INTERFACE_UUID = "25f46b82-1bb8-495a-b6bc-3ad086aad02e" +TEST_CABLE_UUID = "da949a89-23c0-4487-88e1-f14b33326570" @pytest.mark.parametrize( "uuid", [ - # pytest.param("3c9764aa-4981-44ef-8463-87a053016635", id="OA"), pytest.param("86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28", id="SA"), pytest.param("3ef23099-ce9a-4f7d-812f-935f47e7938d", id="LA"), + pytest.param(TEST_PA_INTERFACE_UUID, id="PA - ComponentExchange"), + pytest.param(TEST_CABLE_UUID, id="PA - PhysicalLink"), ], ) def test_interface_diagrams_get_rendered( @@ -77,3 +80,27 @@ def test_interface_diagram_with_nested_functions( diag = obj.context_diagram.render(None) assert {b.uuid for b in diag[fnc.uuid].children} >= expected_uuids + + +@pytest.mark.parametrize( + "port_label_position", + [ + "OUTSIDE", + "INSIDE", + "NEXT_TO_PORT_IF_POSSIBLE", + "ALWAYS_SAME_SIDE", + "ALWAYS_OTHER_SAME_SIDE", + "SPACE_EFFICIENT", + ], +) +def test_interface_diagram_with_label_positions( + model: capellambse.MelodyModel, port_label_position: str +) -> None: + obj = model.by_uuid(TEST_CABLE_UUID) + + diag = obj.context_diagram.render( + None, port_label_position=port_label_position + ) + + assert diag["f3722f0a-c7de-421d-b4aa-fea6f5278672"] + assert diag["682edffb-6b44-48c0-826b-32eb217eb81c"] From 6b11673731d07ef359bec4fa9b59b71daea06408 Mon Sep 17 00:00:00 2001 From: huyenngn Date: Wed, 18 Sep 2024 11:25:02 +0200 Subject: [PATCH 2/4] fix: Handle capellambse errors --- .../collectors/exchanges.py | 24 ++++++++++++++++--- capellambse_context_diagrams/errors.py | 8 +++++++ 2 files changed, 29 insertions(+), 3 deletions(-) create mode 100644 capellambse_context_diagrams/errors.py diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py index d3b1cd70..ee18ceeb 100644 --- a/capellambse_context_diagrams/collectors/exchanges.py +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -13,7 +13,7 @@ from capellambse.metamodel import cs, fa from capellambse.model import DiagramType as DT -from .. import _elkjs, context +from .. import _elkjs, context, errors from . import generic, makers logger = logging.getLogger(__name__) @@ -329,9 +329,27 @@ def __init__( super().__init__(diagram, data, params) + def get_owner_savely(self, attr_getter: t.Callable) -> m.ModelElement: + try: + owner = attr_getter(self.obj) + return owner + except RuntimeError: + # pylint: disable-next=raise-missing-from + raise errors.CapellambseError( + f"Failed to collect source of '{self.obj.name}'" + ) + except AttributeError: + assert owner is None + # pylint: disable-next=raise-missing-from + raise errors.CapellambseError( + f"Port has no owner: '{self.obj.name}'" + ) + def get_left_and_right(self) -> None: - self.left = makers.make_box(self.get_source(self.obj), no_symbol=True) - self.right = makers.make_box(self.get_target(self.obj), no_symbol=True) + source = self.get_owner_savely(self.get_source) + target = self.get_owner_savely(self.get_target) + self.left = makers.make_box(source, no_symbol=True) + self.right = makers.make_box(target, no_symbol=True) self.data.children.extend([self.left, self.right]) def add_interface(self) -> None: diff --git a/capellambse_context_diagrams/errors.py b/capellambse_context_diagrams/errors.py new file mode 100644 index 00000000..c557c5b6 --- /dev/null +++ b/capellambse_context_diagrams/errors.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB InfraGO AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 + +"""Errors for capellambse_context_diagrams.""" + + +class CapellambseError(Exception): + """Error raised by capellambse.""" From ccf91d7b747132a6e27f5b548290f88a0013d97c Mon Sep 17 00:00:00 2001 From: ewuerger Date: Wed, 18 Sep 2024 13:01:39 +0200 Subject: [PATCH 3/4] fix(context-diagram): Use `OUTSIDE` enum as default --- .pre-commit-config.yaml | 4 ++-- capellambse_context_diagrams/context.py | 15 ++++++++++++--- capellambse_context_diagrams/serializers.py | 4 +--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b4b29c24..e27147e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,7 +26,7 @@ repos: - id: fix-byte-order-marker - id: trailing-whitespace - repo: https://github.com/psf/black-pre-commit-mirror - rev: 24.4.2 + rev: 24.8.0 hooks: - id: black - repo: https://github.com/PyCQA/isort @@ -34,7 +34,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.11.1 + rev: v1.11.2 hooks: - id: mypy args: [--follow-imports=silent] diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index e569ec01..9e1055ab 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -223,6 +223,8 @@ class ContextDiagram(m.AbstractDiagram): just the icon and the label. This is False if hierarchy was identified. * display_port_labels — Display port labels on the diagram. + * port_label_position - Position of the port labels. See + [`PORT_LABEL_POSITION`][capellambse_context_diagrams.context._elkjs.PORT_LABEL_POSITION]. """ _display_symbols_as_boxes: bool @@ -230,6 +232,7 @@ class ContextDiagram(m.AbstractDiagram): _display_derived_interfaces: bool _slim_center_box: bool _display_port_labels: bool + _port_label_position: str def __init__( self, @@ -252,6 +255,7 @@ def __init__( "display_derived_interfaces": False, "slim_center_box": True, "display_port_labels": False, + "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name, } | default_render_parameters if standard_filter := STANDARD_FILTERS.get(class_): @@ -312,11 +316,15 @@ def __len__(self) -> int: def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: params = self._default_render_parameters | params - transparent_background = params.pop("transparent_background", False) + transparent_background: bool = params.pop( # type: ignore[assignment] + "transparent_background", False + ) for param_name in self._default_render_parameters: setattr(self, f"_{param_name}", params.pop(param_name)) - data = params.get("elkdata") or get_elkdata(self, params) + data: _elkjs.ELKInputData = params.get("elkdata") or get_elkdata( + self, params + ) # type: ignore[assignment] if not isinstance( self, (ClassTreeDiagram, InterfaceContextDiagram) ) and has_single_child(data): @@ -324,7 +332,8 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: data = get_elkdata(self, params) layout = try_to_layout(data) - add_context(layout, params.get("is_legend", False)) + is_legend: bool = params.get("is_legend", False) # type: ignore[assignment] + add_context(layout, is_legend) return self.serializer.make_diagram( layout, transparent_background=transparent_background, diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index 3ee78cc9..7447b930 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -64,9 +64,7 @@ def __init__(self, elk_diagram: context.ContextDiagram) -> None: self._junctions: dict[str, EdgeContext] = {} def make_diagram( - self, - data: _elkjs.ELKOutputData, - **kwargs: dict[str, t.Any], + self, data: _elkjs.ELKOutputData, **kwargs: t.Any ) -> cdiagram.Diagram: """Transform a layouted diagram into a `diagram.Diagram`. From 6e58c400501488be1c53fa0f89d71c320f3ef2b6 Mon Sep 17 00:00:00 2001 From: ewuerger Date: Wed, 18 Sep 2024 14:24:44 +0200 Subject: [PATCH 4/4] docs: Add documentation for `PhysicalLink` context --- capellambse_context_diagrams/_elkjs.py | 19 +++++++++++- docs/gen_images.py | 1 + docs/interface.md | 43 +++++++++++++++++++------- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py index c63229b9..d6bceaf5 100644 --- a/capellambse_context_diagrams/_elkjs.py +++ b/capellambse_context_diagrams/_elkjs.py @@ -96,7 +96,24 @@ class PORT_LABEL_POSITION(enum.Enum): - """Position of port labels.""" + """Position of port labels. + + Attributes + ---------- + OUTSIDE + The label is placed outside the port. + INSIDE + The label is placed inside the port owner box. + NEXT_TO_PORT_IF_POSSIBLE + The label is placed next to the port if space allows. + ALWAYS_SAME_SIDE + The label is always placed on the same side of the port. + ALWAYS_OTHER_SAME_SIDE + The label is always placed on the opposite side, but on the same + axis. + SPACE_EFFICIENT + The label is positioned in the most space-efficient location. + """ OUTSIDE = enum.auto() INSIDE = enum.auto() diff --git a/docs/gen_images.py b/docs/gen_images.py index 0f13f1dd..4d7f6643 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -34,6 +34,7 @@ interface_context_diagram_uuids: dict[str, str] = { "Left to right": "3ef23099-ce9a-4f7d-812f-935f47e7938d", "Interface": "2f8ed849-fbda-4902-82ec-cbf8104ae686", + "Cable 1": "da949a89-23c0-4487-88e1-f14b33326570", } hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb" diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids diff --git a/docs/interface.md b/docs/interface.md index 280238fb..b98f9921 100644 --- a/docs/interface.md +++ b/docs/interface.md @@ -8,20 +8,41 @@ The data is collected by [get_elkdata_for_exchanges][capellambse_context_diagrams.collectors.exchanges.get_elkdata_for_exchanges] which is using the [`InterfaceContextCollector`][capellambse_context_diagrams.collectors.exchanges.InterfaceContextCollector] underneath. You can render an interface context view just with `context_diagram` on any -[`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange]: +of the following Model elements: -``` py -import capellambse +- ??? example "[`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange]:" -model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") -diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram -diag.render("svgdiagram").save(pretty=True) -``` + ``` py + import capellambse -
- -
Interface context diagram of `Left to right` Logical ComponentExchange with type [LAB]
-
+ model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram + diag.render("svgdiagram").save(pretty=True) + ``` + +
+ +
Interface context diagram of `Left to right` Logical ComponentExchange with type [LAB]
+
+ +- ??? example "[`cs.PhysicalLink`][capellambse.metamodel.cs.PhysicalLink]:" + + Per default port labels are enabled with port label position set to + ``OUTSIDE``. See [`PORT_LABEL_POSITION`][capellambse_context_diagrams._elkjs.PORT_LABEL_POSITION] for more information about the available options + for port label positioning. + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("da949a89-23c0-4487-88e1-f14b33326570").context_diagram + diag.render("svgdiagram").save(pretty=True) + ``` + +
+ +
Interface context diagram of `Cable 1` PhysicalLink with type [PAB]
+
## Exclude the interface itself in the context ??? example "Exclude the interface in the Interface Context"