lange-python 0.3.0__tar.gz → 0.3.2__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.0 → lange_python-0.3.2}/PKG-INFO +14 -1
  2. {lange_python-0.3.0 → lange_python-0.3.2}/README.md +13 -0
  3. lange_python-0.3.2/lange/__init__.py +6 -0
  4. lange_python-0.3.2/lange/_util/__init__.py +5 -0
  5. lange_python-0.3.2/lange/_util/_base_client.py +30 -0
  6. lange_python-0.3.2/lange/_util/_key_handling.py +21 -0
  7. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/__init__.py +5 -2
  8. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/_command.py +8 -6
  9. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/_poetry.py +10 -0
  10. lange_python-0.3.2/lange/cli/distribution/__init__.py +5 -0
  11. lange_python-0.3.2/lange/cli/distribution/_command.py +55 -0
  12. lange_python-0.3.2/lange/distribution/__init__.py +7 -0
  13. lange_python-0.3.2/lange/distribution/_client.py +250 -0
  14. lange_python-0.3.2/lange/distribution/_util.py +91 -0
  15. lange_python-0.3.2/lange/tunnel/__init__.py +7 -0
  16. lange_python-0.3.0/lange/tunnel/__init__.py → lange_python-0.3.2/lange/tunnel/_client.py +30 -108
  17. lange_python-0.3.2/lange/tunnel/_util.py +18 -0
  18. {lange_python-0.3.0 → lange_python-0.3.2}/pyproject.toml +1 -1
  19. lange_python-0.3.0/lange/__init__.py +0 -5
  20. {lange_python-0.3.0 → lange_python-0.3.2}/lange/__main__.py +0 -0
  21. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/__init__.py +0 -0
  22. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/_discovery.py +0 -0
  23. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/_docker.py +0 -0
  24. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/build/_types.py +0 -0
  25. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/code/__init__.py +0 -0
  26. {lange_python-0.3.0 → lange_python-0.3.2}/lange/cli/code/_stats.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lange-python
3
- Version: 0.3.0
3
+ Version: 0.3.2
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.13
@@ -17,6 +17,19 @@ Description-Content-Type: text/markdown
17
17
 
18
18
  Python helpers and clients for Lange services.
19
19
 
20
+ ## Distribution CLI
21
+
22
+ Publish a distribution artifact to the app services distribution system:
23
+
24
+ ```bash
25
+ export LANGE_LABS_API_KEY="your-api-key"
26
+ lange distribution publish \
27
+ --path ./dist/app.dmg \
28
+ --version 1.2.3 \
29
+ --distribution-name desktop-app \
30
+ --os macos
31
+ ```
32
+
20
33
  ## Tunnel worker
21
34
 
22
35
  ```python
@@ -2,6 +2,19 @@
2
2
 
3
3
  Python helpers and clients for Lange services.
4
4
 
5
+ ## Distribution CLI
6
+
7
+ Publish a distribution artifact to the app services distribution system:
8
+
9
+ ```bash
10
+ export LANGE_LABS_API_KEY="your-api-key"
11
+ lange distribution publish \
12
+ --path ./dist/app.dmg \
13
+ --version 1.2.3 \
14
+ --distribution-name desktop-app \
15
+ --os macos
16
+ ```
17
+
5
18
  ## Tunnel worker
6
19
 
7
20
  ```python
@@ -0,0 +1,6 @@
1
+ """Public package exports for lange-python."""
2
+
3
+ from .distribution import DistributionClient
4
+ from .tunnel import Tunnel
5
+
6
+ __all__ = ["Tunnel", "DistributionClient"]
@@ -0,0 +1,5 @@
1
+ from ._base_client import BaseLangeLabsClient
2
+
3
+ __all__ = [
4
+ "BaseLangeLabsClient",
5
+ ]
@@ -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
+ )
@@ -1,11 +1,13 @@
1
1
  """CLI commands for the ``lange`` Python package."""
2
2
 
3
3
  from __future__ import annotations
4
- from .code import code_group
5
- from .build import build_command
6
4
 
7
5
  import click
8
6
 
7
+ from .build import build_command
8
+ from .code import code_group
9
+ from .distribution import distribution_group
10
+
9
11
 
10
12
 
11
13
  @click.group()
@@ -19,3 +21,4 @@ def cli() -> None:
19
21
 
20
22
  cli.add_command(code_group, "code")
21
23
  cli.add_command(build_command, "build")
24
+ cli.add_command(distribution_group, "distribution")
@@ -14,18 +14,18 @@ from ._discovery import (
14
14
  resolve_build_folder,
15
15
  )
16
16
  from ._docker import ensure_docker_is_available, parse_image_reference, run_docker_build
17
- from ._poetry import run_poetry_build, run_poetry_publish
17
+ from ._poetry import run_poetry_build, run_poetry_publish, run_poetry_version_patch
18
18
  from ._types import DOCKER_BUILD_SYSTEM, POETRY_BUILD_SYSTEM, BuildSystem
19
19
 
20
20
 
21
21
  @click.command("build")
22
22
  @click.argument("folder_name", required=False)
23
- @click.option("--publish", is_flag=True, help="Publish after a successful build.")
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
- publish: bool,
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 publish: Whether publishing is enabled via flag.
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=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=publish,
67
+ publish=push,
68
68
  force_docker=force_docker,
69
69
  force_poetry=force_poetry,
70
70
  allow_prompt=False,
@@ -191,6 +191,8 @@ def _run_poetry_flow(folder: Path, publish: bool) -> None:
191
191
  :returns: ``None``.
