lange-python 0.2.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.
Files changed (24) hide show
  1. {lange_python-0.2.0 → lange_python-0.3.1}/PKG-INFO +4 -3
  2. {lange_python-0.2.0 → lange_python-0.3.1}/README.md +3 -2
  3. lange_python-0.3.1/lange/__init__.py +6 -0
  4. lange_python-0.3.1/lange/_util/__init__.py +5 -0
  5. lange_python-0.3.1/lange/_util/_base_client.py +30 -0
  6. lange_python-0.3.1/lange/_util/_key_handling.py +21 -0
  7. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/__init__.py +1 -0
  8. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/_command.py +60 -7
  9. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/_discovery.py +23 -0
  10. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/code/__init__.py +1 -1
  11. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/code/_stats.py +76 -6
  12. lange_python-0.3.1/lange/distribution/__init__.py +7 -0
  13. lange_python-0.3.1/lange/distribution/_client.py +251 -0
  14. lange_python-0.3.1/lange/distribution/_util.py +91 -0
  15. lange_python-0.3.1/lange/tunnel/__init__.py +7 -0
  16. lange_python-0.2.0/lange/tunnel/__init__.py → lange_python-0.3.1/lange/tunnel/_client.py +33 -77
  17. lange_python-0.3.1/lange/tunnel/_util.py +18 -0
  18. {lange_python-0.2.0 → lange_python-0.3.1}/pyproject.toml +1 -1
  19. lange_python-0.2.0/lange/__init__.py +0 -5
  20. {lange_python-0.2.0 → lange_python-0.3.1}/lange/__main__.py +0 -0
  21. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/__init__.py +0 -0
  22. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/_docker.py +0 -0
  23. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/_poetry.py +0 -0
  24. {lange_python-0.2.0 → lange_python-0.3.1}/lange/cli/build/_types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lange-python
3
- Version: 0.2.0
3
+ Version: 0.3.1
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
@@ -23,8 +23,9 @@ Python helpers and clients for Lange services.
23
23
  from lange.tunnel import Tunnel
24
24
 
25
25
  tunnel = Tunnel(
26
- host="example.com",
27
- secret="your-bearer-token",
26
+ host="wss://tunnel.lange-labs.com",
27
+ secret_key="your-bearer-token",
28
+ tunnel_name="dev-edge", # Optional but recommended when one key is linked to multiple tunnels.
28
29
  target="http://localhost:3000",
29
30
  )
30
31
 
@@ -8,8 +8,9 @@ Python helpers and clients for Lange services.
8
8
  from lange.tunnel import Tunnel
9
9
 
10
10
  tunnel = Tunnel(
11
- host="example.com",
12
- secret="your-bearer-token",
11
+ host="wss://tunnel.lange-labs.com",
12
+ secret_key="your-bearer-token",
13
+ tunnel_name="dev-edge", # Optional but recommended when one key is linked to multiple tunnels.
13
14
  target="http://localhost:3000",
14
15
  )
15
16
 
@@ -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
+ )
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
 
11
11
  @click.group()
12
+ @click.version_option(package_name="lange-python", message="%(version)s")
12
13
  def cli() -> None:
13
14
  """
14
15
  Lange CLI entrypoint.
@@ -9,6 +9,7 @@ import click
9
9
 
10
10
  from ._discovery import (
11
11
  detect_available_build_systems,
12
+ list_buildable_folders,
12
13
  prompt_for_build_system_selection,
13
14
  resolve_build_folder,
14
15
  )
@@ -19,12 +20,12 @@ from ._types import DOCKER_BUILD_SYSTEM, POETRY_BUILD_SYSTEM, BuildSystem
19
20
 
20
21
  @click.command("build")
21
22
  @click.argument("folder_name", required=False)
22
- @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.")
23
24
  @click.option("--docker", "force_docker", is_flag=True, help="Force docker build.")
24
25
  @click.option("--poetry", "force_poetry", is_flag=True, help="Force poetry build.")
25
26
  def build_command(
26
27
  folder_name: str | None,
27
- publish: bool,
28
+ push: bool,
28
29
  force_docker: bool,
29
30
  force_poetry: bool,
30
31
  ) -> None:
@@ -32,25 +33,73 @@ def build_command(
32
33
  Build a folder using docker or poetry with optional publishing.
33
34
 
34
35
  :param folder_name: Optional folder name to build.
35
- :param publish: Whether publishing is enabled via flag.
36
+ :param push: Whether publishing is enabled via flag.
36
37
  :param force_docker: Force docker build system.
37
38
  :param force_poetry: Force poetry build system.
38
39
  :returns: ``None``.
39
40
  """
