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/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()