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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lange-python
3
- Version: 0.2.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="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
 
@@ -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
  )
@@ -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
- 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=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=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()
@@ -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 secret: Bearer token used for worker authentication.
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 ``secret`` is empty.
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
- secret: str,
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
- if not secret.strip():
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.secret = secret.strip()
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 set_secret(self, secret: str, reconnect: bool = False) -> None:
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 secret: New non-empty bearer token.
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 ``secret`` is empty.
184
+ :raises ValueError: If ``secret_key`` is empty.
179
185
  """
180
- if not secret.strip():
181
- raise ValueError("A non-empty secret is required for tunnel worker authentication.")
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.secret = secret.strip()
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 only.
390
+ :returns: Header dictionary with bearer authorization and optional tunnel name.
383
391
  """
384
392
  with self._lock:
385
- return {"Authorization": f"Bearer {self.secret}"}
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
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.2.0"
3
+ version = "0.3.0"
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"}