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.
- {lange_python-0.3.8 → lange_python-0.3.9}/PKG-INFO +18 -1
- {lange_python-0.3.8 → lange_python-0.3.9}/README.md +17 -0
- lange_python-0.3.9/lange/_util/_base_client.py +78 -0
- lange_python-0.3.9/lange/_util/_key_handling.py +22 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_client.py +80 -23
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/tunnel/_client.py +90 -15
- {lange_python-0.3.8 → lange_python-0.3.9}/pyproject.toml +1 -1
- lange_python-0.3.8/lange/_util/_base_client.py +0 -30
- lange_python-0.3.8/lange/_util/_key_handling.py +0 -21
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/__main__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/_util/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_command.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_discovery.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_docker.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_poetry.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/build/_types.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/code/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/code/_stats.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/distribution/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/cli/distribution/_command.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/__init__.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_update_macos.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/distribution/_util.py +0 -0
- {lange_python-0.3.8 → lange_python-0.3.9}/lange/tunnel/__init__.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
72
|
-
payload = self._get_distribution_payload()
|
|
73
|
-
versions = payload.get("distribution", {}).get("versions", [])
|
|
111
|
+
self._last_checked_version = normalized_current_version
|
|
74
112
|
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
if not isinstance(version_entry, dict):
|
|
79
|
-
continue
|
|
117
|
+
self._set_status("pending")
|
|
80
118
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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,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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|