archi-cli 0.1.0__py3-none-any.whl

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.
archi_tool/validate.py ADDED
@@ -0,0 +1,150 @@
1
+ """Integrity checks for a native .archimate model.
2
+
3
+ Returns (errors, warnings). Errors mean the model is corrupt or will not
4
+ load correctly in Archi; warnings flag convention deviations.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ from .model import (FOLDER_BY_ELEMENT_TYPE, is_diagram, is_element,
12
+ is_relationship, xsi_type)
13
+
14
+ # tolerate an optional section number ("## Property-keys", "## 3. Property-keys")
15
+ PROPERTY_KEYS_HEADING = re.compile(
16
+ r"^##\s*(?:\d+\.\s*)?Property-keys\s*$", re.MULTILINE)
17
+
18
+
19
+ def allowed_property_keys(conventions_path) -> set:
20
+ """Parse property keys from the conventions doc: in the section under
21
+ the Property-keys heading, every bullet starting with a backticked key."""
22
+ path = Path(conventions_path)
23
+ if not path.exists():
24
+ return set()
25
+ text = path.read_text(encoding="utf-8")
26
+ match = PROPERTY_KEYS_HEADING.search(text)
27
+ if not match:
28
+ return set()
29
+ section = re.split(r"\n## ", text[match.end():], maxsplit=1)[0]
30
+ return set(re.findall(r"^- `([^`\n]+)`", section, flags=re.MULTILINE))
31
+
32
+
33
+ def top_folder_of(model, node):
34
+ parent = node.getparent()
35
+ top = None
36
+ while parent is not None:
37
+ if parent.tag == "folder":
38
+ top = parent
39
+ parent = parent.getparent()
40
+ return top
41
+
42
+
43
+ def validate(model, conventions_path=None, allowed_keys=None) -> tuple[list, list]:
44
+ """Run integrity checks; return (errors, warnings).
45
+
46
+ Property-key checking uses ``allowed_keys`` when given (a set resolved by
47
+ discovery), otherwise parses ``conventions_path``. Passing neither skips
48
+ the property-key check.
49
+ """
50
+ errors, warnings = [], []
51
+
52
+ # 1. unique ids
53
+ index = {}
54
+ for node in model.root.iter():
55
+ node_id = node.get("id")
56
+ if not node_id:
57
+ continue
58
+ if node_id in index:
59
+ errors.append(f"Dubbel id: {node_id}")
60
+ index[node_id] = node
61
+
62
+ # 2. relationship endpoints exist and are valid concepts
63
+ for rel in model.relationships():
64
+ for attr in ("source", "target"):
65
+ ref = rel.get(attr)
66
+ node = index.get(ref)
67
+ if node is None:
68
+ errors.append(
69
+ f"Relatie {rel.get('id')} ({xsi_type(rel)}): {attr} "
70
+ f"verwijst naar onbekend id {ref}")
71
+ elif is_diagram(node):
72
+ errors.append(
73
+ f"Relatie {rel.get('id')} ({xsi_type(rel)}): {attr} "
74
+ f"verwijst naar view {ref}; relaties kunnen geen views "
75
+ "verbinden")
76
+ elif (is_relationship(node)
77
+ and xsi_type(rel) != "AssociationRelationship"):
78
+ errors.append(
79
+ f"Relatie {rel.get('id')} ({xsi_type(rel)}): {attr} "
80
+ f"verwijst naar relatie {ref}; alleen een "
81
+ "AssociationRelationship mag een relatie als eindpunt "
82
+ "hebben")
83
+
84
+ # 3. folder placement
85
+ for node in model.root.iter("element"):
86
+ folder = top_folder_of(model, node)
87
+ folder_type = folder.get("type") if folder is not None else None
88
+ if is_relationship(node) and folder_type != "relations":
89
+ errors.append(
90
+ f"Relatie {node.get('id')} staat in folder '{folder_type}' "
91
+ "in plaats van 'relations'")
92
+ elif is_diagram(node) and folder_type != "diagrams":
93
+ errors.append(
94
+ f"View {node.get('id')} staat in folder '{folder_type}' "
95
+ "in plaats van 'diagrams'")
96
+ elif is_element(node):
97
+ expected = FOLDER_BY_ELEMENT_TYPE.get(xsi_type(node))
98
+ if expected and folder_type != expected:
99
+ warnings.append(
100
+ f"Element {node.get('id')} ({xsi_type(node)}, "
101
+ f"'{node.get('name')}') staat in folder '{folder_type}', "
102
+ f"verwacht '{expected}'")
103
+
104
+ # 4. view integrity
105
+ for diagram in model.diagrams():
106
+ object_ids = {c.get("id") for c in diagram.iter("child")}
107
+ for obj in diagram.iter("child"):
108
+ element_ref = obj.get("archimateElement")
109
+ if element_ref and element_ref not in index:
110
+ errors.append(
111
+ f"View-object {obj.get('id')} verwijst naar onbekend "
112
+ f"element {element_ref}")
113
+ for conn_id in (obj.get("targetConnections") or "").split():
114
+ if conn_id not in index:
115
+ errors.append(
116
+ f"View-object {obj.get('id')}: targetConnections "
117
+ f"bevat onbekend id {conn_id}")
118
+ for conn in diagram.iter("sourceConnection"):
119
+ rel_ref = conn.get("archimateRelationship")
120
+ if rel_ref and rel_ref not in index:
121
+ errors.append(
122
+ f"Verbinding {conn.get('id')} verwijst naar onbekende "
123
+ f"relatie {rel_ref}")
124
+ for attr in ("source", "target"):
125
+ ref = conn.get(attr)
126
+ if ref not in object_ids:
127
+ errors.append(
128
+ f"Verbinding {conn.get('id')}: {attr} verwijst niet "
129
+ f"naar een object in dezelfde view ({ref})")
130
+
131
+ # 5. property keys against conventions (warning only)
132
+ allowed = allowed_keys
133
+ if allowed is None and conventions_path:
134
+ allowed = allowed_property_keys(conventions_path)
135
+ if allowed:
136
+ seen_unknown = set()
137
+ for prop in model.root.iter("property"):
138
+ key = prop.get("key")
139
+ if key and key not in allowed and key not in seen_unknown:
140
+ seen_unknown.add(key)
141
+ warnings.append(
142
+ f"Property-key '{key}' staat niet in de conventielijst")
143
+ elif conventions_path:
144
+ # an empty list silently disables this check; say so loudly
145
+ warnings.append(
146
+ f"Geen property-keys gevonden in {conventions_path}: "
147
+ "de conventiecheck op property-keys staat hierdoor uit "
148
+ "(ontbreekt de sectie 'Property-keys'?)")
149
+
150
+ return errors, warnings
archi_tool/views.py ADDED
@@ -0,0 +1,215 @@
1
+ """Deterministic view generation for native .archimate models.
2
+
3
+ Two layout strategies, ported from the ADO exportscript
4
+ (build_archimate_export.py):
5
+
6
+ - "grid": all selected elements in a simple grid.
7
+ - "cluster": group by a structural relation (default Aggregation/Composition):
8
+ a source element becomes a cluster head placed above its targets, with
9
+ vertical space so connection arrows stay readable. Elements without a
10
+ cluster become standalone nodes at the end.
11
+
12
+ All selected relations between selected elements are drawn as connections.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import math
17
+
18
+ from lxml import etree
19
+
20
+ from .model import XSI_TYPE, ModelError, is_element, new_id, xsi_type
21
+
22
+ NODE_W = 220
23
+ NODE_H = 70
24
+ NODE_GAP = 12
25
+ CLUSTER_COLS = 3 # clusters per canvas row
26
+ CHILD_COLS = 2 # children per row inside a cluster
27
+ CLUSTER_VGAP = 60 # space between cluster head and its child grid
28
+ MARGIN = 60
29
+ GRID_COLS = 5 # columns for the plain grid layout
30
+
31
+ CLUSTER_RELATION_TYPES = {"Aggregation", "Composition"}
32
+
33
+
34
+ def containment_closure(model, root_ref) -> set:
35
+ """Ids of the root element plus everything it aggregates or composes,
36
+ recursively."""
37
+ root = model.resolve(root_ref)
38
+ if not is_element(root):
39
+ raise ModelError(f"'{root_ref}' is geen element")
40
+ selected = {root.get("id")}
41
+ frontier = [root.get("id")]
42
+ while frontier:
43
+ current = frontier.pop(0)
44
+ for rel in model.relationships():
45
+ if (xsi_type(rel).removesuffix("Relationship")
46
+ in CLUSTER_RELATION_TYPES
47
+ and rel.get("source") == current
48
+ and rel.get("target") not in selected):
49
+ selected.add(rel.get("target"))
50
+ frontier.append(rel.get("target"))
51
+ return selected
52
+
53
+
54
+ def related_ids(model, ids) -> set:
55
+ """Ids of elements directly related (one hop, either direction) to the
56
+ given selection, excluding the selection itself."""
57
+ element_ids = {e.get("id") for e in model.elements()}
58
+ extra = set()
59
+ for rel in model.relationships():
60
+ src, tgt = rel.get("source"), rel.get("target")
61
+ if src in ids and tgt in element_ids and tgt not in ids:
62
+ extra.add(tgt)
63
+ elif tgt in ids and src in element_ids and src not in ids:
64
+ extra.add(src)
65
+ return extra
66
+
67
+
68
+ def select(model, element_types=None, relation_types=None, prop=None,
69
+ root=None, related=False, extra_elements=None):
70
+ if root:
71
+ ids = containment_closure(model, root)
72
+ if related:
73
+ ids = ids | related_ids(model, ids)
74
+ # document order keeps the selection (and thus layout) deterministic
75
+ elements = [e for e in model.elements() if e.get("id") in ids]
76
+ elif element_types or prop or not extra_elements:
77
+ elements = model.elements()
78
+ else:
79
+ elements = []
80
+ if element_types:
81
+ elements = [e for e in elements if xsi_type(e) in element_types]
82
+ if prop:
83
+ key, _, value = prop.partition("=")
84
+ elements = [e for e in elements
85
+ if model.properties(e).get(key) == value]
86
+ if extra_elements:
87
+ seen = {e.get("id") for e in elements}
88
+ elements = elements + [e for e in extra_elements
89
+ if e.get("id") not in seen]
90
+ ids = {e.get("id") for e in elements}
91
+ relations = [r for r in model.relationships()
92
+ if r.get("source") in ids and r.get("target") in ids]
93
+ if relation_types:
94
+ relations = [r for r in relations
95
+ if xsi_type(r).removesuffix("Relationship")
96
+ in relation_types]
97
+ return elements, relations
98
+
99
+
100
+ def grid_positions(elements):
101
+ positions = {}
102
+ for i, el in enumerate(elements):
103
+ col, row = i % GRID_COLS, i // GRID_COLS
104
+ positions[el.get("id")] = (
105
+ MARGIN + col * (NODE_W + 2 * NODE_GAP),
106
+ MARGIN + row * (NODE_H + 2 * NODE_GAP))
107
+ return positions
108
+
109
+
110
+ def cluster_positions(elements, relations):
111
+ ids = {e.get("id") for e in elements}
112
+ children = {}
113
+ childless = set()
114
+ for r in relations:
115
+ if xsi_type(r).removesuffix("Relationship") in CLUSTER_RELATION_TYPES:
116
+ src, tgt = r.get("source"), r.get("target")
117
+ if src in ids and tgt in ids:
118
+ children.setdefault(src, []).append(tgt)
119
+ childless.add(tgt)
120
+
121
+ heads = [e for e in elements
122
+ if e.get("id") in children and e.get("id") not in childless]
123
+ placed = {h.get("id") for h in heads} | {
124
+ c for kids in (children[h.get("id")] for h in heads) for c in kids}
125
+ loose = [e for e in elements if e.get("id") not in placed]
126
+ clusters = ([(h.get("id"), children[h.get("id")]) for h in heads]
127
+ + [(e.get("id"), []) for e in loose])
128
+
129
+ # wide canvases beat tall ones on screens and slides: grow the column
130
+ # count with the number of clusters instead of stacking them deep
131
+ n_cols = max(CLUSTER_COLS, math.ceil(math.sqrt(len(clusters) * 1.8)))
132
+
133
+ sizes = []
134
+ for _, kids in clusters:
135
+ n = len(kids)
136
+ cols = min(CHILD_COLS, n) if n else 0
137
+ rows = (n + cols - 1) // cols if n else 0
138
+ grid_w = cols * NODE_W + max(0, cols - 1) * NODE_GAP
139
+ width = max(NODE_W, grid_w)
140
+ height = NODE_H + (CLUSTER_VGAP + rows * NODE_H +
141
+ (rows - 1) * NODE_GAP if n else 0)
142
+ sizes.append((width, height, cols, grid_w))
143
+
144
+ n_rows = (len(clusters) + n_cols - 1) // n_cols
145
+ col_w = [0] * n_cols
146
+ row_h = [0] * max(1, n_rows)
147
+ for i, (width, height, _, _) in enumerate(sizes):
148
+ col_w[i % n_cols] = max(col_w[i % n_cols], width)
149
+ row_h[i // n_cols] = max(row_h[i // n_cols], height)
150
+ col_x = [MARGIN]
151
+ for c in range(n_cols - 1):
152
+ col_x.append(col_x[-1] + col_w[c] + MARGIN)
153
+ row_y = [MARGIN]
154
+ for r in range(n_rows - 1):
155
+ row_y.append(row_y[-1] + row_h[r] + MARGIN)
156
+
157
+ positions = {}
158
+ for i, (head_id, kids) in enumerate(clusters):
159
+ width, height, cols, grid_w = sizes[i]
160
+ x0 = col_x[i % n_cols]
161
+ y0 = row_y[i // n_cols]
162
+ positions[head_id] = (x0 + (width - NODE_W) // 2, y0)
163
+ kid_x0 = x0 + (width - grid_w) // 2
164
+ for j, kid_id in enumerate(kids):
165
+ positions[kid_id] = (
166
+ kid_x0 + (j % cols) * (NODE_W + NODE_GAP),
167
+ y0 + NODE_H + CLUSTER_VGAP + (j // cols) * (NODE_H + NODE_GAP))
168
+ return positions
169
+
170
+
171
+ def add_view(model, name, layout="grid", element_types=None,
172
+ relation_types=None, prop=None, root=None, related=False,
173
+ extra_elements=None):
174
+ elements, relations = select(model, element_types, relation_types, prop,
175
+ root=root, related=related,
176
+ extra_elements=extra_elements)
177
+ if not elements:
178
+ raise ModelError("Selectie is leeg; geen view aangemaakt")
179
+
180
+ if layout == "grid":
181
+ positions = grid_positions(elements)
182
+ elif layout == "cluster":
183
+ positions = cluster_positions(elements, relations)
184
+ else:
185
+ raise ModelError(f"Onbekende layout '{layout}' (grid of cluster)")
186
+
187
+ diagram = etree.SubElement(model.folder("diagrams"), "element", {
188
+ XSI_TYPE: "archimate:ArchimateDiagramModel",
189
+ "name": name, "id": new_id()})
190
+
191
+ objects = {}
192
+ for el in elements:
193
+ el_id = el.get("id")
194
+ obj = etree.SubElement(diagram, "child", {
195
+ XSI_TYPE: "archimate:DiagramObject",
196
+ "id": new_id(), "archimateElement": el_id})
197
+ x, y = positions[el_id]
198
+ etree.SubElement(obj, "bounds", {
199
+ "x": str(x), "y": str(y),
200
+ "width": str(NODE_W), "height": str(NODE_H)})
201
+ objects[el_id] = obj
202
+
203
+ for rel in relations:
204
+ source_obj = objects[rel.get("source")]
205
+ target_obj = objects[rel.get("target")]
206
+ conn_id = new_id()
207
+ etree.SubElement(source_obj, "sourceConnection", {
208
+ XSI_TYPE: "archimate:Connection", "id": conn_id,
209
+ "source": source_obj.get("id"), "target": target_obj.get("id"),
210
+ "archimateRelationship": rel.get("id")})
211
+ existing = target_obj.get("targetConnections")
212
+ target_obj.set("targetConnections",
213
+ f"{existing} {conn_id}" if existing else conn_id)
214
+
215
+ return diagram