lange-python 0.3.11__tar.gz → 0.3.13__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.
Files changed (26) hide show
  1. {lange_python-0.3.11 → lange_python-0.3.13}/PKG-INFO +13 -8
  2. {lange_python-0.3.11 → lange_python-0.3.13}/README.md +12 -7
  3. {lange_python-0.3.11 → lange_python-0.3.13}/lange/distribution/_client.py +132 -17
  4. {lange_python-0.3.11 → lange_python-0.3.13}/lange/distribution/_update_macos.py +41 -11
  5. {lange_python-0.3.11 → lange_python-0.3.13}/lange/distribution/_util.py +58 -8
  6. {lange_python-0.3.11 → lange_python-0.3.13}/pyproject.toml +1 -1
  7. {lange_python-0.3.11 → lange_python-0.3.13}/lange/__init__.py +0 -0
  8. {lange_python-0.3.11 → lange_python-0.3.13}/lange/__main__.py +0 -0
  9. {lange_python-0.3.11 → lange_python-0.3.13}/lange/_util/__init__.py +0 -0
  10. {lange_python-0.3.11 → lange_python-0.3.13}/lange/_util/_base_client.py +0 -0
  11. {lange_python-0.3.11 → lange_python-0.3.13}/lange/_util/_key_handling.py +0 -0
  12. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/__init__.py +0 -0
  13. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/__init__.py +0 -0
  14. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/_command.py +0 -0
  15. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/_discovery.py +0 -0
  16. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/_docker.py +0 -0
  17. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/_poetry.py +0 -0
  18. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/build/_types.py +0 -0
  19. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/code/__init__.py +0 -0
  20. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/code/_stats.py +0 -0
  21. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/distribution/__init__.py +0 -0
  22. {lange_python-0.3.11 → lange_python-0.3.13}/lange/cli/distribution/_command.py +0 -0
  23. {lange_python-0.3.11 → lange_python-0.3.13}/lange/distribution/__init__.py +0 -0
  24. {lange_python-0.3.11 → lange_python-0.3.13}/lange/tunnel/__init__.py +0 -0
  25. {lange_python-0.3.11 → lange_python-0.3.13}/lange/tunnel/_client.py +0 -0
  26. {lange_python-0.3.11 → lange_python-0.3.13}/lange/tunnel/_util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lange-python
3
- Version: 0.3.11
3
+ Version: 0.3.13
4
4
  Summary: A bundeld set of tools, clients for the lange-suite of tools and more.
5
5
  Author: contact@robertlange.me
6
6
  Requires-Python: >=3.10
@@ -42,13 +42,17 @@ from lange.distribution import DistributionClient
42
42
 
43
43
  client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
44
44
 
45
- update_metadata = client.update(
46
- current_version="1.2.3",
47
- installed_app_path=Path("/Applications/Desktop App.app"),
48
- )
49
-
50
- print(update_metadata["version"])
51
- # The caller should now shut down so the detached helper can replace the app bundle.
45
+ latest = client.check_for_update("1.2.3")
46
+ if latest:
47
+ version, artifact_path = client.download(current_version="1.2.3")
48
+ print(version, artifact_path)
49
+ update_metadata = client.update(
50
+ current_version="1.2.3",
51
+ installed_app_path=Path("/Applications/Desktop App.app"),
52
+ relaunch=True,
53
+ )
54
+ print(update_metadata["version"])
55
+ # The caller should now shut down so the detached helper can replace and relaunch the app.
52
56
  ```
53
57
 
54
58
  The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
@@ -58,6 +62,7 @@ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-k
58
62
 
59
63
  latest_version = client.search_for_update("1.2.3")
60
64
  print(client.status) # connected
65
+ print(client.update_state["can_download"])
61
66
 
62
67
  client.reload() # repeats the last update check
63
68
  client.reload(api_key=None) # clears authentication and marks the client unauthenticated
@@ -24,13 +24,17 @@ from lange.distribution import DistributionClient
24
24
 
25
25
  client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
26
26
 
27
- update_metadata = client.update(
28
- current_version="1.2.3",
29
- installed_app_path=Path("/Applications/Desktop App.app"),
30
- )
31
-
32
- print(update_metadata["version"])
33
- # The caller should now shut down so the detached helper can replace the app bundle.
27
+ latest = client.check_for_update("1.2.3")
28
+ if latest:
29
+ version, artifact_path = client.download(current_version="1.2.3")
30
+ print(version, artifact_path)
31
+ update_metadata = client.update(
32
+ current_version="1.2.3",
33
+ installed_app_path=Path("/Applications/Desktop App.app"),
34
+ relaunch=True,
35
+ )
36
+ print(update_metadata["version"])
37
+ # The caller should now shut down so the detached helper can replace and relaunch the app.
34
38
  ```
