lange-python 0.2.0__tar.gz → 0.3.0__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.2.0 → lange_python-0.3.0}/PKG-INFO +4 -3
- {lange_python-0.2.0 → lange_python-0.3.0}/README.md +3 -2
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/__init__.py +1 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/build/_command.py +57 -4
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/build/_discovery.py +23 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/code/__init__.py +1 -1
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/code/_stats.py +76 -6
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/tunnel/__init__.py +49 -15
- {lange_python-0.2.0 → lange_python-0.3.0}/pyproject.toml +1 -1
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/__init__.py +0 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/__main__.py +0 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/build/__init__.py +0 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/build/_docker.py +0 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/lange/cli/build/_poetry.py +0 -0
- {lange_python-0.2.0 → lange_python-0.3.0}/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.
|
|
3
|
+
Version: 0.3.0
|
|
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="
|
|
27
|
-
|
|
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="
|
|
12
|
-
|
|
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
|
|
|
@@ -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
|
)
|
|
@@ -39,18 +40,66 @@ def build_command(
|
|
|
39
40
|
"""
|
|
40
41
|
_validate_force_flags(force_docker=force_docker, force_poetry=force_poetry)
|
|
41
42
|
|
|
42
|
-
|
|
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=publish,
|
|
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=publish,
|
|
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=
|
|
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=
|
|
99
|
+
_run_docker_flow(folder=folder, publish=publish)
|
|
51
100
|
return
|
|
52
101
|
|
|
53
|
-
_run_poetry_flow(folder=
|
|
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.
|
|
@@ -3,8 +3,60 @@ from typing import Iterable
|
|
|
3
3
|
import os
|
|
4
4
|
import click
|
|
5
5
|
|
|
6
|
-
SUPPORTED_EXTENSIONS: tuple[str, ...] = (
|
|
7
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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(
|
|
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()
|
|
@@ -6,6 +6,7 @@ import asyncio
|
|
|
6
6
|
import base64
|
|
7
7
|
import json
|
|
8
8
|
import logging
|
|
9
|
+
import os
|
|
9
10
|
import ssl
|
|
10
11
|
import threading
|
|
11
12
|
from typing import Any, Optional
|
|
@@ -26,35 +27,40 @@ class Tunnel(threading.Thread):
|
|
|
26
27
|
proxy messages to a local HTTP target.
|
|
27
28
|
|
|
28
29
|
:param host: Base service URL, e.g. ``wss://example.com``.
|
|
29
|
-
:param
|
|
30
|
+
:param secret_key: Bearer token used for worker authentication.
|
|
31
|
+
:param tunnel_name: Optional explicit tunnel subdomain for keys linked to multiple tunnels.
|
|
30
32
|
:param target: Local HTTP target URL to forward tunnel traffic to.
|
|
31
33
|
:param verify_ssl: Whether to verify TLS certificates for ``wss://`` hosts.
|
|
32
34
|
:param max_retries: Maximum reconnect attempts, ``0`` for infinite retries.
|
|
33
35
|
:param retry_delay: Initial reconnect delay in seconds.
|
|
36
|
+
:param open_timeout: Timeout in seconds for the WebSocket opening handshake.
|
|
34
37
|
:param daemon: Whether the worker thread is daemonized.
|
|
35
|
-
:raises ValueError: If
|
|
38
|
+
:raises ValueError: If secret key is unavailable or empty.
|
|
36
39
|
"""
|
|
37
40
|
|
|
38
41
|
def __init__(
|
|
39
42
|
self,
|
|
40
|
-
host: str,
|
|
41
|
-
|
|
43
|
+
host: str = "wss://tunnel.lange-labs.com",
|
|
44
|
+
secret_key: str | None = None,
|
|
45
|
+
tunnel_name: str | None = None,
|
|
42
46
|
target: str = "http://localhost:80",
|
|
43
47
|
verify_ssl: bool = True,
|
|
44
48
|
max_retries: int = 5,
|
|
45
49
|
retry_delay: float = 5.0,
|
|
50
|
+
open_timeout: float = 20.0,
|
|
46
51
|
daemon: bool = True,
|
|
47
52
|
) -> None:
|
|
48
|
-
|
|
49
|
-
raise ValueError("A non-empty secret is required for tunnel worker authentication.")
|
|
53
|
+
resolved_secret_key = _resolve_secret_key(secret_key)
|
|
50
54
|
|
|
51
55
|
super().__init__(daemon=daemon)
|
|
52
56
|
self.host = host.rstrip("/")
|
|
53
|
-
self.
|
|
57
|
+
self.secret_key = resolved_secret_key
|
|
58
|
+
self.tunnel_name = tunnel_name.strip().lower() if tunnel_name and tunnel_name.strip() else None
|
|
54
59
|
self.target = target.rstrip("/")
|
|
55
60
|
self.verify_ssl = verify_ssl
|
|
56
61
|
self.max_retries = max_retries
|
|
57
62
|
self.retry_delay = retry_delay
|
|
63
|
+
self.open_timeout = open_timeout
|
|
58
64
|
|
|
59
65
|
self._max_retry_delay = 60.0
|
|
60
66
|
self._stop_event = threading.Event()
|
|
@@ -168,20 +174,20 @@ class Tunnel(threading.Thread):
|
|
|
168
174
|
except Exception as exc: # pragma: no cover - best-effort close path
|
|
169
175
|
logger.debug("Failed to close websocket during reconnect: %s", exc)
|
|
170
176
|
|
|
171
|
-
def
|
|
177
|
+
def set_secret_key(self, secret_key: str, reconnect: bool = False) -> None:
|
|
172
178
|
"""
|
|
173
179
|
Replace the bearer token used for future connections.
|
|
174
180
|
|
|
175
|
-
:param
|
|
181
|
+
:param secret_key: New non-empty bearer token.
|
|
176
182
|
:param reconnect: Whether to reconnect immediately to apply the token.
|
|
177
183
|
:returns: ``None``.
|
|
178
|
-
:raises ValueError: If ``
|
|
184
|
+
:raises ValueError: If ``secret_key`` is empty.
|
|
179
185
|
"""
|
|
180
|
-
if not
|
|
181
|
-
raise ValueError("A non-empty
|
|
186
|
+
if not secret_key.strip():
|
|
187
|
+
raise ValueError("A non-empty secret_key is required for tunnel worker authentication.")
|
|
182
188
|
|
|
183
189
|
with self._lock:
|
|
184
|
-
self.
|
|
190
|
+
self.secret_key = secret_key.strip()
|
|
185
191
|
|
|
186
192
|
if reconnect:
|
|
187
193
|
self.reconnect()
|
|
@@ -209,6 +215,8 @@ class Tunnel(threading.Thread):
|
|
|
209
215
|
tunnel_url,
|
|
210
216
|
additional_headers=headers,
|
|
211
217
|
ssl=ssl_context,
|
|
218
|
+
proxy=None,
|
|
219
|
+
open_timeout=self.open_timeout,
|
|
212
220
|
) as ws:
|
|
213
221
|
with self._lock:
|
|
214
222
|
self._active_ws = ws
|
|
@@ -379,10 +387,14 @@ class Tunnel(threading.Thread):
|
|
|
379
387
|
"""
|
|
380
388
|
Build outbound handshake headers.
|
|
381
389
|
|
|
382
|
-
:returns: Header dictionary with bearer authorization
|
|
390
|
+
:returns: Header dictionary with bearer authorization and optional tunnel name.
|
|
383
391
|
"""
|
|
384
392
|
with self._lock:
|
|
385
|
-
|
|
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
|
|
386
398
|
|
|
387
399
|
def _set_connected(
|
|
388
400
|
self,
|
|
@@ -420,6 +432,7 @@ class Tunnel(threading.Thread):
|
|
|
420
432
|
return None
|
|
421
433
|
|
|
422
434
|
ssl_context = ssl.create_default_context()
|
|
435
|
+
ssl_context.set_alpn_protocols(["http/1.1"])
|
|
423
436
|
if self.verify_ssl:
|
|
424
437
|
return ssl_context
|
|
425
438
|
|
|
@@ -444,3 +457,24 @@ class Tunnel(threading.Thread):
|
|
|
444
457
|
for key, value in headers.items()
|
|
445
458
|
if str(key).lower() not in hop_by_hop
|
|
446
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
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|