40
41
  _validate_force_flags(force_docker=force_docker, force_poetry=force_poetry)
41
42
 
42
- target_folder = resolve_build_folder(folder_name=folder_name, root=Path.cwd())
43
+ if folder_name:
44
+ target_folder = resolve_build_folder(folder_name=folder_name, root=Path.cwd())
45
+ _run_build_for_folder(
46
+ folder=target_folder,
47
+ publish=push,
48
+ force_docker=force_docker,
49
+ force_poetry=force_poetry,
50
+ allow_prompt=True,
51
+ )
52
+ return
53
+
54
+ target_folders = list_buildable_folders(
55
+ root=Path.cwd(),
56
+ force_docker=force_docker,
57
+ force_poetry=force_poetry,
58
+ )
59
+ if not target_folders:
60
+ raise click.ClickException(
61
+ "No buildable services were found in the current directory."
62
+ )
63
+
64
+ for target_folder in target_folders:
65
+ _run_build_for_folder(
66
+ folder=target_folder,
67
+ publish=push,
68
+ force_docker=force_docker,
69
+ force_poetry=force_poetry,
70
+ allow_prompt=False,
71
+ )
72
+
73
+
74
+ def _run_build_for_folder(
75
+ folder: Path,
76
+ publish: bool,
77
+ force_docker: bool,
78
+ force_poetry: bool,
79
+ allow_prompt: bool,
80
+ ) -> None:
81
+ """
82
+ Resolve build system and run the matching build flow for one folder.
83
+
84
+ :param folder: Folder to build.
85
+ :param publish: Whether publishing is enabled via flag.
86
+ :param force_docker: Force docker build system.
87
+ :param force_poetry: Force poetry build system.
88
+ :param allow_prompt: Whether interactive system selection is allowed.
89
+ :returns: ``None``.
90
+ """
43
91
  build_system = resolve_build_system(
44
- folder=target_folder,
92
+ folder=folder,
45
93
  force_docker=force_docker,
46
94
  force_poetry=force_poetry,
95
+ allow_prompt=allow_prompt,
47
96
  )
48
97
 
49
98
  if build_system == DOCKER_BUILD_SYSTEM:
50
- _run_docker_flow(folder=target_folder, publish=publish)
99
+ _run_docker_flow(folder=folder, publish=publish)
51
100
  return
52
101
 
53
- _run_poetry_flow(folder=target_folder, publish=publish)
102
+ _run_poetry_flow(folder=folder, publish=publish)
54
103
 
55
104
 
56
105
  def _validate_force_flags(force_docker: bool, force_poetry: bool) -> None:
@@ -69,6 +118,7 @@ def resolve_build_system(
69
118
  folder: Path,
70
119
  force_docker: bool,
71
120
  force_poetry: bool,
121
+ allow_prompt: bool = True,
72
122
  ) -> BuildSystem:
73
123
  """
74
124
  Resolve the build system from force flags or discovered files.
@@ -76,6 +126,7 @@ def resolve_build_system(
76
126
  :param folder: Folder that should be built.
77
127
  :param force_docker: Whether docker was forced.
78
128
  :param force_poetry: Whether poetry was forced.
129
+ :param allow_prompt: Whether selection prompts are allowed for ambiguity.
79
130
  :returns: Resolved build system value.
80
131
  """
81
132
  if force_docker:
@@ -102,6 +153,8 @@ def resolve_build_system(
102
153
  )
103
154
  if len(detected_systems) == 1:
104
155
  return detected_systems[0]
156
+ if not allow_prompt:
157
+ return detected_systems[0]
105
158
  return prompt_for_build_system_selection(detected_systems)
106
159
 
107
160
 
@@ -67,6 +67,29 @@ def resolve_build_folder(folder_name: str | None, root: Path) -> Path:
67
67
  return prompt_for_folder_selection(folders).resolve()
68
68
 
69
69
 
