archi-cli 0.2.0__tar.gz → 0.2.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: archi-cli
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: ar·cli·mate — de CLI in je ArchiMate: deterministisch native .archimate-modellen inspecteren, muteren, valideren en renderen
5
5
  Project-URL: Homepage, https://github.com/BureauArchitectuurDigitaleOverheid/ai-assisted-architecting
6
6
  Project-URL: Repository, https://github.com/BureauArchitectuurDigitaleOverheid/ai-assisted-architecting
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "archi-cli"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "ar·cli·mate — de CLI in je ArchiMate: deterministisch native .archimate-modellen inspecteren, muteren, valideren en renderen"
5
5
  readme = "README.md"
6
6
  license = "EUPL-1.2"
@@ -8,44 +8,43 @@ There is no smaller headless-only artifact; we download the full distribution
8
8
  once, cache it per user, and point ARCHI_APP at the extracted binary.
9
9
 
10
10
  The version is pinned so the download is deterministic and reproducible. Bump
11
- ARCHI_VERSION deliberately.
11
+ ARCHI_VERSION deliberately. archi.io only keeps the latest release online,
12
+ though, so once Archi publishes a newer version the pinned one disappears. In
13
+ that case we fall back to the latest release, with a warning, rather than
14
+ leaving `normalize` broken until someone bumps the pin (ADR 0011).
12
15
  """
13
16
  from __future__ import annotations
14
17
 
15
18
  import hashlib
19
+ import json
16
20
  import os
17
21
  import platform
18
22
  import shutil
19
23
  import subprocess
24
+ import sys
20
25
  import tarfile
26
+ import urllib.error
21
27
  import urllib.request
22
28
  import zipfile
23
29
  from pathlib import Path
24
30
 
25
31
  from .model import ModelError
26
32
 
27
- ARCHI_VERSION = "5.9.0"
28
- RELEASE_BASE = (
29
- "https://github.com/archimatetool/archi.io/releases/download"
30
- f"/{ARCHI_VERSION}")
33
+ ARCHI_VERSION = "5.10.0"
34
+ RELEASES = "https://github.com/archimatetool/archi.io/releases"
35
+ RELEASES_API = "https://api.github.com/repos/archimatetool/archi.io/releases"
31
36
 
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.
37
+ # per platform: (asset filename pattern, relative path to the binary inside
38
+ # the extracted tree). Archi bundles its own JRE, so no system Java is needed.
34
39
  _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"),
40
+ "linux": ("Archi-Linux64-{version}.tgz", "Archi/Archi"),
41
+ "windows": ("Archi-Win64-{version}.zip", "Archi/Archi.exe"),
41
42
  # macOS ships a .dmg; both Intel and Apple Silicon expose the same binary
42
43
  # 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"),
44
+ "darwin-arm64": ("Archi-Mac-Silicon-{version}.dmg",
45
+ "Archi.app/Contents/MacOS/Archi"),
46
+ "darwin-x86_64": ("Archi-Mac-{version}.dmg",
47
+ "Archi.app/Contents/MacOS/Archi"),
49
48
  }
50
49
 
51
50
 
@@ -77,12 +76,27 @@ def _platform_key() -> str:
77
76
  "Archi handmatig en zet ARCHI_APP.")
78
77
 
79
78
 
79
+ def _version_key(version: str) -> tuple:
80
+ return tuple(int(p) if p.isdigit() else 0 for p in version.split("."))
81
+
82
+
80
83
  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
84
+ """An extracted engine binary in the cache, if present: the pinned
85
+ version when available, otherwise the newest cached one (left behind by
86
+ a fallback download), so a fallback does not refetch on every run."""
87
+ _, rel = _PLATFORMS[_platform_key()]
88
+ root = cache_dir()
89
+ pinned = root / ARCHI_VERSION / rel
90
+ if pinned.exists():
91
+ return pinned
92
+ if not root.is_dir():
93
+ return None
94
+ versions = [d.name for d in root.iterdir()
95
+ if d.is_dir() and (d / rel).exists()
96
+ and not d.name.endswith(".staging")]
97
+ if not versions:
98
+ return None
99
+ return root / max(versions, key=_version_key) / rel
86
100
 
87
101
 
88
102
  def _download(url, dest):
@@ -92,22 +106,76 @@ def _download(url, dest):
92
106
  shutil.copyfileobj(response, out)
93
107
 
94
108
 
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"
109
+ def _fetch_text(url) -> str | None:
110
+ """GET a small text file; None when it does not exist (HTTP 404)."""
105
111
  try:
106
112
  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():
113
+ return response.read().decode("utf-8", "replace")
114
+ except urllib.error.HTTPError as exc:
115
+ if exc.code == 404:
116
+ return None
117
+ raise ModelError(f"Kon {url} niet ophalen: {exc}") from exc
118
+ except OSError as exc:
119
+ raise ModelError(f"Kon {url} niet ophalen: {exc}") from exc
120
+
121
+
122
+ def _tag_candidates(version: str) -> list[str]:
123
+ """Release tags archi.io has used for a version: "5.9.0" for older
124
+ releases, "5.10_0" (last dot as underscore) since 5.10."""
125
+ head, _, last = version.rpartition(".")
126
+ return [version, f"{head}_{last}"] if head else [version]
127
+
128
+
129
+ def _latest_tag() -> str:
130
+ """Tag of the latest release, read from the /releases/latest redirect
131
+ (no API call, so no rate limit)."""
132
+ try:
133
+ with urllib.request.urlopen(f"{RELEASES}/latest") as response:
134
+ final_url = response.geturl()
135
+ except OSError as exc:
136
+ raise ModelError(
137
+ f"Kon de nieuwste Archi-release niet bepalen: {exc}") from exc
138
+ tag = final_url.rstrip("/").rsplit("/", 1)[-1]
139
+ if "/releases/tag/" not in final_url or not tag:
140
+ raise ModelError(
141
+ f"Kon de nieuwste Archi-release niet bepalen uit {final_url}")
142
+ return tag
143
+
144
+
145
+ def _checksums_url(tag: str, version: str) -> str:
146
+ return f"{RELEASES}/download/{tag}/Archi-{version}-SUMSSHA1"
147
+
148
+
149
+ def resolve_release(*, quiet=False) -> tuple[str, str, str]:
150
+ """(version, tag, SUMSSHA1 text) of the release to download.
151
+
152
+ The pinned version when it is still online, else the latest release. The
153
+ checksum file doubles as the existence probe, and fetching it first means
154
+ every download is verified.
155
+ """
156
+ for tag in _tag_candidates(ARCHI_VERSION):
157
+ sums = _fetch_text(_checksums_url(tag, ARCHI_VERSION))
158
+ if sums is not None:
159
+ return ARCHI_VERSION, tag, sums
160
+ tag = _latest_tag()
161
+ version = tag.replace("_", ".")
162
+ sums = _fetch_text(_checksums_url(tag, version))
163
+ if sums is None:
164
+ raise ModelError(
165
+ f"Archi {ARCHI_VERSION} staat niet meer online en de nieuwste "
166
+ f"release ({tag}) heeft geen checksumbestand. Installeer Archi "
167
+ "handmatig en zet ARCHI_APP.")
168
+ if not quiet:
169
+ print(f"WAARSCHUWING: Archi {ARCHI_VERSION} staat niet meer online; "
170
+ f"de nieuwste versie {version} wordt gebruikt.", file=sys.stderr)
171
+ return version, tag, sums
172
+
173
+
174
+ def _expected_sha1(asset: str, sums: str) -> str:
175
+ """The hash for ``asset`` from a SUMSSHA1 text. A checksum file that does
176
+ not list the asset is suspicious (renamed or tampered manifest), so that
177
+ raises rather than skipping verification."""
178
+ for line in sums.splitlines():
111
179
  parts = line.split()
112
180
  if len(parts) == 2 and parts[1].lstrip("*") == asset:
113
181
  return parts[0].lower()
@@ -116,14 +184,63 @@ def _expected_sha1(asset) -> str | None:
116
184
  "afgebroken.")
117
185
 
118
186
 
119
- def _sha1(path) -> str:
120
- digest = hashlib.sha1()
187
+ def _hash(path, algorithm: str) -> str:
188
+ digest = hashlib.new(algorithm)
121
189
  with open(path, "rb") as f:
122
190
  for chunk in iter(lambda: f.read(1 << 20), b""):
123
191
  digest.update(chunk)
124
192
  return digest.hexdigest()
125
193
 
126
194
 
195
+ def _sha1(path) -> str:
196
+ return _hash(path, "sha1")
197
+
198
+
199
+ def _github_sha256(tag: str, asset: str) -> str | None:
200
+ """The SHA-256 that GitHub computed for ``asset`` when it was uploaded,
201
+ from the releases API; None when the API is unreachable or rate limited.
202
+ A token in GITHUB_TOKEN or GH_TOKEN (set in CI) raises the rate limit."""
203
+ request = urllib.request.Request(
204
+ f"{RELEASES_API}/tags/{tag}",
205
+ headers={"Accept": "application/vnd.github+json"})
206
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
207
+ if token:
208
+ request.add_header("Authorization", f"Bearer {token}")
209
+ try:
210
+ with urllib.request.urlopen(request) as response:
211
+ release = json.load(response)
212
+ except (OSError, ValueError):
213
+ return None
214
+ for item in release.get("assets", []):
215
+ digest = item.get("digest") or ""
216
+ if item.get("name") == asset and digest.startswith("sha256:"):
217
+ return digest.removeprefix("sha256:").lower()
218
+ return None
219
+
220
+
221
+ def _verify(archive, asset: str, expected_sha1: str, tag: str,
222
+ *, quiet=False):
223
+ """Check the download against archi.io's SUMSSHA1 list. That list has
224
+ been wrong before (5.10.0 lists a stale hash for the Windows zip), so a
225
+ mismatch is checked against the digest GitHub itself recorded at upload
226
+ before it fails: a file that matches that is byte-identical to the
227
+ published asset."""
228
+ actual = _sha1(archive)
229
+ if actual == expected_sha1:
230
+ return
231
+ github = _github_sha256(tag, asset)
232
+ if github is not None and _hash(archive, "sha256") == github:
233
+ if not quiet:
234
+ print(f"WAARSCHUWING: de checksumlijst van Archi noemt voor "
235
+ f"{asset} een verouderde hash; de download is geverifieerd "
236
+ "tegen de SHA-256 die GitHub bij de upload vastlegde.",
237
+ file=sys.stderr)
238
+ return
239
+ raise ModelError(
240
+ f"Checksum van {asset} klopt niet (verwacht {expected_sha1}, "
241
+ f"kreeg {actual}); download afgebroken.")
242
+
243
+
127
244
  def _extract(archive, key, target):
128
245
  """Unpack the downloaded archive into ``target``; format follows platform."""
129
246
  if key == "linux":
@@ -215,36 +332,34 @@ def find_or_none():
215
332
 
216
333
 
217
334
  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.
335
+ """Download and extract the Archi engine into the cache. Returns the
336
+ binary path. Raises ModelError on any failure.
220
337
 
