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/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")
@@ -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)