lange-python 0.3.0__tar.gz → 0.3.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.
- {lange_python-0.3.0 → lange_python-0.3.1}/PKG-INFO +1 -1
- lange_python-0.3.1/lange/__init__.py +6 -0
- lange_python-0.3.1/lange/_util/__init__.py +5 -0
- lange_python-0.3.1/lange/_util/_base_client.py +30 -0
- lange_python-0.3.1/lange/_util/_key_handling.py +21 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/_command.py +5 -5
- lange_python-0.3.1/lange/distribution/__init__.py +7 -0
- lange_python-0.3.1/lange/distribution/_client.py +251 -0
- lange_python-0.3.1/lange/distribution/_util.py +91 -0
- lange_python-0.3.1/lange/tunnel/__init__.py +7 -0
- lange_python-0.3.0/lange/tunnel/__init__.py → lange_python-0.3.1/lange/tunnel/_client.py +30 -108
- lange_python-0.3.1/lange/tunnel/_util.py +18 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/pyproject.toml +1 -1
- lange_python-0.3.0/lange/__init__.py +0 -5
- {lange_python-0.3.0 → lange_python-0.3.1}/README.md +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/__main__.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/__init__.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/__init__.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/_discovery.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/_docker.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/_poetry.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/build/_types.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/code/__init__.py +0 -0
- {lange_python-0.3.0 → lange_python-0.3.1}/lange/cli/code/_stats.py +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
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}"}
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
)
|
|
@@ -20,12 +20,12 @@ from ._types import DOCKER_BUILD_SYSTEM, POETRY_BUILD_SYSTEM, BuildSystem
|
|
|
20
20
|
|
|
21
21
|
@click.command("build")
|
|
22
22
|
@click.argument("folder_name", required=False)
|
|
23
|
-
@click.option("--
|
|
23
|
+
@click.option("--push", is_flag=True, help="Publish after a successful build.")
|
|
24
24
|
@click.option("--docker", "force_docker", is_flag=True, help="Force docker build.")
|
|
25
25
|
@click.option("--poetry", "force_poetry", is_flag=True, help="Force poetry build.")
|
|
26
26
|
def build_command(
|
|
27
27
|
folder_name: str | None,
|
|
28
|
-
|
|
28
|
+
push: bool,
|
|
29
29
|
force_docker: bool,
|
|
30
30
|
force_poetry: bool,
|
|
31
31
|
) -> None:
|
|
@@ -33,7 +33,7 @@ def build_command(
|
|
|
33
33
|
Build a folder using docker or poetry with optional publishing.
|
|
34
34
|
|
|
35
35
|
:param folder_name: Optional folder name to build.
|
|
36
|
-
:param
|
|
36
|
+
:param push: Whether publishing is enabled via flag.
|
|
37
37
|
:param force_docker: Force docker build system.
|
|
38
38
|
:param force_poetry: Force poetry build system.
|
|
39
39
|
:returns: ``None``.
|
|
@@ -44,7 +44,7 @@ def build_command(
|
|
|
44
44
|
target_folder = resolve_build_folder(folder_name=folder_name, root=Path.cwd())
|
|
45
45
|
_run_build_for_folder(
|
|
46
46
|
folder=target_folder,
|
|
47
|
-
publish=
|
|
47
|
+
publish=push,
|
|
48
48
|
force_docker=force_docker,
|
|
49
49
|
force_poetry=force_poetry,
|
|
50
50
|
allow_prompt=True,
|
|
@@ -64,7 +64,7 @@ def build_command(
|
|
|
64
64
|
for target_folder in target_folders:
|
|
65
65
|
_run_build_for_folder(
|
|
66
66
|
folder=target_folder,
|
|
67
|
-
publish=
|
|
67
|
+
publish=push,
|
|
68
68
|
force_docker=force_docker,
|
|
69
69
|
force_poetry=force_poetry,
|
|
70
70
|
allow_prompt=False,
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import mimetypes
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._util import detect_distribution_os, parse_distribution_os, _compare_versions
|
|
10
|
+
from .._util import BaseLangeLabsClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DistributionClient(BaseLangeLabsClient):
|
|
14
|
+
"""
|
|
15
|
+
Client for querying public distribution metadata and update availability.
|
|
16
|
+
|
|
17
|
+
:param host: Base app-service URL, e.g. ``https://lange-labs.com``.
|
|
18
|
+
:param api_key: Optional API key supplied directly or resolved from the environment.
|
|
19
|
+
:param timeout: HTTP timeout in seconds for metadata requests.
|
|
20
|
+
:raises ValueError: If ``api_key`` is unavailable or empty.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self,
|
|
24
|
+
distribution_name: str,
|
|
25
|
+
host: str = "https://lange-labs.com",
|
|
26
|
+
api_key: str | None = None,
|
|
27
|
+
timeout: float = 10.0) -> None:
|
|
28
|
+
"""
|
|
29
|
+
Initialize a distribution metadata client for the Lange app service.
|
|
30
|
+
|
|
31
|
+
:param host: Base app-service URL, e.g. ``https://lange-labs.com``.
|
|
32
|
+
:param api_key: Optional API key supplied directly or resolved from the environment.
|
|
33
|
+
:param timeout: HTTP timeout in seconds for metadata requests.
|
|
34
|
+
:param distribution_name: Distribution name as stored by the app service.
|
|
35
|
+
:raises ValueError: If ``api_key`` is unavailable or empty.
|
|
36
|
+
"""
|
|
37
|
+
super().__init__(api_key, host=host)
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
normalized_distribution_name = distribution_name.strip()
|
|
40
|
+
|
|
41
|
+
if not normalized_distribution_name:
|
|
42
|
+
raise ValueError("distribution_name is required.")
|
|
43
|
+
|
|
44
|
+
if " " in normalized_distribution_name:
|
|
45
|
+
raise ValueError("check your distribution name again. spaces are not allowed by design")
|
|
46
|
+
|
|
47
|
+
self.distribution_name = normalized_distribution_name
|
|
48
|
+
|
|
49
|
+
def search_for_update(self, current_version: str) -> str | None:
|
|
50
|
+
"""
|
|
51
|
+
Resolve the newest available version for the current OS when it is newer.
|
|
52
|
+
|
|
53
|
+
:param distribution_name: Distribution name as stored by the app service.
|
|
54
|
+
:param current_version: Current installed version string.
|
|
55
|
+
:returns: Newer version string when available, otherwise ``None``.
|
|
56
|
+
:raises ValueError: If required inputs are missing.
|
|
57
|
+
:raises httpx.HTTPError: If the metadata request fails.
|
|
58
|
+
"""
|
|
59
|
+
normalized_current_version = current_version.strip()
|
|
60
|
+
|
|
61
|
+
if not normalized_current_version:
|
|
62
|
+
raise ValueError("current_version is required.")
|
|
63
|
+
|
|
64
|
+
current_os = detect_distribution_os()
|
|
65
|
+
payload = self._get_distribution_payload()
|
|
66
|
+
versions = payload.get("distribution", {}).get("versions", [])
|
|
67
|
+
|
|
68
|
+
newest_version: str | None = None
|
|
69
|
+
|
|
70
|
+
for version_entry in versions:
|
|
71
|
+
if not isinstance(version_entry, dict):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
version = str(version_entry.get("version", "")).strip()
|
|
75
|
+
artifacts = version_entry.get("artifacts", {})
|
|
76
|
+
artifact = artifacts.get(current_os) if isinstance(artifacts, dict) else None
|
|
77
|
+
|
|
78
|
+
if not version or artifact is None:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
if _compare_versions(version, normalized_current_version) <= 0:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if newest_version is None or _compare_versions(version, newest_version) > 0:
|
|
85
|
+
newest_version = version
|
|
86
|
+
|
|
87
|
+
return newest_version
|
|
88
|
+
|
|
89
|
+
def _build_distribution_url(self) -> str:
|
|
90
|
+
"""
|
|
91
|
+
Build the public metadata endpoint URL for one distribution.
|
|
92
|
+
|
|
93
|
+
:param distribution_name: Distribution name to request.
|
|
94
|
+
:returns: Fully qualified public metadata URL.
|
|
95
|
+
"""
|
|
96
|
+
return f"{self.host}/api/public/distributions/{self.distribution_name}"
|
|
97
|
+
|
|
98
|
+
def _build_artifact_download_url(self, version: str, os_name: str) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Build the public artifact download endpoint URL for one version and OS.
|
|
101
|
+
|
|
102
|
+
:param version: Distribution version to download.
|
|
103
|
+
:param os_name: Operating-system label accepted by the app service.
|
|
104
|
+
:returns: Fully qualified public artifact download URL.
|
|
105
|
+
"""
|
|
106
|
+
return (
|
|
107
|
+
f"{self.host}/api/public/distributions/{self.distribution_name}"
|
|
108
|
+
f"/versions/{version}/artifacts/{os_name}/download"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def _build_versions_url(self) -> str:
|
|
112
|
+
"""
|
|
113
|
+
Build the authenticated distribution versions endpoint URL.
|
|
114
|
+
|
|
115
|
+
:returns: Fully qualified versions collection URL.
|
|
116
|
+
"""
|
|
117
|
+
return f"{self.host}/api/distributions/{self.distribution_name}/versions"
|
|
118
|
+
|
|
119
|
+
def _get_newest_version_artifact(self, os_name: str) -> tuple[str, dict[str, Any]] | None:
|
|
120
|
+
"""
|
|
121
|
+
Resolve the newest downloadable artifact for one operating system.
|
|
122
|
+
|
|
123
|
+
:param os_name: Operating-system label accepted by the app service.
|
|
124
|
+
:returns: Tuple of version string and artifact payload, otherwise ``None``.
|
|
125
|
+
:raises httpx.HTTPError: If the metadata request fails.
|
|
126
|
+
"""
|
|
127
|
+
payload = self._get_distribution_payload()
|
|
128
|
+
versions = payload.get("distribution", {}).get("versions", [])
|
|
129
|
+
newest_match: tuple[str, dict[str, Any]] | None = None
|
|
130
|
+
|
|
131
|
+
for version_entry in versions:
|
|
132
|
+
if not isinstance(version_entry, dict):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
version = str(version_entry.get("version", "")).strip()
|
|
136
|
+
artifacts = version_entry.get("artifacts", {})
|
|
137
|
+
artifact = artifacts.get(os_name) if isinstance(artifacts, dict) else None
|
|
138
|
+
|
|
139
|
+
if not version or not isinstance(artifact, dict):
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if newest_match is None or _compare_versions(version, newest_match[0]) > 0:
|
|
143
|
+
newest_match = (version, artifact)
|
|
144
|
+
|
|
145
|
+
return newest_match
|
|
146
|
+
|
|
147
|
+
def download_to_temp(self) -> Path:
|
|
148
|
+
"""
|
|
149
|
+
Download the newest version artifact for the current OS into ``/tmp``.
|
|
150
|
+
|
|
151
|
+
:returns: Absolute path to the downloaded artifact.
|
|
152
|
+
:raises ValueError: If no downloadable artifact exists for the current OS.
|
|
153
|
+
:raises httpx.HTTPError: If the metadata or artifact download fails.
|
|
154
|
+
"""
|
|
155
|
+
current_os = detect_distribution_os()
|
|
156
|
+
newest_match = self._get_newest_version_artifact(current_os)
|
|
157
|
+
|
|
158
|
+
if newest_match is None:
|
|
159
|
+
raise ValueError(f"No downloadable versions available for operating system: {current_os}.")
|
|
160
|
+
|
|
161
|
+
version, artifact = newest_match
|
|
162
|
+
filename = str(artifact.get("filename", "")).strip()
|
|
163
|
+
|
|
164
|
+
if not filename:
|
|
165
|
+
raise ValueError("Artifact filename is required.")
|
|
166
|
+
|
|
167
|
+
destination_dir = Path("/tmp") / self.distribution_name / "versions" / version
|
|
168
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
destination_path = destination_dir / filename
|
|
170
|
+
|
|
171
|
+
client = httpx.Client(timeout=self.timeout)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
response = client.get(
|
|
175
|
+
self._build_artifact_download_url(version, current_os),
|
|
176
|
+
headers=self._build_connection_headers(),
|
|
177
|
+
)
|
|
178
|
+
response.raise_for_status()
|
|
179
|
+
destination_path.write_bytes(response.content)
|
|
180
|
+
finally:
|
|
181
|
+
client.close()
|
|
182
|
+
|
|
183
|
+
return destination_path
|
|
184
|
+
|
|
185
|
+
def upload(self, path: str | Path, version_name: str, os_name: str) -> dict[str, Any]:
|
|
186
|
+
"""
|
|
187
|
+
Upload one OS-specific distribution artifact for a version.
|
|
188
|
+
|
|
189
|
+
:param path: Artifact file path to upload.
|
|
190
|
+
:param version_name: Version string to associate with the artifact.
|
|
191
|
+
:param os_name: Operating-system label for the artifact.
|
|
192
|
+
:returns: Decoded JSON response payload.
|
|
193
|
+
:raises ValueError: If any input is missing, invalid, or the file does not exist.
|
|
194
|
+
:raises httpx.HTTPError: If the upload request fails.
|
|
195
|
+
"""
|
|
196
|
+
artifact_path = Path(path)
|
|
197
|
+
normalized_version_name = version_name.strip()
|
|
198
|
+
normalized_os_name = parse_distribution_os(os_name)
|
|
199
|
+
|
|
200
|
+
if not normalized_version_name:
|
|
201
|
+
raise ValueError("version_name is required.")
|
|
202
|
+
|
|
203
|
+
if not artifact_path.exists():
|
|
204
|
+
raise ValueError("Artifact file does not exist.")
|
|
205
|
+
|
|
206
|
+
if not artifact_path.is_file():
|
|
207
|
+
raise ValueError("Artifact path must point to a file.")
|
|
208
|
+
|
|
209
|
+
content_type = mimetypes.guess_type(artifact_path.name)[0] or "application/octet-stream"
|
|
210
|
+
file_field_name = f"{normalized_os_name}File"
|
|
211
|
+
|
|
212
|
+
client = httpx.Client(timeout=self.timeout)
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
with artifact_path.open("rb") as artifact_file:
|
|
216
|
+
response = client.post(
|
|
217
|
+
self._build_versions_url(),
|
|
218
|
+
headers=self._build_connection_headers(),
|
|
219
|
+
data={"version": normalized_version_name},
|
|
220
|
+
files={
|
|
221
|
+
file_field_name: (
|
|
222
|
+
artifact_path.name,
|
|
223
|
+
artifact_file,
|
|
224
|
+
content_type,
|
|
225
|
+
)
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
return response.json()
|
|
230
|
+
finally:
|
|
231
|
+
client.close()
|
|
232
|
+
|
|
233
|
+
def _get_distribution_payload(self) -> dict[str, Any]:
|
|
234
|
+
"""
|
|
235
|
+
Fetch the public distribution metadata payload from the app service.
|
|
236
|
+
|
|
237
|
+
:param distribution_name: Distribution name to fetch.
|
|
238
|
+
:returns: Decoded JSON payload.
|
|
239
|
+
:raises httpx.HTTPError: If the request fails or returns invalid JSON.
|
|
240
|
+
"""
|
|
241
|
+
client = httpx.Client(timeout=self.timeout)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
response = client.get(
|
|
245
|
+
self._build_distribution_url(),
|
|
246
|
+
headers=self._build_connection_headers(),
|
|
247
|
+
)
|
|
248
|
+
response.raise_for_status()
|
|
249
|
+
return response.json()
|
|
250
|
+
finally:
|
|
251
|
+
client.close()
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def detect_distribution_os() -> str:
|
|
7
|
+
"""
|
|
8
|
+
Detect the current operating system for distribution artifact selection.
|
|
9
|
+
|
|
10
|
+
:returns: Normalized operating-system label accepted by the app service.
|
|
11
|
+
:raises ValueError: If the current platform is unsupported.
|
|
12
|
+
"""
|
|
13
|
+
system_name = platform.system().strip().lower()
|
|
14
|
+
|
|
15
|
+
if system_name == "windows":
|
|
16
|
+
return "windows"
|
|
17
|
+
|
|
18
|
+
if system_name == "darwin":
|
|
19
|
+
return "macos"
|
|
20
|
+
|
|
21
|
+
if system_name == "linux":
|
|
22
|
+
return "linux"
|
|
23
|
+
|
|
24
|
+
raise ValueError(f"Unsupported operating system: {platform.system()}.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_distribution_os(os_name: str) -> str:
|
|
28
|
+
"""
|
|
29
|
+
Validate and normalize a distribution artifact operating-system label.
|
|
30
|
+
|
|
31
|
+
:param os_name: Untrusted operating-system label.
|
|
32
|
+
:returns: Normalized operating-system label accepted by the app service.
|
|
33
|
+
:raises ValueError: If the operating-system label is missing or unsupported.
|
|
34
|
+
"""
|
|
35
|
+
normalized_os_name = os_name.strip().lower()
|
|
36
|
+
|
|
37
|
+
if not normalized_os_name:
|
|
38
|
+
raise ValueError("os_name is required.")
|
|
39
|
+
|
|
40
|
+
if normalized_os_name not in {"windows", "macos", "linux"}:
|
|
41
|
+
raise ValueError("os_name must be one of: windows, macos, linux.")
|
|
42
|
+
|
|
43
|
+
return normalized_os_name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _compare_versions(left: str, right: str) -> int:
|
|
47
|
+
"""
|
|
48
|
+
Compare two dotted numeric version strings.
|
|
49
|
+
|
|
50
|
+
:param left: Left-hand version string.
|
|
51
|
+
:param right: Right-hand version string.
|
|
52
|
+
:returns: Positive when ``left`` is newer, negative when older, otherwise ``0``.
|
|
53
|
+
"""
|
|
54
|
+
left_parts = _normalize_version_parts(left)
|
|
55
|
+
right_parts = _normalize_version_parts(right)
|
|
56
|
+
max_length = max(len(left_parts), len(right_parts))
|
|
57
|
+
|
|
58
|
+
padded_left = left_parts + (0,) * (max_length - len(left_parts))
|
|
59
|
+
padded_right = right_parts + (0,) * (max_length - len(right_parts))
|
|
60
|
+
|
|
61
|
+
if padded_left > padded_right:
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
if padded_left < padded_right:
|
|
65
|
+
return -1
|
|
66
|
+
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_version_parts(version: str) -> tuple[int, ...]:
|
|
71
|
+
"""
|
|
72
|
+
Convert a dotted numeric version string into comparable integer parts.
|
|
73
|
+
|
|
74
|
+
:param version: Version string to normalize.
|
|
75
|
+
:returns: Comparable integer tuple.
|
|
76
|
+
:raises ValueError: If the version is empty or malformed.
|
|
77
|
+
"""
|
|
78
|
+
normalized_version = version.strip()
|
|
79
|
+
if not normalized_version:
|
|
80
|
+
raise ValueError("Version is required.")
|
|
81
|
+
|
|
82
|
+
parts = normalized_version.split(".")
|
|
83
|
+
normalized_parts: list[int] = []
|
|
84
|
+
|
|
85
|
+
for part in parts:
|
|
86
|
+
if not part.isdigit():
|
|
87
|
+
raise ValueError(f"Unsupported version format: {version}.")
|
|
88
|
+
|
|
89
|
+
normalized_parts.append(int(part))
|
|
90
|
+
|
|
91
|
+
return tuple(normalized_parts)
|
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
"""Tunnel worker client implementation for the Lange tunnel service."""
|
|
2
|
-
|
|
3
1
|
from __future__ import annotations
|
|
4
2
|
|
|
5
3
|
import asyncio
|
|
6
4
|
import base64
|
|
7
5
|
import json
|
|
8
6
|
import logging
|
|
9
|
-
import os
|
|
10
7
|
import ssl
|
|
11
8
|
import threading
|
|
12
9
|
from typing import Any, Optional
|
|
@@ -15,11 +12,13 @@ from urllib.parse import urljoin
|
|
|
15
12
|
import httpx
|
|
16
13
|
import websockets
|
|
17
14
|
from httpx import Timeout
|
|
15
|
+
from .._util import BaseLangeLabsClient
|
|
16
|
+
from ._util import _filter_hop_by_hop_headers
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger("lange.tunnel")
|
|
20
19
|
|
|
21
20
|
|
|
22
|
-
class Tunnel(
|
|
21
|
+
class Tunnel(BaseLangeLabsClient):
|
|
23
22
|
"""
|
|
24
23
|
Thread-based tunnel worker client.
|
|
25
24
|
|
|
@@ -27,35 +26,28 @@ class Tunnel(threading.Thread):
|
|
|
27
26
|
proxy messages to a local HTTP target.
|
|
28
27
|
|
|
29
28
|
:param host: Base service URL, e.g. ``wss://example.com``.
|
|
30
|
-
:param
|
|
31
|
-
:param tunnel_name: Optional explicit tunnel subdomain for keys linked to multiple tunnels.
|
|
29
|
+
:param api_key: Bearer token used for worker authentication.
|
|
32
30
|
:param target: Local HTTP target URL to forward tunnel traffic to.
|
|
33
31
|
:param verify_ssl: Whether to verify TLS certificates for ``wss://`` hosts.
|
|
34
32
|
:param max_retries: Maximum reconnect attempts, ``0`` for infinite retries.
|
|
35
33
|
:param retry_delay: Initial reconnect delay in seconds.
|
|
36
34
|
:param open_timeout: Timeout in seconds for the WebSocket opening handshake.
|
|
37
35
|
:param daemon: Whether the worker thread is daemonized.
|
|
38
|
-
:raises ValueError: If
|
|
36
|
+
:raises ValueError: If API key is unavailable or empty.
|
|
39
37
|
"""
|
|
40
38
|
|
|
41
39
|
def __init__(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
daemon: bool = True,
|
|
40
|
+
self,
|
|
41
|
+
host: str = "wss://tunnel.lange-labs.com",
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
target: str = "http://localhost:80",
|
|
44
|
+
verify_ssl: bool = True,
|
|
45
|
+
max_retries: int = 5,
|
|
46
|
+
retry_delay: float = 5.0,
|
|
47
|
+
open_timeout: float = 20.0,
|
|
48
|
+
daemon: bool = True,
|
|
52
49
|
) -> None:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
super().__init__(daemon=daemon)
|
|
56
|
-
self.host = host.rstrip("/")
|
|
57
|
-
self.secret_key = resolved_secret_key
|
|
58
|
-
self.tunnel_name = tunnel_name.strip().lower() if tunnel_name and tunnel_name.strip() else None
|
|
50
|
+
super().__init__(api_key=api_key, daemon=daemon, host=host)
|
|
59
51
|
self.target = target.rstrip("/")
|
|
60
52
|
self.verify_ssl = verify_ssl
|
|
61
53
|
self.max_retries = max_retries
|
|
@@ -174,24 +166,6 @@ class Tunnel(threading.Thread):
|
|
|
174
166
|
except Exception as exc: # pragma: no cover - best-effort close path
|
|
175
167
|
logger.debug("Failed to close websocket during reconnect: %s", exc)
|
|
176
168
|
|
|
177
|
-
def set_secret_key(self, secret_key: str, reconnect: bool = False) -> None:
|
|
178
|
-
"""
|
|
179
|
-
Replace the bearer token used for future connections.
|
|
180
|
-
|
|
181
|
-
:param secret_key: New non-empty bearer token.
|
|
182
|
-
:param reconnect: Whether to reconnect immediately to apply the token.
|
|
183
|
-
:returns: ``None``.
|
|
184
|
-
:raises ValueError: If ``secret_key`` is empty.
|
|
185
|
-
"""
|
|
186
|
-
if not secret_key.strip():
|
|
187
|
-
raise ValueError("A non-empty secret_key is required for tunnel worker authentication.")
|
|
188
|
-
|
|
189
|
-
with self._lock:
|
|
190
|
-
self.secret_key = secret_key.strip()
|
|
191
|
-
|
|
192
|
-
if reconnect:
|
|
193
|
-
self.reconnect()
|
|
194
|
-
|
|
195
169
|
async def _run_async(self) -> None:
|
|
196
170
|
"""
|
|
197
171
|
Run the worker connection loop with reconnect backoff.
|
|
@@ -212,11 +186,11 @@ class Tunnel(threading.Thread):
|
|
|
212
186
|
try:
|
|
213
187
|
logger.info("Connecting to %s", tunnel_url)
|
|
214
188
|
async with websockets.connect(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
189
|
+
tunnel_url,
|
|
190
|
+
additional_headers=headers,
|
|
191
|
+
ssl=ssl_context,
|
|
192
|
+
proxy=None,
|
|
193
|
+
open_timeout=self.open_timeout,
|
|
220
194
|
) as ws:
|
|
221
195
|
with self._lock:
|
|
222
196
|
self._active_ws = ws
|
|
@@ -267,9 +241,9 @@ class Tunnel(threading.Thread):
|
|
|
267
241
|
|
|
268
242
|
wait_time = 0.0
|
|
269
243
|
while (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
244
|
+
wait_time < current_delay
|
|
245
|
+
and not self._stop_event.is_set()
|
|
246
|
+
and not self._reconnect_event.is_set()
|
|
273
247
|
):
|
|
274
248
|
await asyncio.sleep(0.5)
|
|
275
249
|
wait_time += 0.5
|
|
@@ -349,7 +323,7 @@ class Tunnel(threading.Thread):
|
|
|
349
323
|
body = base64.b64decode(body_b64) if body_b64 else None
|
|
350
324
|
|
|
351
325
|
url = urljoin(f"{self.target}/", path.lstrip("/"))
|
|
352
|
-
filtered_headers =
|
|
326
|
+
filtered_headers = _filter_hop_by_hop_headers(headers)
|
|
353
327
|
|
|
354
328
|
try:
|
|
355
329
|
response = await client.request(
|
|
@@ -383,26 +357,13 @@ class Tunnel(threading.Thread):
|
|
|
383
357
|
"""
|
|
384
358
|
return f"{self.host}/api/tunnels/connection"
|
|
385
359
|
|
|
386
|
-
def _build_connection_headers(self) -> dict[str, str]:
|
|
387
|
-
"""
|
|
388
|
-
Build outbound handshake headers.
|
|
389
|
-
|
|
390
|
-
:returns: Header dictionary with bearer authorization and optional tunnel name.
|
|
391
|
-
"""
|
|
392
|
-
with self._lock:
|
|
393
|
-
headers = {"Authorization": f"Bearer {self.secret_key}"}
|
|
394
|
-
if self.tunnel_name is not None:
|
|
395
|
-
headers["X-Tunnel-Name"] = self.tunnel_name
|
|
396
|
-
|
|
397
|
-
return headers
|
|
398
|
-
|
|
399
360
|
def _set_connected(
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
361
|
+
self,
|
|
362
|
+
value: bool,
|
|
363
|
+
remote_address: Optional[str] = None,
|
|
364
|
+
remote_address_roundrobin: Optional[str] = None,
|
|
365
|
+
worker_index: int = -1,
|
|
366
|
+
pool_size: int = 0,
|
|
406
367
|
) -> None:
|
|
407
368
|
"""
|
|
408
369
|
Update connection state atomically.
|
|
@@ -439,42 +400,3 @@ class Tunnel(threading.Thread):
|
|
|
439
400
|
ssl_context.check_hostname = False
|
|
440
401
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
441
402
|
return ssl_context
|
|
442
|
-
|
|
443
|
-
@staticmethod
|
|
444
|
-
def _filter_hop_by_hop_headers(headers: Any) -> dict[str, str]:
|
|
445
|
-
"""
|
|
446
|
-
Remove hop-by-hop headers before forwarding to the local target.
|
|
447
|
-
|
|
448
|
-
:param headers: Request header mapping.
|
|
449
|
-
:returns: Filtered header mapping.
|
|
450
|
-
"""
|
|
451
|
-
if not isinstance(headers, dict):
|
|
452
|
-
return {}
|
|
453
|
-
|
|
454
|
-
hop_by_hop = {"connection", "keep-alive", "transfer-encoding", "upgrade"}
|
|
455
|
-
return {
|
|
456
|
-
str(key): str(value)
|
|
457
|
-
for key, value in headers.items()
|
|
458
|
-
if str(key).lower() not in hop_by_hop
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _resolve_secret_key(secret_key: str | None) -> str:
|
|
463
|
-
"""
|
|
464
|
-
Resolve secret key from argument or environment.
|
|
465
|
-
|
|
466
|
-
:param secret_key: Optional direct secret key value.
|
|
467
|
-
:returns: Sanitized non-empty secret key.
|
|
468
|
-
:raises ValueError: If no non-empty secret key is available.
|
|
469
|
-
"""
|
|
470
|
-
if secret_key is not None and secret_key.strip():
|
|
471
|
-
return secret_key.strip()
|
|
472
|
-
|
|
473
|
-
env_secret_key = os.getenv("LANGE_SECRET_KEY", "")
|
|
474
|
-
if env_secret_key.strip():
|
|
475
|
-
return env_secret_key.strip()
|
|
476
|
-
|
|
477
|
-
raise ValueError(
|
|
478
|
-
"A non-empty secret_key is required. "
|
|
479
|
-
"Pass secret_key directly or set LANGE_SECRET_KEY."
|
|
480
|
-
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
def _filter_hop_by_hop_headers(headers: Any) -> dict[str, str]:
|
|
4
|
+
"""
|
|
5
|
+
Remove hop-by-hop headers before forwarding to the local target.
|
|
6
|
+
|
|
7
|
+
:param headers: Request header mapping.
|
|
8
|
+
:returns: Filtered header mapping.
|
|
9
|
+
"""
|
|
10
|
+
if not isinstance(headers, dict):
|
|
11
|
+
return {}
|
|
12
|
+
|
|
13
|
+
hop_by_hop = {"connection", "keep-alive", "transfer-encoding", "upgrade"}
|
|
14
|
+
return {
|
|
15
|
+
str(key): str(value)
|
|
16
|
+
for key, value in headers.items()
|
|
17
|
+
if str(key).lower() not in hop_by_hop
|
|
18
|
+
}
|
|
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
|