35
39
 
36
40
  The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
@@ -40,6 +44,7 @@ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-k
40
44
 
41
45
  latest_version = client.search_for_update("1.2.3")
42
46
  print(client.status) # connected
47
+ print(client.update_state["can_download"])
43
48
 
44
49
  client.reload() # repeats the last update check
45
50
  client.reload(api_key=None) # clears authentication and marks the client unauthenticated
@@ -1,13 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import os
4
5
  import mimetypes
6
+ import shutil
5
7
  from pathlib import Path
6
8
  from typing import Any
7
9
 
8
10
  import httpx
9
11
 
10
- from ._update_macos import extract_macos_app_from_zip, spawn_macos_update_process
12
+ from ._update_macos import (
13
+ extract_macos_app_from_zip,
14
+ read_macos_bundle_identifier,
15
+ spawn_macos_update_process,
16
+ )
11
17
  from ._util import (
12
18
  _compare_versions,
13
19
  detect_distribution_os,
@@ -46,6 +52,9 @@ class DistributionClient(BaseLangeLabsClient):
46
52
  self.timeout = timeout
47
53
  self._last_checked_version: str | None = None
48
54
  self._last_update_result: str | None = None
55
+ self._last_downloaded_version: str | None = None
56
+ self._last_downloaded_artifact_path: str | None = None
57
+ self._last_error: str | None = None
49
58
  normalized_distribution_name = distribution_name.strip()
50
59
 
51
60
  if not normalized_distribution_name:
@@ -55,6 +64,7 @@ class DistributionClient(BaseLangeLabsClient):
55
64
  raise ValueError("check your distribution name again. spaces are not allowed by design")
56
65
 
57
66
  self.distribution_name = normalized_distribution_name
67
+ self._refresh_capabilities()
58
68
 
59
69
  @property
60
70
  def status(self) -> str:
@@ -74,6 +84,22 @@ class DistributionClient(BaseLangeLabsClient):
74
84
  """
75
85
  return self._last_checked_version
76
86
 
87
+ @property
88
+ def update_state(self) -> dict[str, Any]:
89
+ can_check = self.api_key is not None
90
+ can_download = self._last_update_result is not None and can_check
91
+ return {
92
+ "status": self._status,
93
+ "last_checked_version": self._last_checked_version,
94
+ "available_version": self._last_update_result,
95
+ "downloaded_version": self._last_downloaded_version,
96
+ "downloaded_artifact_path": self._last_downloaded_artifact_path,
97
+ "can_check_for_update": can_check,
98
+ "can_download": can_download,
99
+ "can_update": can_download or self._last_downloaded_artifact_path is not None,
100
+ "last_error": self._last_error,
101
+ }
102
+
77
103
  def reload(self, api_key: str | None | object = _UNSET_API_KEY) -> None:
78
104
  """
79
105
  Reload authentication and repeat the last update check when available.
@@ -82,18 +108,24 @@ class DistributionClient(BaseLangeLabsClient):
82
108
  :returns: ``None``.
83
109
  """
84
110
  super().reload(api_key=api_key)
111
+ self._last_error = None
85
112
 
86
113
  if self.api_key is None:
87
114
  self._last_update_result = None
115
+ self._refresh_capabilities()
88
116
  return
89
117
 
90
118
  if self._last_checked_version is None:
91
119
  self._set_status("off")
120
+ self._refresh_capabilities()
92
121
  return
93
122
 
94
- self.search_for_update(self._last_checked_version)
123
+ self.check_for_update(self._last_checked_version)
95
124
 
96
125
  def search_for_update(self, current_version: str) -> str | None:
126
+ return self.check_for_update(current_version)
127
+
128
+ def check_for_update(self, current_version: str) -> str | None:
97
129
  """
98
130
  Resolve the newest available version for the current OS when it is newer.
99
131
 
@@ -112,6 +144,8 @@ class DistributionClient(BaseLangeLabsClient):
112
144
 
113
145
  if self.api_key is None:
114
146
  self._set_status("unauthenticated")
147
+ self._last_error = "API key is required for this operation."
148
+ self._refresh_capabilities()
115
149
  raise ValueError("API key is required for this operation.")
116
150
 
117
151
  self._set_status("pending")
@@ -141,13 +175,17 @@ class DistributionClient(BaseLangeLabsClient):
141
175
  newest_version = version
142
176
 
143
177
  self._last_update_result = newest_version
178
+ self._last_error = None
144
179
  self._set_status("connected")
180
+ self._refresh_capabilities()
145
181
  return newest_version
146
- except Exception:
182
+ except Exception as error:
147
183
  if self.api_key is None:
148
184
  self._set_status("unauthenticated")
149
185
  else:
150
186
  self._set_status("failed")
187
+ self._last_error = str(error)
188
+ self._refresh_capabilities()
151
189
  raise
152
190
 
153
191
  def _build_distribution_url(self) -> str:
@@ -209,22 +247,42 @@ class DistributionClient(BaseLangeLabsClient):
209
247
  return newest_match
210
248
 
211
249
  def download_to_temp(self) -> Path:
250
+ version, artifact_path = self.download()
251
+ _ = version
252
+ return artifact_path
253
+
254
+ def download(self, current_version: str | None = None, target_version: str | None = None) -> tuple[str, Path]:
212
255
  """
213
- Download the newest version artifact for the current OS into ``/tmp``.
256
+ Download one update artifact for the current OS into ``/tmp``.
214
257
 
215
- :returns: Absolute path to the downloaded artifact.
258
+ :param current_version: Optional current installed version used to discover newer versions.
259
+ :param target_version: Optional explicit version to download.
260
+ :returns: Tuple of resolved version and downloaded artifact path.
216
261
  :raises ValueError: If no downloadable artifact exists for the current OS.
217
262
  :raises httpx.HTTPError: If the metadata or artifact download fails.
218
263
  """
219
264
  current_os = detect_distribution_os()
220
- newest_match = self._get_newest_version_artifact(current_os)
221
-
222
- if newest_match is None:
265
+ resolved_version: str | None = None
266
+
267
+ if target_version is not None:
268
+ resolved_version = target_version.strip()
269
+ elif current_version is not None:
270
+ resolved_version = self.check_for_update(current_version)
271
+ else:
272
+ newest_match = self._get_newest_version_artifact(current_os)
273
+ if newest_match is not None:
274
+ resolved_version = newest_match[0]
275
+
276
+ if not resolved_version:
223
277
  raise ValueError(f"No downloadable versions available for operating system: {current_os}.")
224
278
 
225
- version, _artifact = newest_match
226
-
227
- return self._download_version_artifact_to_temp(version=version, os_name=current_os)
279
+ artifact_path = self._download_version_artifact_to_temp(version=resolved_version, os_name=current_os)
280
+ self._last_downloaded_version = resolved_version
281
+ self._last_downloaded_artifact_path = str(artifact_path)
282
+ self._last_error = None
283
+ self._refresh_capabilities()
284
+ self._cleanup_old_temp_versions()
285
+ return resolved_version, artifact_path
228
286
 
229
287
  def update(
230
288
  self,
@@ -234,6 +292,8 @@ class DistributionClient(BaseLangeLabsClient):
234
292
  target_version: str | None = None,
235
293
  current_process_id: int | None = None,
236
294
  wait_for_exit_timeout: float = 30.0,
295
+ relaunch: bool = True,
296
+ strict_bundle_identifier: bool = True,
237
297
  ) -> dict[str, str]:
238
298
  """
239
299
  Download and stage a macOS update, then spawn a detached helper to apply it.
@@ -256,7 +316,7 @@ class DistributionClient(BaseLangeLabsClient):
256
316
  raise ValueError("current_version is required.")
257
317
 
258
318
  normalized_installed_app_path = validate_installed_macos_app_path(installed_app_path)
259
- resolved_target_version = target_version.strip() if target_version is not None else self.search_for_update(
319
+ resolved_target_version = target_version.strip() if target_version is not None else self.check_for_update(
260
320
  normalized_current_version
261
321
  )
262
322
 
@@ -267,16 +327,37 @@ class DistributionClient(BaseLangeLabsClient):
267
327
  if artifact is None:
268
328
  raise ValueError(f"No macos artifact is available for version {resolved_target_version}.")
269
329
 
270
- artifact_path = self._download_version_artifact_to_temp(version=resolved_target_version, os_name="macos")
330
+ artifact_path = self._download_version_artifact_to_temp(
331
+ version=resolved_target_version,
332
+ os_name="macos",
333
+ artifact=artifact,
334
+ )
271
335
  extracted_app_path = extract_macos_app_from_zip(artifact_path)
336
+ if strict_bundle_identifier:
337
+ installed_bundle_identifier = read_macos_bundle_identifier(normalized_installed_app_path)
338
+ extracted_bundle_identifier = read_macos_bundle_identifier(extracted_app_path)
339
+ if (
340
+ installed_bundle_identifier is not None
341
+ and extracted_bundle_identifier is not None
342
+ and installed_bundle_identifier != extracted_bundle_identifier
343
+ ):
344
+ raise ValueError(
345
+ "Downloaded app bundle identifier does not match installed application bundle identifier."
346
+ )
272
347
  helper_process = spawn_macos_update_process(
273
348
  source_app_path=extracted_app_path,
274
349
  installed_app_path=normalized_installed_app_path,
275
350
  current_process_id=os.getpid() if current_process_id is None else current_process_id,
276
351
  workspace_path=artifact_path.parent,
277
352
  wait_for_exit_timeout=wait_for_exit_timeout,
353
+ relaunch=relaunch,
278
354
  )
279
355
  process_id = helper_process.get("process_id", helper_process.get("pid", ""))
356
+ self._last_downloaded_version = resolved_target_version
357
+ self._last_downloaded_artifact_path = str(artifact_path)
358
+ self._last_error = None
359
+ self._refresh_capabilities()
360
+ self._cleanup_old_temp_versions()
280
361
 
281
362
  return {
282
363
  "version": resolved_target_version,
@@ -288,7 +369,12 @@ class DistributionClient(BaseLangeLabsClient):
288
369
  "process_id": str(process_id),
289
370
  }
290
371
 
291
- def _download_version_artifact_to_temp(self, version: str, os_name: str) -> Path:
372
+ def _download_version_artifact_to_temp(
373
+ self,
374
+ version: str,
375
+ os_name: str,
376
+ artifact: dict[str, Any] | None = None,
377
+ ) -> Path:
292
378
  """
293
379
  Download one explicit version artifact for one operating system into ``/tmp``.
294
380
 
@@ -298,12 +384,12 @@ class DistributionClient(BaseLangeLabsClient):
298
384
  :raises ValueError: If the artifact metadata is incomplete or missing.
299
385
  :raises httpx.HTTPError: If the metadata or artifact download fails.
300
386
  """
301
- artifact = self._get_version_artifact(version=version, os_name=os_name)
387
+ resolved_artifact = artifact if artifact is not None else self._get_version_artifact(version=version, os_name=os_name)
302
388
 
303
- if artifact is None:
389
+ if resolved_artifact is None:
304
390
  raise ValueError(f"No {os_name} artifact is available for version {version}.")
305
391
 
306
- filename = str(artifact.get("filename", "")).strip()
392
+ filename = str(resolved_artifact.get("filename", "")).strip()
307
393
 
308
394
  if not filename:
309
395
  raise ValueError("Artifact filename is required.")
@@ -321,11 +407,40 @@ class DistributionClient(BaseLangeLabsClient):
321
407
  )
322
408
  response.raise_for_status()
323
409
  destination_path.write_bytes(response.content)
410
+ self._verify_downloaded_artifact(destination_path, resolved_artifact)
324
411
  finally:
325
412
  client.close()
326
413
 
327
414
  return destination_path
328
415
 
416
+ def _verify_downloaded_artifact(self, artifact_path: Path, artifact_payload: dict[str, Any]) -> None:
417
+ checksum = str(
418
+ artifact_payload.get("sha256")
419
+ or artifact_payload.get("checksumSha256")
420
+ or artifact_payload.get("checksum")
421
+ or ""
422
+ ).strip().lower()
423
+ if not checksum:
424
+ raise ValueError("Artifact checksum metadata is required for secure updates.")
425
+
426
+ digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
427
+ if digest.lower() != checksum:
428
+ raise ValueError("Downloaded artifact checksum validation failed.")
429
+
430
+ def _cleanup_old_temp_versions(self, retain_count: int = 3) -> None:
431
+ versions_root = Path("/tmp") / self.distribution_name / "versions"
432
+ if not versions_root.is_dir():
433
+ return
434
+
435
+ version_dirs = [path for path in versions_root.iterdir() if path.is_dir()]
436
+ version_dirs.sort(key=lambda directory: directory.stat().st_mtime, reverse=True)
437
+ for stale_path in version_dirs[retain_count:]:
438
+ shutil.rmtree(stale_path, ignore_errors=True)
439
+
440
+ def _refresh_capabilities(self) -> None:
441
+ # The update_state property is computed from current fields; this method keeps callers consistent.
442
+ return None
443
+
329
444
  def _get_version_artifact(self, version: str, os_name: str) -> dict[str, Any] | None:
330
445
  """
331
446
  Resolve the downloadable artifact payload for one version and operating system.
@@ -6,6 +6,7 @@ import shutil
6
6
  import stat
7
7
  import subprocess
8
8
  import zipfile
9
+ import plistlib
9
10
  from pathlib import Path
10
11
  from shlex import quote
11
12
 
@@ -29,18 +30,15 @@ def extract_macos_app_from_zip(archive_path: str | Path) -> Path:
29
30
  destination_path.mkdir(parents=True, exist_ok=True)
30
31
 
31
32
  with zipfile.ZipFile(normalized_archive_path) as archive:
33
+ app_bundle_roots = _collect_zip_app_bundle_roots(archive)
34
+ if len(app_bundle_roots) != 1:
35
+ raise ValueError("macOS update archives must contain exactly one .app bundle.")
32
36
  archive.extractall(destination_path)
33
37
 
34
- app_bundles = [
35
- child.resolve()
36
- for child in destination_path.iterdir()
37
- if child.is_dir() and child.suffix == ".app"
38
- ]
39
-
40
- if len(app_bundles) != 1:
41
- raise ValueError("macOS update archives must contain exactly one top-level .app bundle.")
42
-
43
- return app_bundles[0]
38
+ extracted_path = (destination_path / app_bundle_roots[0]).resolve()
39
+ if not extracted_path.is_dir() or extracted_path.suffix != ".app":
40
+ raise ValueError("macOS update archive extraction did not produce a valid .app bundle.")
41
+ return extracted_path
44
42
 
45
43
 
46
44
  def spawn_macos_update_process(
@@ -50,6 +48,7 @@ def spawn_macos_update_process(
50
48
  current_process_id: int,
51
49
  workspace_path: str | Path,
52
50
  wait_for_exit_timeout: float,
51
+ relaunch: bool = True,
53
52
  ) -> dict[str, str]:
54
53
  """
55
54
  Generate and launch the detached macOS helper process that applies the update.
@@ -77,6 +76,7 @@ def spawn_macos_update_process(
77
76
  backup_app_path=backup_app_path,
78
77
  current_process_id=current_process_id,
79
78
  wait_for_exit_timeout=wait_for_exit_timeout,
79
+ relaunch=relaunch,
80
80
  )
81
81
 
82
82
  script_path.write_text(script_contents, encoding="utf-8")
@@ -109,6 +109,7 @@ def _build_macos_update_script(
109
109
  backup_app_path: Path,
110
110
  current_process_id: int,
111
111
  wait_for_exit_timeout: float,
112
+ relaunch: bool,
112
113
  ) -> str:
113
114
  """
114
115
  Build the shell script that applies the macOS app replacement with rollback.
@@ -122,7 +123,7 @@ def _build_macos_update_script(
122
123
  """
123
124
  timeout_seconds = max(int(wait_for_exit_timeout), 0)
124
125
 
125
- return f"""#!/bin/bash
126
+ script = f"""#!/bin/bash
126
127
  set -euo pipefail
127
128
 
128
129
  SOURCE_APP={quote(str(source_app_path))}
@@ -159,3 +160,32 @@ fi
159
160
  /usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
160
161
  rm -rf "$BACKUP_APP"
161
162
  """
163
+ if relaunch:
164
+ script += '\n/usr/bin/open "$TARGET_APP"\n'
165
+ return script
166
+
167
+
168
+ def _collect_zip_app_bundle_roots(archive: zipfile.ZipFile) -> list[Path]:
169
+ app_bundle_roots: set[Path] = set()
170
+ for member_name in archive.namelist():
171
+ parts = Path(member_name).parts
172
+ for index, part in enumerate(parts):
173
+ if part.endswith(".app"):
174
+ app_bundle_roots.add(Path(*parts[: index + 1]))
175
+ break
176
+
177
+ return sorted(app_bundle_roots, key=lambda bundle_path: len(bundle_path.parts))
178
+
179
+
180
+ def read_macos_bundle_identifier(app_path: str | Path) -> str | None:
181
+ info_plist_path = Path(app_path).expanduser().resolve() / "Contents" / "Info.plist"
182
+ if not info_plist_path.is_file():
183
+ return None
184
+
185
+ with info_plist_path.open("rb") as info_plist_file:
186
+ payload = plistlib.load(info_plist_file)
187
+
188
+ bundle_identifier = payload.get("CFBundleIdentifier")
189
+ if not isinstance(bundle_identifier, str):
190
+ return None
191
+ return bundle_identifier.strip() or None
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import platform
5
+ import re
4
6
  from pathlib import Path
5
7
 
6
8
 
@@ -52,12 +54,12 @@ def _compare_versions(left: str, right: str) -> int:
52
54
  :param right: Right-hand version string.
53
55
  :returns: Positive when ``left`` is newer, negative when older, otherwise ``0``.
54
56
  """
55
- left_parts = _normalize_version_parts(left)
56
- right_parts = _normalize_version_parts(right)
57
- max_length = max(len(left_parts), len(right_parts))
57
+ left_core, left_pre = _normalize_version_parts(left)
58
+ right_core, right_pre = _normalize_version_parts(right)
59
+ max_length = max(len(left_core), len(right_core))
58
60
 
59
- padded_left = left_parts + (0,) * (max_length - len(left_parts))
60
- padded_right = right_parts + (0,) * (max_length - len(right_parts))
61
+ padded_left = left_core + (0,) * (max_length - len(left_core))
62
+ padded_right = right_core + (0,) * (max_length - len(right_core))
61
63
 
62
64
  if padded_left > padded_right:
63
65
  return 1
@@ -65,10 +67,41 @@ def _compare_versions(left: str, right: str) -> int:
65
67
  if padded_left < padded_right:
66
68
  return -1
67
69
 
70
+ if left_pre is None and right_pre is None:
71
+ return 0
72
+ if left_pre is None:
73
+ return 1
74
+ if right_pre is None:
75
+ return -1
76
+
77
+ max_pre_len = max(len(left_pre), len(right_pre))
78
+ for index in range(max_pre_len):
79
+ if index >= len(left_pre):
80
+ return -1
81
+ if index >= len(right_pre):
82
+ return 1
83
+
84
+ left_identifier = left_pre[index]
85
+ right_identifier = right_pre[index]
86
+
87
+ if left_identifier == right_identifier:
88
+ continue
89
+
90
+ left_is_number = left_identifier.isdigit()
91
+ right_is_number = right_identifier.isdigit()
92
+
93
+ if left_is_number and right_is_number:
94
+ return 1 if int(left_identifier) > int(right_identifier) else -1
95
+ if left_is_number and not right_is_number:
96
+ return -1
97
+ if not left_is_number and right_is_number:
98
+ return 1
99
+ return 1 if left_identifier > right_identifier else -1
100
+
68
101
  return 0
69
102
 
70
103
 
71
- def _normalize_version_parts(version: str) -> tuple[int, ...]:
104
+ def _normalize_version_parts(version: str) -> tuple[tuple[int, ...], tuple[str, ...] | None]:
72
105
  """
73
106
  Convert a dotted numeric version string into comparable integer parts.
74
107
 
@@ -80,7 +113,9 @@ def _normalize_version_parts(version: str) -> tuple[int, ...]:
80
113
  if not normalized_version:
81
114
  raise ValueError("Version is required.")
82
115
 
83
- parts = normalized_version.split(".")
116
+ version_without_build = normalized_version.split("+", 1)[0]
117
+ core_part, separator, prerelease_part = version_without_build.partition("-")
118
+ parts = core_part.split(".")
84
119
  normalized_parts: list[int] = []
85
120
 
86
121
  for part in parts:
@@ -89,7 +124,16 @@ def _normalize_version_parts(version: str) -> tuple[int, ...]:
89
124
 
90
125
  normalized_parts.append(int(part))
91
126
 
92
- return tuple(normalized_parts)
127
+ prerelease_identifiers: tuple[str, ...] | None = None
128
+ if separator:
129
+ if not prerelease_part:
130
+ raise ValueError(f"Unsupported version format: {version}.")
131
+ tokens = prerelease_part.split(".")
132
+ if any(not token or not re.match(r"^[0-9A-Za-z-]+$", token) for token in tokens):
133
+ raise ValueError(f"Unsupported version format: {version}.")
134
+ prerelease_identifiers = tuple(tokens)
135
+
136
+ return tuple(normalized_parts), prerelease_identifiers
93
137
 
94
138
 
95
139
  def validate_installed_macos_app_path(installed_app_path: str | Path) -> Path:
@@ -108,4 +152,10 @@ def validate_installed_macos_app_path(installed_app_path: str | Path) -> Path:
108
152
  if not normalized_path.is_dir() or normalized_path.suffix != ".app":
109
153
  raise ValueError("installed_app_path must point to an existing .app bundle.")
110
154
 
155
+ if not os.access(normalized_path, os.W_OK):
156
+ raise ValueError("installed_app_path is not writable for replacement.")
157
+
158
+ if not os.access(normalized_path.parent, os.W_OK):
159
+ raise ValueError("installed_app_path parent directory is not writable for replacement.")
160
+
111
161
  return normalized_path
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.3.11"
3
+ version = "0.3.13"
4
4
  description = "A bundeld set of tools, clients for the lange-suite of tools and more."
5
5
  authors = [
6
6
  {name = "contact@robertlange.me"}