diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64699dcd..4bd9e976 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ exclude: '^(versioneer\.py|.*/_version\.py)$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-ast @@ -24,8 +24,8 @@ repos: - id: end-of-file-fixer - id: fix-byte-order-marker - id: trailing-whitespace - - repo: https://github.com/psf/black - rev: 23.3.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 23.10.0 hooks: - id: black - repo: https://github.com/PyCQA/isort @@ -33,7 +33,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 + rev: v1.6.1 hooks: - id: mypy - repo: https://github.com/Lucas-C/pre-commit-hooks @@ -67,6 +67,6 @@ repos: - --comment-style - '/*| *| */' - repo: https://github.com/fsfe/reuse-tool - rev: v1.1.2 + rev: v2.1.0 hooks: - id: reuse diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 716b95fd..1075dc65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,7 +150,7 @@ Example: > > model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") > diag = model.by_uuid("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6").context_diagram -> diag.render("svgdiagram").save_drawing(True) +> diag.render("svgdiagram").save_drawing(pretty=True) > ``` >
> diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 4c7230dc..3d496816 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -24,7 +24,7 @@ from capellambse.diagram import COLORS, CSSdef, capstyle from capellambse.model import common -from capellambse.model.crosslayer import fa +from capellambse.model.crosslayer import fa, information from capellambse.model.layers import ctx, la, oa, pa from capellambse.model.modeltypes import DiagramType @@ -47,6 +47,7 @@ def init() -> None: """Initialize the extension.""" register_classes() register_interface_context() + register_class_tree() # register_functional_context() XXX: Future @@ -148,3 +149,12 @@ def register_functional_context() -> None: attr_name, context.FunctionalContextAccessor(dgcls.value), ) + + +def register_class_tree() -> None: + """Add the `tree_diagram` attribute to ``Class``es.""" + common.set_accessor( + information.Class, + "tree_diagram", + context.ClassTreeAccessor(DiagramType.CDB.value), + ) diff --git a/capellambse_context_diagrams/collectors/class_tree.py b/capellambse_context_diagrams/collectors/class_tree.py new file mode 100644 index 00000000..009913de --- /dev/null +++ b/capellambse_context_diagrams/collectors/class_tree.py @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors +# SPDX-License-Identifier: Apache-2.0 +"""This submodule defines the collector for the Class-Tree diagram.""" +from __future__ import annotations + +import collections.abc as cabc +import typing as t + +from capellambse import helpers +from capellambse.model.crosslayer import information + +from .. import _elkjs, context +from . import generic, makers + + +def collector( + diagram: context.ContextDiagram, params: dict[str, t.Any] +) -> _elkjs.ELKInputData: + """Return the class tree data for ELK.""" + assert isinstance(diagram.target, information.Class) + data = generic.collector(diagram, no_symbol=True) + if params.get("partitioning", False): + data["layoutOptions"]["partitioning.activate"] = True + data["children"][0]["layoutOptions"] = {} + data["children"][0]["layoutOptions"]["elk.partitioning.partition"] = 0 + + data["layoutOptions"]["edgeLabels.sideSelection"] = params.get( + "edgeLabelsSide", "ALWAYS_DOWN" + ) + data["layoutOptions"]["algorithm"] = params.get("algorithm", "layered") + data["layoutOptions"]["elk.direction"] = params.get("direction", "DOWN") + data["layoutOptions"]["edgeRouting"] = params.get( + "edgeRouting", "ORTHOGONAL" + ) + + made_boxes: set[str] = set() + for _, (source, prop, target, partition) in get_all_classes( + diagram.target + ): + if target.uuid not in made_boxes: + made_boxes.add(target.uuid) + box = makers.make_box(target) + if params.get("partitioning", False): + box["layoutOptions"] = {} + box["layoutOptions"]["elk.partitioning.partition"] = int( + partition + ) + data["children"].append(box) + + width, height = helpers.extent_func(prop.name) + label: _elkjs.ELKInputLabel = { + "text": prop.name, + "width": width + 2 * makers.LABEL_HPAD, + "height": height + 2 * makers.LABEL_VPAD, + } + data["edges"].append( + { + "id": prop.uuid, + "sources": [source.uuid], + "targets": [target.uuid], + "labels": [label], + } + ) + return data + + +ClassContext = tuple[ + information.Class, information.Property, information.Class, int +] + + +def get_all_classes( + root: information.Class, partition: int = 0 +) -> cabc.Iterator[tuple[str, ClassContext]]: + """Yield all classes of the class tree.""" + partition += 1 + classes: dict[str, ClassContext] = {} + for prop in root.properties: + if prop.type.xtype.endswith("Class"): + edge_id = f"{root.uuid} {prop.uuid} {prop.type.uuid}" + if edge_id not in classes: + classes[edge_id] = (root, prop, prop.type, partition) + classes.update(dict(get_all_classes(prop.type, partition))) + + yield from classes.items() diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 08f0f409..536f888a 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -15,7 +15,7 @@ from capellambse.model import common, diagram, modeltypes from . import _elkjs, filters, serializers, styling -from .collectors import exchanges, get_elkdata +from .collectors import class_tree, exchanges, get_elkdata logger = logging.getLogger(__name__) @@ -129,6 +129,26 @@ def __get__( # type: ignore ) +class ClassTreeAccessor(ContextAccessor): + """Provides access to the class tree diagrams.""" + + # pylint: disable=super-init-not-called + def __init__(self, diagclass: str) -> None: + self._dgcls = diagclass + + def __get__( # type: ignore + self, + obj: common.T | None, + objtype: type | None = None, + ) -> common.Accessor | ContextDiagram: + """Make a ContextDiagram for the given model object.""" + del objtype + if obj is None: # pragma: no cover + return self + assert isinstance(obj, common.GenericElement) + return self._get(obj, ClassTreeDiagram, "{}_class_tree") + + class ContextDiagram(diagram.AbstractDiagram): """An automatically generated context diagram. @@ -305,3 +325,32 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: self, exchanges.FunctionalContextCollector, params ) return super()._create_diagram(params) + + +class ClassTreeDiagram(ContextDiagram): + """An automatically generated ClassTree Diagram. + + This diagram is exclusively for ``Class``es. + """ + + def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None: + super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) + + @property + def uuid(self) -> str: # type: ignore + """Returns the UUID of the diagram.""" + return f"{self.target.uuid}_class-tree" + + @property + def name(self) -> str: # type: ignore + """Returns the name of the diagram.""" + return f"Class Tree of {self.target.name}" + + def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram: + params.setdefault("algorithm", "layered") + params.setdefault("direction", "DOWN") + params.setdefault("edgeRouting", "POLYLINE") + params.setdefault("partitioning", True) + params.setdefault("edgeLabelsSide", "SMART_DOWN") + params["elkdata"] = class_tree.collector(self, params) + return super()._create_diagram(params) diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index 36b1cbd8..58f22389 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -28,6 +28,8 @@ * `junction`. """ +REMAP_STYLECLASS: dict[str, str] = {"Unset": "Association"} + class DiagramSerializer: """Serialize an ``elk_diagram`` into an @@ -47,7 +49,7 @@ class DiagramSerializer: def __init__(self, elk_diagram: context.ContextDiagram) -> None: self.model = elk_diagram.target._model self._diagram = elk_diagram - self._cache: dict[str, diagram.Box] = {} + self._cache: dict[str, diagram.Box | diagram.Edge] = {} def make_diagram(self, data: _elkjs.ELKOutputData) -> diagram.Diagram: """Transform a layouted diagram into an `diagram.Diagram`. @@ -136,6 +138,7 @@ class type that stores all previously named classes. self.diagram.add_element(element) self._cache[child["id"]] = element elif child["type"] == "edge": + styleclass = REMAP_STYLECLASS.get(styleclass, styleclass) # type: ignore[arg-type] element = diagram.Edge( [ ref + (point["x"], point["y"]) diff --git a/docs/class-tree.md b/docs/class-tree.md new file mode 100644 index 00000000..40709290 --- /dev/null +++ b/docs/class-tree.md @@ -0,0 +1,83 @@ + + +# Class Tree Diagram + +With release [`v0.5.35`](https://github.com/DSD-DBS/py-capellambse/releases/tag/v0.5.35) of [py-capellambse](https://github.com/DSD-DBS/py-capellambse) you can access the +`.tree_diagram` on [`Class`][capellambse.model.crosslayer.information.Class] +objects. A class tree diagram shows a tree made from all properties of the +parent class. + +??? example "Class Tree of Root" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("b7c7f442-377f-492c-90bf-331e66988bda").tree_diagram + diag.render("svgdiagram").save_drawing(pretty=True) + ``` +
+ +
[CDB] Class Tree Diagram of Root
+
+ +Additional rendering parameters enable the control over the layout computed by +ELK. The available options are: + +1. edgeRouting - Controls the style of the edges. + - POLYLINE (default) + - ORTHOGONAL + - SPLINE +2. algorithm - Controls the algorithm for the diagram layout. + - layered (default) + - mr.tree + - ... Have a look for [all available ELK algorithms](https://eclipse.dev/elk/reference/algorithms.html). +3. direction - The flow direction for the ELK Layered algortihm. + - DOWN (DEFAULT) + - UP + - RIGHT + - LEFT +4. partitioning - Enable partitioning. Each recursion level for collecting the +classes is its own partition. + - True (default) + - False +5. edgeLabelSide - Controls edge label placement. + - SMART_DOWN (default) + - SMART_UP + - ALWAYS_UP + - ALWAYS_DOWN + - DIRECTION_UP + - DIRECTION_DOWN + +Here is an example that shows how convenient these parameters can be passed +before rendering: + +??? example "Class Tree of Root" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("b7c7f442-377f-492c-90bf-331e66988bda").tree_diagram + diag.render( + "svgdiagram", + edgeRouting="ORTHOGONAL", + direction="Right", + # partitioning=False, + # edgeLabelsSide="ALWAYS_DOWN", + ).save_drawing(pretty=True) + ``` +
+ +
[CDB] Class Tree Diagram of Root
+
+ +They are optional and don't need to be set all together. + +## Check out the code + +To understand the collection have a look into the +[`class_tree`][capellambse_context_diagrams.collectors.class_tree] module. diff --git a/docs/extras/filters.md b/docs/extras/filters.md index 26019f93..f6f60e69 100644 --- a/docs/extras/filters.md +++ b/docs/extras/filters.md @@ -26,7 +26,7 @@ Currently the supported filters are: diag = obj.context_diagram assert filters.EX_ITEMS == "show.exchange.items.filter" diag.filters.add(filters.EX_ITEMS) - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -43,7 +43,7 @@ Currently the supported filters are: diag = obj.context_diagram assert filters.FEX_EX_ITEMS == "show.functional.exchanges.exchange.items.filter" filters.filters = {filters.FEX_EX_ITEMS} - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -62,7 +62,7 @@ Currently the supported filters are: diag = obj.context_diagram assert filters.FEX_OR_EX_ITEMS == "capellambse_context_diagrams-show.functional.exchanges.or.exchange.items.filter" filters.filters.add(filters.FEX_OR_EX_ITEMS) - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
diff --git a/docs/extras/styling.md b/docs/extras/styling.md index 343b0254..2e2400bb 100644 --- a/docs/extras/styling.md +++ b/docs/extras/styling.md @@ -22,7 +22,7 @@ Capella. These appear to be blue. model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ``` produces
@@ -47,7 +47,7 @@ displayed as an icon beside the box-label. diag = model.by_uuid("da08ddb6-92ba-4c3b-956a-017424dbfe85").context_diagram diag.display_symbols_as_boxes = True - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ``` produces
@@ -62,7 +62,7 @@ displayed as an icon beside the box-label. diag = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06").context_diagram diag.display_symbols_as_boxes = True - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ``` produces
@@ -81,7 +81,7 @@ The `no_edgelabels` render parameter prevents edge labels from being displayed. model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram - diag.render("svgdiagram", no_edgelabels=True).save_drawing(True) + diag.render("svgdiagram", no_edgelabels=True).save_drawing(pretty=True) ```
@@ -101,7 +101,7 @@ You can switch to py-capellambse default styling by overriding the diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram diag.render_styles = {} - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ``` produces
@@ -126,7 +126,7 @@ Style your diagram elements ([ElkChildType][capellambse_context_diagrams.seriali styling.BLUE_ACTOR_FNCS, junction=lambda obj, serializer: {"fill": aird.RGB(220, 20, 60)}, ) - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ``` produces
diff --git a/docs/gen_images.py b/docs/gen_images.py index a3432a21..f9004f57 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -32,6 +32,7 @@ } hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb" diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids +class_tree_uuid = "b7c7f442-377f-492c-90bf-331e66988bda" def generate_index_images() -> None: @@ -91,6 +92,26 @@ def generate_hierarchy_image() -> None: print(diag.render("svg", include_inner_objects=True), file=fd) +def generate_class_tree_images() -> None: + obj = model.by_uuid(class_tree_uuid) + diag = obj.tree_diagram + with mkdocs_gen_files.open(f"{str(dest / diag.name)}.svg", "w") as fd: + print(diag.render("svg"), file=fd) + with mkdocs_gen_files.open( + f"{str(dest / diag.name)}-params.svg", "w" + ) as fd: + print( + diag.render( + "svg", + edgeRouting="ORTHOGONAL", + direction="Right", + partitioning=False, + edgeLabelsSide="ALWAYS_DOWN", + ), + file=fd, + ) + + generate_index_images() generate_hierarchy_image() generate_no_symbol_images() @@ -112,3 +133,4 @@ def generate_hierarchy_image() -> None: "red junction", ) generate_styling_image(wizard, {}, "no_styles") +generate_class_tree_images() diff --git a/docs/index.md b/docs/index.md index 8134c415..3a75e2ee 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,7 +41,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("e37510b9-3166-4f80-a919-dfaac9b696c7").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -55,7 +55,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("8bcb11e6-443b-4b92-bec2-ff1d87a224e7").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -69,7 +69,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("da08ddb6-92ba-4c3b-956a-017424dbfe85").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -83,7 +83,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -97,7 +97,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("9390b7d5-598a-42db-bef8-23677e45ba06").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -113,7 +113,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("a5642060-c9cc-4d49-af09-defaa3024bae").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -127,7 +127,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("f632888e-51bc-4c9f-8e81-73e9404de784").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -141,7 +141,7 @@ Available via `.context_diagram` on a [`ModelObject`][capellambse.model.common.e model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
@@ -167,7 +167,7 @@ Hierarchy is identified and supported: model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") obj = model.by_uuid("16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb") diagram = obj.context_diagram.render("svgdiagram", include_inner_objects=True) - diagram.save_drawing(True) + diagram.save_drawing(pretty=True) ```
@@ -185,7 +185,7 @@ The data is collected by [get_elkdata_for_exchanges][capellambse_context_diagram model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram - diag.render("svgdiagram").save_drawing(True) + diag.render("svgdiagram").save_drawing(pretty=True) ```
diff --git a/mkdocs.yml b/mkdocs.yml index 5340a0eb..de5e508a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,8 @@ nav: - Extras: - Filters: extras/filters.md - Styling: extras/styling.md + - Class Tree: + - Overview: class-tree.md - Code Reference: reference/ extra_css: diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index fbcacb6d..c60fd5c9 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -1,5 +1,5 @@ - + ContextDiagram.afm ContextDiagram.capella @@ -8,6 +8,10 @@ + + + + @@ -7978,4 +7982,213 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 bf35e631..0c76cd14 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -768,8 +768,78 @@ The predator is far away id="e3ccf45c-d714-40cd-9261-21f5b79f1a77" name="good advise" exchangeMechanism="FLOW"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3093,7 +3163,7 @@ The predator is far away + name="Hierarchy" abstractType="#16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb"/> None: + obj = model.by_uuid(CLASS_UUID) + + diag = obj.tree_diagram + + assert diag.render(fmt) + + +@pytest.mark.parametrize("edgeRouting", ["SPLINE", "ORTHOGONAL", "POLYLINE"]) +@pytest.mark.parametrize("direction", ["DOWN", "RIGHT"]) +@pytest.mark.parametrize("partitioning", [True, False]) +@pytest.mark.parametrize( + "edgeLabelsSide", ["ALWAYS_DOWN", "DIRECTION_DOWN", "SMART_DOWN"] +) +def test_tree_diagram_renders_with_additional_params( + model: capellambse.MelodyModel, + edgeRouting: str, + direction: str, + partitioning: bool, + edgeLabelsSide: str, +) -> None: + obj = model.by_uuid(CLASS_UUID) + + diag = obj.tree_diagram + + assert diag.render( + "svgdiagram", + edgeRouting=edgeRouting, + direction=direction, + partitioning=partitioning, + edgeLabelsSide=edgeLabelsSide, + )