limbo33 0.9.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.
limbo/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """Limbo: a zero-dependency Python web framework foundation."""
2
+ from .app import Limbo
3
+ from .router import RouteGroup, Router
4
+ from .http import HTTPError, Request, Response
5
+ from .database import Database
6
+ from .frontend import DependencyGraph, FrontendCompiler, FrontendDevServer, FrontendError, ModuleResolver
7
+ from .frontend_engine import (AdapterCapability, AdapterError, AdapterRegistry, BrowserNativeAdapter,
8
+ CompilerAdapter, EsbuildAdapter, FrameworkSFCAdapter, TransformResult)
9
+ from .security import SessionSigner, hash_password, session_cookie, verify_password
10
+ from .sessions import Session, SessionBackend, SessionStore, session_middleware
11
+ from .csrf import csrf_protect
12
+ from .tasks import BackgroundTasks
13
+ from .auth import authenticate, authorize
14
+ from .templates import TemplateEngine
15
+ from .uploads import UploadFile, UploadLimitError
16
+ from .middleware import access_log, cors, gzip_compress, rate_limit, request_id, security_headers
17
+
18
+ from .migrations import DatabaseURLError, Migration, MigrationError, Migrator, load_directory, open_database
19
+ from .build_engine import BuildError, BuildReport, build_project
20
+ from .tunnel import TunnelError, TunnelSupervisor, detect_cloudflared, redact_secrets
21
+ from .runtime_ssr import LocalProcessRuntime, SsrRuntimeError
22
+ from .capabilities import capability_matrix
23
+
24
+ __version__ = "0.9.0"
25
+ __all__ = ["Limbo", "Router", "RouteGroup", "Request", "Response", "HTTPError", "Database", "SessionSigner",
26
+ "Session", "SessionBackend", "SessionStore", "session_middleware", "csrf_protect", "BackgroundTasks", "authenticate", "authorize", "TemplateEngine", "UploadFile", "UploadLimitError", "DependencyGraph",
27
+ "FrontendCompiler", "FrontendDevServer", "FrontendError", "ModuleResolver", "hash_password",
28
+ "verify_password", "session_cookie", "cors", "gzip_compress", "security_headers", "request_id",
29
+ "access_log", "rate_limit", "AdapterCapability", "AdapterError", "AdapterRegistry",
30
+ "BrowserNativeAdapter", "CompilerAdapter", "EsbuildAdapter", "FrameworkSFCAdapter", "TransformResult",
31
+ "Migration", "Migrator", "MigrationError", "DatabaseURLError", "load_directory", "open_database",
32
+ "BuildError", "BuildReport", "build_project", "TunnelError", "TunnelSupervisor", "detect_cloudflared",
33
+ "redact_secrets", "LocalProcessRuntime", "SsrRuntimeError", "capability_matrix", "__version__"]
limbo/app.py ADDED
@@ -0,0 +1,565 @@
1
+ """Public Limbo application API."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import mimetypes
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Callable
9
+ from urllib.parse import unquote, urlsplit
10
+
11
+ from .http import HTTPError, Request, Response
12
+ from .frontend import FrontendDevServer
13
+ from .router import RouteGroup, Router, compile_path
14
+ from .server import LimboServer
15
+ from .templates import TemplateEngine
16
+ from .tasks import BackgroundTasks
17
+
18
+
19
+ class _FileDescriptorStream:
20
+ """Close an eagerly opened file descriptor even if iteration never starts."""
21
+
22
+ def __init__(self, descriptor: int, length: int, chunk_size: int = 64 * 1024) -> None:
23
+ self.descriptor = descriptor
24
+ self.remaining = length
25
+ self.chunk_size = chunk_size
26
+
27
+ def __iter__(self):
28
+ return self
29
+
30
+ def __next__(self) -> bytes:
31
+ if self.descriptor is None or self.remaining <= 0:
32
+ self.close()
33
+ raise StopIteration
34
+ try:
35
+ chunk = os.read(self.descriptor, min(self.chunk_size, self.remaining))
36
+ except Exception:
37
+ self.close()
38
+ raise
39
+ if not chunk:
40
+ self.close()
41
+ raise StopIteration
42
+ self.remaining -= len(chunk)
43
+ return chunk
44
+
45
+ def close(self) -> None:
46
+ descriptor = self.descriptor
47
+ self.descriptor = None
48
+ if descriptor is not None:
49
+ try:
50
+ os.close(descriptor)
51
+ except OSError:
52
+ pass
53
+
54
+
55
+ class Limbo:
56
+ def __init__(self, *, name: str = "Limbo", max_body_bytes: int = 8 * 1024 * 1024,
57
+ max_upload_file_bytes: int = 8 * 1024 * 1024,
58
+ max_upload_files: int = 20, max_form_fields: int = 100) -> None:
59
+ if not isinstance(max_body_bytes, int) or max_body_bytes < 0:
60
+ raise ValueError("max_body_bytes must be a non-negative integer")
61
+ if not isinstance(max_upload_file_bytes, int) or max_upload_file_bytes < 1:
62
+ raise ValueError("max_upload_file_bytes must be positive")
63
+ if not isinstance(max_upload_files, int) or max_upload_files < 1:
64
+ raise ValueError("max_upload_files must be positive")
65
+ if not isinstance(max_form_fields, int) or max_form_fields < 1:
66
+ raise ValueError("max_form_fields must be positive")
67
+ self.name = name
68
+ self.router = Router()
69
+ self.middlewares: list[Callable] = []
70
+ self.static_mounts: list[tuple[str, Path, str | None, str, str, str | None]] = []
71
+ self.websocket_routes: list[tuple[object, Callable, tuple[str, ...] | None, int, float | None]] = []
72
+ self.templates = TemplateEngine()
73
+ self.frontend_servers: list[FrontendDevServer] = []
74
+ self.max_body_bytes = max_body_bytes
75
+ self.upload_limits = {"max_body_bytes": max_body_bytes, "max_file_bytes": max_upload_file_bytes,
76
+ "max_files": max_upload_files, "max_fields": max_form_fields}
77
+ self.exception_handlers: dict[object, Callable] = {}
78
+ self.background_tasks = BackgroundTasks()
79
+ self.startup_hooks: list[Callable] = []
80
+ self.shutdown_hooks: list[Callable] = []
81
+ self._server: LimboServer | None = None
82
+
83
+ def on_startup(self, function: Callable | None = None):
84
+ """Register a synchronous hook called immediately before serving."""
85
+ def register(fn):
86
+ if not callable(fn):
87
+ raise TypeError("Startup hook must be callable")
88
+ self.startup_hooks.append(fn)
89
+ return fn
90
+ return register(function) if function else register
91
+
92
+ def on_shutdown(self, function: Callable | None = None):
93
+ """Register a synchronous hook called in reverse order during shutdown."""
94
+ def register(fn):
95
+ if not callable(fn):
96
+ raise TypeError("Shutdown hook must be callable")
97
+ self.shutdown_hooks.append(fn)
98
+ return fn
99
+ return register(function) if function else register
100
+
101
+ def shutdown(self) -> None:
102
+ """Request server shutdown; run() performs orderly hook/task cleanup."""
103
+ if self._server is not None:
104
+ self._server.shutdown()
105
+
106
+ @property
107
+ def routes(self) -> list[dict[str, object]]:
108
+ """Return a snapshot of registered HTTP route metadata."""
109
+ return self.router.describe()
110
+
111
+ def url_for(self, name: str, **parameters: object) -> str:
112
+ """Build a URL from a named route and its path parameters."""
113
+ return self.router.url_for(name, **parameters)
114
+
115
+ def group(self, prefix: str = "", *, name: str | None = None) -> RouteGroup:
116
+ """Create a route group that shares this application's router."""
117
+ return RouteGroup(self.router, prefix=prefix, name=name)
118
+
119
+ def route(self, path: str, methods: tuple[str, ...] = ("GET",), *,
120
+ name: str | None = None, priority: int = 0, middleware: tuple[Callable, ...] = ()):
121
+ def decorate(handler):
122
+ for method in methods:
123
+ self.router.add(method, path, handler, name=name, priority=priority, middleware=middleware)
124
+ return handler
125
+ return decorate
126
+
127
+ def get(self, path: str, **kwargs): return self.route(path, ("GET",), **kwargs)
128
+ def post(self, path: str, **kwargs): return self.route(path, ("POST",), **kwargs)
129
+ def put(self, path: str, **kwargs): return self.route(path, ("PUT",), **kwargs)
130
+ def patch(self, path: str, **kwargs): return self.route(path, ("PATCH",), **kwargs)
131
+ def delete(self, path: str, **kwargs): return self.route(path, ("DELETE",), **kwargs)
132
+
133
+ def websocket(self, path: str, *, origins: tuple[str, ...] | list[str] | None = None,
134
+ max_message_bytes: int = 8 * 1024 * 1024,
135
+ idle_timeout: float | None = 60.0):
136
+ """Register a WebSocket route with explicit Origin, message and idle policies."""
137
+ if not isinstance(max_message_bytes, int) or max_message_bytes < 1:
138
+ raise ValueError("max_message_bytes must be a positive integer")
139
+ if idle_timeout is not None and (not isinstance(idle_timeout, (int, float)) or idle_timeout <= 0):
140
+ raise ValueError("idle_timeout must be positive or None")
141
+ allowed_origins = None if origins is None else tuple(origins)
142
+ if allowed_origins is not None:
143
+ if not allowed_origins:
144
+ raise ValueError("WebSocket Origin allowlist cannot be empty")
145
+ for origin in allowed_origins:
146
+ try:
147
+ parsed = urlsplit(origin)
148
+ valid = (parsed.scheme in {"http", "https"} and bool(parsed.netloc)
149
+ and parsed.hostname is not None and parsed.username is None
150
+ and parsed.password is None and not parsed.path
151
+ and not parsed.query and not parsed.fragment
152
+ and (parsed.port is None or 1 <= parsed.port <= 65535))
153
+ except (TypeError, ValueError):
154
+ valid = False
155
+ if not valid:
156
+ raise ValueError("WebSocket origins must be exact HTTP(S) origins")
157
+ def decorate(handler):
158
+ pattern = compile_path(path)
159
+ if any(existing.pattern == pattern.pattern for existing, _, _, _, _ in self.websocket_routes):
160
+ raise ValueError(f"Duplicate WebSocket route: {path}")
161
+ self.websocket_routes.append((pattern, handler, allowed_origins, max_message_bytes, idle_timeout))
162
+ return handler
163
+ return decorate
164
+
165
+ def websocket_origin_allowed(self, request: Request) -> bool:
166
+ """Check a matching WebSocket route's configured Origin policy."""
167
+ for pattern, _, origins, _, _ in self.websocket_routes:
168
+ if pattern.fullmatch(request.path):
169
+ return origins is None or request.headers.get("origin") in origins
170
+ return True
171
+
172
+ def websocket_options(self, request: Request) -> dict[str, object]:
173
+ """Return configured transport limits for the matching route."""
174
+ for pattern, _, _, max_message_bytes, idle_timeout in self.websocket_routes:
175
+ if pattern.fullmatch(request.path):
176
+ return {"max_message_bytes": max_message_bytes, "idle_timeout": idle_timeout}
177
+ return {}
178
+
179
+ def render_template(self, template_name: str, **context) -> Response:
180
+ return self.html(self.templates.render(template_name, context))
181
+
182
+ def template_directory(self, directory: str | os.PathLike[str]) -> None:
183
+ self.templates = TemplateEngine(directory)
184
+
185
+ def enable_frontend(self, directory: str | os.PathLike[str], *, url_prefix: str = "/__limbo") -> FrontendDevServer:
186
+ frontend = FrontendDevServer(directory, url_prefix=url_prefix)
187
+ if any(existing.prefix == frontend.prefix for existing in self.frontend_servers):
188
+ raise ValueError(f"Frontend URL prefix already mounted: {frontend.prefix}")
189
+ self.frontend_servers.append(frontend)
190
+ return frontend
191
+
192
+ def frontend_script(self, entry: str, *, mount: int = 0) -> str:
193
+ try:
194
+ frontend = self.frontend_servers[mount]
195
+ except IndexError as exc:
196
+ raise ValueError("Enable a frontend directory before requesting its script tag") from exc
197
+ return frontend.script_tag(entry)
198
+
199
+ def dispatch_websocket(self, request: Request, websocket) -> None:
200
+ for pattern, handler, _, _, _ in self.websocket_routes:
201
+ match = pattern.fullmatch(request.path)
202
+ if match:
203
+ request.path_params = match.groupdict()
204
+ handler(request, websocket)
205
+ return
206
+ websocket.close(1008, "Route not found")
207
+
208
+ def exception_handler(self, exception_or_status):
209
+ """Register an error handler for an HTTP status or exception class."""
210
+ if not (isinstance(exception_or_status, int) and 400 <= exception_or_status <= 599) and not (
211
+ isinstance(exception_or_status, type) and issubclass(exception_or_status, Exception)
212
+ ):
213
+ raise TypeError("Use an HTTP error status or Exception subclass")
214
+ def register(handler):
215
+ self.exception_handlers[exception_or_status] = handler
216
+ return handler
217
+ return register
218
+
219
+ def _error_response(self, error: Exception, status: int) -> Response:
220
+ handler = self.exception_handlers.get(status)
221
+ if handler is None:
222
+ for cls in type(error).__mro__:
223
+ if cls in self.exception_handlers:
224
+ handler = self.exception_handlers[cls]
225
+ break
226
+ if handler is None:
227
+ return Response.text(str(error) if isinstance(error, HTTPError) else "Internal Server Error", status)
228
+ try:
229
+ return self._coerce(handler(error))
230
+ except Exception:
231
+ return Response.text("Internal Server Error", 500)
232
+ def middleware(self, function: Callable | None = None):
233
+ def register(fn):
234
+ self.middlewares.append(fn)
235
+ return fn
236
+ return register(function) if function else register
237
+
238
+ def static(self, url_prefix: str, directory: str | os.PathLike[str], *,
239
+ index: str | None = None, cache_control: str = "public, max-age=0, must-revalidate",
240
+ symlinks: str = "deny", spa_fallback: str | None = None) -> None:
241
+ """Mount static files with optional safe SPA navigation fallback."""
242
+ prefix = "/" + url_prefix.strip("/")
243
+ if prefix == "/":
244
+ raise ValueError("Static URL prefix cannot be root")
245
+ if symlinks not in {"deny", "within-root"}:
246
+ raise ValueError("symlinks must be 'deny' or 'within-root'")
247
+ if index is not None and (not index or "/" in index or "\\" in index or index in {".", ".."}):
248
+ raise ValueError("Static index must be a single safe filename")
249
+ if spa_fallback is not None:
250
+ fallback_parts = spa_fallback.split("/")
251
+ if not spa_fallback or spa_fallback.startswith("/") or "\\" in spa_fallback or any(part in {"", ".", ".."} for part in fallback_parts):
252
+ raise ValueError("SPA fallback must be a safe relative path")
253
+ if any(existing[0] == prefix for existing in self.static_mounts):
254
+ raise ValueError(f"Static URL prefix already mounted: {prefix}")
255
+ root = Path(directory).expanduser().resolve(strict=True)
256
+ if not root.is_dir():
257
+ raise ValueError("Static directory must be a directory")
258
+ self.static_mounts.append((prefix, root, index, cache_control, symlinks, spa_fallback))
259
+
260
+ @staticmethod
261
+ def html(value: str, status: int = 200, headers: dict[str, str] | None = None) -> Response:
262
+ result = {"Content-Type": "text/html; charset=utf-8"}; result.update(headers or {})
263
+ return Response(value, status, result)
264
+
265
+ @staticmethod
266
+ def json(value, status: int = 200, headers: dict[str, str] | None = None) -> Response:
267
+ return Response.json(value, status, headers)
268
+
269
+ @staticmethod
270
+ def redirect(location: str, status: int = 303) -> Response:
271
+ return Response.redirect(location, status)
272
+
273
+ @staticmethod
274
+ def send_file(path: str | os.PathLike[str], *, download_name: str | None = None) -> Response:
275
+ file_path = Path(path).resolve(strict=True)
276
+ if not file_path.is_file():
277
+ raise FileNotFoundError(path)
278
+ size = file_path.stat().st_size
279
+ def chunks():
280
+ with file_path.open("rb") as file:
281
+ while True:
282
+ chunk = file.read(64 * 1024)
283
+ if not chunk: break
284
+ yield chunk
285
+ content_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
286
+ headers = {"Content-Type": content_type, "Content-Length": str(size)}
287
+ if download_name:
288
+ safe = download_name.replace('"', "")
289
+ headers["Content-Disposition"] = f'attachment; filename="{safe}"'
290
+ return Response(chunks(), 200, headers)
291
+
292
+ def dispatch(self, request: Request) -> Response:
293
+ if len(request.body) > self.max_body_bytes:
294
+ request.close_uploads()
295
+ return Response.text("Request body too large", 413)
296
+ request._multipart_limits = self.upload_limits
297
+ try:
298
+ def endpoint(req: Request) -> Response:
299
+ if req.method == "OPTIONS" and req.path == "*":
300
+ methods = {route.method for route in self.router.routes}
301
+ methods.add("OPTIONS")
302
+ return Response(b"", 204, {"Allow": ", ".join(sorted(methods))})
303
+ route, params, allowed = self.router.match(req.method, req.path)
304
+ if route:
305
+ req.path_params = params
306
+ endpoint = lambda current: self._coerce(route.handler(current))
307
+ for middleware in reversed(route.middleware):
308
+ downstream = endpoint
309
+ endpoint = lambda current, mw=middleware, nxt=downstream: self._coerce(mw(current, nxt))
310
+ return endpoint(req)
311
+ if req.method == "OPTIONS" and allowed:
312
+ return Response(b"", 204, {"Allow": ", ".join(sorted(allowed | {"OPTIONS"}))})
313
+ for prefix, root, index, cache_control, symlinks, spa_fallback in self.static_mounts:
314
+ if req.path == prefix or req.path.startswith(prefix + "/"):
315
+ return self._serve_static(req, prefix, root, index, cache_control, symlinks, spa_fallback)
316
+ for frontend in self.frontend_servers:
317
+ if req.path == frontend.prefix or req.path.startswith(frontend.prefix + "/"):
318
+ return frontend.serve(req)
319
+ if allowed:
320
+ response = self._error_response(HTTPError(405, "Method Not Allowed"), 405)
321
+ response.headers.setdefault("Allow", ", ".join(sorted(allowed | {"OPTIONS"})))
322
+ return response
323
+ return self._error_response(HTTPError(404, "Not Found"), 404)
324
+
325
+ next_handler = endpoint
326
+ for middleware in reversed(self.middlewares):
327
+ downstream = next_handler
328
+ next_handler = lambda req, mw=middleware, nxt=downstream: self._coerce(mw(req, nxt))
329
+ return next_handler(request)
330
+ except HTTPError as exc:
331
+ return self._error_response(exc, exc.status)
332
+ except Exception as exc:
333
+ return self._error_response(exc, 500)
334
+ finally:
335
+ request.close_uploads()
336
+
337
+ @staticmethod
338
+ def _coerce(value) -> Response:
339
+ if isinstance(value, Response): return value
340
+ if isinstance(value, dict) or isinstance(value, list): return Response.json(value)
341
+ if isinstance(value, bytes): return Response(value, 200, {"Content-Type": "application/octet-stream"})
342
+ if isinstance(value, str): return Response.text(value)
343
+ if value is None: return Response(b"", 204)
344
+ raise TypeError(f"Unsupported route return type: {type(value).__name__}")
345
+
346
+ @staticmethod
347
+ def _etag_matches(header: str, etag: str) -> bool:
348
+ target = etag.removeprefix("W/")
349
+ return any(item.strip() == "*" or item.strip().removeprefix("W/") == target
350
+ for item in header.split(","))
351
+
352
+ def _serve_static(self, request: Request, prefix: str, root: Path,
353
+ index: str | None = None,
354
+ cache_control: str = "public, max-age=0, must-revalidate",
355
+ symlinks: str = "deny",
356
+ spa_fallback: str | None = None) -> Response:
357
+ if request.method not in {"GET", "HEAD"}:
358
+ return Response.text("Method Not Allowed", 405, {"Allow": "GET, HEAD, OPTIONS"})
359
+ relative = unquote(request.path[len(prefix):].strip("/"))
360
+ if not relative or "\\" in relative or "\x00" in relative:
361
+ return Response.text("Not Found", 404)
362
+ parts = relative.split("/")
363
+ if any(part in {"", ".", ".."} for part in parts):
364
+ return Response.text("Not Found", 404)
365
+ candidate = root.joinpath(*parts)
366
+ # SPA fallback is restricted to HTML navigation and missing paths.
367
+ if spa_fallback and request.accepts("text/html") and not candidate.exists():
368
+ parts = spa_fallback.split("/")
369
+ candidate = root.joinpath(*parts)
370
+ descriptor = None
371
+ try:
372
+ if symlinks == "deny" and os.open in getattr(os, "supports_dir_fd", set()):
373
+ # Open each path component relative to a trusted directory FD.
374
+ # O_NOFOLLOW on every component prevents symlink-swap traversal.
375
+ nofollow = getattr(os, "O_NOFOLLOW", 0)
376
+ directory_flag = getattr(os, "O_DIRECTORY", 0)
377
+ if not nofollow or not directory_flag:
378
+ raise OSError("No race-resistant no-follow directory traversal")
379
+ root_fd = os.open(root, os.O_RDONLY | directory_flag | getattr(os, "O_BINARY", 0))
380
+ current_fd = root_fd
381
+ try:
382
+ for part in parts[:-1]:
383
+ next_fd = os.open(part, os.O_RDONLY | directory_flag | nofollow |
384
+ getattr(os, "O_BINARY", 0), dir_fd=current_fd)
385
+ if current_fd != root_fd:
386
+ os.close(current_fd)
387
+ current_fd = next_fd
388
+ if index:
389
+ try:
390
+ directory_fd = os.open(parts[-1], os.O_RDONLY | directory_flag | nofollow |
391
+ getattr(os, "O_BINARY", 0), dir_fd=current_fd)
392
+ except NotADirectoryError:
393
+ directory_fd = None
394
+ if directory_fd is not None:
395
+ try:
396
+ descriptor = os.open(index, os.O_RDONLY | nofollow |
397
+ getattr(os, "O_BINARY", 0), dir_fd=directory_fd)
398
+ finally:
399
+ os.close(directory_fd)
400
+ candidate = candidate / index
401
+ else:
402
+ descriptor = os.open(parts[-1], os.O_RDONLY | nofollow |
403
+ getattr(os, "O_BINARY", 0), dir_fd=current_fd)
404
+ else:
405
+ descriptor = os.open(parts[-1], os.O_RDONLY | nofollow |
406
+ getattr(os, "O_BINARY", 0), dir_fd=current_fd)
407
+ finally:
408
+ if current_fd != root_fd:
409
+ os.close(current_fd)
410
+ os.close(root_fd)
411
+ if index and os.path.isdir(candidate):
412
+ candidate = candidate / index
413
+ else:
414
+ # Portable fallback: containment checks and final-component
415
+ # O_NOFOLLOW. Platforms without dir_fd traversal cannot offer
416
+ # the same protection against intermediate-component races.
417
+ if symlinks == "deny":
418
+ current = root
419
+ for part in parts:
420
+ current = current / part
421
+ if current.is_symlink():
422
+ return Response.text("Not Found", 404)
423
+ if candidate.is_dir() and index:
424
+ candidate = candidate / index
425
+ if candidate.is_symlink():
426
+ return Response.text("Not Found", 404)
427
+ else:
428
+ candidate = candidate.resolve(strict=True)
429
+ candidate.relative_to(root)
430
+ if candidate.is_dir() and index:
431
+ candidate = (candidate / index).resolve(strict=True)
432
+ candidate.relative_to(root)
433
+ candidate.parent.resolve(strict=True).relative_to(root)
434
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
435
+ descriptor = os.open(candidate, flags)
436
+ file_stat = os.fstat(descriptor)
437
+ stat_module = __import__("stat")
438
+ if stat_module.S_ISDIR(file_stat.st_mode) and symlinks == "deny" and os.open in getattr(os, "supports_dir_fd", set()):
439
+ if not index:
440
+ os.close(descriptor)
441
+ return Response.text("Not Found", 404)
442
+ directory_fd = descriptor
443
+ try:
444
+ descriptor = os.open(index, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) |
445
+ getattr(os, "O_BINARY", 0), dir_fd=directory_fd)
446
+ finally:
447
+ os.close(directory_fd)
448
+ file_stat = os.fstat(descriptor)
449
+ if not stat_module.S_ISREG(file_stat.st_mode):
450
+ os.close(descriptor)
451
+ return Response.text("Not Found", 404)
452
+ except (OSError, ValueError, RuntimeError):
453
+ if descriptor is not None:
454
+ try: os.close(descriptor)
455
+ except OSError: pass
456
+ return Response.text("Not Found", 404)
457
+
458
+ size = file_stat.st_size
459
+ from email.utils import formatdate
460
+ etag = f'W/"{file_stat.st_mtime_ns:x}-{size:x}-{getattr(file_stat, "st_ino", 0):x}"'
461
+ headers = {"ETag": etag, "Last-Modified": formatdate(file_stat.st_mtime, usegmt=True),
462
+ "Cache-Control": cache_control,
463
+ "Content-Type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream",
464
+ "Accept-Ranges": "bytes"}
465
+
466
+ def close_fd():
467
+ try: os.close(descriptor)
468
+ except OSError: pass
469
+
470
+ inm = request.headers.get("if-none-match")
471
+ if inm is not None:
472
+ if self._etag_matches(inm, etag):
473
+ close_fd()
474
+ return Response(b"", 304, headers)
475
+ else:
476
+ ims = request.headers.get("if-modified-since")
477
+ if ims:
478
+ try:
479
+ from email.utils import parsedate_to_datetime
480
+ from datetime import timezone
481
+ date = parsedate_to_datetime(ims)
482
+ if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc)
483
+ if int(file_stat.st_mtime) <= int(date.timestamp()):
484
+ close_fd()
485
+ return Response(b"", 304, headers)
486
+ except (TypeError, ValueError, OverflowError):
487
+ pass
488
+
489
+ range_header = request.headers.get("range")
490
+ if_range = request.headers.get("if-range")
491
+ if range_header and if_range:
492
+ if if_range.startswith('"') or if_range.startswith("W/"):
493
+ if if_range != etag or etag.startswith("W/"): range_header = None
494
+ else:
495
+ try:
496
+ from email.utils import parsedate_to_datetime
497
+ from datetime import timezone
498
+ date = parsedate_to_datetime(if_range)
499
+ if date.tzinfo is None: date = date.replace(tzinfo=timezone.utc)
500
+ if int(file_stat.st_mtime) > int(date.timestamp()): range_header = None
501
+ except (TypeError, ValueError, OverflowError):
502
+ range_header = None
503
+
504
+ start, end, status = 0, size - 1, 200
505
+ if range_header:
506
+ # Ignore unsupported units, malformed syntax and multi-range requests.
507
+ # Return 416 only for a valid byte range that cannot be satisfied.
508
+ match = __import__("re").fullmatch(r"bytes=(\d*)-(\d*)", range_header.strip())
509
+ if match:
510
+ first, last = match.groups()
511
+ if first or last:
512
+ try:
513
+ if not first:
514
+ suffix = int(last)
515
+ if suffix <= 0 or size == 0:
516
+ raise IndexError
517
+ start, end, status = max(0, size - suffix), size - 1, 206
518
+ else:
519
+ start = int(first)
520
+ end = int(last) if last else size - 1
521
+ if start >= size or end < start or size == 0:
522
+ raise IndexError
523
+ end = min(end, size - 1)
524
+ status = 206
525
+ except IndexError:
526
+ close_fd()
527
+ return Response(b"", 416, {**headers, "Content-Range": f"bytes */{size}"})
528
+ except (ValueError, OverflowError):
529
+ # Reject pathological integer fields by ignoring Range.
530
+ start, end, status = 0, size - 1, 200
531
+ if status == 206:
532
+ headers["Content-Range"] = f"bytes {start}-{end}/{size}"
533
+
534
+ length = max(0, end - start + 1)
535
+ headers["Content-Length"] = str(length)
536
+ if status == 206: os.lseek(descriptor, start, os.SEEK_SET)
537
+ if request.method == "HEAD":
538
+ close_fd()
539
+ return Response(b"", status, headers)
540
+ return Response(_FileDescriptorStream(descriptor, length), status, headers)
541
+
542
+ def run(self, host: str = "127.0.0.1", port: int = 8000, *, request_timeout: float = 15.0,
543
+ max_header_bytes: int = 32768, trusted_hosts: tuple[str, ...] | list[str] | None = None) -> None:
544
+ if self.background_tasks.closed:
545
+ self.background_tasks = BackgroundTasks()
546
+ server = LimboServer(self.dispatch, host, port, request_timeout=request_timeout,
547
+ max_header_bytes=max_header_bytes, max_body_bytes=self.max_body_bytes,
548
+ trusted_hosts=trusted_hosts, websocket_handler=self.dispatch_websocket if self.websocket_routes else None,
549
+ websocket_origin_check=self.websocket_origin_allowed,
550
+ websocket_options=self.websocket_options, upload_limits=self.upload_limits)
551
+ self._server = server
552
+ try:
553
+ for hook in self.startup_hooks:
554
+ hook()
555
+ server.serve_forever()
556
+ except KeyboardInterrupt:
557
+ server.shutdown()
558
+ finally:
559
+ server.shutdown()
560
+ try:
561
+ for hook in reversed(self.shutdown_hooks):
562
+ hook()
563
+ finally:
564
+ self.background_tasks.shutdown(wait=True, cancel_pending=True)
565
+ self._server = None
limbo/auth.py ADDED
@@ -0,0 +1,42 @@
1
+ """Authentication and authorization middleware extension points."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable
5
+
6
+ from .http import Request, Response
7
+
8
+
9
+ def authenticate(resolve_user: Callable[[Request], Any | None], *,
10
+ unauthorized: Callable[[Request], Response] | None = None) -> Callable:
11
+ """Resolve a user for each request and attach it as request.user.
12
+
13
+ The resolver owns credential verification (for example, session lookup or
14
+ an API token). This helper deliberately does not prescribe an identity
15
+ provider, password database, or authentication protocol.
16
+ """
17
+ if not callable(resolve_user):
18
+ raise TypeError("resolve_user must be callable")
19
+
20
+ def middleware(request: Request, next_handler: Callable) -> Response:
21
+ user = resolve_user(request)
22
+ if user is None:
23
+ return unauthorized(request) if unauthorized else Response.text("Unauthorized", 401)
24
+ request.user = user
25
+ return next_handler(request)
26
+ return middleware
27
+
28
+
29
+ def authorize(check: Callable[[Request, Any], bool], *,
30
+ forbidden: Callable[[Request], Response] | None = None) -> Callable:
31
+ """Apply an application-defined authorization predicate to request.user."""
32
+ if not callable(check):
33
+ raise TypeError("authorization check must be callable")
34
+
35
+ def middleware(request: Request, next_handler: Callable) -> Response:
36
+ user = getattr(request, "user", None)
37
+ if user is None:
38
+ return Response.text("Unauthorized", 401)
39
+ if not check(request, user):
40
+ return forbidden(request) if forbidden else Response.text("Forbidden", 403)
41
+ return next_handler(request)
42
+ return middleware