70
+ def list_buildable_folders(
71
+ root: Path,
72
+ force_docker: bool,
73
+ force_poetry: bool,
74
+ ) -> list[Path]:
75
+ """
76
+ List top-level folders that are buildable for the selected mode.
77
+
78
+ :param root: Directory that should be scanned.
79
+ :param force_docker: Whether docker mode was explicitly forced.
80
+ :param force_poetry: Whether poetry mode was explicitly forced.
81
+ :returns: Sorted list of buildable folders.
82
+ """
83
+ candidates = list_candidate_folders(root)
84
+ if force_docker:
85
+ return [folder for folder in candidates if (folder / "Dockerfile").is_file()]
86
+ if force_poetry:
87
+ return [folder for folder in candidates if (folder / "pyproject.toml").is_file()]
88
+ return [
89
+ folder for folder in candidates if detect_available_build_systems(folder)
90
+ ]
91
+
92
+
70
93
  def detect_available_build_systems(folder: Path) -> list[BuildSystem]:
71
94
  """
72
95
  Detect supported build systems available in the given folder.
@@ -10,4 +10,4 @@ def code_group() -> None:
10
10
  :returns: ``None``.
11
11
  """
12
12
 
13
- code_group.add_command(code_stats,"code_stats")
13
+ code_group.add_command(code_stats,"stats")
@@ -3,8 +3,60 @@ from typing import Iterable
3
3
  import os
4
4
  import click
5
5
 
6
- SUPPORTED_EXTENSIONS: tuple[str, ...] = (".py", ".tsx", ".js", ".jsx", ".ts", ".html", ".css")
7
- IGNORED_DIRECTORIES: tuple[str, ...] = (".venv", "node_modules", ".git", ".next")
6
+ SUPPORTED_EXTENSIONS: tuple[str, ...] = (
7
+ # Python
8
+ ".py",
9
+ # JavaScript / TypeScript / React
10
+ ".js", ".jsx", ".ts", ".tsx",
11
+ # Web (Markup / Styling)
12
+ ".html", ".css", ".scss", ".sass", ".less",
13
+ # Shell / Bash
14
+ ".sh", ".bash", ".zsh",
15
+ # C / C++
16
+ ".c", ".h", ".cpp", ".hpp", ".cc", ".cxx",
17
+ # Java
18
+ ".java",
19
+ # C#
20
+ ".cs",
21
+ # Ruby
22
+ ".rb",
23
+ # PHP
24
+ ".php",
25
+ # Go
26
+ ".go",
27
+ # Rust
28
+ ".rs",
29
+ # Swift
30
+ ".swift",
31
+ # Kotlin
32
+ ".kt",
33
+ # Dart
34
+ ".dart",
35
+ # Lua
36
+ ".lua",
37
+ # SQL
38
+ ".sql",
39
+ # R
40
+ ".r",
41
+ # Perl
42
+ ".pl",
43
+ # Scala
44
+ ".scala",
45
+ )
46
+
47
+ IGNORED_DIRECTORIES: tuple[str, ...] = (
48
+ ".venv", "venv", "env",
49
+ "node_modules",
50
+ ".git",
51
+ ".next",
52
+ "__pycache__",
53
+ "build",
54
+ "dist",
55
+ "target",
56
+ "vendor",
57
+ ".idea",
58
+ ".vscode"
59
+ )
8
60
 
9
61
 
10
62
  def _render_stats_table(stats: dict[str, int]) -> str:
@@ -18,6 +70,10 @@ def _render_stats_table(stats: dict[str, int]) -> str:
18
70
  rows: list[tuple[str, str, str]] = []
19
71
 
20
72
  for extension, loc in sorted(stats.items(), key=lambda item: (-item[1], item[0])):
73
+ # Skip extensions with 0 lines to keep the table clean
74
+ if loc == 0:
75
+ continue
76
+
21
77
  percentage = (loc / total * 100.0) if total else 0.0
22
78
  rows.append((extension, str(loc), f"{percentage:.2f}%"))
23
79
 
@@ -41,6 +97,7 @@ def _render_stats_table(stats: dict[str, int]) -> str:
41
97
  lines.append(border)
42
98
  return "\n".join(lines)
43
99
 
100
+
44
101
  def _count_lines_by_extension(root: Path, extensions: Iterable[str]) -> dict[str, int]:
45
102
  """
46
103
  Count file lines recursively grouped by file ending.
@@ -61,11 +118,16 @@ def _count_lines_by_extension(root: Path, extensions: Iterable[str]) -> dict[str
61
118
  continue
62
119
 
63
120
  file_path = Path(current_root) / file_name
64
- with file_path.open("r", encoding="utf-8", errors="ignore") as file_handle:
65
- counts[suffix] += sum(1 for _ in file_handle)
121
+ try:
122
+ with file_path.open("r", encoding="utf-8", errors="ignore") as file_handle:
123
+ counts[suffix] += sum(1 for _ in file_handle)
124
+ except Exception:
125
+ # Silently skip files that can't be opened (e.g., permissions issues)
126
+ pass
66
127
 
67
128
  return counts
68
129
 
130
+
69
131
  @click.command("stats")
70
132
  def code_stats() -> None:
71
133
  """
@@ -74,8 +136,16 @@ def code_stats() -> None:
74
136
  :returns: ``None``.
75
137
  """
76
138
  stats = _count_lines_by_extension(Path.cwd(), SUPPORTED_EXTENSIONS)
139
+
140
+ # Filter out empty languages for the recognized printout so it's not overwhelming
141
+ active_extensions = [ext for ext, count in stats.items() if count > 0]
142
+
77
143
  click.echo()
78
144
  click.echo()
79
- click.echo(f"Recognized file endings: {' '.join(SUPPORTED_EXTENSIONS)}")
145
+ click.echo(f"Recognized file endings found: {' '.join(active_extensions) if active_extensions else 'None'}")
80
146
  click.echo(f"Ignored folders: {' '.join(IGNORED_DIRECTORIES)}")
81
- click.echo(_render_stats_table(stats))
147
+ click.echo(_render_stats_table(stats))
148
+
149
+
150
+ if __name__ == '__main__':
151
+ code_stats()
@@ -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,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)
@@ -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,5 +1,3 @@
1
- """Tunnel worker client implementation for the Lange tunnel service."""
2
-
3
1
  from __future__ import annotations
4
2
 
5
3
  import asyncio
@@ -14,11 +12,13 @@ from urllib.parse import urljoin
14
12
  import httpx
15
13
  import websockets
16
14
  from httpx import Timeout
15
+ from .._util import BaseLangeLabsClient
16
+ from ._util import _filter_hop_by_hop_headers
17
17
 
18
18
  logger = logging.getLogger("lange.tunnel")
19
19
 
20
20
 
21
- class Tunnel(threading.Thread):
21
+ class Tunnel(BaseLangeLabsClient):
22
22
  """
23
23
  Thread-based tunnel worker client.
24
24
 
@@ -26,35 +26,33 @@ class Tunnel(threading.Thread):
26
26
  proxy messages to a local HTTP target.
27
27
 
28
28
  :param host: Base service URL, e.g. ``wss://example.com``.
29
- :param secret: Bearer token used for worker authentication.
29
+ :param api_key: Bearer token used for worker authentication.
30
30
  :param target: Local HTTP target URL to forward tunnel traffic to.
31
31
  :param verify_ssl: Whether to verify TLS certificates for ``wss://`` hosts.
32
32
  :param max_retries: Maximum reconnect attempts, ``0`` for infinite retries.
33
33
  :param retry_delay: Initial reconnect delay in seconds.
34
+ :param open_timeout: Timeout in seconds for the WebSocket opening handshake.
34
35
  :param daemon: Whether the worker thread is daemonized.
35
- :raises ValueError: If ``secret`` is empty.
36
+ :raises ValueError: If API key is unavailable or empty.
36
37
  """
37
38
 
38
39
  def __init__(
39
- self,
40
- host: str,
41
- secret: str,
42
- target: str = "http://localhost:80",
43
- verify_ssl: bool = True,
44
- max_retries: int = 5,
45
- retry_delay: float = 5.0,
46
- 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,
47
49
  ) -> None:
48
- if not secret.strip():
49
- raise ValueError("A non-empty secret is required for tunnel worker authentication.")
50
-
51
- super().__init__(daemon=daemon)
52
- self.host = host.rstrip("/")
53
- self.secret = secret.strip()
50
+ super().__init__(api_key=api_key, daemon=daemon, host=host)
54
51
  self.target = target.rstrip("/")
55
52
  self.verify_ssl = verify_ssl
56
53
  self.max_retries = max_retries
57
54
  self.retry_delay = retry_delay
55
+ self.open_timeout = open_timeout
58
56
 
59
57
  self._max_retry_delay = 60.0
60
58
  self._stop_event = threading.Event()
@@ -168,24 +166,6 @@ class Tunnel(threading.Thread):
168
166
  except Exception as exc: # pragma: no cover - best-effort close path
169
167
  logger.debug("Failed to close websocket during reconnect: %s", exc)
170
168
 
171
- def set_secret(self, secret: str, reconnect: bool = False) -> None:
172
- """
173
- Replace the bearer token used for future connections.
174
-
175
- :param secret: New non-empty bearer token.
176
- :param reconnect: Whether to reconnect immediately to apply the token.
177
- :returns: ``None``.
178
- :raises ValueError: If ``secret`` is empty.
179
- """
180
- if not secret.strip():
181
- raise ValueError("A non-empty secret is required for tunnel worker authentication.")
182
-
183
- with self._lock:
184
- self.secret = secret.strip()
185
-
186
- if reconnect:
187
- self.reconnect()
188
-
189
169
  async def _run_async(self) -> None:
190
170
  """
