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/engine.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Fetch and cache the Archi engine so `normalize` works without a manual
|
|
2
|
+
install.
|
|
3
|
+
|
|
4
|
+
`normalize` drives Archi's headless command-line application. Archi is a full
|
|
5
|
+
cross-platform desktop distribution (~165 MB, with a bundled Java runtime, so
|
|
6
|
+
it is self-contained) published on GitHub releases at ``archimatetool/archi.io``.
|
|
7
|
+
There is no smaller headless-only artifact; we download the full distribution
|
|
8
|
+
once, cache it per user, and point ARCHI_APP at the extracted binary.
|
|
9
|
+
|
|
10
|
+
The version is pinned so the download is deterministic and reproducible. Bump
|
|
11
|
+
ARCHI_VERSION deliberately.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
import tarfile
|
|
21
|
+
import urllib.request
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .model import ModelError
|
|
26
|
+
|
|
27
|
+
ARCHI_VERSION = "5.9.0"
|
|
28
|
+
RELEASE_BASE = (
|
|
29
|
+
"https://github.com/archimatetool/archi.io/releases/download"
|
|
30
|
+
f"/{ARCHI_VERSION}")
|
|
31
|
+
|
|
32
|
+
# per platform: (asset filename, relative path to the binary inside the
|
|
33
|
+
# extracted tree). Archi bundles its own JRE, so no system Java is needed.
|
|
34
|
+
_PLATFORMS = {
|
|
35
|
+
"linux": (
|
|
36
|
+
f"Archi-Linux64-{ARCHI_VERSION}.tgz",
|
|
37
|
+
"Archi/Archi"),
|
|
38
|
+
"windows": (
|
|
39
|
+
f"Archi-Win64-{ARCHI_VERSION}.zip",
|
|
40
|
+
"Archi/Archi.exe"),
|
|
41
|
+
# macOS ships a .dmg; both Intel and Apple Silicon expose the same binary
|
|
42
|
+
# path once the app bundle is copied out of the mounted image
|
|
43
|
+
"darwin-arm64": (
|
|
44
|
+
f"Archi-Mac-Silicon-{ARCHI_VERSION}.dmg",
|
|
45
|
+
"Archi.app/Contents/MacOS/Archi"),
|
|
46
|
+
"darwin-x86_64": (
|
|
47
|
+
f"Archi-Mac-{ARCHI_VERSION}.dmg",
|
|
48
|
+
"Archi.app/Contents/MacOS/Archi"),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cache_dir() -> Path:
|
|
53
|
+
"""Per-user cache root, overridable for tests and air-gapped setups."""
|
|
54
|
+
override = os.environ.get("ARCHI_CACHE")
|
|
55
|
+
if override:
|
|
56
|
+
return Path(override)
|
|
57
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
58
|
+
return Path(base) / "archi-cli"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _platform_key() -> str:
|
|
62
|
+
system = platform.system().lower()
|
|
63
|
+
machine = platform.machine().lower()
|
|
64
|
+
if system == "darwin":
|
|
65
|
+
# Archi ships both Intel and Apple Silicon builds
|
|
66
|
+
return "darwin-arm64" if machine in ("arm64", "aarch64") else "darwin-x86_64"
|
|
67
|
+
if system in ("linux", "windows"):
|
|
68
|
+
# only 64-bit x86 builds exist; refuse ARM rather than hand back an
|
|
69
|
+
# unrunnable x86 binary that fails cryptically at normalize time
|
|
70
|
+
if machine not in ("x86_64", "amd64"):
|
|
71
|
+
raise ModelError(
|
|
72
|
+
f"Archi levert alleen een x86_64-build voor {system}, geen "
|
|
73
|
+
f"'{machine}'. Installeer Archi handmatig en zet ARCHI_APP.")
|
|
74
|
+
return system
|
|
75
|
+
raise ModelError(
|
|
76
|
+
f"Geen Archi-download bekend voor platform '{system}'. Installeer "
|
|
77
|
+
"Archi handmatig en zet ARCHI_APP.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cached_binary() -> Path | None:
|
|
81
|
+
"""The extracted engine binary for the pinned version, if present."""
|
|
82
|
+
key = _platform_key()
|
|
83
|
+
_, rel = _PLATFORMS[key]
|
|
84
|
+
binary = cache_dir() / ARCHI_VERSION / rel
|
|
85
|
+
return binary if binary.exists() else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _download(url, dest):
|
|
89
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
# a plain urlopen keeps the dependency footprint at zero beyond stdlib
|
|
91
|
+
with urllib.request.urlopen(url) as response, open(dest, "wb") as out:
|
|
92
|
+
shutil.copyfileobj(response, out)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _expected_sha1(asset) -> str | None:
|
|
96
|
+
"""Fetch the release's SUMSSHA1 file and return the hash for ``asset``.
|
|
97
|
+
|
|
98
|
+
Returns None only when the checksum file can't be fetched (a transient
|
|
99
|
+
network failure shouldn't block install; the download itself is over HTTPS
|
|
100
|
+
from GitHub). If the file *is* fetched but lists no hash for ``asset``,
|
|
101
|
+
that is suspicious (renamed/tampered manifest) and raises rather than
|
|
102
|
+
silently skipping verification.
|
|
103
|
+
"""
|
|
104
|
+
url = f"{RELEASE_BASE}/Archi-{ARCHI_VERSION}-SUMSSHA1"
|
|
105
|
+
try:
|
|
106
|
+
with urllib.request.urlopen(url) as response:
|
|
107
|
+
text = response.read().decode("utf-8", "replace")
|
|
108
|
+
except OSError:
|
|
109
|
+
return None
|
|
110
|
+
for line in text.splitlines():
|
|
111
|
+
parts = line.split()
|
|
112
|
+
if len(parts) == 2 and parts[1].lstrip("*") == asset:
|
|
113
|
+
return parts[0].lower()
|
|
114
|
+
raise ModelError(
|
|
115
|
+
f"Checksumbestand bevat geen hash voor {asset}; verificatie "
|
|
116
|
+
"afgebroken.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _sha1(path) -> str:
|
|
120
|
+
digest = hashlib.sha1()
|
|
121
|
+
with open(path, "rb") as f:
|
|
122
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
123
|
+
digest.update(chunk)
|
|
124
|
+
return digest.hexdigest()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract(archive, key, target):
|
|
128
|
+
"""Unpack the downloaded archive into ``target``; format follows platform."""
|
|
129
|
+
if key == "linux":
|
|
130
|
+
with tarfile.open(archive, "r:gz") as tar:
|
|
131
|
+
_safe_extract_tar(tar, target)
|
|
132
|
+
elif key == "windows":
|
|
133
|
+
with zipfile.ZipFile(archive) as zf:
|
|
134
|
+
_safe_extract_zip(zf, target)
|
|
135
|
+
else: # macOS .dmg: mount, copy the .app out, unmount
|
|
136
|
+
_extract_dmg(archive, target)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _is_within(base, target) -> bool:
|
|
140
|
+
base = Path(base).resolve()
|
|
141
|
+
try:
|
|
142
|
+
Path(target).resolve().relative_to(base)
|
|
143
|
+
return True
|
|
144
|
+
except ValueError:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _safe_extract_tar(tar, target):
|
|
149
|
+
# filter="data" (Python 3.12+) refuses absolute paths, parent traversal,
|
|
150
|
+
# and — crucially — symlinks/hardlinks that point outside the tree, which a
|
|
151
|
+
# name-only check misses. The Archi tgz has 145 legit *relative in-tree*
|
|
152
|
+
# symlinks (bundled JRE license files), which this filter allows.
|
|
153
|
+
try:
|
|
154
|
+
tar.extractall(target, filter="data")
|
|
155
|
+
except tarfile.FilterError as exc:
|
|
156
|
+
raise ModelError(f"Onveilig pad in archief: {exc}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _safe_extract_zip(zf, target):
|
|
160
|
+
# zip has no symlink members, so a name check is sufficient here
|
|
161
|
+
for name in zf.namelist():
|
|
162
|
+
if not _is_within(target, Path(target) / name):
|
|
163
|
+
raise ModelError(f"Onveilig pad in archief: {name}")
|
|
164
|
+
zf.extractall(target)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_mount_point(hdiutil_stdout) -> str | None:
|
|
168
|
+
"""Extract the /Volumes mount point from `hdiutil attach` output.
|
|
169
|
+
|
|
170
|
+
Output is tab-separated with a dev node, a type and (for the data volume)
|
|
171
|
+
a mount point. A multi-partition image has extra lines without a mount
|
|
172
|
+
point, so we select the line whose last field is an actual /Volumes path
|
|
173
|
+
rather than blindly taking the last line.
|
|
174
|
+
"""
|
|
175
|
+
for line in reversed(hdiutil_stdout.splitlines()):
|
|
176
|
+
field = line.split("\t")[-1].strip()
|
|
177
|
+
if field.startswith("/Volumes/"):
|
|
178
|
+
return field
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _extract_dmg(archive, target):
|
|
183
|
+
"""Mount a .dmg, copy the Archi.app out, detach again. macOS only."""
|
|
184
|
+
mount = subprocess.run(
|
|
185
|
+
["hdiutil", "attach", "-nobrowse", "-readonly", str(archive)],
|
|
186
|
+
capture_output=True, text=True)
|
|
187
|
+
if mount.returncode != 0:
|
|
188
|
+
raise ModelError("Kon de Archi-.dmg niet mounten:\n" + mount.stderr)
|
|
189
|
+
mount_point = _parse_mount_point(mount.stdout)
|
|
190
|
+
if not mount_point:
|
|
191
|
+
raise ModelError(
|
|
192
|
+
"Kon het mountpunt van de Archi-.dmg niet bepalen:\n"
|
|
193
|
+
+ mount.stdout)
|
|
194
|
+
try:
|
|
195
|
+
source = Path(mount_point) / "Archi.app"
|
|
196
|
+
if not source.exists():
|
|
197
|
+
raise ModelError(
|
|
198
|
+
f"Archi.app niet gevonden in de gemounte image ({mount_point})")
|
|
199
|
+
Path(target).mkdir(parents=True, exist_ok=True)
|
|
200
|
+
shutil.copytree(source, Path(target) / "Archi.app")
|
|
201
|
+
finally:
|
|
202
|
+
detach = subprocess.run(["hdiutil", "detach", mount_point],
|
|
203
|
+
capture_output=True, text=True)
|
|
204
|
+
if detach.returncode != 0:
|
|
205
|
+
# a copytree can leave the volume briefly busy; force as a fallback
|
|
206
|
+
subprocess.run(["hdiutil", "detach", "-force", mount_point],
|
|
207
|
+
capture_output=True, text=True)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def find_or_none():
|
|
211
|
+
"""Any already-available Archi binary (installed, on PATH, or cached),
|
|
212
|
+
without triggering a download. Returns a path string or None."""
|
|
213
|
+
from .normalize import find_archi_binary
|
|
214
|
+
return find_archi_binary()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def download_engine(*, quiet=False) -> Path:
|
|
218
|
+
"""Download and extract the pinned Archi engine into the cache. Returns
|
|
219
|
+
the binary path. Raises ModelError on any failure.
|
|
220
|
+
|
|
221
|
+
Extraction happens in a staging directory and is only moved into place
|
|
222
|
+
atomically once the binary is present, so a crash mid-extract never leaves
|
|
223
|
+
a half-populated cache that later looks complete.
|
|
224
|
+
"""
|
|
225
|
+
key = _platform_key()
|
|
226
|
+
asset, rel = _PLATFORMS[key]
|
|
227
|
+
version_dir = cache_dir() / ARCHI_VERSION
|
|
228
|
+
binary = version_dir / rel
|
|
229
|
+
if binary.exists():
|
|
230
|
+
return binary
|
|
231
|
+
|
|
232
|
+
url = f"{RELEASE_BASE}/{asset}"
|
|
233
|
+
if not quiet:
|
|
234
|
+
print(f"Archi-engine ophalen ({asset}, ~165 MB, eenmalig) …")
|
|
235
|
+
cache_dir().mkdir(parents=True, exist_ok=True)
|
|
236
|
+
staging = version_dir.with_name(f"{ARCHI_VERSION}.staging")
|
|
237
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
238
|
+
archive = staging / "_download" / asset
|
|
239
|
+
try:
|
|
240
|
+
_download(url, archive)
|
|
241
|
+
expected = _expected_sha1(asset)
|
|
242
|
+
if expected is not None:
|
|
243
|
+
actual = _sha1(archive)
|
|
244
|
+
if actual != expected:
|
|
245
|
+
raise ModelError(
|
|
246
|
+
f"Checksum van {asset} klopt niet (verwacht {expected}, "
|
|
247
|
+
f"kreeg {actual}); download afgebroken.")
|
|
248
|
+
_extract(archive, key, staging)
|
|
249
|
+
shutil.rmtree(staging / "_download", ignore_errors=True)
|
|
250
|
+
staged_binary = staging / rel
|
|
251
|
+
if not staged_binary.exists():
|
|
252
|
+
raise ModelError(
|
|
253
|
+
"Archi-engine uitgepakt maar de binary staat niet op de "
|
|
254
|
+
f"verwachte plek: {staged_binary}")
|
|
255
|
+
if key != "windows":
|
|
256
|
+
staged_binary.chmod(0o755)
|
|
257
|
+
# atomic swap into the real cache location
|
|
258
|
+
shutil.rmtree(version_dir, ignore_errors=True)
|
|
259
|
+
os.replace(staging, version_dir)
|
|
260
|
+
except OSError as exc:
|
|
261
|
+
raise ModelError(
|
|
262
|
+
f"Kon de Archi-engine niet ophalen van {url}: {exc}")
|
|
263
|
+
finally:
|
|
264
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
265
|
+
|
|
266
|
+
return binary
|
archi_tool/model.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Load, query, mutate and save native Archi .archimate models.
|
|
2
|
+
|
|
3
|
+
The .archimate format is Eclipse EMF XML. Only the root element lives in the
|
|
4
|
+
archimate namespace; all other elements (folder, element, property, child,
|
|
5
|
+
bounds, sourceConnection, documentation) are unqualified. Concepts carry an
|
|
6
|
+
xsi:type attribute such as "archimate:Capability"; relationship types end in
|
|
7
|
+
"Relationship" (American spelling: RealizationRelationship). Folders per
|
|
8
|
+
ArchiMate layer are fixed by Archi and identified by their type attribute.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
from lxml import etree
|
|
15
|
+
|
|
16
|
+
ARCHIMATE_NS = "http://www.archimatetool.com/archimate"
|
|
17
|
+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
|
18
|
+
XSI_TYPE = "{%s}type" % XSI_NS
|
|
19
|
+
MODEL_TAG = "{%s}model" % ARCHIMATE_NS
|
|
20
|
+
|
|
21
|
+
DIAGRAM_TYPE = "ArchimateDiagramModel"
|
|
22
|
+
|
|
23
|
+
ELEMENT_LAYERS = {
|
|
24
|
+
"strategy": {"Resource", "Capability", "CourseOfAction", "ValueStream"},
|
|
25
|
+
"business": {
|
|
26
|
+
"BusinessActor", "BusinessRole", "BusinessCollaboration",
|
|
27
|
+
"BusinessInterface", "BusinessProcess", "BusinessFunction",
|
|
28
|
+
"BusinessInteraction", "BusinessEvent", "BusinessService",
|
|
29
|
+
"BusinessObject", "Contract", "Representation", "Product",
|
|
30
|
+
},
|
|
31
|
+
"application": {
|
|
32
|
+
"ApplicationComponent", "ApplicationCollaboration",
|
|
33
|
+
"ApplicationInterface", "ApplicationFunction", "ApplicationInteraction",
|
|
34
|
+
"ApplicationProcess", "ApplicationEvent", "ApplicationService",
|
|
35
|
+
"DataObject",
|
|
36
|
+
},
|
|
37
|
+
"technology": {
|
|
38
|
+
"Node", "Device", "SystemSoftware", "TechnologyCollaboration",
|
|
39
|
+
"TechnologyInterface", "Path", "CommunicationNetwork",
|
|
40
|
+
"TechnologyFunction", "TechnologyProcess", "TechnologyInteraction",
|
|
41
|
+
"TechnologyEvent", "TechnologyService", "Artifact", "Material",
|
|
42
|
+
"Equipment", "Facility", "DistributionNetwork",
|
|
43
|
+
},
|
|
44
|
+
"motivation": {
|
|
45
|
+
"Stakeholder", "Driver", "Assessment", "Goal", "Outcome",
|
|
46
|
+
"Principle", "Requirement", "Constraint", "Meaning", "Value",
|
|
47
|
+
},
|
|
48
|
+
"implementation_migration": {
|
|
49
|
+
"WorkPackage", "Deliverable", "ImplementationEvent", "Plateau", "Gap",
|
|
50
|
+
},
|
|
51
|
+
"other": {"Location", "Grouping", "Junction"},
|
|
52
|
+
}
|
|
53
|
+
FOLDER_BY_ELEMENT_TYPE = {
|
|
54
|
+
t: layer for layer, types in ELEMENT_LAYERS.items() for t in types
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
RELATIONSHIP_TYPES = {
|
|
58
|
+
"Composition", "Aggregation", "Assignment", "Realization", "Serving",
|
|
59
|
+
"Access", "Influence", "Triggering", "Flow", "Specialization",
|
|
60
|
+
"Association",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ModelError(Exception):
|
|
65
|
+
"""User-facing error; message is Dutch."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def new_id() -> str:
|
|
69
|
+
return "id-" + str(uuid.uuid4())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def xsi_type(node) -> str:
|
|
73
|
+
"""Return the bare xsi:type ("archimate:Capability" -> "Capability")."""
|
|
74
|
+
value = node.get(XSI_TYPE) or ""
|
|
75
|
+
return value.split(":", 1)[-1]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_relationship(node) -> bool:
|
|
79
|
+
return node.tag == "element" and xsi_type(node).endswith("Relationship")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def is_diagram(node) -> bool:
|
|
83
|
+
return node.tag == "element" and xsi_type(node) == DIAGRAM_TYPE
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def is_element(node) -> bool:
|
|
87
|
+
return (node.tag == "element"
|
|
88
|
+
and not is_relationship(node) and not is_diagram(node))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ArchiModel:
|
|
92
|
+
def __init__(self, path):
|
|
93
|
+
self.path = str(path)
|
|
94
|
+
self.tree = etree.parse(self.path)
|
|
95
|
+
self.root = self.tree.getroot()
|
|
96
|
+
if self.root.tag != MODEL_TAG:
|
|
97
|
+
raise ModelError(
|
|
98
|
+
f"Geen .archimate-model: rootelement is {self.root.tag}")
|
|
99
|
+
|
|
100
|
+
# --- queries ---------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def name(self) -> str:
|
|
104
|
+
return self.root.get("name") or ""
|
|
105
|
+
|
|
106
|
+
def id_index(self) -> dict:
|
|
107
|
+
return {n.get("id"): n for n in self.root.iter() if n.get("id")}
|
|
108
|
+
|
|
109
|
+
def folder(self, folder_type: str):
|
|
110
|
+
for f in self.root.iter("folder"):
|
|
111
|
+
if f.get("type") == folder_type:
|
|
112
|
+
return f
|
|
113
|
+
raise ModelError(f"Folder met type '{folder_type}' niet gevonden")
|
|
114
|
+
|
|
115
|
+
def elements(self) -> list:
|
|
116
|
+
return [n for n in self.root.iter("element") if is_element(n)]
|
|
117
|
+
|
|
118
|
+
def relationships(self) -> list:
|
|
119
|
+
return [n for n in self.root.iter("element") if is_relationship(n)]
|
|
120
|
+
|
|
121
|
+
def diagrams(self) -> list:
|
|
122
|
+
return [n for n in self.root.iter("element") if is_diagram(n)]
|
|
123
|
+
|
|
124
|
+
def properties(self, node) -> dict:
|
|
125
|
+
return {p.get("key"): p.get("value") for p in node.findall("property")}
|
|
126
|
+
|
|
127
|
+
def documentation(self, node) -> str:
|
|
128
|
+
doc = node.find("documentation")
|
|
129
|
+
return (doc.text or "") if doc is not None else ""
|
|
130
|
+
|
|
131
|
+
def resolve(self, ref: str):
|
|
132
|
+
"""Resolve a concept by id, or by exact name if that is unique."""
|
|
133
|
+
index = self.id_index()
|
|
134
|
+
if ref in index:
|
|
135
|
+
return index[ref]
|
|
136
|
+
matches = [n for n in self.root.iter("element") if n.get("name") == ref]
|
|
137
|
+
if len(matches) == 1:
|
|
138
|
+
return matches[0]
|
|
139
|
+
if not matches:
|
|
140
|
+
raise ModelError(f"Niet gevonden: '{ref}' (geen id en geen naam)")
|
|
141
|
+
ids = ", ".join(m.get("id") for m in matches)
|
|
142
|
+
raise ModelError(
|
|
143
|
+
f"Naam '{ref}' is niet uniek ({len(matches)}x); "
|
|
144
|
+
f"gebruik het id. Kandidaten: {ids}")
|
|
145
|
+
|
|
146
|
+
def relations_of(self, concept_id: str) -> list:
|
|
147
|
+
return [r for r in self.relationships()
|
|
148
|
+
if concept_id in (r.get("source"), r.get("target"))]
|
|
149
|
+
|
|
150
|
+
# --- mutations ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def set_model_name(self, name: str):
|
|
153
|
+
self.root.set("name", name)
|
|
154
|
+
|
|
155
|
+
def add_element(self, el_type: str, name: str, folder_type: str = None,
|
|
156
|
+
properties: dict = None, documentation: str = None):
|
|
157
|
+
if el_type not in FOLDER_BY_ELEMENT_TYPE:
|
|
158
|
+
known = ", ".join(sorted(FOLDER_BY_ELEMENT_TYPE))
|
|
159
|
+
raise ModelError(
|
|
160
|
+
f"Onbekend elementtype '{el_type}'. Toegestaan: {known}")
|
|
161
|
+
target = self.folder(folder_type or FOLDER_BY_ELEMENT_TYPE[el_type])
|
|
162
|
+
el = etree.SubElement(target, "element", {
|
|
163
|
+
XSI_TYPE: f"archimate:{el_type}", "name": name, "id": new_id()})
|
|
164
|
+
if documentation:
|
|
165
|
+
doc = etree.SubElement(el, "documentation")
|
|
166
|
+
doc.text = documentation
|
|
167
|
+
for key, value in (properties or {}).items():
|
|
168
|
+
etree.SubElement(el, "property", {"key": key, "value": value})
|
|
169
|
+
return el
|
|
170
|
+
|
|
171
|
+
def add_relation(self, rel_type: str, source: str, target: str,
|
|
172
|
+
name: str = None):
|
|
173
|
+
rel_type = rel_type.removesuffix("Relationship")
|
|
174
|
+
if rel_type not in RELATIONSHIP_TYPES:
|
|
175
|
+
known = ", ".join(sorted(RELATIONSHIP_TYPES))
|
|
176
|
+
raise ModelError(
|
|
177
|
+
f"Onbekend relatietype '{rel_type}'. Toegestaan: {known}")
|
|
178
|
+
src = self.resolve(source)
|
|
179
|
+
tgt = self.resolve(target)
|
|
180
|
+
# resolve() also matches views and relations by name; only elements
|
|
181
|
+
# are valid endpoints (ArchiMate allows a relation endpoint solely
|
|
182
|
+
# for Association)
|
|
183
|
+
for role, node in (("source", src), ("target", tgt)):
|
|
184
|
+
if is_diagram(node):
|
|
185
|
+
raise ModelError(
|
|
186
|
+
f"{role} '{node.get('name')}' is een view; relaties "
|
|
187
|
+
"kunnen geen views verbinden")
|
|
188
|
+
if is_relationship(node) and rel_type != "Association":
|
|
189
|
+
raise ModelError(
|
|
190
|
+
f"{role} '{node.get('name')}' is zelf een relatie; "
|
|
191
|
+
"alleen een AssociationRelationship mag een relatie "
|
|
192
|
+
"als eindpunt hebben")
|
|
193
|
+
attrs = {XSI_TYPE: f"archimate:{rel_type}Relationship",
|
|
194
|
+
"id": new_id(),
|
|
195
|
+
"source": src.get("id"), "target": tgt.get("id")}
|
|
196
|
+
if name:
|
|
197
|
+
attrs["name"] = name
|
|
198
|
+
return etree.SubElement(self.folder("relations"), "element", attrs)
|
|
199
|
+
|
|
200
|
+
def set_property(self, ref: str, key: str, value: str):
|
|
201
|
+
el = self.resolve(ref)
|
|
202
|
+
for p in el.findall("property"):
|
|
203
|
+
if p.get("key") == key:
|
|
204
|
+
p.set("value", value)
|
|
205
|
+
return
|
|
206
|
+
etree.SubElement(el, "property", {"key": key, "value": value})
|
|
207
|
+
|
|
208
|
+
def rename(self, ref: str, name: str):
|
|
209
|
+
self.resolve(ref).set("name", name)
|
|
210
|
+
|
|
211
|
+
def set_documentation(self, ref: str, text: str):
|
|
212
|
+
el = self.resolve(ref)
|
|
213
|
+
doc = el.find("documentation")
|
|
214
|
+
if doc is None:
|
|
215
|
+
doc = etree.Element("documentation")
|
|
216
|
+
el.insert(0, doc)
|
|
217
|
+
doc.text = text
|
|
218
|
+
|
|
219
|
+
def remove(self, ref: str, cascade: bool = False):
|
|
220
|
+
el = self.resolve(ref)
|
|
221
|
+
el_id = el.get("id")
|
|
222
|
+
if is_relationship(el):
|
|
223
|
+
connections = [c for c in self.root.iter("sourceConnection")
|
|
224
|
+
if c.get("archimateRelationship") == el_id]
|
|
225
|
+
if connections and not cascade:
|
|
226
|
+
raise ModelError(
|
|
227
|
+
f"Relatie {el_id} wordt gebruikt in {len(connections)} "
|
|
228
|
+
"view-verbinding(en); gebruik --cascade om die mee te "
|
|
229
|
+
"verwijderen")
|
|
230
|
+
for c in connections:
|
|
231
|
+
self._remove_connection(c)
|
|
232
|
+
else:
|
|
233
|
+
relations = self.relations_of(el_id)
|
|
234
|
+
diagram_objects = [d for d in self.root.iter("child")
|
|
235
|
+
if d.get("archimateElement") == el_id]
|
|
236
|
+
if (relations or diagram_objects) and not cascade:
|
|
237
|
+
raise ModelError(
|
|
238
|
+
f"Element {el_id} heeft {len(relations)} relatie(s) en "
|
|
239
|
+
f"{len(diagram_objects)} view-object(en); gebruik "
|
|
240
|
+
"--cascade om die mee te verwijderen")
|
|
241
|
+
for r in relations:
|
|
242
|
+
self.remove(r.get("id"), cascade=True)
|
|
243
|
+
for d in diagram_objects:
|
|
244
|
+
self._remove_diagram_object(d)
|
|
245
|
+
el.getparent().remove(el)
|
|
246
|
+
|
|
247
|
+
def _remove_connection(self, conn):
|
|
248
|
+
conn_id = conn.get("id")
|
|
249
|
+
for node in self.root.iter("child"):
|
|
250
|
+
listed = (node.get("targetConnections") or "").split()
|
|
251
|
+
if conn_id in listed:
|
|
252
|
+
remaining = [i for i in listed if i != conn_id]
|
|
253
|
+
if remaining:
|
|
254
|
+
node.set("targetConnections", " ".join(remaining))
|
|
255
|
+
else:
|
|
256
|
+
del node.attrib["targetConnections"]
|
|
257
|
+
conn.getparent().remove(conn)
|
|
258
|
+
|
|
259
|
+
def _remove_diagram_object(self, obj):
|
|
260
|
+
for sub in list(obj.findall("child")):
|
|
261
|
+
self._remove_diagram_object(sub)
|
|
262
|
+
for conn in list(obj.findall("sourceConnection")):
|
|
263
|
+
self._remove_connection(conn)
|
|
264
|
+
obj_id = obj.get("id")
|
|
265
|
+
incoming = [c for c in self.root.iter("sourceConnection")
|
|
266
|
+
if c.get("target") == obj_id]
|
|
267
|
+
for conn in incoming:
|
|
268
|
+
self._remove_connection(conn)
|
|
269
|
+
obj.getparent().remove(obj)
|
|
270
|
+
|
|
271
|
+
# --- persistence -------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def save(self, path=None):
|
|
274
|
+
etree.indent(self.root, space=" ")
|
|
275
|
+
body = etree.tostring(self.root, encoding="unicode")
|
|
276
|
+
# newline="\n" keeps LF on every platform (no CRLF churn on Windows)
|
|
277
|
+
with open(path or self.path, "w", encoding="utf-8", newline="\n") as f:
|
|
278
|
+
f.write('<?xml version="1.0" encoding="UTF-8"?>\n')
|
|
279
|
+
f.write(body)
|
|
280
|
+
f.write("\n")
|
archi_tool/normalize.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Normalize a .archimate file through the headless Archi CLI.
|
|
2
|
+
|
|
3
|
+
Archi is the canonical serializer: a load + save roundtrip rewrites the file
|
|
4
|
+
exactly as Archi itself would, so git diffs stay small regardless of whether
|
|
5
|
+
the last edit came from this tool or from the Archi GUI. A throwaway
|
|
6
|
+
workspace (-data) avoids lock conflicts with a running Archi GUI.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
|
|
15
|
+
from .model import ModelError
|
|
16
|
+
|
|
17
|
+
ARCHI_CANDIDATES = [
|
|
18
|
+
"/Applications/Archi.app/Contents/MacOS/Archi", # macOS
|
|
19
|
+
r"C:\Program Files\Archi\Archi.exe", # Windows (winget/inno, machine scope)
|
|
20
|
+
# Windows Inno installer without admin rights falls back to user scope;
|
|
21
|
+
# %LOCALAPPDATA% stays unexpanded (and never matches) on other platforms
|
|
22
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Archi\Archi.exe"),
|
|
23
|
+
"/opt/Archi/Archi", # Linux tgz
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_archi_binary():
|
|
28
|
+
"""Locate an already-available Archi binary. Returns None if none is
|
|
29
|
+
found; fetching a cached copy is handled separately by normalize()."""
|
|
30
|
+
override = os.environ.get("ARCHI_APP")
|
|
31
|
+
if override:
|
|
32
|
+
# an explicit override must not silently fall through to defaults
|
|
33
|
+
return override if os.path.exists(override) else None
|
|
34
|
+
for candidate in ARCHI_CANDIDATES:
|
|
35
|
+
if os.path.exists(candidate):
|
|
36
|
+
return candidate
|
|
37
|
+
# a previously downloaded engine in the per-user cache, before any PATH
|
|
38
|
+
# lookup, so a fetched engine always wins
|
|
39
|
+
from .engine import cached_binary
|
|
40
|
+
cached = cached_binary()
|
|
41
|
+
if cached:
|
|
42
|
+
return str(cached)
|
|
43
|
+
on_path = shutil.which("Archi")
|
|
44
|
+
# on case-insensitive filesystems (macOS, Windows) which("Archi") can match
|
|
45
|
+
# our own `archi` entry point; reject it so we never drive ourselves
|
|
46
|
+
if on_path and not _is_own_entry_point(on_path):
|
|
47
|
+
return on_path
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_own_entry_point(path) -> bool:
|
|
52
|
+
"""True when ``path`` is this package's own `archi` CLI rather than the
|
|
53
|
+
real Archi desktop app (a case-insensitive PATH collision)."""
|
|
54
|
+
ours = shutil.which("archi")
|
|
55
|
+
if ours and os.path.exists(ours) and os.path.exists(path):
|
|
56
|
+
return os.path.samefile(ours, path)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize(path, *, download=True) -> None:
|
|
61
|
+
binary = find_archi_binary()
|
|
62
|
+
if not binary and download:
|
|
63
|
+
# nothing installed and downloading is allowed: fetch the pinned
|
|
64
|
+
# engine into the per-user cache (once), then use it
|
|
65
|
+
from .engine import download_engine
|
|
66
|
+
binary = str(download_engine())
|
|
67
|
+
if not binary:
|
|
68
|
+
raise ModelError(
|
|
69
|
+
"Archi niet gevonden. Haal de engine op met `archi setup`, "
|
|
70
|
+
"installeer Archi zelf (macOS: brew install --cask archi; "
|
|
71
|
+
"Windows: winget install --id Archi.Archi -e), of zet de env var "
|
|
72
|
+
"ARCHI_APP naar het pad van de Archi-binary.")
|
|
73
|
+
absolute = os.path.abspath(path)
|
|
74
|
+
with tempfile.TemporaryDirectory() as workspace:
|
|
75
|
+
result = subprocess.run(
|
|
76
|
+
[binary, "-application", "com.archimatetool.commandline.app",
|
|
77
|
+
"-consoleLog", "-nosplash", "-data", workspace,
|
|
78
|
+
"--loadModel", absolute, "--saveModel", absolute],
|
|
79
|
+
capture_output=True, text=True)
|
|
80
|
+
if result.returncode != 0:
|
|
81
|
+
output = result.stdout + result.stderr
|
|
82
|
+
if "gtk_init_check" in output or "No more handles" in output:
|
|
83
|
+
# Archi's command-line app still needs an X display on Linux; on a
|
|
84
|
+
# headless host it must run under a virtual framebuffer
|
|
85
|
+
raise ModelError(
|
|
86
|
+
"Archi kon geen X-display openen (headless Linux). Draai "
|
|
87
|
+
"normalize onder een virtueel scherm, bijvoorbeeld:\n"
|
|
88
|
+
" xvfb-run -a archi normalize\n"
|
|
89
|
+
"(installeer xvfb, bv. `apt-get install xvfb`). Op een "
|
|
90
|
+
"desktop met display is dit niet nodig.")
|
|
91
|
+
raise ModelError("Archi-normalisatie mislukt:\n" + output)
|