lange-python 0.3.6__tar.gz → 0.3.7__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.6 → lange_python-0.3.7}/PKG-INFO +19 -1
  2. {lange_python-0.3.6 → lange_python-0.3.7}/README.md +18 -0
  3. {lange_python-0.3.6 → lange_python-0.3.7}/lange/distribution/_client.py +124 -3
  4. lange_python-0.3.7/lange/distribution/_update_macos.py +161 -0
  5. {lange_python-0.3.6 → lange_python-0.3.7}/lange/distribution/_util.py +20 -0
  6. {lange_python-0.3.6 → lange_python-0.3.7}/pyproject.toml +1 -1
  7. {lange_python-0.3.6 → lange_python-0.3.7}/lange/__init__.py +0 -0
  8. {lange_python-0.3.6 → lange_python-0.3.7}/lange/__main__.py +0 -0
  9. {lange_python-0.3.6 → lange_python-0.3.7}/lange/_util/__init__.py +0 -0
  10. {lange_python-0.3.6 → lange_python-0.3.7}/lange/_util/_base_client.py +0 -0
  11. {lange_python-0.3.6 → lange_python-0.3.7}/lange/_util/_key_handling.py +0 -0
  12. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/__init__.py +0 -0
  13. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/__init__.py +0 -0
  14. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/_command.py +0 -0
  15. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/_discovery.py +0 -0
  16. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/_docker.py +0 -0
  17. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/_poetry.py +0 -0
  18. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/build/_types.py +0 -0
  19. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/code/__init__.py +0 -0
  20. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/code/_stats.py +0 -0
  21. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/distribution/__init__.py +0 -0
  22. {lange_python-0.3.6 → lange_python-0.3.7}/lange/cli/distribution/_command.py +0 -0
  23. {lange_python-0.3.6 → lange_python-0.3.7}/lange/distribution/__init__.py +0 -0
  24. {lange_python-0.3.6 → lange_python-0.3.7}/lange/tunnel/__init__.py +0 -0
  25. {lange_python-0.3.6 → lange_python-0.3.7}/lange/tunnel/_client.py +0 -0
  26. {lange_python-0.3.6 → lange_python-0.3.7}/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.6
3
+ Version: 0.3.7
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
@@ -33,6 +33,24 @@ lange distribution publish \
33
33
  --os macos
34
34
  ```
35
35
 
36
+ Apply a macOS app update after downloading the published zip artifact:
37
+
38
+ ```python
39
+ from pathlib import Path
40
+
41
+ from lange.distribution import DistributionClient
42
+
43
+ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
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.
52
+ ```
53
+
36
54
  ## Tunnel worker
37
55
 
38
56
  ```python
@@ -15,6 +15,24 @@ lange distribution publish \
15
15
  --os macos
16
16
  ```
17
17
 
18
+ Apply a macOS app update after downloading the published zip artifact:
19
+
20
+ ```python
21
+ from pathlib import Path
22
+
23
+ from lange.distribution import DistributionClient
24
+
25
+ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
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.
34
+ ```
35
+
18
36
  ## Tunnel worker
19
37
 
20
38
  ```python
@@ -1,12 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import mimetypes
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
7
8
  import httpx
8
9
 
9
- from ._util import detect_distribution_os, parse_distribution_os, _compare_versions
10
+ from ._update_macos import extract_macos_app_from_zip, spawn_macos_update_process
11
+ from ._util import (
12
+ _compare_versions,
13
+ detect_distribution_os,
14
+ parse_distribution_os,
15
+ validate_installed_macos_app_path,
16
+ )
10
17
  from .._util import BaseLangeLabsClient
11
18
 
12
19
 
@@ -158,7 +165,87 @@ class DistributionClient(BaseLangeLabsClient):
158
165
  if newest_match is None:
159
166
  raise ValueError(f"No downloadable versions available for operating system: {current_os}.")
160
167
 