221
338
  Extraction happens in a staging directory and is only moved into place
222
339
  atomically once the binary is present, so a crash mid-extract never leaves
223
340
  a half-populated cache that later looks complete.
224
341
  """
342
+ cached = cached_binary()
343
+ if cached is not None:
344
+ return cached
225
345
  key = _platform_key()
226
- asset, rel = _PLATFORMS[key]
227
- version_dir = cache_dir() / ARCHI_VERSION
346
+ pattern, rel = _PLATFORMS[key]
347
+ version, tag, sums = resolve_release(quiet=quiet)
348
+ asset = pattern.format(version=version)
349
+ expected = _expected_sha1(asset, sums)
350
+ version_dir = cache_dir() / version
228
351
  binary = version_dir / rel
229
- if binary.exists():
230
- return binary
231
352
 
232
- url = f"{RELEASE_BASE}/{asset}"
353
+ url = f"{RELEASES}/download/{tag}/{asset}"
233
354
  if not quiet:
234
355
  print(f"Archi-engine ophalen ({asset}, ~165 MB, eenmalig) …")
235
356
  cache_dir().mkdir(parents=True, exist_ok=True)
236
- staging = version_dir.with_name(f"{ARCHI_VERSION}.staging")
357
+ staging = version_dir.with_name(f"{version}.staging")
237
358
  shutil.rmtree(staging, ignore_errors=True)
238
359
  archive = staging / "_download" / asset
239
360
  try:
240
361
  _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.")
362
+ _verify(archive, asset, expected, tag, quiet=quiet)
248
363
  _extract(archive, key, staging)
249
364
  shutil.rmtree(staging / "_download", ignore_errors=True)
250
365
  staged_binary = staging / rel
File without changes
File without changes
File without changes
File without changes