mal-toolbox 1.0.6__tar.gz → 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {mal_toolbox-1.0.6/mal_toolbox.egg-info → mal_toolbox-1.1.0}/PKG-INFO +1 -1
  2. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0/mal_toolbox.egg-info}/PKG-INFO +1 -1
  3. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/mal_toolbox.egg-info/SOURCES.txt +2 -0
  4. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/__init__.py +2 -2
  5. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/__main__.py +6 -4
  6. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/attackgraph/attackgraph.py +11 -0
  7. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/visualization/__init__.py +3 -1
  8. mal_toolbox-1.1.0/maltoolbox/visualization/draw_io_utils.py +317 -0
  9. mal_toolbox-1.1.0/maltoolbox/visualization/utils.py +41 -0
  10. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/pyproject.toml +1 -1
  11. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/AUTHORS +0 -0
  12. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/LICENSE +0 -0
  13. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/README.md +0 -0
  14. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/mal_toolbox.egg-info/dependency_links.txt +0 -0
  15. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/mal_toolbox.egg-info/entry_points.txt +0 -0
  16. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/mal_toolbox.egg-info/requires.txt +0 -0
  17. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/mal_toolbox.egg-info/top_level.txt +0 -0
  18. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/attackgraph/__init__.py +0 -0
  19. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/attackgraph/analyzers/__init__.py +0 -0
  20. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/attackgraph/node.py +0 -0
  21. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/exceptions.py +0 -0
  22. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/file_utils.py +0 -0
  23. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/language/__init__.py +0 -0
  24. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/language/compiler/__init__.py +0 -0
  25. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/language/compiler/mal_lexer.py +0 -0
  26. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/language/compiler/mal_parser.py +0 -0
  27. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/language/languagegraph.py +0 -0
  28. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/model.py +0 -0
  29. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/patternfinder/__init__.py +0 -0
  30. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/patternfinder/attackgraph_patterns.py +0 -0
  31. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/py.typed +0 -0
  32. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/translators/__init__.py +0 -0
  33. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/translators/securicad.py +0 -0
  34. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/translators/updater.py +0 -0
  35. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/visualization/graphviz_utils.py +0 -0
  36. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/maltoolbox/visualization/neo4j_utils.py +0 -0
  37. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/setup.cfg +0 -0
  38. {mal_toolbox-1.0.6 → mal_toolbox-1.1.0}/tests/test_model.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mal-toolbox
3
- Version: 1.0.6
3
+ Version: 1.1.0
4
4
  Summary: A collection of tools used to create MAL models and attack graphs.
5
5
  Author-email: Andrei Buhaiu <buhaiu@kth.se>, Joakim Loxdal <loxdal@kth.se>, Nikolaos Kakouros <nkak@kth.se>, Jakob Nyberg <jaknyb@kth.se>, Giuseppe Nebbione <nebbione@kth.se>, Sandor Berglund <sandor@kth.se>
6
6
  License: Apache Software License
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mal-toolbox
3
- Version: 1.0.6
3
+ Version: 1.1.0
4
4
  Summary: A collection of tools used to create MAL models and attack graphs.
5
5
  Author-email: Andrei Buhaiu <buhaiu@kth.se>, Joakim Loxdal <loxdal@kth.se>, Nikolaos Kakouros <nkak@kth.se>, Jakob Nyberg <jaknyb@kth.se>, Giuseppe Nebbione <nebbione@kth.se>, Sandor Berglund <sandor@kth.se>
6
6
  License: Apache Software License
@@ -29,6 +29,8 @@ maltoolbox/translators/__init__.py
29
29
  maltoolbox/translators/securicad.py
30
30
  maltoolbox/translators/updater.py
31
31
  maltoolbox/visualization/__init__.py
32
+ maltoolbox/visualization/draw_io_utils.py
32
33
  maltoolbox/visualization/graphviz_utils.py
33
34
  maltoolbox/visualization/neo4j_utils.py
35
+ maltoolbox/visualization/utils.py
34
36
  tests/test_model.py
@@ -1,5 +1,5 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # MAL Toolbox v1.0.6
2
+ # MAL Toolbox v1.1.0
3
3
  # Copyright 2025, Andrei Buhaiu.
4
4
  #
5
5
  # Licensed under the Apache License, Version 2.0 (the "License");