191
171
  Run the worker connection loop with reconnect backoff.
@@ -206,9 +186,11 @@ class Tunnel(threading.Thread):
206
186
  try:
207
187
  logger.info("Connecting to %s", tunnel_url)
208
188
  async with websockets.connect(
209
- tunnel_url,
210
- additional_headers=headers,
211
- ssl=ssl_context,
189
+ tunnel_url,
190
+ additional_headers=headers,
191
+ ssl=ssl_context,
192
+ proxy=None,
193
+ open_timeout=self.open_timeout,
212
194
  ) as ws:
213
195
  with self._lock:
214
196
  self._active_ws = ws
@@ -259,9 +241,9 @@ class Tunnel(threading.Thread):
259
241
 
260
242
  wait_time = 0.0
261
243
  while (
262
- wait_time < current_delay
263
- and not self._stop_event.is_set()
264
- 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()
265
247
  ):
266
248
  await asyncio.sleep(0.5)
267
249
  wait_time += 0.5
@@ -341,7 +323,7 @@ class Tunnel(threading.Thread):
341
323
  body = base64.b64decode(body_b64) if body_b64 else None
342
324
 
343
325
  url = urljoin(f"{self.target}/", path.lstrip("/"))
344
- filtered_headers = self._filter_hop_by_hop_headers(headers)
326
+ filtered_headers = _filter_hop_by_hop_headers(headers)
345
327
 
346
328
  try:
347
329
  response = await client.request(
@@ -375,22 +357,13 @@ class Tunnel(threading.Thread):
375
357
  """
376
358
  return f"{self.host}/api/tunnels/connection"
377
359
 
378
- def _build_connection_headers(self) -> dict[str, str]:
379
- """
380
- Build outbound handshake headers.
381
-
382
- :returns: Header dictionary with bearer authorization only.
383
- """
384
- with self._lock:
385
- return {"Authorization": f"Bearer {self.secret}"}
386
-
387
360
  def _set_connected(
388
- self,
389
- value: bool,
390
- remote_address: Optional[str] = None,
391
- remote_address_roundrobin: Optional[str] = None,
392
- worker_index: int = -1,
393
- 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,
394
367
  ) -> None:
395
368
  """
396
369
  Update connection state atomically.
@@ -420,27 +393,10 @@ class Tunnel(threading.Thread):
420
393
  return None
421
394
 
422
395
  ssl_context = ssl.create_default_context()
396
+ ssl_context.set_alpn_protocols(["http/1.1"])
423
397
  if self.verify_ssl:
424
398
  return ssl_context
425
399
 
426
400
  ssl_context.check_hostname = False
427
401
  ssl_context.verify_mode = ssl.CERT_NONE
428
402
  return ssl_context
429
-
430
- @staticmethod
431
- def _filter_hop_by_hop_headers(headers: Any) -> dict[str, str]:
432
- """
433
- Remove hop-by-hop headers before forwarding to the local target.
434
-
435
- :param headers: Request header mapping.
436
- :returns: Filtered header mapping.
437
- """
438
- if not isinstance(headers, dict):
439
- return {}
440
-
441
- hop_by_hop = {"connection", "keep-alive", "transfer-encoding", "upgrade"}
442
- return {
443
- str(key): str(value)
444
- for key, value in headers.items()
445
- if str(key).lower() not in hop_by_hop
446
- }
@@ -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.2.0"
3
+ version = "0.3.1"
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"]