Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion slp_drawio/resources/schemas/drawio_mapping_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
"required": ["label"],
"properties": {
"label": {"$ref": "#/definitions/LabelUnion"},
"type": {"$ref": "#/definitions/query"}
"type": {"$ref": "#/definitions/query"},
"name": {
"type": "string",
"description": "The name of the type. This is used to tag the OTM component."
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions slp_drawio/slp_drawio/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .validate.drawio_mapping_file_validator import DrawioMappingFileValidator
from .drawio_processor import DrawioProcessor
58 changes: 5 additions & 53 deletions slp_drawio/slp_drawio/load/diagram_component_loader.py
Original file line number Diff line number Diff line change
@@ -1,78 +1,30 @@
import re
from typing import List, Dict

from otm.otm.entity.representation import RepresentationElement
from slp_drawio.slp_drawio.load.drawio_dict_utils import get_attributes, get_position, get_size, get_mx_cell_components
from slp_drawio.slp_drawio.load.drawio_dict_utils import get_position, get_size, get_mx_cell_components
from slp_drawio.slp_drawio.load.stencil_extractors import extract_stencil_type
from slp_drawio.slp_drawio.objects.diagram_objects import DiagramComponent

__CALCULATE_SHAPE_TYPE_EQUIVALENCES = resource_types_equivalences = {
'aws.group': 'grIcon',
'aws.groupCenter': 'grIcon',
'aws.resourceIcon': 'resIcon',
'aws.productIcon': 'prIcon'
}


def __remove_mxgraph_aws(text):
return re.sub(r"aws\d.", "aws.", text)


def __remove_mxgraph(text):
return re.sub(r"mxgraph.", "", text)


def __normalize_shape_type(text):
for normalize_function in [__remove_mxgraph, __remove_mxgraph_aws]:
text = normalize_function(text)

return text


def _calculate_shape_type(mx_cell: Dict, attr: str = 'shape'):
shape = get_attributes(mx_cell).get(attr)
if not shape:
return
shape_type = __normalize_shape_type(shape)
if shape_type in __CALCULATE_SHAPE_TYPE_EQUIVALENCES:
shape_type = _calculate_shape_type(mx_cell, __CALCULATE_SHAPE_TYPE_EQUIVALENCES.get(shape_type))

return shape_type


def _get_shape_parent_id(mx_cell: Dict, mx_cell_components: List[Dict]):
return mx_cell.get('parent') \
if any(item.get('id') == mx_cell.get('parent') for item in mx_cell_components) else None


def _get_shape_name(mx_cell: Dict):
name = mx_cell.get('value')
if not name:
name = _calculate_shape_type(mx_cell) or ''
if '.' in name:
name = name.split('.')[-1]
name = name.replace('_', ' ')
if not name:
name = 'N/A'
if len(name) == 1:
name = f'_{name}'
return name


class DiagramComponentLoader:

def __init__(self, project_id: str, source: dict):
self._project_id = project_id
self._source: dict = source

def load(self) -> [DiagramComponent]:
def load(self) -> list[DiagramComponent]:
result: List[DiagramComponent] = []

mx_cell_components = get_mx_cell_components(self._source)
for mx_cell in mx_cell_components:
result.append(DiagramComponent(
id=mx_cell.get('id'),
name=_get_shape_name(mx_cell),
shape_type=_calculate_shape_type(mx_cell),
name=mx_cell.get('value'),
shape_type=extract_stencil_type(mx_cell),
shape_parent_id=_get_shape_parent_id(mx_cell, mx_cell_components),
representations=[self._get_representation_element(mx_cell)]
))
Expand Down
30 changes: 30 additions & 0 deletions slp_drawio/slp_drawio/load/stencil_extractors/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import importlib
import os
from typing import Any, Optional, Callable


MxCell = dict[str, Any]
MxCellTypeExtractor = Callable[[MxCell], Optional[str]]

_extractors: list[MxCellTypeExtractor] = []

def register_extractor(extract_fn: MxCellTypeExtractor):
_extractors.append(extract_fn)
return extract_fn

def extract_stencil_type(mx_cell: MxCell) -> Optional[str]:
for extractor in _extractors:
if shape_type := extractor(mx_cell):
return shape_type
return None

# Auto-discover all sibling modules
current_dir = os.path.dirname(__file__)
modules = [
os.path.splitext(f)[0]
for f in os.listdir(current_dir)
if f.endswith(".py") and f not in ["__init__.py", "__pycache__"]
]

for module in modules:
importlib.import_module(f".{module}", __package__)
44 changes: 44 additions & 0 deletions slp_drawio/slp_drawio/load/stencil_extractors/aws_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import sl_util.sl_util.secure_regex as re
from typing import Optional

from slp_drawio.slp_drawio.load.stencil_extractors import MxCell, register_extractor

_AWS_EQUIVALENCES = {
'aws.group': 'grIcon',
'aws.groupCenter': 'grIcon',
'aws.resourceIcon': 'resIcon',
'aws.productIcon': 'prIcon',
}


def _remove_mxgraph(text: str) -> str:
return text.replace("mxgraph.", "")


def _remove_mxgraph_aws(text: str) -> str:
return re.sub(r"aws\d\.", "aws.", text)


def _normalize_shape_type(text: str) -> str:
text = _remove_mxgraph(text)
text = _remove_mxgraph_aws(text)
return text


def _get_shape_attribute(mx_cell: MxCell, attr) -> Optional[str]:
style = mx_cell.get("style", "")
for part in style.split(";"):
if part.startswith(attr + "="):
return part.split("=")[1]
return None


@register_extractor
def extract_aws_type(mx_cell: MxCell, attr: str = 'shape') -> Optional[str]:
shape_type = _get_shape_attribute(mx_cell, attr)
if not shape_type:
return None
shape_type = _normalize_shape_type(shape_type)
if shape_type in _AWS_EQUIVALENCES:
shape_type = extract_aws_type(mx_cell, _AWS_EQUIVALENCES[shape_type])
return shape_type
24 changes: 24 additions & 0 deletions slp_drawio/slp_drawio/load/stencil_extractors/azure_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import sl_util.sl_util.secure_regex as re
from typing import Optional
from slp_drawio.slp_drawio.load.stencil_extractors import MxCell, register_extractor


def __extract_image_value(mx_cell: MxCell) -> Optional[str]:
style = mx_cell.get("style", "")
for part in style.split(";"):
if part.startswith("image="):
image_value = part[len("image="):]
if not image_value.startswith("data:") and re.match(r"img/lib/azure\d*/", image_value):
return image_value
return None


@register_extractor
def extract_azure_type(mx_cell: MxCell) -> Optional[str]:
"""
Extract an Azure image path from mx_cell if it matches a specific pattern.
"""
image_path = __extract_image_value(mx_cell)
return '/'.join(image_path.split('.')[-2].split('/')[-2:]) \
if image_path and '.' in image_path and '/' in image_path \
else None
47 changes: 33 additions & 14 deletions slp_drawio/slp_drawio/parse/diagram_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from slp_drawio.slp_drawio.load.drawio_mapping_file_loader import DrawioMapping
from slp_drawio.slp_drawio.objects.diagram_objects import Diagram, DiagramComponent, DiagramTrustZone

DEFAULT_COMPONENT_TYPE = 'empty-component'
EMPTY_COMPONENT_MAPPING = {
'type': 'CD-V2-EMPTY-COMPONENT',
'name': 'Empty Component'
}


@singledispatch
Expand All @@ -27,11 +30,12 @@ def __match_by_dict(mapping_label: dict, component_label: str) -> bool:

def _find_mapping(label: str, mappings: List[Dict]) -> Optional[Dict]:
if not label:
return
return None

for mapping in mappings:
if __match(mapping['label'], label):
return mapping
return None


def _create_default_trustzone(trustzone_mapping: Dict) -> DiagramTrustZone:
Expand All @@ -55,6 +59,23 @@ def _create_trustzone_from_component(component: DiagramComponent) -> DiagramTrus
)


def _get_component_name_from_type(shape_type: str, mapping: Dict) -> str:
name = None
if mapping.get('type') != EMPTY_COMPONENT_MAPPING['type'] and mapping.get('name'):
name = mapping['name']
elif shape_type:
name = shape_type \
.capitalize() \
.replace('Aws.', 'AWS ') \
.replace('_', ' ') \
.replace('-', ' ') \
.replace('/', ' ')

if not name:
return 'N/A'

return f'_{name}' if len(name) == 1 else name

class DiagramMapper:
def __init__(self, diagram: Diagram, mapping: DrawioMapping):
self._diagram: Diagram = diagram
Expand All @@ -66,7 +87,6 @@ def map(self):

if self._diagram.components:
self._map_components()
self._set_default_type_to_unmapped_components()

def _add_default_trustzone(self):
self._diagram.default_trustzone = _create_default_trustzone(
Expand All @@ -76,25 +96,24 @@ def _map_components(self):
mappings = self.__merge_mappings()

for component in self._diagram.components:
mapping = _find_mapping(component.otm.name, mappings) or _find_mapping(component.shape_type, mappings)
if mapping:
self.__change_component_type(component, mapping)
component.otm.add_tag(mapping.get('name', mapping.get('type')))
mapping = \
_find_mapping(component.otm.name, mappings) or \
_find_mapping(component.shape_type, mappings) or \
EMPTY_COMPONENT_MAPPING

self.__update_component_data(component, mapping)

remove_from_list(self._diagram.components,
filter_function=lambda c: c.otm.id in [tz.otm.id for tz in self._diagram.trustzones])

def _set_default_type_to_unmapped_components(self):
for component in self._diagram.components:
if not component.otm.type:
component.otm.type = DEFAULT_COMPONENT_TYPE
component.otm.add_tag(DEFAULT_COMPONENT_TYPE)

def __merge_mappings(self) -> List[Dict]:
trustzone_mappings = [{**m, 'trustzone': True} for m in self._mapping.trustzones]
return trustzone_mappings + self._mapping.components

def __change_component_type(self, component: DiagramComponent, mapping: Dict):
def __update_component_data(self, component: DiagramComponent, mapping: Dict):
component.otm.type = mapping['type']
component.otm.add_tag(mapping.get('name', mapping.get('type')))
if not component.otm.name:
component.otm.name = _get_component_name_from_type(component.shape_type, mapping)
if mapping.get('trustzone', False):
self._diagram.trustzones.append(_create_trustzone_from_component(component))
25 changes: 25 additions & 0 deletions slp_drawio/tests/unit/load/shape_type/test_aws_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pytest
from slp_drawio.slp_drawio.load.stencil_extractors.aws_extractor import extract_aws_type


# --- Extractor function tests ---

@pytest.mark.parametrize("style, expected", [
pytest.param("shape=mxgraph.aws4.ec2", "aws.ec2", id="simple AWS shape"),
pytest.param("shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_aws_cloud_alt;", "aws.group_aws_cloud_alt", id="AWS group → grIcon"),
pytest.param("shape=mxgraph.aws4.groupCenter;grIcon=mxgraph.aws4.group_elastic_load_balancing;", "aws.group_elastic_load_balancing", id="AWS groupCenter → grIcon"),
pytest.param("shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.queue;", "aws.queue", id="AWS resourceIcon → resIcon"),
pytest.param("shape=mxgraph.aws4.productIcon;prIcon=mxgraph.aws4.athena;", "aws.athena", id="AWS productIcon → prIcon"),
pytest.param("shape=mxgraph.gcp.compute", "gcp.compute", id="Non AWS"),
])
def test_extract_aws_type_valid_cases(style, expected):
mx_cell = {"style": style}
result = extract_aws_type(mx_cell)
assert result == expected


@pytest.mark.parametrize("style", [None, "", "rounded=1;"])
def test_extract_aws_type_returns_none_for_unsupported(style):
mx_cell = {"style": style} if style is not None else {}
result = extract_aws_type(mx_cell)
assert result is None
24 changes: 24 additions & 0 deletions slp_drawio/tests/unit/load/shape_type/test_azure_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import pytest
from slp_drawio.slp_drawio.load.stencil_extractors.azure_extractor import extract_azure_type


# --- Extractor function tests ---

@pytest.mark.parametrize("style, expected", [
("image;image=img/lib/azure3/path.svg;", "azure3/path"),
("image;image=img/lib/azure/network/firewall.svg;", "network/firewall"),
])
def test_extract_azure_type_returns_expected_string(style, expected):
mx_cell = {"style": style}
result = extract_azure_type(mx_cell)
assert result == expected


@pytest.mark.parametrize("style", [
"image;image=img/lib/gcp/path.svg;",
"image;image=data:image/svg+xml;base64,XYZ",
"rounded=1;"
])
def test_extract_azure_type_returns_none_when_not_matched(style):
mx_cell = {"style": style}
assert extract_azure_type(mx_cell) is None
52 changes: 52 additions & 0 deletions slp_drawio/tests/unit/load/shape_type/test_extract_stencil_type.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pytest
from typing import Optional

from slp_drawio.slp_drawio.load.stencil_extractors import (
MxCell,
register_extractor,
extract_stencil_type,
_extractors,
)


@pytest.fixture
def clear_registry():
_extractors.clear()

# --- Registry structural integrity test ---

def test_all_registered_extractors_are_callable():
assert len(_extractors) == 2
for extractor in _extractors:
assert callable(extractor)


# --- extract_stencil_type behavior tests ---

def test_extract_stencil_type_calls_first_matching_extractor(clear_registry):
called = []

@register_extractor
def extractor1(_: MxCell) -> Optional[str]:
called.append("1")
return None

@register_extractor
def extractor2(_: MxCell) -> Optional[str]:
called.append("2")
return "azure.vm"

result = extract_stencil_type({"style": "irrelevant"})

assert result == "azure.vm"
assert called == ["1", "2"]


def test_extract_stencil_type_returns_none_if_no_extractors_match(clear_registry):
@register_extractor
def extractor(_: MxCell) -> Optional[str]:
return None

result = extract_stencil_type({"style": "none"})
assert result is None

Loading