@@ -21,7 +21,7 @@ MAL-Toolbox Framework
21
21
  """
22
22
 
23
23
  __title__ = "maltoolbox"
24
- __version__ = "1.0.6"
24
+ __version__ = "1.1.0"
25
25
  __authors__ = [
26
26
  "Andrei Buhaiu",
27
27
  "Giuseppe Nebbione",
@@ -5,7 +5,7 @@ Usage:
5
5
  maltoolbox compile <lang_file> <output_file>
6
6
  maltoolbox generate-attack-graph [--graphviz] [--neo4j] <model_file> <lang_file>
7
7
  maltoolbox upgrade-model <model_file> <lang_file> <output_file>
8
- maltoolbox visualize-model [--neo4j] [--graphviz] <model_file> <lang_file>
8
+ maltoolbox visualize-model [--neo4j] [--graphviz] [-drawio] <model_file> <lang_file>
9
9
 
10
10
  Arguments:
11
11
  <model_file> Path to JSON instance model file.
@@ -16,6 +16,7 @@ Options:
16
16
  -h --help Show this screen.
17
17
  -g --graphviz Visualize with graphviz
18
18
  -n --neo4j Send to neo4j
19
+ -d --drawio Export draw.io file
19
20
 
20
21
  Notes:
21
22
  - <lang_file> can be either a .mar file (generated by the older MAL
@@ -35,7 +36,8 @@ from .visualization import (
35
36
  render_model,
36
37
  render_attack_graph,
37
38
  ingest_model_neo4j,
38
- ingest_attack_graph_neo4j
39
+ ingest_attack_graph_neo4j,
40
+ create_drawio_file_with_images
39
41
  )
40
42
  from .model import Model
41
43
 
@@ -100,10 +102,10 @@ def main():
100
102
  model = Model.load_from_file(args['<model_file>'], lang_graph)
101
103
  if args['--graphviz']:
102
104
  render_model(model)
103
- else:
104
- print("Use flag --graphviz to generate a pdf")
105
105
  if args['--neo4j']:
106
106
  ingest_model_neo4j(model, neo4j_configs)
107
+ if args['--drawio']:
108
+ create_drawio_file_with_images(model)
107
109
 
108
110
  if __name__ == "__main__":
109
111
  main()
@@ -91,6 +91,9 @@ class AttackGraph():
91
91
  """Graph representation of attack steps"""
92
92
  def __init__(self, lang_graph: LanguageGraph, model: Optional[Model] = None):
93
93
  self.nodes: dict[int, AttackGraphNode] = {}
94
+ self.attack_steps: list[AttackGraphNode] = []
95
+ self.defense_steps: list[AttackGraphNode] = []
96
+
94
97
  # Dictionaries used in optimization to get nodes by id or full name
95
98
  # faster
96
99
  self._full_name_to_node: dict[str, AttackGraphNode] = {}
@@ -665,6 +668,14 @@ class AttackGraph():
665
668
  )
666
669
 
667
670
  self.nodes[node_id] = node
671
+
672
+ # Add to different lists depending on types
673
+ # Useful but not vital for functionality
674
+ if node.type in ('or', 'and'):
675
+ self.attack_steps.append(node)
676
+ if node.type == 'defense':
677
+ self.defense_steps.append(node)
678
+
668
679
  self._full_name_to_node[node.full_name] = node
669
680
 
670
681
  return node
@@ -1,9 +1,11 @@
1
1
  from .graphviz_utils import render_attack_graph, render_model
2
2
  from .neo4j_utils import ingest_attack_graph_neo4j, ingest_model_neo4j
3
+ from .draw_io_utils import create_drawio_file_with_images
3
4
 
4
5
  __all__ = [
5
6
  'render_attack_graph',
6
7
  'render_model',
7
8
  'ingest_attack_graph_neo4j',
8
- 'ingest_model_neo4j'
9
+ 'ingest_model_neo4j',
10
+ 'create_drawio_file_with_images'
9
11
  ]
@@ -0,0 +1,317 @@
1
+ """DrawIO exporter made by Sandor"""
2
+ import xml.etree.ElementTree as ET
3
+ from xml.dom import minidom
4
+ import math
5
+
6
+ from maltoolbox.model import Model
7
+
8
+ from .utils import position_assets
9
+
10
+ type2iconURL = {
11
+ "Hardware": "https://uxwing.com/wp-content/themes/uxwing/download/domain-hosting/server-rack-outline-icon.png",
12
+ "SoftwareProduct": "https://uxwing.com/wp-content/themes/uxwing/download/logistics-shipping-delivery/packing-icon.png",
13
+ "Application": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/coding-icon.png",
14
+ "IDPS": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/web-page-source-code-icon.png",
15
+ "PhysicalZone": "https://uxwing.com/wp-content/themes/uxwing/download/location-travel-map/address-location-icon.png",
16
+ "Information": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/more-info-icon.png",
17
+ "Data": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/database-line-icon.png",
18
+ "IAMObject": "https://uxwing.com/wp-content/themes/uxwing/download/communication-chat-call/name-id-icon.png",
19
+ "Identity": "https://uxwing.com/wp-content/themes/uxwing/download/communication-chat-call/name-id-icon.png",
20
+ "Privileges": "https://uxwing.com/wp-content/themes/uxwing/download/banking-finance/access-hand-key-icon.png",
21
+ "Group": "https://uxwing.com/wp-content/themes/uxwing/download/business-professional-services/team-icon.png",
22
+ "Credentials": "https://uxwing.com/wp-content/themes/uxwing/download/household-and-furniture/key-line-icon.png",
23
+ "User": "https://uxwing.com/wp-content/themes/uxwing/download/peoples-avatars/unisex-male-and-female-icon.png",
24
+ "Network": "https://uxwing.com/wp-content/themes/uxwing/download/internet-network-technology/big-data-icon.png",
25
+ "RoutingFirewall": "https://uxwing.com/wp-content/themes/uxwing/download/internet-network-technology/encryption-icon.png",
26
+ "ConnectionRule": "https://uxwing.com/wp-content/themes/uxwing/download/internet-network-technology/pc-network-icon.png",
27
+ "Vulnerability": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/cyber-security-icon.png",
28
+ "SoftwareVulnerability": "https://uxwing.com/wp-content/themes/uxwing/download/web-app-development/cyber-security-icon.png",
29
+ "HardwareVulnerability": "https://uxwing.com/wp-content/themes/uxwing/download/crime-security-military-law/shield-sedo-line-icon.png",
30
+ }
31
+
32
+ def create_drawio_file_with_images(
33
+ model: Model,
34
+ show_edge_labels=True,
35
+ line_thickness=2,
36
+ coordinate_scale=0.75,
37
+ output_filename=None
38
+ ):
39
+ """
40
+ Create a draw.io file with all model assets as boxes using their actual positions and images
41
+
42
+ Args:
43
+ model: The model containing assets and associations
44
+ output_filename: Name of the output draw.io file
45
+ show_edge_labels: If True, show association type as text on edges. If False, edges will have no labels.
46
+ line_thickness: Thickness of the edges in pixels (default: 2)
47
+ coordinate_scale: Scale factor for model coordinates (default: 1.0, use 0.5 for half size, 2.0 for double size)
48
+ """
49
+
50
+ if not all(a.extras.get('position') for a in model.assets.values()):
51
+ # Give assets positions if not already set
52
+ position_assets(model)
53
+
54
+ output_filename = output_filename or (
55
+ (model.name or "model_assets_with_images") + ".drawio"
56
+ )
57
+
58
+ # Use the type2iconURL mapping for image URLs
59
+ type_images = type2iconURL
60
+
61
+ # Create root element
62
+ root = ET.Element("mxfile")
63
+ root.set("host", "app.diagrams.net")
64
+ root.set("modified", "2024-01-01T00:00:00.000Z")
65
+ root.set("agent", "5.0")
66
+ root.set("version", "24.7.17")
67
+ root.set("et", "https://www.diagrams.net/")
68
+ root.set("type", "device")
69
+
70
+ # Create diagram element
71
+ diagram = ET.SubElement(root, "diagram")
72
+ diagram.set("id", "model-assets")
73
+ diagram.set("name", "Model Assets")
74
+
75
+ # Create mxGraphModel
76
+ mxgraph = ET.SubElement(diagram, "mxGraphModel")
77
+ mxgraph.set("dx", "1422")
78
+ mxgraph.set("dy", "754")
79
+ mxgraph.set("grid", "1")
80
+ mxgraph.set("gridSize", "10")
81
+ mxgraph.set("guides", "1")
82
+ mxgraph.set("tooltips", "1")
83
+ mxgraph.set("connect", "1")
84
+ mxgraph.set("arrows", "1")
85
+ mxgraph.set("fold", "1")
86
+ mxgraph.set("page", "1")
87
+ mxgraph.set("pageScale", "1")
88
+ mxgraph.set("pageWidth", "2000")
89
+ mxgraph.set("pageHeight", "1500")
90
+ mxgraph.set("math", "0")
91
+ mxgraph.set("shadow", "0")
92
+
93
+ # Create root cell
94
+ root_cell = ET.SubElement(mxgraph, "root")
95
+
96
+ # Create default parent
97
+ default_parent = ET.SubElement(root_cell, "mxCell")
98
+ default_parent.set("id", "0")
99
+
100
+ # Create parent for edges (back layer)
101
+ edges_parent = ET.SubElement(root_cell, "mxCell")
102
+ edges_parent.set("id", "1")
103
+ edges_parent.set("parent", "0")
104
+
105
+ # Create default parent for model (front layer)
106
+ model_parent = ET.SubElement(root_cell, "mxCell")
107
+ model_parent.set("id", "2")
108
+ model_parent.set("parent", "0")
109
+
110
+ # Get assets and use their actual positions
111
+ assets = list(model.assets.values())
112
+
113
+ # Box dimensions
114
+ box_width = 150
115
+ box_height = 80
116
+
117
+ # Find the bounds of all assets to center the diagram
118
+ min_x = min_y = float("inf")
119
+ max_x = max_y = float("-inf")
120
+
121
+ for asset in assets:
122
+ if asset.extras:
123
+ x = asset.extras.get("position", {}).get("x", 0)
124
+ y = asset.extras.get("position", {}).get("y", 0)
125
+ min_x = min(min_x, x)
126
+ min_y = min(min_y, y)
127
+ max_x = max(max_x, x)
128
+ max_y = max(max_y, y)
129
+
130
+ # If no positions found, use default bounds
131
+ if min_x == float("inf"):
132
+ min_x = min_y = 0
133
+ max_x = max_y = 1000
134
+
135
+ # Add some padding
136
+ padding = 100
137
+ min_x -= padding
138
+ min_y -= padding
139
+ max_x += padding
140
+ max_y += padding
141
+
142
+ # Create boxes for each asset using their actual positions
143
+ for asset in assets:
144
+
145
+ # Get position from extras, with fallback to grid layout
146
+ if hasattr(asset, "extras") and asset.extras and "position" in asset.extras:
147
+ x = round(asset.extras["position"].get("x", 0) * coordinate_scale)
148
+ y = round(asset.extras["position"].get("y", 0) * coordinate_scale)
149
+ else:
150
+ # Fallback to grid layout if no position data
151
+ i = list(model.assets.keys()).index(asset.id)
152
+ cols = math.ceil(math.sqrt(len(assets)))
153
+ row = i // cols
154
+ col = i % cols
155
+ x = round((50 + col * 200) * coordinate_scale)
156
+ y = round((50 + row * 120) * coordinate_scale)
157
+
158
+ # Get image URL for asset type
159
+ image_url = type_images.get(
160
+ asset.type,
161
+ "https://uxwing.com/wp-content/themes/uxwing/download/communication-chat-call/question-mark-icon.png",
162
+ )
163
+
164
+ # Define colors for each asset type (matching the design)
165
+ type_colors = {
166
+ "Hardware": "#4CAF50", # Green
167
+ "Application": "#2196F3", # Blue
168
+ "Network": "#9C27B0", # Purple
169
+ "ConnectionRule": "#FF9800", # Orange
170
+ "Identity": "#607D8B", # Blue Grey
171
+ "Credentials": "#4CAF50", # Green
172
+ "SoftwareVulnerability": "#F44336", # Red
173
+ "Data": "#795548", # Brown
174
+ "User": "#00BCD4", # Cyan
175
+ }
176
+
177
+ # Get color for this asset type
178
+ top_color = type_colors.get(asset.type, "#4CAF50")
179
+
180
+ # Create the HTML content for the two-segment box
181
+ html_content = f"""
182
+ <div style="width: {box_width}px; height: {box_height}px; position: relative; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
183
+ <!-- Top segment with icon and type -->
184
+ <div style="background: linear-gradient(to bottom, {top_color}, {top_color}dd); height: 40%; display: flex; align-items: center; padding: 0 8px; position: relative;">
185
+ <img src="{image_url}" width="20" height="20" style="margin-right: 8px;"/>
186
+ <span style="color: white; font-weight: bold; font-size: 11px; flex: 1;">{asset.type}</span>
187
+ <div style="width: 8px; height: 8px; background: {top_color}; position: absolute; top: 4px; right: 4px; border-radius: 2px;"></div>
188
+ </div>
189
+ <!-- Bottom segment with asset name -->
190
+ <div style="background: #424242; height: 60%; display: flex; align-items: center; justify-content: center; padding: 0 8px;">
191
+ <span style="color: white; font-size: 10px; text-align: center; line-height: 1.2;">{asset.name}</span>
192
+ </div>
193
+ <!-- Connecting line -->
194
+ <div style="position: absolute; bottom: 40%; left: 0; width: 0; height: 0; border-left: 8px solid transparent; border-right: 8px solid transparent; border-top: 8px solid {top_color};"></div>
195
+ </div>
196
+ """
197
+
198
+ # Create mxCell for the asset box
199
+ cell = ET.SubElement(root_cell, "mxCell")
200
+ cell.set("id", f"asset_{asset.id}")
201
+ cell.set("value", html_content)
202
+ cell.set(
203
+ "style",
204
+ "rounded=0;whiteSpace=wrap;html=1;overflow=fill;"
205
+ "align=center;verticalAlign=middle;spacing=0;"
206
+ "fillColor=none;strokeColor=none;fontSize=10;fontStyle=1;",
207
+ )
208
+ cell.set("vertex", "1")
209
+ cell.set("parent", "2") # Put assets in front layer
210
+
211
+ # Create geometry element
212
+ geometry = ET.SubElement(cell, "mxGeometry")
213
+ geometry.set("x", str(x))
214
+ geometry.set("y", str(y))
215
+ geometry.set("width", str(box_width))
216
+ geometry.set("height", str(box_height))
217
+ geometry.set("as", "geometry")
218
+
219
+ # Create edges for associations (avoiding duplicates)
220
+ edge_id = 1000 # Start edge IDs from 1000 to avoid conflicts with asset IDs
221
+ processed_edges = set() # Track processed edges to avoid duplicates
222
+
223
+ for asset in assets:
224
+ if hasattr(asset, "associated_assets") and asset.associated_assets:
225
+ for asset_assoc_field, associated_assets in asset.associated_assets.items():
226
+ association_type = asset.lg_asset.associations[asset_assoc_field].name
227
+ for assoc_asset in associated_assets:
228
+ # Create a unique edge identifier (sorted to handle bidirectional associations)
229
+ edge_key = tuple(sorted([asset.id, assoc_asset.id]))
230
+
231
+ # Skip if we've already processed this edge
232
+ if edge_key in processed_edges:
233
+ continue
234
+
235
+ # Find the target asset
236
+ target_asset = None
237
+ for a in assets:
238
+ if a.id == assoc_asset.id:
239
+ target_asset = a
240
+ break
241
+
242
+ if target_asset:
243
+ # Mark this edge as processed
244
+ processed_edges.add(edge_key)
245
+ # Get positions for both assets
246
+ source_x = asset.extras.get("position", {}).get("x", 0) + box_width / 2
247
+ source_y = asset.extras.get("position", {}).get("y", 0) + box_height / 2
248
+ target_x = (
249
+ target_asset.extras.get("position", {}).get("x", 0) + box_width / 2
250
+ )
251
+ target_y = (
252
+ target_asset.extras.get("position", {}).get("y", 0) + box_height / 2
253
+ )
254
+
255
+ # Create edge cell
256
+ edge_cell = ET.SubElement(root_cell, "mxCell")
257
+ edge_cell.set("id", f"edge_{edge_id}")
258
+ edge_cell.set(
259
+ "value", association_type if show_edge_labels else ""
260
+ )
261
+ edge_cell.set(
262
+ "style",
263
+ f"edgeStyle=straightEdgeStyle;rounded=0;html=1;fontSize=10;fontStyle=1;endArrow=none;startArrow=none;strokeWidth={line_thickness};",
264
+ )
265
+ edge_cell.set("edge", "1")
266
+ edge_cell.set("parent", "1") # Put edges in back layer
267
+ edge_cell.set("source", f"asset_{asset.id}")
268
+ edge_cell.set("target", f"asset_{assoc_asset.id}")
269
+
270
+ # Create geometry for edge
271
+ edge_geometry = ET.SubElement(edge_cell, "mxGeometry")
272
+ edge_geometry.set("relative", "1")
273
+ edge_geometry.set("as", "geometry")
274
+
275
+ # Create array of points for the edge
276
+ array = ET.SubElement(edge_geometry, "Array")
277
+ array.set("as", "points")
278
+
279
+ # Add source point
280
+ point1 = ET.SubElement(array, "mxPoint")
281
+ point1.set("x", str(source_x))
282
+ point1.set("y", str(source_y))
283
+ point1.set("as", "sourcePoint")
284
+
285
+ # Add target point
286
+ point2 = ET.SubElement(array, "mxPoint")
287
+ point2.set("x", str(target_x))
288
+ point2.set("y", str(target_y))
289
+ point2.set("as", "targetPoint")
290
+
291
+ edge_id += 1
292
+
293
+ # Convert to pretty XML
294
+ rough_string = ET.tostring(root, "utf-8")
295
+ reparsed = minidom.parseString(rough_string)
296
+ pretty_xml = reparsed.toprettyxml(indent=" ")
297
+
298
+ # Remove empty lines
299
+ lines = [line for line in pretty_xml.split("\n") if line.strip()]
300
+ pretty_xml = "\n".join(lines)
301
+
302
+ # Write to file
303
+ with open(output_filename, "w", encoding="utf-8") as f:
304
+ f.write(pretty_xml)
305
+
306
+ print(f"Draw.io file with images created: {output_filename}")
307
+ print(f"Total assets: {len(assets)}")
308
+ print(f"Diagram bounds: x={min_x:.0f} to {max_x:.0f}, y={min_y:.0f} to {max_y:.0f}")
309
+
310
+ # Print asset summary
311
+ type_counts: dict[str, int] = {}
312
+ for asset in assets:
313
+ type_counts[asset.type] = type_counts.get(asset.type, 0) + 1
314
+
315
+ print("\nAsset type distribution:")
316
+ for asset_type, count in sorted(type_counts.items()):
317
+ print(f" {asset_type}: {count}")
@@ -0,0 +1,41 @@
1
+ from maltoolbox.model import Model
2
+
3
+ def position_assets(model: Model):
4
+ """
5
+ Assigns (x, y) positions to assets in a graph where relations are stored
6
+ in asset.associated_assets[relation_name] = [related_assets...].
7
+ Positions are stored in asset.extras['position'] = {'x': ..., 'y': ...}.
8
+ Layout is computed by traversing connected components.
9
+ Adds uniform padding between assets.
10
+ """
11
+
12
+ visited = set()
13
+ x_spacing = 200
14
+ y_spacing = 200
15
+ padding = 50 # uniform padding
16
+
17
+ def traverse(asset, depth, index, component):
18
+ if asset in visited:
19
+ return
20
+ visited.add(asset)
21
+ component.append((asset, depth, index))
22
+ neighbors = []
23
+ for rel_list in asset.associated_assets.values():
24
+ neighbors.extend(rel_list)
25
+ for i, neighbor in enumerate(neighbors):
26
+ if neighbor not in visited:
27
+ traverse(neighbor, depth + 1, i, component)
28
+
29
+ def assign_positions(component):
30
+ for i, (asset, depth, index) in enumerate(component):
31
+ x = index * (x_spacing + padding)
32
+ y = depth * (y_spacing + padding)
33
+ asset.extras.setdefault('position', {})
34
+ asset.extras['position']['x'] = x
35
+ asset.extras['position']['y'] = y
36
+
37
+ for asset in model.assets.values():
38
+ if asset not in visited:
39
+ component: list[tuple] = []
40
+ traverse(asset, 0, 0, component)
41
+ assign_positions(component)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "mal-toolbox"
3
- version = "1.0.6"
3
+ version = "1.1.0"
4
4
  authors = [
5
5
  { name="Andrei Buhaiu", email="buhaiu@kth.se" },
6
6
  { name="Joakim Loxdal", email="loxdal@kth.se" },
File without changes
File without changes
File without changes
File without changes