diff --git a/capella2polarion/__main__.py b/capella2polarion/__main__.py index 6bd8625..7b2a368 100644 --- a/capella2polarion/__main__.py +++ b/capella2polarion/__main__.py @@ -1,6 +1,7 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 """Main entry point into capella2polarion.""" + from __future__ import annotations import logging diff --git a/capella2polarion/converters/converter_config.py b/capella2polarion/converters/converter_config.py index 5d5aff1..99f90f9 100644 --- a/capella2polarion/converters/converter_config.py +++ b/capella2polarion/converters/converter_config.py @@ -1,6 +1,7 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 """Module providing capella2polarion config class.""" + from __future__ import annotations import dataclasses @@ -18,6 +19,10 @@ DESCRIPTION_REFERENCE_SERIALIZER = "description_reference" DIAGRAM_ELEMENTS_SERIALIZER = "diagram_elements" +ConverterConfigDict_type: t.TypeAlias = dict[ + str, t.Union[dict[str, t.Any], list[dict[str, t.Any]]] +] + @dataclasses.dataclass class LinkConfig: @@ -45,14 +50,27 @@ class CapellaTypeConfig: """A single Capella Type configuration.""" p_type: str | None = None - converters: str | list[str] | dict[str, dict[str, t.Any]] | None = None + converters: str | list[str] | ConverterConfigDict_type | None = None links: list[LinkConfig] = dataclasses.field(default_factory=list) is_actor: bool | None = None nature: str | None = None def __post_init__(self): """Post processing for the initialization.""" - self.converters = _force_dict(self.converters) + self.converters = _force_dict( # type: ignore[assignment] + self.converters + ) + + +@dataclasses.dataclass +class CustomDiagramConfig: + """A single Capella Custom Diagram configuration.""" + + capella_attr: str + polarion_id: str + title: str + render_params: dict[str, t.Any] | None = None + filters: list[str] | None = None def _default_type_conversion(c_type: str) -> str: @@ -71,7 +89,7 @@ def __init__(self): def read_config_file( self, - synchronize_config: t.TextIO, + synchronize_config: str | t.TextIO, type_prefix: str = "", role_prefix: str = "", ): @@ -283,7 +301,7 @@ def config_matches(config: CapellaTypeConfig | None, **kwargs: t.Any) -> bool: def _read_capella_type_configs( - conf: dict[str, t.Any] | list[dict[str, t.Any]] | None + conf: dict[str, t.Any] | list[dict[str, t.Any]] | None, ) -> list[dict]: if conf is None: return [{}] @@ -300,7 +318,7 @@ def _read_capella_type_configs( def _force_dict( - config: str | list[str] | dict[str, dict[str, t.Any]] | None + config: str | list[str] | ConverterConfigDict_type | None, ) -> dict[str, dict[str, t.Any]]: match config: case None: @@ -323,17 +341,18 @@ def add_prefix(polarion_type: str, prefix: str) -> str: def _filter_converter_config( - config: dict[str, dict[str, t.Any]] + config: ConverterConfigDict_type, ) -> dict[str, dict[str, t.Any]]: custom_converters = ( "include_pre_and_post_condition", "linked_text_as_description", - "add_context_diagram", - "add_tree_diagram", + "add_custom_diagrams", + "add_context_diagram", # TODO: Deprecated, so remove in next release + "add_tree_diagram", # TODO: Deprecated, so remove in next release "add_jinja_fields", "jinja_as_description", ) - filtered_config = {} + filtered_config: dict[str, dict[str, t.Any]] = {} for name, params in config.items(): params = params or {} if name not in custom_converters: @@ -341,8 +360,28 @@ def _filter_converter_config( continue if name in ("add_context_diagram", "add_tree_diagram"): + assert isinstance(params, dict) params = _filter_context_diagram_config(params) + if name in ("add_custom_diagrams",): + if isinstance(params, list): + params = { + "custom_diagrams_configs": [ + CustomDiagramConfig( + **_filter_context_diagram_config(rp) + ) + for rp in params + ] + } + elif isinstance(params, dict): + assert "custom_diagrams_configs" in params + else: + logger.error( # type: ignore[unreachable] + "Unknown 'add_custom_diagrams' config %r", params + ) + continue + + assert isinstance(params, dict) filtered_config[name] = params return filtered_config diff --git a/capella2polarion/converters/element_converter.py b/capella2polarion/converters/element_converter.py index 56b9409..ba2afa9 100644 --- a/capella2polarion/converters/element_converter.py +++ b/capella2polarion/converters/element_converter.py @@ -1,6 +1,7 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 """Objects for serialization of capella objects to workitems.""" + from __future__ import annotations import collections @@ -10,6 +11,7 @@ import pathlib import re import typing as t +import warnings from collections import abc as cabc import capellambse @@ -23,7 +25,11 @@ from capella2polarion import data_model from capella2polarion.connectors import polarion_repo -from capella2polarion.converters import data_session, polarion_html_helper +from capella2polarion.converters import ( + converter_config, + data_session, + polarion_html_helper, +) RE_DESCR_LINK_PATTERN = re.compile( r"([^<]+)<\/a>" @@ -102,6 +108,7 @@ def serialize(self, uuid: str) -> data_model.CapellaWorkItem | None: ..., data_model.CapellaWorkItem, ] = getattr(self, f"_{converter}") + assert isinstance(params, dict) serializer(converter_data, **params) except Exception as error: converter_data.errors.add( @@ -109,6 +116,10 @@ def serialize(self, uuid: str) -> data_model.CapellaWorkItem | None: ) converter_data.work_item = None + if converter_data.work_item is not None: + assert converter_data.work_item.title is not None + assert converter_data.work_item.type is not None + if converter_data.errors: log_args = ( converter_data.capella_element._short_repr_(), @@ -525,49 +536,91 @@ def _linked_text_as_description( converter_data.work_item.attachments += attachments return converter_data.work_item - def _add_context_diagram( + def _add_custom_diagrams( self, converter_data: data_session.ConverterData, - render_params: dict[str, t.Any] | None = None, - filters: list[str] | None = None, + custom_diagrams_configs: list[converter_config.CustomDiagramConfig], ) -> data_model.CapellaWorkItem: - """Add a new custom field context diagram.""" + """Add new custom field diagrams to the work item.""" assert converter_data.work_item, "No work item set yet" - diagram = converter_data.capella_element.context_diagram - for filter in filters or []: - diagram.filters.add(filter) + for cd_config in custom_diagrams_configs: + diagram = getattr( + converter_data.capella_element, cd_config.capella_attr + ) - self._draw_additional_attributes_diagram( - converter_data.work_item, - diagram, - "context_diagram", - "Context Diagram", - render_params, - ) + for filter in cd_config.filters or []: + diagram.filters.add(filter) + + diagram_html, attachment = self._draw_diagram_svg( + diagram, + cd_config.capella_attr, + cd_config.title, + 650, + "additional-attributes-diagram", + cd_config.render_params, + ( + ("Figure", f"Context Diagram of {diagram.target.name}") + if self.generate_figure_captions + else None + ), + ) + if attachment: + self._add_attachment(converter_data.work_item, attachment) + converter_data.work_item.additional_attributes[ + cd_config.polarion_id + ] = polarion_api.HtmlContent(diagram_html) return converter_data.work_item - def _add_tree_diagram( + def _add_context_diagram( self, converter_data: data_session.ConverterData, render_params: dict[str, t.Any] | None = None, filters: list[str] | None = None, ) -> data_model.CapellaWorkItem: - """Add a new custom field tree diagram.""" - assert converter_data.work_item, "No work item set yet" - diagram = converter_data.capella_element.tree_view - for filter in filters or []: - diagram.filters.add(filter) - - self._draw_additional_attributes_diagram( - converter_data.work_item, - diagram, - "tree_view", - "Tree View", - render_params, + warnings.warn( + "Using 'add_context_diagram' is deprecated. " + "Use 'add_custom_diagrams' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self._add_custom_diagrams( + converter_data, + [ + converter_config.CustomDiagramConfig( + capella_attr="context_diagram", + polarion_id="context_diagram", + title="Context Diagram", + render_params=render_params, + filters=filters, + ) + ], ) - return converter_data.work_item + def _add_tree_diagram( + self, + converter_data: data_session.ConverterData, + render_params: dict[str, t.Any] | None = None, + filters: list[str] | None = None, + ) -> data_model.CapellaWorkItem: + warnings.warn( + "Using 'add_tree_diagram' is deprecated. " + "Use 'add_custom_diagrams' instead.", + DeprecationWarning, + stacklevel=2, + ) + return self._add_custom_diagrams( + converter_data, + [ + converter_config.CustomDiagramConfig( + capella_attr="tree_view", + polarion_id="tree_view", + title="Tree View", + render_params=render_params, + filters=filters, + ) + ], + ) def _add_jinja_fields( self, @@ -577,14 +630,15 @@ def _add_jinja_fields( """Add a new custom field and fill it with rendered jinja content.""" assert converter_data.work_item, "No work item set yet" for field, jinja_properties in fields.items(): - converter_data.work_item.additional_attributes[field] = { - "type": "text/html", - "value": self._render_jinja_template( - jinja_properties.get("template_folder", ""), - jinja_properties["template_path"], - converter_data, - ), - } + converter_data.work_item.additional_attributes[field] = ( + polarion_api.HtmlContent( + self._render_jinja_template( + jinja_properties.get("template_folder", ""), + jinja_properties["template_path"], + converter_data, + ), + ) + ) return converter_data.work_item diff --git a/capella2polarion/converters/model_converter.py b/capella2polarion/converters/model_converter.py index 07204c3..e8ffc9c 100644 --- a/capella2polarion/converters/model_converter.py +++ b/capella2polarion/converters/model_converter.py @@ -116,10 +116,6 @@ def generate_work_items( generate_figure_captions, ) work_items = serializer.serialize_all() - for work_item in work_items: - assert work_item.title is not None - assert work_item.type is not None - if generate_links: self.generate_work_item_links( polarion_data_repo, generate_grouped_links_custom_fields diff --git a/capella2polarion/converters/text_work_item_provider.py b/capella2polarion/converters/text_work_item_provider.py index a075102..282f6c3 100644 --- a/capella2polarion/converters/text_work_item_provider.py +++ b/capella2polarion/converters/text_work_item_provider.py @@ -1,6 +1,7 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 """Provides a class to generate and inset text work items in documents.""" + import polarion_rest_api_client as polarion_api from lxml import html diff --git a/capella2polarion/data_model/work_item_attachments.py b/capella2polarion/data_model/work_item_attachments.py index 62f2add..f726060 100644 --- a/capella2polarion/data_model/work_item_attachments.py +++ b/capella2polarion/data_model/work_item_attachments.py @@ -1,9 +1,11 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 """Module providing the CapellaWorkItemAttachment classes.""" + from __future__ import annotations import base64 +import copy import dataclasses import hashlib import logging @@ -12,7 +14,7 @@ import cairosvg import polarion_rest_api_client as polarion_api from capellambse import model -from capellambse_context_diagrams import context +from capellambse_context_diagrams import _elkjs, context SVG_MIME_TYPE = "image/svg+xml" PNG_MIME_TYPE = "image/png" @@ -116,9 +118,15 @@ def content_checksum(self) -> str: try: elk_input = self.diagram.elk_input_data(self.render_params) if isinstance(elk_input, tuple): - input_str = ";".join(eit.json() for eit in elk_input) + input_str = ";".join( + remove_width_and_height(eit).model_dump_json() + for eit in elk_input + ) else: - input_str = elk_input.json() + input_str = remove_width_and_height( + elk_input + ).model_dump_json() + self._checksum = hashlib.sha256( input_str.encode("utf-8") ).hexdigest() @@ -134,6 +142,26 @@ def content_checksum(self) -> str: return self._checksum +def remove_width_and_height( + elk_input: _elkjs.ELKInputData, +) -> _elkjs.ELKInputData: + """Remove width and height from all elements in elk input.""" + + def process_item(item): + if hasattr(item, "width"): + del item.width + if hasattr(item, "height"): + del item.height + + for attr in ("children", "edges", "ports", "labels"): + for element in getattr(item, attr, []): + process_item(element) + + elk_input_copy = copy.deepcopy(elk_input) + process_item(elk_input_copy) + return elk_input_copy + + class PngConvertedSvgAttachment(Capella2PolarionAttachment): """A special attachment type for PNGs which shall be created from SVGs. diff --git a/docs/source/configuration/sync.rst b/docs/source/configuration/sync.rst index 5ce31d3..c542476 100644 --- a/docs/source/configuration/sync.rst +++ b/docs/source/configuration/sync.rst @@ -45,17 +45,20 @@ serializers in the configs. These will be called in the order provided in the list. Some serializers also support additional configuration. This can be done by providing a dictionary of serializers with the serializer as key and the configuration of the serializer as value. For example ``Class`` using the -``add_tree_diagram`` serializer: +``add_custom_diagrams`` serializer to render the tree view diagram from the +``tree_view`` Capella attribute into a custom field with the ID ``tree_view`` +and title ``Tree View``: .. literalinclude:: ../../../tests/data/model_elements/config.yaml :language: yaml :lines: 9-13 -or ``SystemFunction`` with the ``add_context_diagram`` serializer using ``filters``: +or ``SystemFunction`` with the ``add_custom_diagrams`` serializer using +``filters``: .. literalinclude:: ../../../tests/data/model_elements/config.yaml :language: yaml - :lines: 64-67 + :lines: 57-72 If a serializer supports additional parameters this will be documented in the supported capella serializers table in :ref:`Model synchronization @@ -73,7 +76,7 @@ to the desired Polarion type. .. literalinclude:: ../../../tests/data/model_elements/config.yaml :language: yaml - :lines: 73-91 + :lines: 79-104 For the ``PhysicalComponent`` you can see this in extreme action, where based on the different permutation of the attributes actor and nature different @@ -90,14 +93,14 @@ Links can be configured by just providing a list of strings: .. literalinclude:: ../../../tests/data/model_elements/config.yaml :language: yaml - :lines: 33-37 + :lines: 35-37 However there is a more verbose way that gives you the option to configure the link further: .. literalinclude:: ../../../tests/data/model_elements/config.yaml :language: yaml - :lines: 52-63 + :lines: 58-68 The links of ``SystemFunction`` are configured such that a ``polarion_role``, a separate ``capella_attr``, an ``include``, ``link_field`` and diff --git a/docs/source/features/sync.rst b/docs/source/features/sync.rst index d3b244f..0418cd2 100644 --- a/docs/source/features/sync.rst +++ b/docs/source/features/sync.rst @@ -59,26 +59,37 @@ specific serializer alone: | linked_text_as_description | A serializer resolving ``Constraint`` s and their | | | linked text. | +--------------------------------------+------------------------------------------------------+ -| add_context_diagram | A serializer adding a context diagram to the work | +| add_context_diagram (Deprecated) | A serializer adding a context diagram to the work | | | item. This requires node.js to be installed. | | | The Capella objects where ``context_diagram`` is | -| | available can be seen in the `context-diagrams | -| | documentation`_. | +| | available can be seen in the `Context Diagram`_ | +| | documentation. | | | You can provide ``render_params`` in the config and | | | these will be passed to the render function of | | | capellambse. | | | You can provide ``filters`` in the config, and these | | | will be passed to the render function of capellambse.| -| | See `context-diagrams filters`_ for documentation. | +| | See `Context Diagram filters`_ for documentation. | +--------------------------------------+------------------------------------------------------+ -| add_tree_view | A serializer adding a tree view diagram to the | +| add_tree_diagram (Deprecated) | A serializer adding a tree view diagram to the | | | work item. Same requirements as for | -| | ``add_context_diagram``. `Tree View Documentation`_. | +| | ``add_context_diagram``. `Tree View`_ Documentation. | | | You can provide ``render_params`` in the config and | | | these will be passed to the render function of | | | capellambse. | | | ``filters`` are available here too. | +--------------------------------------+------------------------------------------------------+ +| add_custom_diagrams | A serializer for adding custom diagrams to work | +| | items. Requires node.js to be installed. Supported | +| | diagrams include `Context Diagram`_, `Tree View`_, | +| | `Realization Diagram`_ and `Cable Tree Diagram`_. | +| | The `capella_attr`, `polarion_id` and `title` need to| +| | provided in the configuration. | +| | You can provide ``render_params`` and ``filters`` in | +| | the config for customization. Documentation for each | +| | diagram type can be found in their respective | +| | sections. | ++--------------------------------------+------------------------------------------------------+ | add_jinja_fields | A serializer that allows custom field values to be | | | filled with rendered Jinja2 template content. This | | | makes it possible to add complex HTML structures | @@ -91,9 +102,11 @@ specific serializer alone: | | description field. | +--------------------------------------+------------------------------------------------------+ -.. _context-diagrams documentation: https://capellambse-context-diagrams.readthedocs.io/#context-diagram-extension-for-capellambse -.. _Tree View documentation: https://capellambse-context-diagrams.readthedocs.io/tree_view/ -.. _context-diagrams filters: https://capellambse-context-diagrams.readthedocs.io/extras/filters/ +.. _context-diagrams documentation: https://capellambse-context-diagrams.readthedocs.io/en/stable/#context-diagram-extension-for-capellambse +.. _Tree View documentation: https://capellambse-context-diagrams.readthedocs.io/en/stable/tree_view/ +.. _Realization Diagram: https://capellambse-context-diagrams.readthedocs.io/en/stable/realization_view/ +.. _Cable Tree Diagram: https://capellambse-context-diagrams.readthedocs.io/en/stable/cable_tree/ +.. _context-diagrams filters: https://capellambse-context-diagrams.readthedocs.io/en/stable/extras/filters/ Links ***** diff --git a/tests/conftest.py b/tests/conftest.py index a4d97ba..c3d603e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,16 @@ def model() -> capellambse.MelodyModel: return capellambse.MelodyModel(**TEST_MODEL) +@pytest.fixture +def test_config() -> converter_config.ConverterConfig: + """Return the test config.""" + config = converter_config.ConverterConfig() + config.read_config_file( + TEST_MODEL_ELEMENTS_CONFIG.read_text(encoding="utf8") + ) + return config + + @pytest.fixture def dummy_work_items() -> dict[str, data_model.CapellaWorkItem]: return { diff --git a/tests/data/model/Melody Model Test.capella b/tests/data/model/Melody Model Test.capella index 635c7d5..d52ee14 100644 --- a/tests/data/model/Melody Model Test.capella +++ b/tests/data/model/Melody Model Test.capella @@ -2058,7 +2058,11 @@ The predator is far away + human="true"> + + id="74bd0ab3-6a28-4025-822e-90201445a56e" targetElement="#3612f112-f0c7-42ec-b0e9-f53afc1ef486" sourceElement="#b4e39757-b0fd-41ff-a7b8-c9fc36de2ca9"/> + diff --git a/tests/data/model_elements/config.yaml b/tests/data/model_elements/config.yaml index fbae34a..1180b19 100644 --- a/tests/data/model_elements/config.yaml +++ b/tests/data/model_elements/config.yaml @@ -42,6 +42,11 @@ sa: SystemComponent: - links: - allocated_functions + serializer: + add_custom_diagrams: + - capella_attr: realization_view + polarion_id: realization_view + title: Realization View Diagram - is_actor: false polarion_type: systemComponent - is_actor: true @@ -74,6 +79,14 @@ pa: PhysicalComponent: - links: - allocated_functions + serializer: + add_custom_diagrams: + - capella_attr: context_diagram + polarion_id: context_diagram + title: Context Diagram + - capella_attr: realization_view + polarion_id: realization_view + title: Realization View Diagram - is_actor: false nature: UNSET polarion_type: physicalComponent @@ -89,6 +102,12 @@ pa: - is_actor: true nature: BEHAVIOR polarion_type: physicalActorBehavior + PhysicalLink: + serializer: + add_custom_diagrams: + - capella_attr: cable_tree + polarion_id: cable_tree + title: Cable Tree Diagram la: LogicalComponent: diff --git a/tests/test_elements.py b/tests/test_elements.py index a0f5424..1a0f89c 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -13,7 +13,7 @@ import polarion_rest_api_client as polarion_api import pytest from capellambse import model as m -from capellambse_context_diagrams import context, filters +from capellambse_context_diagrams import context from capella2polarion import data_model from capella2polarion.connectors import polarion_repo @@ -54,7 +54,11 @@ TEST_CAP_REAL = "b80b3141-a7fc-48c7-84b2-1467dcef5fce" TEST_CONSTRAINT = "95cbd4af-7224-43fe-98cb-f13dda540b8e" TEST_SYS_FNC = "ceffa011-7b66-4b3c-9885-8e075e312ffa" +TEST_SYS_FNC_CTX = "c710f1c2-ede6-444e-9e2b-0ff30d7fd040" TEST_SYS_FNC_EX = "1a414995-f4cd-488c-8152-486e459fb9de" +TEST_SYS_CMP = "344a405e-c7e5-4367-8a9a-41d3d9a27f81" +TEST_PHYS_LINK = "3078ec08-956a-4c61-87ed-0143d1d66715" +TEST_PHYS_CONTEXT_DIAGRAM = "11906f7b-3ae9-4343-b998-95b170be2e2b" TEST_DIAG_DESCR = ( '