webdev 2.0.0__py3-none-any.whl
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.
- webdev/__init__.py +30 -0
- webdev/__main__.py +29 -0
- webdev/cli.py +330 -0
- webdev/config.py +210 -0
- webdev/index.py +298 -0
- webdev/livereload.py +169 -0
- webdev/net.py +145 -0
- webdev/run.py +124 -0
- webdev/server.py +336 -0
- webdev/static.py +195 -0
- webdev/templates/dark/base.html.jinja2 +17 -0
- webdev/templates/dark/index.html.jinja2 +71 -0
- webdev/templates/dark/partials/sort.js.jinja2 +52 -0
- webdev/templates/dark/partials/styles.css.jinja2 +110 -0
- webdev/tls.py +159 -0
- webdev-2.0.0.dist-info/METADATA +240 -0
- webdev-2.0.0.dist-info/RECORD +20 -0
- webdev-2.0.0.dist-info/WHEEL +4 -0
- webdev-2.0.0.dist-info/entry_points.txt +2 -0
- webdev-2.0.0.dist-info/licenses/LICENSE +202 -0
webdev/index.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Directory index rendering.
|
|
19
|
+
|
|
20
|
+
Builds the template context from a filesystem directory and renders it with a
|
|
21
|
+
themed Jinja2 template. Templates live under ``webdev/templates/<theme>/`` and
|
|
22
|
+
are shipped as package data, so new themes are added by dropping in a new
|
|
23
|
+
sibling directory — no code changes required.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import os
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from importlib import resources
|
|
33
|
+
from pathlib import Path, PurePosixPath
|
|
34
|
+
|
|
35
|
+
from jinja2 import Environment, PackageLoader, select_autoescape
|
|
36
|
+
|
|
37
|
+
#: Template package and root directory inside this package.
|
|
38
|
+
_TEMPLATE_PACKAGE = 'webdev'
|
|
39
|
+
_TEMPLATE_DIR = 'templates'
|
|
40
|
+
|
|
41
|
+
_INDEX_TEMPLATE = 'index.html.jinja2'
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ThemeNotFoundError(Exception):
|
|
45
|
+
"""
|
|
46
|
+
Raised when a requested theme directory does not exist.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, theme: str, available: list[str]) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Initialize the error with the missing theme and the known themes.
|
|
52
|
+
|
|
53
|
+
:param theme: The theme name that could not be found.
|
|
54
|
+
:param available: The list of theme names that are available.
|
|
55
|
+
"""
|
|
56
|
+
self.theme = theme
|
|
57
|
+
self.available = available
|
|
58
|
+
choices = ', '.join(available) if available else '(none found)'
|
|
59
|
+
super().__init__(f'Unknown theme {theme!r}. Available themes: {choices}.')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Entry:
|
|
64
|
+
"""
|
|
65
|
+
A single directory entry for the index listing.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
name: str
|
|
69
|
+
href: str
|
|
70
|
+
is_dir: bool
|
|
71
|
+
size: int | None
|
|
72
|
+
size_human: str
|
|
73
|
+
mtime: float
|
|
74
|
+
mtime_human: str
|
|
75
|
+
icon: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@lru_cache(maxsize=1)
|
|
79
|
+
def _environment() -> Environment:
|
|
80
|
+
"""
|
|
81
|
+
Build and cache the Jinja2 environment used to render themes.
|
|
82
|
+
|
|
83
|
+
The environment loads templates from this package's template directory and
|
|
84
|
+
enables HTML autoescaping. The result is cached so it is built only once.
|
|
85
|
+
|
|
86
|
+
:return: The shared Jinja2 environment instance.
|
|
87
|
+
"""
|
|
88
|
+
return Environment(
|
|
89
|
+
loader=PackageLoader(_TEMPLATE_PACKAGE, _TEMPLATE_DIR),
|
|
90
|
+
autoescape=select_autoescape(['html', 'xml', 'jinja2']),
|
|
91
|
+
trim_blocks=True,
|
|
92
|
+
lstrip_blocks=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def available_themes() -> list[str]:
|
|
97
|
+
"""
|
|
98
|
+
Return the sorted list of bundled theme names.
|
|
99
|
+
|
|
100
|
+
Scans the package template directory for subdirectories, skipping any whose
|
|
101
|
+
name starts with an underscore (reserved for shared/partial templates).
|
|
102
|
+
|
|
103
|
+
:return: The sorted list of available theme names.
|
|
104
|
+
"""
|
|
105
|
+
root = resources.files(_TEMPLATE_PACKAGE).joinpath(_TEMPLATE_DIR)
|
|
106
|
+
themes = []
|
|
107
|
+
for item in root.iterdir():
|
|
108
|
+
name = item.name
|
|
109
|
+
if item.is_dir() and not name.startswith('_'):
|
|
110
|
+
themes.append(name)
|
|
111
|
+
return sorted(themes)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_theme(theme: str) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Validate that *theme* is one of the bundled themes.
|
|
117
|
+
|
|
118
|
+
:param theme: The theme name to validate.
|
|
119
|
+
:raises ThemeNotFoundError: If *theme* is not an available theme.
|
|
120
|
+
"""
|
|
121
|
+
themes = available_themes()
|
|
122
|
+
if theme not in themes:
|
|
123
|
+
raise ThemeNotFoundError(theme, themes)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def human_size(num_bytes: int) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Format a byte count as a short human-readable string.
|
|
129
|
+
|
|
130
|
+
Scales the value through binary (1024) units up to petabytes, using an
|
|
131
|
+
integer for bytes and one decimal place for larger units.
|
|
132
|
+
|
|
133
|
+
:param num_bytes: The size in bytes to format.
|
|
134
|
+
:return: The formatted size string, e.g. ``"1.5 MB"`` or ``"12 B"``.
|
|
135
|
+
"""
|
|
136
|
+
size = float(num_bytes)
|
|
137
|
+
for unit in ('B', 'KB', 'MB', 'GB', 'TB', 'PB'):
|
|
138
|
+
if size < 1024.0 or unit == 'PB':
|
|
139
|
+
if unit == 'B':
|
|
140
|
+
return '{} {}'.format(int(size), unit)
|
|
141
|
+
return f'{size:.1f} {unit}'
|
|
142
|
+
size /= 1024.0
|
|
143
|
+
return f'{size:.1f} PB' # pragma: no cover
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _icon_for(name: str, is_dir: bool) -> str:
|
|
147
|
+
"""
|
|
148
|
+
Return a coarse icon class/name based on the entry type/extension.
|
|
149
|
+
|
|
150
|
+
Directories map to ``"dir"``; files are classified by extension into a
|
|
151
|
+
small set of categories, falling back to ``"file"`` when unrecognized.
|
|
152
|
+
|
|
153
|
+
:param name: The entry file or directory name.
|
|
154
|
+
:param is_dir: Whether the entry is a directory.
|
|
155
|
+
:return: The icon identifier for the entry.
|
|
156
|
+
"""
|
|
157
|
+
if is_dir:
|
|
158
|
+
return 'dir'
|
|
159
|
+
ext = Path(name).suffix.lower().lstrip('.')
|
|
160
|
+
mapping = {
|
|
161
|
+
'image': {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp'},
|
|
162
|
+
'video': {'mp4', 'webm', 'mov', 'avi', 'mkv'},
|
|
163
|
+
'audio': {'mp3', 'wav', 'ogg', 'flac', 'm4a'},
|
|
164
|
+
'code': {
|
|
165
|
+
'py',
|
|
166
|
+
'js',
|
|
167
|
+
'ts',
|
|
168
|
+
'jsx',
|
|
169
|
+
'tsx',
|
|
170
|
+
'c',
|
|
171
|
+
'h',
|
|
172
|
+
'cpp',
|
|
173
|
+
'go',
|
|
174
|
+
'rs',
|
|
175
|
+
'java',
|
|
176
|
+
'rb',
|
|
177
|
+
'sh',
|
|
178
|
+
'css',
|
|
179
|
+
'html',
|
|
180
|
+
'json',
|
|
181
|
+
'yaml',
|
|
182
|
+
'yml',
|
|
183
|
+
'toml',
|
|
184
|
+
'xml',
|
|
185
|
+
},
|
|
186
|
+
'archive': {'zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'},
|
|
187
|
+
'doc': {'pdf', 'md', 'rst', 'txt', 'doc', 'docx', 'odt'},
|
|
188
|
+
}
|
|
189
|
+
for icon, extensions in mapping.items():
|
|
190
|
+
if ext in extensions:
|
|
191
|
+
return icon
|
|
192
|
+
return 'file'
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _breadcrumbs(rel_path: str) -> list[dict[str, str]]:
|
|
196
|
+
"""
|
|
197
|
+
Build breadcrumb segments (name + href) for *rel_path*.
|
|
198
|
+
|
|
199
|
+
The result always starts with the root segment and appends one entry per
|
|
200
|
+
path component, each with a cumulative, trailing-slashed href.
|
|
201
|
+
|
|
202
|
+
:param rel_path: The POSIX-style path relative to the served root.
|
|
203
|
+
:return: The ordered list of breadcrumb dicts with ``name`` and ``href``.
|
|
204
|
+
"""
|
|
205
|
+
crumbs: list[dict[str, str]] = [{'name': '/', 'href': '/'}]
|
|
206
|
+
accumulated = PurePosixPath('/')
|
|
207
|
+
for part in PurePosixPath(rel_path).parts:
|
|
208
|
+
if part in ('', '/'):
|
|
209
|
+
continue
|
|
210
|
+
accumulated = accumulated / part
|
|
211
|
+
href = str(accumulated)
|
|
212
|
+
if not href.endswith('/'):
|
|
213
|
+
href += '/'
|
|
214
|
+
crumbs.append({'name': part, 'href': href})
|
|
215
|
+
return crumbs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def list_directory(fs_dir: Path, url_path: str) -> list[Entry]:
|
|
219
|
+
"""
|
|
220
|
+
Build the sorted list of :class:`Entry` for *fs_dir*.
|
|
221
|
+
|
|
222
|
+
Each child of *fs_dir* is stat-ed to produce an entry; children that cannot
|
|
223
|
+
be accessed are skipped. Entries are sorted with directories first and then
|
|
224
|
+
alphabetically (case-insensitive).
|
|
225
|
+
|
|
226
|
+
:param fs_dir: The filesystem directory to list.
|
|
227
|
+
:param url_path: The request URL path used as the base for entry hrefs.
|
|
228
|
+
:return: The sorted list of directory entries.
|
|
229
|
+
"""
|
|
230
|
+
base = url_path if url_path.endswith('/') else url_path + '/'
|
|
231
|
+
entries: list[Entry] = []
|
|
232
|
+
with os.scandir(fs_dir) as iterator:
|
|
233
|
+
for item in iterator:
|
|
234
|
+
try:
|
|
235
|
+
is_dir = item.is_dir()
|
|
236
|
+
stat = item.stat()
|
|
237
|
+
except OSError:
|
|
238
|
+
continue
|
|
239
|
+
size = None if is_dir else stat.st_size
|
|
240
|
+
name = item.name
|
|
241
|
+
href = base + name + ('/' if is_dir else '')
|
|
242
|
+
entries.append(
|
|
243
|
+
Entry(
|
|
244
|
+
name=name,
|
|
245
|
+
href=href,
|
|
246
|
+
is_dir=is_dir,
|
|
247
|
+
size=size,
|
|
248
|
+
size_human='' if size is None else human_size(size),
|
|
249
|
+
mtime=stat.st_mtime,
|
|
250
|
+
mtime_human=datetime.fromtimestamp(stat.st_mtime).strftime(
|
|
251
|
+
'%Y-%m-%d %H:%M'
|
|
252
|
+
),
|
|
253
|
+
icon=_icon_for(name, is_dir),
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
entries.sort(key=lambda e: (not e.is_dir, e.name.lower()))
|
|
257
|
+
return entries
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def render_index(
|
|
261
|
+
*,
|
|
262
|
+
fs_dir: Path,
|
|
263
|
+
url_path: str,
|
|
264
|
+
served_root: Path,
|
|
265
|
+
theme: str,
|
|
266
|
+
live_reload: bool,
|
|
267
|
+
) -> str:
|
|
268
|
+
"""
|
|
269
|
+
Render the directory index HTML for *fs_dir*.
|
|
270
|
+
|
|
271
|
+
Lists the directory, builds the template context (breadcrumbs, counts and
|
|
272
|
+
metadata) and renders it with the selected theme's index template.
|
|
273
|
+
|
|
274
|
+
:param fs_dir: The filesystem directory being served.
|
|
275
|
+
:param url_path: The request URL path for this directory.
|
|
276
|
+
:param served_root: The root directory the server exposes.
|
|
277
|
+
:param theme: The theme whose template is used for rendering.
|
|
278
|
+
:param live_reload: Whether live-reload support should be enabled.
|
|
279
|
+
:return: The rendered HTML document as a string.
|
|
280
|
+
"""
|
|
281
|
+
from . import __version__
|
|
282
|
+
|
|
283
|
+
rel = url_path.strip('/')
|
|
284
|
+
entries = list_directory(fs_dir, url_path)
|
|
285
|
+
template = _environment().get_template(f'{theme}/{_INDEX_TEMPLATE}')
|
|
286
|
+
return template.render(
|
|
287
|
+
entries=entries,
|
|
288
|
+
version=__version__,
|
|
289
|
+
breadcrumbs=_breadcrumbs(rel),
|
|
290
|
+
url_path=url_path,
|
|
291
|
+
rel_path=rel,
|
|
292
|
+
served_root=str(served_root),
|
|
293
|
+
is_root=url_path in ('', '/'),
|
|
294
|
+
theme=theme,
|
|
295
|
+
live_reload=live_reload,
|
|
296
|
+
n_dirs=sum(1 for e in entries if e.is_dir),
|
|
297
|
+
n_files=sum(1 for e in entries if not e.is_dir),
|
|
298
|
+
)
|
webdev/livereload.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Live reload: watch the served tree and tell connected browsers to refresh.
|
|
19
|
+
|
|
20
|
+
Pieces:
|
|
21
|
+
|
|
22
|
+
* :class:`ReloadHub` — an in-process registry of connected WebSocket clients
|
|
23
|
+
with a broadcast method.
|
|
24
|
+
* :func:`watch_tree` — a background task (started from the app lifespan) that
|
|
25
|
+
runs :func:`watchfiles.awatch` over the served root and broadcasts a reload
|
|
26
|
+
on every debounced change batch. Handles the ``--future`` case by watching the
|
|
27
|
+
nearest existing ancestor until the root appears.
|
|
28
|
+
* :data:`LIVERELOAD_PATH` / :data:`CLIENT_SNIPLET` — the reserved WebSocket
|
|
29
|
+
endpoint path and the auto-reconnecting client snippet injected into HTML.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import asyncio
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from watchfiles import awatch
|
|
38
|
+
|
|
39
|
+
#: Reserved, collision-resistant path for the live-reload WebSocket endpoint.
|
|
40
|
+
LIVERELOAD_PATH = '/__webdev__/livereload'
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ReloadHub:
|
|
44
|
+
"""
|
|
45
|
+
Tracks connected live-reload clients and broadcasts reload events.
|
|
46
|
+
|
|
47
|
+
Each connected WebSocket client is represented by an :class:`asyncio.Queue`
|
|
48
|
+
onto which reload messages are pushed.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
"""
|
|
53
|
+
Initialize the hub with an empty set of client queues.
|
|
54
|
+
"""
|
|
55
|
+
self._clients: set[asyncio.Queue[str]] = set()
|
|
56
|
+
|
|
57
|
+
def register(self) -> asyncio.Queue[str]:
|
|
58
|
+
"""
|
|
59
|
+
Register a new client and return its message queue.
|
|
60
|
+
|
|
61
|
+
:return: A fresh queue that will receive broadcast reload messages.
|
|
62
|
+
"""
|
|
63
|
+
queue: asyncio.Queue[str] = asyncio.Queue()
|
|
64
|
+
self._clients.add(queue)
|
|
65
|
+
return queue
|
|
66
|
+
|
|
67
|
+
def unregister(self, queue: asyncio.Queue[str]) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Remove a client's queue from the hub.
|
|
70
|
+
|
|
71
|
+
:param queue: The queue previously returned by :meth:`register`.
|
|
72
|
+
"""
|
|
73
|
+
self._clients.discard(queue)
|
|
74
|
+
|
|
75
|
+
def broadcast(self, message: str = 'reload') -> None:
|
|
76
|
+
"""
|
|
77
|
+
Push a message to every registered client queue.
|
|
78
|
+
|
|
79
|
+
:param message: The message to deliver to each client.
|
|
80
|
+
"""
|
|
81
|
+
for queue in list(self._clients):
|
|
82
|
+
queue.put_nowait(message)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def client_count(self) -> int:
|
|
86
|
+
"""
|
|
87
|
+
Return the number of currently registered clients.
|
|
88
|
+
|
|
89
|
+
:return: The count of connected live-reload clients.
|
|
90
|
+
"""
|
|
91
|
+
return len(self._clients)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _nearest_existing(path: Path) -> Path:
|
|
95
|
+
"""
|
|
96
|
+
Return *path* if it exists, else the closest existing ancestor.
|
|
97
|
+
|
|
98
|
+
:param path: The starting path to resolve.
|
|
99
|
+
:return: The first existing path among *path* and its parents, or the
|
|
100
|
+
filesystem anchor if none exist.
|
|
101
|
+
"""
|
|
102
|
+
current = path
|
|
103
|
+
for candidate in (current, *current.parents):
|
|
104
|
+
if candidate.exists():
|
|
105
|
+
return candidate
|
|
106
|
+
return Path(current.anchor or '.')
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def watch_tree(
|
|
110
|
+
root: Path,
|
|
111
|
+
hub: ReloadHub,
|
|
112
|
+
stop_event: asyncio.Event,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""
|
|
115
|
+
Watch *root* and broadcast a reload on every change batch.
|
|
116
|
+
|
|
117
|
+
If *root* does not exist yet (``--future``), watch the nearest existing
|
|
118
|
+
ancestor so that the directory's eventual creation is observed; the watcher
|
|
119
|
+
is then restarted targeting *root* once it appears. Runs until *stop_event*
|
|
120
|
+
is set.
|
|
121
|
+
|
|
122
|
+
:param root: The directory tree to watch for changes.
|
|
123
|
+
:param hub: The hub whose clients are notified on each change batch.
|
|
124
|
+
:param stop_event: Event signalling the watcher to stop.
|
|
125
|
+
"""
|
|
126
|
+
while not stop_event.is_set():
|
|
127
|
+
target = root if root.exists() else _nearest_existing(root)
|
|
128
|
+
try:
|
|
129
|
+
async for _changes in awatch(
|
|
130
|
+
target,
|
|
131
|
+
stop_event=stop_event,
|
|
132
|
+
):
|
|
133
|
+
hub.broadcast()
|
|
134
|
+
# If we were watching an ancestor and the root now exists,
|
|
135
|
+
# break to restart the watcher on the real root.
|
|
136
|
+
if target != root and root.exists():
|
|
137
|
+
break
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
# The watched path vanished; wait briefly and re-resolve.
|
|
140
|
+
await asyncio.sleep(0.5)
|
|
141
|
+
if stop_event.is_set():
|
|
142
|
+
break
|
|
143
|
+
# Small debounce before (potentially) re-targeting the watcher.
|
|
144
|
+
await asyncio.sleep(0.1)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
#: Auto-reconnecting client snippet injected before </body> of HTML responses.
|
|
148
|
+
CLIENT_SNIPLET = """
|
|
149
|
+
<script>
|
|
150
|
+
(function () {
|
|
151
|
+
var path = "__LIVERELOAD_PATH__";
|
|
152
|
+
var proto = location.protocol === "https:" ? "wss" : "ws";
|
|
153
|
+
var wasConnected = false;
|
|
154
|
+
function connect() {
|
|
155
|
+
var ws = new WebSocket(proto + "://" + location.host + path);
|
|
156
|
+
ws.onopen = function () {
|
|
157
|
+
if (wasConnected) { location.reload(); return; }
|
|
158
|
+
wasConnected = true;
|
|
159
|
+
};
|
|
160
|
+
ws.onmessage = function (event) {
|
|
161
|
+
if (event.data === "reload") { location.reload(); }
|
|
162
|
+
};
|
|
163
|
+
ws.onclose = function () { setTimeout(connect, 1000); };
|
|
164
|
+
ws.onerror = function () { try { ws.close(); } catch (e) {} };
|
|
165
|
+
}
|
|
166
|
+
connect();
|
|
167
|
+
})();
|
|
168
|
+
</script>
|
|
169
|
+
""".replace('__LIVERELOAD_PATH__', LIVERELOAD_PATH)
|
webdev/net.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Copyright (C) 2026 KuraLabs S.R.L <info@kuralabs.io>
|
|
2
|
+
# Copyright (C) 2015-2016 Carlos Jenkins <carlos@jenkins.co.cr>
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing,
|
|
11
|
+
# software distributed under the License is distributed on an
|
|
12
|
+
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
13
|
+
# KIND, either express or implied. See the License for the
|
|
14
|
+
# specific language governing permissions and limitations
|
|
15
|
+
# under the License.
|
|
16
|
+
|
|
17
|
+
"""
|
|
18
|
+
Networking helpers: incremental port selection, LAN address detection and QR.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import socket
|
|
24
|
+
from errno import EADDRINUSE
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NoFreePortError(Exception):
|
|
28
|
+
"""
|
|
29
|
+
Raised when no free port is found in the requested range.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, start: int, end: int) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Initialize the error with the exhausted port range.
|
|
35
|
+
|
|
36
|
+
:param start: The first port of the range that was probed.
|
|
37
|
+
:param end: The last port of the range that was probed.
|
|
38
|
+
"""
|
|
39
|
+
self.start = start
|
|
40
|
+
self.end = end
|
|
41
|
+
super().__init__(f'Port(s) {start} - {end} already in use.')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_free_port(ip: str, base_port: int, port_range: int) -> int:
|
|
45
|
+
"""
|
|
46
|
+
Return the first bindable port in ``[base_port, base_port + port_range]``.
|
|
47
|
+
|
|
48
|
+
A port is considered free if we can bind a TCP socket to it on *ip*. The
|
|
49
|
+
probe socket is closed immediately; the caller binds the real server right
|
|
50
|
+
after. ``SO_REUSEADDR`` is set to match Uvicorn's own bind behaviour: on
|
|
51
|
+
Linux it lets us reclaim a port still lingering in ``TIME_WAIT`` from a
|
|
52
|
+
previous run (common right after Ctrl+C with keep-alive clients) while still
|
|
53
|
+
reporting a port actively held by another ``LISTEN``ing process as busy.
|
|
54
|
+
|
|
55
|
+
:param ip: The interface IP address to bind the probe socket to.
|
|
56
|
+
:param base_port: The first port to try.
|
|
57
|
+
:param port_range: How many additional ports past *base_port* to try.
|
|
58
|
+
:return: The first bindable port in the range.
|
|
59
|
+
:raises NoFreePortError: If every port in the range is already in use.
|
|
60
|
+
:raises OSError: If binding fails for a reason other than the address
|
|
61
|
+
being in use.
|
|
62
|
+
"""
|
|
63
|
+
last = base_port + port_range
|
|
64
|
+
for port in range(base_port, last + 1):
|
|
65
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
66
|
+
probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
67
|
+
try:
|
|
68
|
+
probe.bind((ip, port))
|
|
69
|
+
except OSError as error:
|
|
70
|
+
if error.errno != EADDRINUSE:
|
|
71
|
+
raise
|
|
72
|
+
continue
|
|
73
|
+
return port
|
|
74
|
+
raise NoFreePortError(base_port, last)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def detect_lan_ip() -> str | None:
|
|
78
|
+
"""
|
|
79
|
+
Best-effort detection of the primary LAN IPv4 address.
|
|
80
|
+
|
|
81
|
+
Uses a connected UDP socket to a public address to learn which local
|
|
82
|
+
interface the OS would route through. No packets are actually sent. Returns
|
|
83
|
+
``None`` if detection fails or only loopback is available.
|
|
84
|
+
|
|
85
|
+
:return: The primary LAN IPv4 address, or ``None`` if it cannot be
|
|
86
|
+
determined.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
90
|
+
sock.connect(('8.8.8.8', 80))
|
|
91
|
+
ip = sock.getsockname()[0]
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
if not ip or ip.startswith('127.'):
|
|
95
|
+
return None
|
|
96
|
+
return ip
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_urls(bind_ip: str, port: int, https: bool) -> list[str]:
|
|
100
|
+
"""
|
|
101
|
+
Return human-friendly URLs to reach the server.
|
|
102
|
+
|
|
103
|
+
When bound to a wildcard address, includes the loopback URL and, if
|
|
104
|
+
detectable, the LAN URL; otherwise returns a single URL for *bind_ip*.
|
|
105
|
+
|
|
106
|
+
:param bind_ip: The IP address the server is bound to.
|
|
107
|
+
:param port: The port the server is listening on.
|
|
108
|
+
:param https: Whether to use the ``https`` scheme instead of ``http``.
|
|
109
|
+
:return: The list of reachable URLs for the server.
|
|
110
|
+
"""
|
|
111
|
+
scheme = 'https' if https else 'http'
|
|
112
|
+
urls: list[str] = []
|
|
113
|
+
if bind_ip in ('0.0.0.0', '::'):
|
|
114
|
+
urls.append(f'{scheme}://127.0.0.1:{port}')
|
|
115
|
+
lan = detect_lan_ip()
|
|
116
|
+
if lan:
|
|
117
|
+
urls.append(f'{scheme}://{lan}:{port}')
|
|
118
|
+
else:
|
|
119
|
+
urls.append(f'{scheme}://{bind_ip}:{port}')
|
|
120
|
+
return urls
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_qr(url: str) -> str | None:
|
|
124
|
+
"""
|
|
125
|
+
Render *url* as an ASCII QR code suitable for a terminal.
|
|
126
|
+
|
|
127
|
+
Returns ``None`` if the optional ``qrcode`` dependency is unavailable.
|
|
128
|
+
|
|
129
|
+
:param url: The URL to encode as a QR code.
|
|
130
|
+
:return: The ASCII QR code as a string, or ``None`` if ``qrcode`` is not
|
|
131
|
+
installed.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
import io
|
|
135
|
+
|
|
136
|
+
import qrcode
|
|
137
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
qr = qrcode.QRCode(border=1)
|
|
141
|
+
qr.add_data(url)
|
|
142
|
+
qr.make(fit=True)
|
|
143
|
+
buffer = io.StringIO()
|
|
144
|
+
qr.print_ascii(out=buffer, invert=True)
|
|
145
|
+
return buffer.getvalue()
|