192
192
  """
193
193
  try:
194
+ if publish:
195
+ run_poetry_version_patch(folder=folder)
194
196
  run_poetry_build(folder=folder)
195
197
  if publish or _confirm_publish():
196
198
  run_poetry_publish(folder=folder)
@@ -16,6 +16,16 @@ def run_poetry_build(folder: Path) -> None:
16
16
  subprocess.run(["poetry", "build"], check=True, cwd=folder)
17
17
 
18
18
 
19
+ def run_poetry_version_patch(folder: Path) -> None:
20
+ """
21
+ Run ``poetry version patch`` in the target folder.
22
+
23
+ :param folder: Target folder containing ``pyproject.toml``.
24
+ :returns: ``None``.
25
+ """
26
+ subprocess.run(["poetry", "version", "patch"], check=True, cwd=folder)
27
+
28
+
19
29
  def run_poetry_publish(folder: Path) -> None:
20
30
  """
21
31
  Run ``poetry publish`` in the target folder.
@@ -0,0 +1,5 @@
1
+ """Distribution-related CLI commands for the ``lange`` package."""
2
+
3
+ from ._command import distribution_group
4
+
5
+ __all__ = ["distribution_group"]
@@ -0,0 +1,55 @@
1
+ """CLI commands for publishing distribution artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from ...distribution import DistributionClient
11
+
12
+
13
+ @click.group("distribution")
14
+ def distribution_group() -> None:
15
+ """
16
+ Group distribution-related CLI commands.
17
+
18
+ :returns: ``None``.
19
+ """
20
+
21
+
22
+ @distribution_group.command("publish")
23
+ @click.option("--path", "artifact_path", required=True, type=click.Path(path_type=Path), help="Artifact file to upload.")
24
+ @click.option("--version", "version_name", required=True, type=str, help="Distribution version to publish.")
25
+ @click.option(
26
+ "--distribution-name",
27
+ required=True,
28
+ type=str,
29
+ help="Distribution name in the app services distribution system.",
30
+ )
31
+ @click.option("--os", "os_name", required=True, type=str, help="Artifact operating system: windows, macos, or linux.")
32
+ def publish_distribution_command(
33
+ artifact_path: Path,
34
+ version_name: str,
35
+ distribution_name: str,
36
+ os_name: str,
37
+ ) -> None:
38
+ """
39
+ Publish one distribution artifact to the app services distribution system.
40
+
41
+ :param artifact_path: Artifact file path to upload.
42
+ :param version_name: Version string associated with the artifact.
43
+ :param distribution_name: Distribution name in the app service.
44
+ :param os_name: Operating-system label for the uploaded artifact.
45
+ :returns: ``None``.
46
+ """
47
+ try:
48
+ client = DistributionClient(distribution_name=distribution_name)
49
+ client.upload(path=artifact_path, version_name=version_name, os_name=os_name)
50
+ except (ValueError, httpx.HTTPError) as error:
51
+ raise click.ClickException(str(error)) from error
52
+
53
+ click.echo(
54
+ f"Published distribution '{distribution_name}' version '{version_name}' for '{os_name}'."
55
+ )
@@ -0,0 +1,7 @@
1
+ import logging
2
+
3
+ logger = logging.getLogger("lange.distribution")
4
+
5
+ from ._client import DistributionClient
6
+
7
+ __all__ = ["DistributionClient"]
@@ -0,0 +1,250 @@
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
+ :returns: Decoded JSON payload.
238
+ :raises httpx.HTTPError: If the request fails or returns invalid JSON.
239
+ """
240
+ client = httpx.Client(timeout=self.timeout)
241
+
242
+ try:
243
+ response = client.get(
244
+ self._build_distribution_url(),
245
+ headers=self._build_connection_headers(),
246
+ )
247
+ response.raise_for_status()
248
+ return response.json()
249
+ finally:
250
+ 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)
@@ -0,0 +1,7 @@
1
+ """Tunnel worker client implementation for the Lange tunnel service."""
2
+
3
+
4
+
5
+ from ._client import Tunnel
6
+
7
+ __all__ = ["Tunnel"]
@@ -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(threading.Thread):
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 secret_key: Bearer token used for worker authentication.
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 secret key is unavailable or empty.
36
+ :raises ValueError: If API key is unavailable or empty.
39
37
  """
40
38
 
41
39
  def __init__(
42
- self,
43
- host: str = "wss://tunnel.lange-labs.com",
44
- secret_key: str | None = None,
45
- tunnel_name: str | None = None,
46
- target: str = "http://localhost:80",
47
- verify_ssl: bool = True,
48
- max_retries: int = 5,
49
- retry_delay: float = 5.0,
50
- open_timeout: float = 20.0,
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
- resolved_secret_key = _resolve_secret_key(secret_key)
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
- tunnel_url,
216
- additional_headers=headers,
217
- ssl=ssl_context,
218
- proxy=None,
219
- open_timeout=self.open_timeout,
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
- wait_time < current_delay
271
- and not self._stop_event.is_set()
272
- and not self._reconnect_event.is_set()
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 = self._filter_hop_by_hop_headers(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
- self,
401
- value: bool,
402
- remote_address: Optional[str] = None,
403
- remote_address_roundrobin: Optional[str] = None,
404
- worker_index: int = -1,
405
- pool_size: int = 0,
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
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.3.0"
3
+ version = "0.3.2"
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,5 +0,0 @@
1
- """Public package exports for lange-python."""
2
-
3
- from .tunnel import Tunnel
4
-
5
- __all__ = ["Tunnel"]