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_cli-0.1.0.dist-info/METADATA +214 -0
- archi_cli-0.1.0.dist-info/RECORD +16 -0
- archi_cli-0.1.0.dist-info/WHEEL +4 -0
- archi_cli-0.1.0.dist-info/entry_points.txt +2 -0
- archi_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- archi_tool/__init__.py +1 -0
- archi_tool/cli.py +528 -0
- archi_tool/discovery.py +137 -0
- archi_tool/engine.py +266 -0
- archi_tool/model.py +280 -0
- archi_tool/normalize.py +91 -0
- archi_tool/render.py +207 -0
- archi_tool/render_html.py +645 -0
- archi_tool/render_slides.py +650 -0
- archi_tool/validate.py +150 -0
- archi_tool/views.py +215 -0
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
|