161
- version, artifact = newest_match
168
+ version, _artifact = newest_match
169
+
170
+ return self._download_version_artifact_to_temp(version=version, os_name=current_os)
171
+
172
+ def update(
173
+ self,
174
+ *,
175
+ current_version: str,
176
+ installed_app_path: str | Path,
177
+ target_version: str | None = None,
178
+ current_process_id: int | None = None,
179
+ wait_for_exit_timeout: float = 30.0,
180
+ ) -> dict[str, str]:
181
+ """
182
+ Download and stage a macOS update, then spawn a detached helper to apply it.
183
+
184
+ :param current_version: Current installed version string.
185
+ :param installed_app_path: Path to the installed macOS ``.app`` bundle to replace.
186
+ :param target_version: Explicit target version to install. When omitted, the newest newer version is used.
187
+ :param current_process_id: PID that must exit before replacement begins. Defaults to the current process.
188
+ :param wait_for_exit_timeout: Maximum seconds the helper waits for the current process to exit.
189
+ :returns: Metadata describing the staged update and spawned helper process.
190
+ :raises ValueError: If the platform, app path, or artifact layout is invalid.
191
+ :raises RuntimeError: If the detached helper process could not be launched.
192
+ """
193
+ current_os = detect_distribution_os()
194
+ if current_os != "macos":
195
+ raise ValueError(f"macOS updates are not implemented for operating system: {current_os}.")
196
+
197
+ normalized_current_version = current_version.strip()
198
+ if not normalized_current_version:
199
+ raise ValueError("current_version is required.")
200
+
201
+ normalized_installed_app_path = validate_installed_macos_app_path(installed_app_path)
202
+ resolved_target_version = target_version.strip() if target_version is not None else self.search_for_update(
203
+ normalized_current_version
204
+ )
205
+
206
+ if not resolved_target_version:
207
+ raise ValueError("No newer update is available for the current macOS installation.")
208
+
209
+ artifact = self._get_version_artifact(version=resolved_target_version, os_name="macos")
210
+ if artifact is None:
211
+ raise ValueError(f"No macos artifact is available for version {resolved_target_version}.")
212
+
213
+ artifact_path = self._download_version_artifact_to_temp(version=resolved_target_version, os_name="macos")
214
+ extracted_app_path = extract_macos_app_from_zip(artifact_path)
215
+ helper_process = spawn_macos_update_process(
216
+ source_app_path=extracted_app_path,
217
+ installed_app_path=normalized_installed_app_path,
218
+ current_process_id=os.getpid() if current_process_id is None else current_process_id,
219
+ workspace_path=artifact_path.parent,
220
+ wait_for_exit_timeout=wait_for_exit_timeout,
221
+ )
222
+ process_id = helper_process.get("process_id", helper_process.get("pid", ""))
223
+
224
+ return {
225
+ "version": resolved_target_version,
226
+ "artifact_path": str(artifact_path),
227
+ "installed_app_path": str(normalized_installed_app_path),
228
+ "extracted_app_path": str(extracted_app_path),
229
+ "script_path": helper_process["script_path"],
230
+ "log_path": helper_process["log_path"],
231
+ "process_id": str(process_id),
232
+ }
233
+
234
+ def _download_version_artifact_to_temp(self, version: str, os_name: str) -> Path:
235
+ """
236
+ Download one explicit version artifact for one operating system into ``/tmp``.
237
+
238
+ :param version: Distribution version to download.
239
+ :param os_name: Operating-system label accepted by the app service.
240
+ :returns: Absolute path to the downloaded artifact.
241
+ :raises ValueError: If the artifact metadata is incomplete or missing.
242
+ :raises httpx.HTTPError: If the metadata or artifact download fails.
243
+ """
244
+ artifact = self._get_version_artifact(version=version, os_name=os_name)
245
+
246
+ if artifact is None:
247
+ raise ValueError(f"No {os_name} artifact is available for version {version}.")
248
+
162
249
  filename = str(artifact.get("filename", "")).strip()
163
250
 
164
251
  if not filename:
@@ -172,7 +259,7 @@ class DistributionClient(BaseLangeLabsClient):
172
259
 
173
260
  try:
174
261
  response = client.get(
175
- self._build_artifact_download_url(version, current_os),
262
+ self._build_artifact_download_url(version, os_name),
176
263
  headers=self._build_connection_headers(),
177
264
  )
178
265
  response.raise_for_status()
@@ -182,6 +269,40 @@ class DistributionClient(BaseLangeLabsClient):
182
269
 
183
270
  return destination_path
184
271
 
