lange-python 0.3.8__tar.gz → 0.3.9__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 (28) hide show
  1. {lange_python-0.3.8 → lange_python-0.3.9}/PKG-INFO +18 -1
  2. {lange_python-0.3.8 → lange_python-0.3.9}/README.md +17 -0
  3. lange_python-0.3.9/lange/_util/_base_client.py +78 -0
  4. lange_python-0.3.9/lange/_util/_key_handling.py +22 -0
  5. {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_client.py +80 -23
  6. {lange_python-0.3.8 → lange_python-0.3.9}/lange/tunnel/_client.py +90 -15
  7. {lange_python-0.3.8 → lange_python-0.3.9}/pyproject.toml +1 -1
  8. lange_python-0.3.8/lange/_util/_base_client.py +0 -30
  9. lange_python-0.3.8/lange/_util/_key_handling.py +0 -21
  10. {lange_python-0.3.8 → lange_python-0.3.9}/lange/__init__.py +0 -0
  11. {lange_python-0.3.8 → lange_python-0.3.9}/lange/__main__.py +0 -0
  12. {lange_python-0.3.8 → lange_python-0.3.9}/lange/_util/__init__.py +0 -0
  13. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/__init__.py +0 -0
  14. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/__init__.py +0 -0
  15. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_command.py +0 -0
  16. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_discovery.py +0 -0
  17. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_docker.py +0 -0
  18. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_poetry.py +0 -0
  19. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_types.py +0 -0
  20. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/code/__init__.py +0 -0
  21. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/code/_stats.py +0 -0
  22. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/distribution/__init__.py +0 -0
  23. {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/distribution/_command.py +0 -0
  24. {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/__init__.py +0 -0
  25. {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_update_macos.py +0 -0
  26. {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_util.py +0 -0
  27. {lange_python-0.3.8 → lange_python-0.3.9}/lange/tunnel/__init__.py +0 -0
  28. {lange_python-0.3.8 → lange_python-0.3.9}/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.8
3
+ Version: 0.3.9
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
@@ -51,6 +51,18 @@ print(update_metadata["version"])
51
51
  # The caller should now shut down so the detached helper can replace the app bundle.
52
52
  ```
53
53
 
54
+ The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
55
+
56
+ ```python
57
+ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
58
+
59
+ latest_version = client.search_for_update("1.2.3")
60
+ print(client.status) # connected
61
+
62
+ client.reload() # repeats the last update check
63
+ client.reload(api_key=None) # clears authentication and marks the client unauthenticated
64
+ ```
65
+
54
66
  ## Tunnel worker
55
67
 
56
68
  ```python
@@ -64,6 +76,11 @@ tunnel = Tunnel(
64
76
 
65
77
  tunnel.start()
66
78
  # ...
79
+ tunnel.reload() # restarts the connection flow with the current API key
80
+ tunnel.reload(api_key=None) # clears authentication and leaves the client unauthenticated
67
81
  tunnel.stop()
68
82
  ```
69
83
 
84
+ Tunnel clients also expose a `status` property with one of:
85
+ `"unauthenticated"`, `"off"`, `"pending"`, `"connected"`, or `"failed"`.
86
+
@@ -33,6 +33,18 @@ print(update_metadata["version"])
33
33
  # The caller should now shut down so the detached helper can replace the app bundle.
34
34
  ```
35
35
 
36
+ The distribution client also exposes `status` and `reload()` for refresh-aware integrations:
37
+
38
+ ```python
39
+ client = DistributionClient(distribution_name="desktop-app", api_key="your-api-key")
40
+
41
+ latest_version = client.search_for_update("1.2.3")
42
+ print(client.status) # connected
43
+
44
+ client.reload() # repeats the last update check
45
+ client.reload(api_key=None) # clears authentication and marks the client unauthenticated
46
+ ```
47
+
36
48
  ## Tunnel worker
37
49
 
38
50
  ```python
@@ -46,5 +58,10 @@ tunnel = Tunnel(
46
58
 
47
59
  tunnel.start()
48
60
  # ...
61
+ tunnel.reload() # restarts the connection flow with the current API key
62
+ tunnel.reload(api_key=None) # clears authentication and leaves the client unauthenticated
49
63
  tunnel.stop()
50
64
  ```
65
+
66
+ Tunnel clients also expose a `status` property with one of:
67
+ `"unauthenticated"`, `"off"`, `"pending"`, `"connected"`, or `"failed"`.
@@ -0,0 +1,78 @@
1
+ from threading import Thread
2
+ from typing import Literal
3
+
4
+ from ._key_handling import _resolve_api_key
5
+
6
+ ClientStatus = Literal["unauthenticated", "off", "pending", "connected", "failed"]
7
+ _UNSET_API_KEY = object()
8
+
9
+
10
+ class BaseLangeLabsClient(Thread):
11
+ """Shared thread-based Lange Labs client base class."""
12
+
13
+ def __init__(self,
14
+ api_key: str | None = None,
15
+ daemon: bool = True,
16
+ host: str | None = None,
17
+ ) -> None:
18
+ """
19
+ Initialize a thread-based Lange Labs client with shared connection settings.
20
+
21
+ :param api_key: Optional API key supplied directly or resolved from the environment.
22
+ :param daemon: Whether the client thread should run as a daemon thread.
23
+ :param host: Base service URL used for outbound requests.
24
+ :raises ValueError: If ``api_key`` is provided but empty.
25
+ """
26
+ super().__init__(daemon=daemon)
27
+ self.api_key = _resolve_api_key(api_key)
28
+ self.host = host.rstrip("/") if host is not None else ""
29
+ self._status: ClientStatus = self._status_for_auth()
30
+
31
+ def reload(self, api_key: str | None | object = _UNSET_API_KEY) -> None:
32
+ """
33
+ Reload shared client configuration.
34
+
35
+ :param api_key: Optional replacement API key. Pass ``None`` explicitly to clear authentication.
36
+ :returns: ``None``.
37
+ :raises ValueError: If ``api_key`` is provided but empty.
38
+ """
39
+ if api_key is _UNSET_API_KEY:
40
+ pass
41
+ elif api_key is None:
42
+ self.api_key = None
43
+ else:
44
+ self.api_key = _resolve_api_key(api_key)
45
+
46
+ self._status = self._status_for_auth()
47
+
48
+ def _build_connection_headers(self) -> dict[str, str]:
49
+ """
50
+ Build outbound handshake headers.
51
+
52
+ :returns: Header dictionary with bearer authorization.
53
+ :raises ValueError: If the client is unauthenticated.
54
+ """
55
+ if self.api_key is None:
56
+ raise ValueError("API key is required for this operation.")
57
+
58
+ return {"Authorization": f"Bearer {self.api_key}"}
59
+
60
+ def _set_status(self, status: ClientStatus) -> None:
61
+ """
62
+ Store the current client status.
63
+
64
+ :param status: New shared client status value.
65
+ :returns: ``None``.
66
+ """
67
+ self._status = status
68
+
69
+ def _status_for_auth(self) -> ClientStatus:
70
+ """
71
+ Resolve the default status from the configured authentication state.
72
+
73
+ :returns: ``\"unauthenticated\"`` when no key is set, otherwise ``\"off\"``.
74
+ """
75
+ if self.api_key is None:
76
+ return "unauthenticated"
77
+
78
+ return "off"
@@ -0,0 +1,22 @@
1
+ import os
2
+
3
+
4
+ def _resolve_api_key(api_key: str | None) -> str | None:
5
+ """
6
+ Resolve API key from argument or environment.
7
+
8
+ :param api_key: Optional direct API key value.
9
+ :returns: Sanitized API key or ``None`` when no key is configured.
10
+ :raises ValueError: If the provided API key is empty or whitespace-only.
11
+ """
12
+ if api_key is not None:
13
+ normalized_api_key = api_key.strip()
14
+ if not normalized_api_key:
15
+ raise ValueError("api_key cannot be empty.")
16
+ return normalized_api_key
17
+
18
+ env_api_key = os.getenv("LANGE_LABS_API_KEY", "")
19
+ if env_api_key.strip():
20
+ return env_api_key.strip()
21
+
22
+ return None
@@ -15,6 +15,7 @@ from ._util import (
15
15
  validate_installed_macos_app_path,
16
16
  )
17
17
  from .._util import BaseLangeLabsClient
18
+ from .._util._base_client import _UNSET_API_KEY
18
19
 
19
20
 
20
21
  class DistributionClient(BaseLangeLabsClient):
@@ -24,7 +25,7 @@ class DistributionClient(BaseLangeLabsClient):
24
25
  :param host: Base app-service URL, e.g. ``https://lange-labs.com``.
25
26
  :param api_key: Optional API key supplied directly or resolved from the environment.
26
27
  :param timeout: HTTP timeout in seconds for metadata requests.
27
- :raises ValueError: If ``api_key`` is unavailable or empty.
28
+ :raises ValueError: If ``api_key`` is provided but empty.
28
29
  """
29
30
 
30
31
  def __init__(self,
@@ -39,10 +40,12 @@ class DistributionClient(BaseLangeLabsClient):
39
40
  :param api_key: Optional API key supplied directly or resolved from the environment.
40
41
  :param timeout: HTTP timeout in seconds for metadata requests.
41
42
  :param distribution_name: Distribution name as stored by the app service.
42
- :raises ValueError: If ``api_key`` is unavailable or empty.
43
+ :raises ValueError: If ``api_key`` is provided but empty.
43
44
  """
44
45
  super().__init__(api_key, host=host)
45
46
  self.timeout = timeout
47
+ self._last_checked_version: str | None = None
48
+ self._last_update_result: str | None = None
46
49
  normalized_distribution_name = distribution_name.strip()
47
50
 
48
51
  if not normalized_distribution_name:
@@ -53,6 +56,43 @@ class DistributionClient(BaseLangeLabsClient):
53
56
 
54
57
  self.distribution_name = normalized_distribution_name
55
58
 
59
+ @property
60
+ def status(self) -> str:
61
+ """
62
+ Get the current distribution client status.
63
+
64
+ :returns: Shared client status string.
65
+ """
66
+ return self._status
67
+
68
+ @property
69
+ def last_checked_version(self) -> str | None:
70
+ """
71
+ Get the version used for the most recent update check.
72
+
73
+ :returns: Last checked version string or ``None``.
74
+ """
75
+ return self._last_checked_version
76
+
77
+ def reload(self, api_key: str | None | object = _UNSET_API_KEY) -> None:
78
+ """
79
+ Reload authentication and repeat the last update check when available.
80
+
81
+ :param api_key: Optional replacement API key. Pass ``None`` explicitly to clear authentication.
82
+ :returns: ``None``.
83
+ """
84
+ super().reload(api_key=api_key)
85
+
86
+ if self.api_key is None:
87
+ self._last_update_result = None
88
+ return
89
+
90
+ if self._last_checked_version is None:
91
+ self._set_status("off")
92
+ return
93
+
94
+ self.search_for_update(self._last_checked_version)
95
+
56
96
  def search_for_update(self, current_version: str) -> str | None:
57
97
  """
58
98
  Resolve the newest available version for the current OS when it is newer.
@@ -68,30 +108,47 @@ class DistributionClient(BaseLangeLabsClient):
68
108
  if not normalized_current_version:
69
109
  raise ValueError("current_version is required.")
70
110
 
71
- current_os = detect_distribution_os()
72
- payload = self._get_distribution_payload()
73
- versions = payload.get("distribution", {}).get("versions", [])
111
+ self._last_checked_version = normalized_current_version
74
112
 
75
- newest_version: str | None = None
113
+ if self.api_key is None:
114
+ self._set_status("unauthenticated")
115
+ raise ValueError("API key is required for this operation.")
76
116
 
77
- for version_entry in versions:
78
- if not isinstance(version_entry, dict):
79
- continue
117
+ self._set_status("pending")
80
118
 
81
- version = str(version_entry.get("version", "")).strip()
82
- artifacts = version_entry.get("artifacts", {})
83
- artifact = artifacts.get(current_os) if isinstance(artifacts, dict) else None
84
-
85
- if not version or artifact is None:
86
- continue
87
-
88
- if _compare_versions(version, normalized_current_version) <= 0:
89
- continue
90
-
91
- if newest_version is None or _compare_versions(version, newest_version) > 0:
92
- newest_version = version
93
-
94
- return newest_version
119
+ try:
120
+ current_os = detect_distribution_os()
121
+ payload = self._get_distribution_payload()
122
+ versions = payload.get("distribution", {}).get("versions", [])
123
+
124
+ newest_version: str | None = None
125
+
126
+ for version_entry in versions:
127
+ if not isinstance(version_entry, dict):
128
+ continue
129
+
130
+ version = str(version_entry.get("version", "")).strip()
131
+ artifacts = version_entry.get("artifacts", {})
132
+ artifact = artifacts.get(current_os) if isinstance(artifacts, dict) else None
133
+
134
+ if not version or artifact is None:
135
+ continue
136
+
137
+ if _compare_versions(version, normalized_current_version) <= 0:
138
+ continue
139
+
140
+ if newest_version is None or _compare_versions(version, newest_version) > 0:
141
+ newest_version = version
142
+
143
+ self._last_update_result = newest_version
144
+ self._set_status("connected")
145
+ return newest_version
146
+ except Exception:
147
+ if self.api_key is None:
148
+ self._set_status("unauthenticated")
149
+ else:
150
+ self._set_status("failed")
151
+ raise
95
152
 
96
153
  def _build_distribution_url(self) -> str:
97
154
  """
@@ -13,6 +13,7 @@ import httpx
13
13
  import websockets
14
14
  from httpx import Timeout
15
15
  from .._util import BaseLangeLabsClient
16
+ from .._util._base_client import _UNSET_API_KEY
16
17
  from ._util import _filter_hop_by_hop_headers
17
18
 
18
19
  logger = logging.getLogger("lange.tunnel")
@@ -33,7 +34,7 @@ class Tunnel(BaseLangeLabsClient):
33
34
  :param retry_delay: Initial reconnect delay in seconds.
34
35
  :param open_timeout: Timeout in seconds for the WebSocket opening handshake.
35
36
  :param daemon: Whether the worker thread is daemonized.
36
- :raises ValueError: If API key is unavailable or empty.
37
+ :raises ValueError: If API key is provided but empty.
37
38
  """
38
39
 
39
40
  def __init__(
@@ -77,6 +78,16 @@ class Tunnel(BaseLangeLabsClient):
77
78
  with self._lock:
78
79
  return self._connected
79
80
 
81
+ @property
82
+ def status(self) -> str:
83
+ """
84
+ Get the current tunnel lifecycle status.
85
+
86
+ :returns: Shared client status string.
87
+ """
88
+ with self._lock:
89
+ return self._status
90
+
80
91
  @property
81
92
  def remote_address(self) -> Optional[str]:
82
93
  """
@@ -149,22 +160,23 @@ class Tunnel(BaseLangeLabsClient):
149
160
 
150
161
  :returns: ``None``.
151
162
  """
163
+ self._request_reconnect()
164
+
165
+ def reload(self, api_key: str | None | object = _UNSET_API_KEY) -> None:
166
+ """
167
+ Reload tunnel authentication and restart the connection flow when running.
168
+
169
+ :param api_key: Optional replacement API key. Pass ``None`` explicitly to clear authentication.
170
+ :returns: ``None``.
171
+ """
172
+ super().reload(api_key=api_key)
152
173
  self._set_connected(False)
153
- with self._lock:
154
- self._reconnect_count = 0
155
- ws = self._active_ws
156
- loop = self._loop
157
- self._reconnect_event.set()
158
174
 
159
- if ws is not None and loop is not None and loop.is_running():
160
- try:
161
- future = asyncio.run_coroutine_threadsafe(
162
- ws.close(code=1012, reason="Client reconnect"),
163
- loop,
164
- )
165
- future.result(timeout=2)
166
- except Exception as exc: # pragma: no cover - best-effort close path
167
- logger.debug("Failed to close websocket during reconnect: %s", exc)
175
+ if self.is_alive() or self._loop is not None:
176
+ self._request_reconnect()
177
+ return
178
+
179
+ self._set_status(self._status_for_auth())
168
180
 
169
181
  async def _run_async(self) -> None:
170
182
  """
@@ -181,6 +193,13 @@ class Tunnel(BaseLangeLabsClient):
181
193
  current_delay = self.retry_delay
182
194
 
183
195
  while not self._stop_event.is_set():
196
+ if self.api_key is None:
197
+ self._set_connected(False)
198
+ self._set_status("unauthenticated")
199
+ await self._wait_for_reconnect_signal()
200
+ continue
201
+
202
+ self._set_status("pending")
184
203
  headers = self._build_connection_headers()
185
204
 
186
205
  try:
@@ -209,9 +228,11 @@ class Tunnel(BaseLangeLabsClient):
209
228
  self._set_connected(False)
210
229
  except websockets.exceptions.ConnectionClosed as exc:
211
230
  self._set_connected(False)
231
+ self._set_status("failed")
212
232
  logger.warning("Connection closed: %s", exc)
213
233
  except Exception as exc: # pragma: no cover - network error path
214
234
  self._set_connected(False)
235
+ self._set_status("failed")
215
236
  logger.error("Connection error: %s", exc)
216
237
  finally:
217
238
  with self._lock:
@@ -226,6 +247,7 @@ class Tunnel(BaseLangeLabsClient):
226
247
  self._reconnect_count = 0
227
248
  self._reconnect_event.clear()
228
249
  current_delay = self.retry_delay
250
+ self._set_status(self._status_for_auth())
229
251
  logger.info("Manual reconnect requested. Reconnecting now.")
230
252
  continue
231
253
 
@@ -234,6 +256,7 @@ class Tunnel(BaseLangeLabsClient):
234
256
  attempt = self._reconnect_count
235
257
 
236
258
  if self.max_retries > 0 and attempt > self.max_retries:
259
+ self._set_status("failed")
237
260
  logger.error("Max reconnection attempts (%s) reached. Giving up.", self.max_retries)
238
261
  break
239
262
 
@@ -256,6 +279,8 @@ class Tunnel(BaseLangeLabsClient):
256
279
  with self._lock:
257
280
  self._active_ws = None
258
281
  self._loop = None
282
+ if self._status in {"connected", "pending"}:
283
+ self._status = self._status_for_auth()
259
284
 
260
285
  logger.info("Disconnected from server.")
261
286
 
@@ -381,6 +406,56 @@ class Tunnel(BaseLangeLabsClient):
381
406
  self._remote_address_roundrobin = remote_address_roundrobin if value else None
382
407
  self._worker_index = worker_index if value else -1
383
408
  self._pool_size = pool_size if value else 0
409
+ if value:
410
+ self._status = "connected"
411
+ elif self.api_key is None:
412
+ self._status = "unauthenticated"
413
+
414
+ def _set_status(self, status: str) -> None:
415
+ """
416
+ Store the current tunnel lifecycle status.
417
+
418
+ :param status: New lifecycle status.
419
+ :returns: ``None``.
420
+ """
421
+ with self._lock:
422
+ self._status = status
423
+
424
+ def _request_reconnect(self) -> None:
425
+ """
426
+ Trigger a reconnect cycle and close the active websocket when needed.
427
+
428
+ :returns: ``None``.
429
+ """
430
+ self._set_connected(False)
431
+ with self._lock:
432
+ self._reconnect_count = 0
433
+ ws = self._active_ws
434
+ loop = self._loop
435
+ self._status = self._status_for_auth()
436
+ self._reconnect_event.set()
437
+
438
+ if ws is not None and loop is not None and loop.is_running():
439
+ try:
440
+ future = asyncio.run_coroutine_threadsafe(
441
+ ws.close(code=1012, reason="Client reconnect"),
442
+ loop,
443
+ )
444
+ future.result(timeout=2)
445
+ except Exception as exc: # pragma: no cover - best-effort close path
446
+ logger.debug("Failed to close websocket during reconnect: %s", exc)
447
+
448
+ async def _wait_for_reconnect_signal(self) -> None:
449
+ """
450
+ Wait until the client is reloaded, stopped, or resumed from an unauthenticated state.
451
+
452
+ :returns: ``None``.
453
+ """
454
+ while not self._stop_event.is_set() and not self._reconnect_event.is_set():
455
+ await asyncio.sleep(0.5)
456
+
457
+ if self._reconnect_event.is_set():
458
+ self._reconnect_event.clear()
384
459
 
385
460
  def _build_ssl_context(self, tunnel_url: str) -> Optional[ssl.SSLContext]:
386
461
  """
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.3.8"
3
+ version = "0.3.9"
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"}
@@ -1,30 +0,0 @@
1
- from threading import Thread
2
- from ._key_handling import _resolve_api_key
3
-
4
-
5
- class BaseLangeLabsClient(Thread):
6
-
7
- def __init__(self,
8
- api_key: str | None = None,
9
- daemon: bool = True,
10
- host: str | None = None,
11
- ) -> None:
12
- """
13
- Initialize a thread-based Lange Labs client with shared connection settings.
14
-
15
- :param api_key: Optional API key supplied directly or resolved from the environment.
16
- :param daemon: Whether the client thread should run as a daemon thread.
17
- :param host: Base service URL used for outbound requests.
18
- :raises ValueError: If ``api_key`` is unavailable or empty.
19
- """
20
- super().__init__(daemon=daemon)
21
- self.api_key = _resolve_api_key(api_key)
22
- self.host = host.rstrip("/")
23
-
24
- def _build_connection_headers(self) -> dict[str, str]:
25
- """
26
- Build outbound handshake headers.
27
-
28
- :returns: Header dictionary with bearer authorization.
29
- """
30
- return {"Authorization": f"Bearer {self.api_key}"}
@@ -1,21 +0,0 @@
1
- import os
2
-
3
- def _resolve_api_key(api_key: str | None) -> str:
4
- """
5
- Resolve API key from argument or environment.
6
-
7
- :param api_key: Optional direct API key value.
8
- :returns: Sanitized non-empty API key.
9
- :raises ValueError: If no non-empty API key is available.
10
- """
11
- if api_key is not None and api_key.strip():
12
- return api_key.strip()
13
-
14
- env_api_key = os.getenv("LANGE_LABS_API_KEY", "")
15
- if env_api_key.strip():
16
- return env_api_key.strip()
17
-
18
- raise ValueError(
19
- "A non-empty api_key is required. "
20
- "Pass api_key directly or set LANGE_LABS_API_KEY."
21
- )