272
+ def _get_version_artifact(self, version: str, os_name: str) -> dict[str, Any] | None:
273
+ """
274
+ Resolve the downloadable artifact payload for one version and operating system.
275
+
276
+ :param version: Distribution version to inspect.
277
+ :param os_name: Operating-system label accepted by the app service.
278
+ :returns: Artifact metadata for the version and OS, otherwise ``None``.
279
+ :raises ValueError: If the version is missing.
280
+ :raises httpx.HTTPError: If the metadata request fails.
281
+ """
282
+ normalized_version = version.strip()
283
+ normalized_os_name = parse_distribution_os(os_name)
284
+
285
+ if not normalized_version:
286
+ raise ValueError("version is required.")
287
+
288
+ payload = self._get_distribution_payload()
289
+ versions = payload.get("distribution", {}).get("versions", [])
290
+
291
+ for version_entry in versions:
292
+ if not isinstance(version_entry, dict):
293
+ continue
294
+
295
+ if str(version_entry.get("version", "")).strip() != normalized_version:
296
+ continue
297
+
298
+ artifacts = version_entry.get("artifacts", {})
299
+ artifact = artifacts.get(normalized_os_name) if isinstance(artifacts, dict) else None
300
+ if isinstance(artifact, dict):
301
+ return artifact
302
+ return None
303
+
304
+ return None
305
+
185
306
  def upload(self, path: str | Path, version_name: str, os_name: str) -> dict[str, Any]:
186
307
  """
187
308
  Upload one OS-specific distribution artifact for a version.
@@ -0,0 +1,161 @@
1
+ """macOS-specific helpers for the distribution update workflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import stat
7
+ import subprocess
8
+ import zipfile
9
+ from pathlib import Path
10
+ from shlex import quote
11
+
12
+
13
+ def extract_macos_app_from_zip(archive_path: str | Path) -> Path:
14
+ """
15
+ Extract a macOS update archive and return the only top-level ``.app`` bundle.
16
+
17
+ :param archive_path: Path to the downloaded zip archive.
18
+ :returns: Absolute path to the extracted ``.app`` bundle.
19
+ :raises ValueError: If the archive is not a zip or does not contain exactly one top-level app bundle.
20
+ """
21
+ normalized_archive_path = Path(archive_path).expanduser().resolve()
22
+
23
+ if normalized_archive_path.suffix.lower() != ".zip":
24
+ raise ValueError("macOS update artifacts must be .zip files.")
25
+
26
+ destination_path = normalized_archive_path.parent / "extracted"
27
+ if destination_path.exists():
28
+ shutil.rmtree(destination_path)
29
+ destination_path.mkdir(parents=True, exist_ok=True)
30
+
31
+ with zipfile.ZipFile(normalized_archive_path) as archive:
32
+ archive.extractall(destination_path)
33
+
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]
44
+
45
+
46
+ def spawn_macos_update_process(
47
+ *,
48
+ source_app_path: str | Path,
49
+ installed_app_path: str | Path,
50
+ current_process_id: int,
51
+ workspace_path: str | Path,
52
+ wait_for_exit_timeout: float,
53
+ ) -> dict[str, str]:
54
+ """
55
+ Generate and launch the detached macOS helper process that applies the update.
56
+
57
+ :param source_app_path: Extracted replacement app bundle.
58
+ :param installed_app_path: Installed app bundle that should be replaced.
59
+ :param current_process_id: PID that must exit before replacement begins.
60
+ :param workspace_path: Temporary workspace for helper files and logs.
61
+ :param wait_for_exit_timeout: Maximum number of seconds to wait for the PID to exit.
62
+ :returns: Metadata about the spawned helper process and generated files.
63
+ :raises RuntimeError: If the helper process could not be launched.
64
+ """
65
+ normalized_source_app_path = Path(source_app_path).expanduser().resolve()
66
+ normalized_installed_app_path = Path(installed_app_path).expanduser().resolve()
67
+ normalized_workspace_path = Path(workspace_path).expanduser().resolve()
68
+
69
+ normalized_workspace_path.mkdir(parents=True, exist_ok=True)
70
+
71
+ backup_app_path = normalized_installed_app_path.parent / f"{normalized_installed_app_path.name}.previous"
72
+ script_path = normalized_workspace_path / "apply-update.sh"
73
+ log_path = normalized_workspace_path / "apply-update.log"
74
+ script_contents = _build_macos_update_script(
75
+ source_app_path=normalized_source_app_path,
76
+ installed_app_path=normalized_installed_app_path,
77
+ backup_app_path=backup_app_path,
78
+ current_process_id=current_process_id,
79
+ wait_for_exit_timeout=wait_for_exit_timeout,
80
+ )
81
+
82
+ script_path.write_text(script_contents, encoding="utf-8")
83
+ current_mode = script_path.stat().st_mode
84
+ script_path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
85
+
86
+ with log_path.open("ab") as log_file:
87
+ try:
88
+ process = subprocess.Popen(
89
+ ["/bin/bash", str(script_path)],
90
+ stdout=log_file,
91
+ stderr=subprocess.STDOUT,
92
+ stdin=subprocess.DEVNULL,
93
+ start_new_session=True,
94
+ )
95
+ except OSError as error:
96
+ raise RuntimeError(f"Failed to launch the macOS updater helper process: {error}") from error
97
+
98
+ return {
99
+ "script_path": str(script_path),
100
+ "log_path": str(log_path),
101
+ "process_id": str(process.pid),
102
+ }
103
+
104
+
105
+ def _build_macos_update_script(
106
+ *,
107
+ source_app_path: Path,
108
+ installed_app_path: Path,
109
+ backup_app_path: Path,
110
+ current_process_id: int,
111
+ wait_for_exit_timeout: float,
112
+ ) -> str:
113
+ """
114
+ Build the shell script that applies the macOS app replacement with rollback.
115
+
116
+ :param source_app_path: Extracted replacement app bundle.
117
+ :param installed_app_path: Installed app bundle that should be replaced.
118
+ :param backup_app_path: Temporary backup location used for rollback.
119
+ :param current_process_id: PID that must exit before replacement begins.
120
+ :param wait_for_exit_timeout: Maximum number of seconds to wait for the PID to exit.
121
+ :returns: Shell script source code.
122
+ """
123
+ timeout_seconds = max(int(wait_for_exit_timeout), 0)
124
+
125
+ return f"""#!/bin/bash
126
+ set -euo pipefail
127
+
128
+ SOURCE_APP={quote(str(source_app_path))}
129
+ TARGET_APP={quote(str(installed_app_path))}
130
+ BACKUP_APP={quote(str(backup_app_path))}
131
+ TARGET_PID={int(current_process_id)}
132
+ TIMEOUT_SECONDS={timeout_seconds}
133
+ WAITED_SECONDS=0
134
+
135
+ restore_backup() {{
136
+ if [ -d "$BACKUP_APP" ]; then
137
+ rm -rf "$TARGET_APP"
138
+ mv "$BACKUP_APP" "$TARGET_APP"
139
+ fi
140
+ }}
141
+
142
+ while kill -0 "$TARGET_PID" 2>/dev/null; do
143
+ if [ "$WAITED_SECONDS" -ge "$TIMEOUT_SECONDS" ]; then
144
+ echo "Timed out waiting for process $TARGET_PID to exit."
145
+ exit 1
146
+ fi
147
+ sleep 1
148
+ WAITED_SECONDS=$((WAITED_SECONDS + 1))
149
+ done
150
+
151
+ rm -rf "$BACKUP_APP"
152
+
153
+ trap 'restore_backup' ERR
154
+
155
+ if [ -d "$TARGET_APP" ]; then
156
+ mv "$TARGET_APP" "$BACKUP_APP"
157
+ fi
158
+
159
+ /usr/bin/ditto "$SOURCE_APP" "$TARGET_APP"
160
+ rm -rf "$BACKUP_APP"
161
+ """
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import platform
4
+ from pathlib import Path
4
5
 
5
6
 
6
7
  def detect_distribution_os() -> str:
@@ -89,3 +90,22 @@ def _normalize_version_parts(version: str) -> tuple[int, ...]:
89
90
  normalized_parts.append(int(part))
90
91
 
91
92
  return tuple(normalized_parts)
93
+
94
+
95
+ def validate_installed_macos_app_path(installed_app_path: str | Path) -> Path:
96
+ """
97
+ Validate and normalize the macOS app bundle path that should be replaced.
98
+
99
+ :param installed_app_path: Untrusted path to the installed ``.app`` bundle.
100
+ :returns: Absolute path to the installed ``.app`` bundle.
101
+ :raises ValueError: If the path is missing, does not exist, or is not a ``.app`` directory.
102
+ """
103
+ normalized_path = Path(installed_app_path).expanduser().resolve()
104
+
105
+ if not normalized_path.exists():
106
+ raise ValueError("installed_app_path must point to an existing .app bundle.")
107
+
108
+ if not normalized_path.is_dir() or normalized_path.suffix != ".app":
109
+ raise ValueError("installed_app_path must point to an existing .app bundle.")
110
+
111
+ return normalized_path
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.3.6"
3
+ version = "0.3.7"
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"}