xitzin 0.1.3__py3-none-any.whl → 0.3.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.
- xitzin/__init__.py +7 -0
- xitzin/application.py +161 -1
- xitzin/exceptions.py +18 -0
- xitzin/middleware.py +168 -5
- xitzin/scgi.py +447 -0
- xitzin/sqlmodel.py +172 -0
- xitzin/tasks.py +176 -0
- {xitzin-0.1.3.dist-info → xitzin-0.3.0.dist-info}/METADATA +6 -1
- xitzin-0.3.0.dist-info/RECORD +18 -0
- xitzin-0.1.3.dist-info/RECORD +0 -15
- {xitzin-0.1.3.dist-info → xitzin-0.3.0.dist-info}/WHEEL +0 -0
xitzin/__init__.py
CHANGED
|
@@ -22,6 +22,7 @@ Example:
|
|
|
22
22
|
|
|
23
23
|
from .application import Xitzin
|
|
24
24
|
from .cgi import CGIConfig, CGIHandler, CGIScript
|
|
25
|
+
from .scgi import SCGIApp, SCGIConfig, SCGIHandler
|
|
25
26
|
from .exceptions import (
|
|
26
27
|
BadRequest,
|
|
27
28
|
CertificateNotAuthorized,
|
|
@@ -38,6 +39,7 @@ from .exceptions import (
|
|
|
38
39
|
SensitiveInputRequired,
|
|
39
40
|
ServerUnavailable,
|
|
40
41
|
SlowDown,
|
|
42
|
+
TaskConfigurationError,
|
|
41
43
|
TemporaryFailure,
|
|
42
44
|
)
|
|
43
45
|
from .requests import Request
|
|
@@ -56,6 +58,10 @@ __all__ = [
|
|
|
56
58
|
"CGIConfig",
|
|
57
59
|
"CGIHandler",
|
|
58
60
|
"CGIScript",
|
|
61
|
+
# SCGI support
|
|
62
|
+
"SCGIApp",
|
|
63
|
+
"SCGIConfig",
|
|
64
|
+
"SCGIHandler",
|
|
59
65
|
# Exceptions
|
|
60
66
|
"GeminiException",
|
|
61
67
|
"InputRequired",
|
|
@@ -73,6 +79,7 @@ __all__ = [
|
|
|
73
79
|
"CertificateRequired",
|
|
74
80
|
"CertificateNotAuthorized",
|
|
75
81
|
"CertificateNotValid",
|
|
82
|
+
"TaskConfigurationError",
|
|
76
83
|
]
|
|
77
84
|
|
|
78
85
|
__version__ = "0.1.0"
|
xitzin/application.py
CHANGED
|
@@ -14,12 +14,13 @@ from nauyaca.protocol.request import GeminiRequest
|
|
|
14
14
|
from nauyaca.protocol.response import GeminiResponse
|
|
15
15
|
from nauyaca.protocol.status import StatusCode
|
|
16
16
|
|
|
17
|
-
from .exceptions import GeminiException, NotFound
|
|
17
|
+
from .exceptions import GeminiException, NotFound, TaskConfigurationError
|
|
18
18
|
from .requests import Request
|
|
19
19
|
from .responses import Input, Redirect, convert_response
|
|
20
20
|
from .routing import MountedRoute, Route, Router
|
|
21
21
|
|
|
22
22
|
if TYPE_CHECKING:
|
|
23
|
+
from .tasks import BackgroundTask
|
|
23
24
|
from .templating import TemplateEngine
|
|
24
25
|
|
|
25
26
|
|
|
@@ -84,6 +85,8 @@ class Xitzin:
|
|
|
84
85
|
self._startup_handlers: list[Callable[[], Any]] = []
|
|
85
86
|
self._shutdown_handlers: list[Callable[[], Any]] = []
|
|
86
87
|
self._middleware: list[Callable[..., Any]] = []
|
|
88
|
+
self._tasks: list[BackgroundTask] = []
|
|
89
|
+
self._task_handles: list[asyncio.Task[Any]] = []
|
|
87
90
|
|
|
88
91
|
if templates_dir:
|
|
89
92
|
self._init_templates(Path(templates_dir))
|
|
@@ -288,6 +291,69 @@ class Xitzin:
|
|
|
288
291
|
handler = CGIHandler(script_dir, config=config)
|
|
289
292
|
self.mount(path, handler, name=name)
|
|
290
293
|
|
|
294
|
+
def scgi(
|
|
295
|
+
self,
|
|
296
|
+
path: str,
|
|
297
|
+
host: str | None = None,
|
|
298
|
+
port: int | None = None,
|
|
299
|
+
socket_path: Path | str | None = None,
|
|
300
|
+
*,
|
|
301
|
+
name: str | None = None,
|
|
302
|
+
timeout: float = 30.0,
|
|
303
|
+
app_state_keys: list[str] | None = None,
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Mount an SCGI backend at a path prefix.
|
|
306
|
+
|
|
307
|
+
This is a convenience method that creates an SCGIHandler or SCGIApp
|
|
308
|
+
and mounts it. Exactly one of (host+port) or socket_path must be provided.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
path: Mount point prefix (e.g., "/dynamic").
|
|
312
|
+
host: SCGI server hostname (for TCP connection).
|
|
313
|
+
port: SCGI server port (for TCP connection).
|
|
314
|
+
socket_path: Path to Unix socket (for local connection).
|
|
315
|
+
name: Optional name for the mount.
|
|
316
|
+
timeout: Maximum response wait time in seconds.
|
|
317
|
+
app_state_keys: App state keys to pass as XITZIN_* env vars.
|
|
318
|
+
|
|
319
|
+
Raises:
|
|
320
|
+
ValueError: If neither or both connection types are specified.
|
|
321
|
+
|
|
322
|
+
Example:
|
|
323
|
+
# TCP connection
|
|
324
|
+
app.scgi("/dynamic", host="127.0.0.1", port=4000, timeout=30)
|
|
325
|
+
|
|
326
|
+
# Unix socket connection
|
|
327
|
+
app.scgi("/dynamic", socket_path="/tmp/scgi.sock", timeout=30)
|
|
328
|
+
"""
|
|
329
|
+
from .scgi import SCGIApp, SCGIConfig, SCGIHandler
|
|
330
|
+
|
|
331
|
+
# Validate parameters
|
|
332
|
+
tcp_specified = host is not None or port is not None
|
|
333
|
+
unix_specified = socket_path is not None
|
|
334
|
+
|
|
335
|
+
if tcp_specified and unix_specified:
|
|
336
|
+
msg = "Cannot specify both TCP (host/port) and Unix socket (socket_path)"
|
|
337
|
+
raise ValueError(msg)
|
|
338
|
+
if not tcp_specified and not unix_specified:
|
|
339
|
+
msg = "Must specify either TCP (host and port) or Unix socket (socket_path)"
|
|
340
|
+
raise ValueError(msg)
|
|
341
|
+
if tcp_specified and (host is None or port is None):
|
|
342
|
+
msg = "Both host and port must be specified for TCP connection"
|
|
343
|
+
raise ValueError(msg)
|
|
344
|
+
|
|
345
|
+
config = SCGIConfig(
|
|
346
|
+
timeout=timeout,
|
|
347
|
+
app_state_keys=app_state_keys or [],
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if tcp_specified:
|
|
351
|
+
handler = SCGIHandler(host, port, config=config) # type: ignore[arg-type]
|
|
352
|
+
else:
|
|
353
|
+
handler = SCGIApp(socket_path, config=config) # type: ignore[arg-type]
|
|
354
|
+
|
|
355
|
+
self.mount(path, handler, name=name)
|
|
356
|
+
|
|
291
357
|
def on_startup(self, handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
292
358
|
"""Register a startup event handler.
|
|
293
359
|
|
|
@@ -336,6 +402,75 @@ class Xitzin:
|
|
|
336
402
|
self._middleware.append(handler)
|
|
337
403
|
return handler
|
|
338
404
|
|
|
405
|
+
def task(
|
|
406
|
+
self,
|
|
407
|
+
*,
|
|
408
|
+
interval: str | int | float | None = None,
|
|
409
|
+
cron: str | None = None,
|
|
410
|
+
) -> Callable[[Callable[[], Any]], Callable[[], Any]]:
|
|
411
|
+
"""Register a background task.
|
|
412
|
+
|
|
413
|
+
Tasks run continuously while the server is running. They are started
|
|
414
|
+
after startup handlers and stopped before shutdown handlers.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
interval: Run every N seconds (int) or duration string ("1h", "30m", "1d").
|
|
418
|
+
cron: Cron expression string ("0 * * * *" runs hourly).
|
|
419
|
+
Requires croniter: pip install 'xitzin[tasks]'
|
|
420
|
+
|
|
421
|
+
Exactly one of interval or cron must be provided.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Decorator function.
|
|
425
|
+
|
|
426
|
+
Raises:
|
|
427
|
+
TaskConfigurationError: If neither or both parameters provided,
|
|
428
|
+
or if cron is used but croniter is not installed.
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
@app.task(interval="1h")
|
|
432
|
+
async def cleanup():
|
|
433
|
+
await app.state.db.cleanup_old_records()
|
|
434
|
+
|
|
435
|
+
@app.task(cron="0 2 * * *") # 2 AM daily
|
|
436
|
+
def backup():
|
|
437
|
+
backup_database()
|
|
438
|
+
"""
|
|
439
|
+
from .tasks import BackgroundTask, parse_interval
|
|
440
|
+
|
|
441
|
+
# Validate parameters
|
|
442
|
+
if interval is None and cron is None:
|
|
443
|
+
raise TaskConfigurationError("Either 'interval' or 'cron' must be provided")
|
|
444
|
+
if interval is not None and cron is not None:
|
|
445
|
+
raise TaskConfigurationError(
|
|
446
|
+
"Only one of 'interval' or 'cron' can be provided, not both"
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Check croniter availability
|
|
450
|
+
if cron is not None:
|
|
451
|
+
try:
|
|
452
|
+
from croniter import croniter as _ # noqa: F401
|
|
453
|
+
except ImportError:
|
|
454
|
+
raise TaskConfigurationError(
|
|
455
|
+
"croniter is required for cron tasks. "
|
|
456
|
+
"Install with: pip install 'xitzin[tasks]'"
|
|
457
|
+
) from None
|
|
458
|
+
|
|
459
|
+
def decorator(handler: Callable[[], Any]) -> Callable[[], Any]:
|
|
460
|
+
# Parse interval if provided
|
|
461
|
+
parsed_interval = parse_interval(interval) if interval else None
|
|
462
|
+
|
|
463
|
+
task = BackgroundTask(
|
|
464
|
+
handler=handler,
|
|
465
|
+
interval=parsed_interval,
|
|
466
|
+
cron=cron,
|
|
467
|
+
name=getattr(handler, "__name__", "<anonymous>"),
|
|
468
|
+
)
|
|
469
|
+
self._tasks.append(task)
|
|
470
|
+
return handler
|
|
471
|
+
|
|
472
|
+
return decorator
|
|
473
|
+
|
|
339
474
|
async def _run_startup(self) -> None:
|
|
340
475
|
"""Run all startup handlers."""
|
|
341
476
|
for handler in self._startup_handlers:
|
|
@@ -352,6 +487,26 @@ class Xitzin:
|
|
|
352
487
|
else:
|
|
353
488
|
handler()
|
|
354
489
|
|
|
490
|
+
async def _run_tasks(self) -> None:
|
|
491
|
+
"""Start all registered background tasks."""
|
|
492
|
+
from .tasks import run_cron_task, run_interval_task
|
|
493
|
+
|
|
494
|
+
for task in self._tasks:
|
|
495
|
+
if task.interval is not None:
|
|
496
|
+
handle = asyncio.create_task(run_interval_task(task))
|
|
497
|
+
else: # task.cron is not None
|
|
498
|
+
handle = asyncio.create_task(run_cron_task(task))
|
|
499
|
+
self._task_handles.append(handle)
|
|
500
|
+
|
|
501
|
+
async def _stop_tasks(self) -> None:
|
|
502
|
+
"""Stop all running background tasks."""
|
|
503
|
+
for handle in self._task_handles:
|
|
504
|
+
handle.cancel()
|
|
505
|
+
# Wait for all tasks to finish cancelling
|
|
506
|
+
if self._task_handles:
|
|
507
|
+
await asyncio.gather(*self._task_handles, return_exceptions=True)
|
|
508
|
+
self._task_handles.clear()
|
|
509
|
+
|
|
355
510
|
async def _handle_request(self, raw_request: GeminiRequest) -> GeminiResponse:
|
|
356
511
|
"""Handle an incoming request.
|
|
357
512
|
|
|
@@ -462,6 +617,9 @@ class Xitzin:
|
|
|
462
617
|
# Run startup handlers
|
|
463
618
|
await self._run_startup()
|
|
464
619
|
|
|
620
|
+
# Start background tasks
|
|
621
|
+
await self._run_tasks()
|
|
622
|
+
|
|
465
623
|
try:
|
|
466
624
|
# Create PyOpenSSL context (accepts any self-signed client cert)
|
|
467
625
|
if certfile and keyfile:
|
|
@@ -523,6 +681,8 @@ class Xitzin:
|
|
|
523
681
|
await server.serve_forever()
|
|
524
682
|
|
|
525
683
|
finally:
|
|
684
|
+
# Stop background tasks
|
|
685
|
+
await self._stop_tasks()
|
|
526
686
|
await self._run_shutdown()
|
|
527
687
|
|
|
528
688
|
def run(
|
xitzin/exceptions.py
CHANGED
|
@@ -136,3 +136,21 @@ class CertificateNotValid(GeminiException):
|
|
|
136
136
|
|
|
137
137
|
status_code = 62
|
|
138
138
|
default_message = "Certificate not valid"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Application configuration errors
|
|
142
|
+
class TaskConfigurationError(Exception):
|
|
143
|
+
"""Raised when a background task is misconfigured.
|
|
144
|
+
|
|
145
|
+
This typically indicates mutually exclusive parameters were provided,
|
|
146
|
+
or a required optional dependency is missing.
|
|
147
|
+
|
|
148
|
+
Example:
|
|
149
|
+
@app.task() # Error: neither interval nor cron provided
|
|
150
|
+
def my_task():
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
@app.task(interval="1h", cron="* * * * *") # Error: both provided
|
|
154
|
+
def my_task():
|
|
155
|
+
pass
|
|
156
|
+
"""
|
xitzin/middleware.py
CHANGED
|
@@ -6,9 +6,13 @@ and modify responses before they are sent to clients.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import asyncio
|
|
9
10
|
import time
|
|
10
11
|
from abc import ABC
|
|
11
|
-
from
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from functools import lru_cache
|
|
14
|
+
from inspect import iscoroutinefunction
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|
12
16
|
|
|
13
17
|
from nauyaca.protocol.response import GeminiResponse
|
|
14
18
|
from nauyaca.protocol.status import StatusCode
|
|
@@ -40,7 +44,11 @@ class BaseMiddleware(ABC):
|
|
|
40
44
|
print(f"Response: {response.status}")
|
|
41
45
|
return response
|
|
42
46
|
|
|
43
|
-
|
|
47
|
+
logging_mw = LoggingMiddleware()
|
|
48
|
+
|
|
49
|
+
@app.middleware
|
|
50
|
+
async def logging(request, call_next):
|
|
51
|
+
return await logging_mw(request, call_next)
|
|
44
52
|
"""
|
|
45
53
|
|
|
46
54
|
async def before_request(
|
|
@@ -98,7 +106,11 @@ class TimingMiddleware(BaseMiddleware):
|
|
|
98
106
|
Stores the elapsed time in request.state.elapsed_time.
|
|
99
107
|
|
|
100
108
|
Example:
|
|
101
|
-
|
|
109
|
+
timing_mw = TimingMiddleware()
|
|
110
|
+
|
|
111
|
+
@app.middleware
|
|
112
|
+
async def timing(request, call_next):
|
|
113
|
+
return await timing_mw(request, call_next)
|
|
102
114
|
|
|
103
115
|
@app.gemini("/")
|
|
104
116
|
def home(request: Request):
|
|
@@ -124,7 +136,11 @@ class LoggingMiddleware(BaseMiddleware):
|
|
|
124
136
|
"""Middleware that logs requests and responses.
|
|
125
137
|
|
|
126
138
|
Example:
|
|
127
|
-
|
|
139
|
+
logging_mw = LoggingMiddleware()
|
|
140
|
+
|
|
141
|
+
@app.middleware
|
|
142
|
+
async def logging(request, call_next):
|
|
143
|
+
return await logging_mw(request, call_next)
|
|
128
144
|
"""
|
|
129
145
|
|
|
130
146
|
def __init__(self, logger: Callable[[str], None] | None = None) -> None:
|
|
@@ -157,7 +173,11 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
157
173
|
Limits requests per client based on certificate fingerprint or IP.
|
|
158
174
|
|
|
159
175
|
Example:
|
|
160
|
-
|
|
176
|
+
rate_limit_mw = RateLimitMiddleware(max_requests=10, window_seconds=60)
|
|
177
|
+
|
|
178
|
+
@app.middleware
|
|
179
|
+
async def rate_limit(request, call_next):
|
|
180
|
+
return await rate_limit_mw(request, call_next)
|
|
161
181
|
"""
|
|
162
182
|
|
|
163
183
|
def __init__(
|
|
@@ -217,3 +237,146 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
217
237
|
)
|
|
218
238
|
|
|
219
239
|
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class UserSessionMiddleware(BaseMiddleware):
|
|
243
|
+
"""Middleware that loads and caches user data from certificate fingerprints.
|
|
244
|
+
|
|
245
|
+
Stores the loaded user in request.state.user. Uses an LRU cache to avoid
|
|
246
|
+
repeated database lookups for the same user across requests.
|
|
247
|
+
|
|
248
|
+
Supports both sync and async user_loader functions. Sync loaders are
|
|
249
|
+
executed in a thread pool to avoid blocking the event loop.
|
|
250
|
+
|
|
251
|
+
Example with sync loader:
|
|
252
|
+
from xitzin.middleware import UserSessionMiddleware
|
|
253
|
+
|
|
254
|
+
def load_user(fingerprint: str) -> User | None:
|
|
255
|
+
with Session(engine) as session:
|
|
256
|
+
return session.exec(
|
|
257
|
+
select(User).where(User.fingerprint == fingerprint)
|
|
258
|
+
).first()
|
|
259
|
+
|
|
260
|
+
user_mw = UserSessionMiddleware(load_user)
|
|
261
|
+
|
|
262
|
+
@app.middleware
|
|
263
|
+
async def user_session(request, call_next):
|
|
264
|
+
return await user_mw(request, call_next)
|
|
265
|
+
|
|
266
|
+
Example with async loader:
|
|
267
|
+
async def load_user(fingerprint: str) -> User | None:
|
|
268
|
+
async with async_session() as session:
|
|
269
|
+
result = await session.execute(
|
|
270
|
+
select(User).where(User.fingerprint == fingerprint)
|
|
271
|
+
)
|
|
272
|
+
return result.scalar_one_or_none()
|
|
273
|
+
|
|
274
|
+
user_mw = UserSessionMiddleware(load_user)
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def __init__(
|
|
278
|
+
self,
|
|
279
|
+
user_loader: Callable[[str], Any] | Callable[[str], Awaitable[Any]],
|
|
280
|
+
cache_size: int = 100,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Create user session middleware.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
user_loader: Function that takes a fingerprint and returns a user
|
|
286
|
+
object (or None if not found). Can be sync or async. Sync
|
|
287
|
+
loaders are executed in a thread pool to avoid blocking.
|
|
288
|
+
cache_size: Maximum number of users to cache. Defaults to 100.
|
|
289
|
+
"""
|
|
290
|
+
self._user_loader = user_loader
|
|
291
|
+
self._cache_size = cache_size
|
|
292
|
+
self._is_async = iscoroutinefunction(user_loader)
|
|
293
|
+
|
|
294
|
+
# For sync loaders, use lru_cache
|
|
295
|
+
# For async loaders, use a simple OrderedDict-based LRU cache
|
|
296
|
+
if self._is_async:
|
|
297
|
+
self._async_cache: OrderedDict[str, Any] = OrderedDict()
|
|
298
|
+
self._cache_hits = 0
|
|
299
|
+
self._cache_misses = 0
|
|
300
|
+
else:
|
|
301
|
+
self._sync_cached_loader = lru_cache(maxsize=cache_size)(user_loader)
|
|
302
|
+
|
|
303
|
+
async def _get_user_async(self, fingerprint: str) -> Any:
|
|
304
|
+
"""Get user with async loader and caching."""
|
|
305
|
+
if fingerprint in self._async_cache:
|
|
306
|
+
self._cache_hits += 1
|
|
307
|
+
# Move to end (most recently used)
|
|
308
|
+
self._async_cache.move_to_end(fingerprint)
|
|
309
|
+
return self._async_cache[fingerprint]
|
|
310
|
+
|
|
311
|
+
self._cache_misses += 1
|
|
312
|
+
user = await self._user_loader(fingerprint) # type: ignore[misc]
|
|
313
|
+
|
|
314
|
+
# Add to cache
|
|
315
|
+
self._async_cache[fingerprint] = user
|
|
316
|
+
self._async_cache.move_to_end(fingerprint)
|
|
317
|
+
|
|
318
|
+
# Evict oldest if over capacity
|
|
319
|
+
while len(self._async_cache) > self._cache_size:
|
|
320
|
+
self._async_cache.popitem(last=False)
|
|
321
|
+
|
|
322
|
+
return user
|
|
323
|
+
|
|
324
|
+
async def _get_user_sync(self, fingerprint: str) -> Any:
|
|
325
|
+
"""Get user with sync loader, running in executor."""
|
|
326
|
+
loop = asyncio.get_running_loop()
|
|
327
|
+
return await loop.run_in_executor(None, self._sync_cached_loader, fingerprint)
|
|
328
|
+
|
|
329
|
+
async def before_request(
|
|
330
|
+
self, request: "Request"
|
|
331
|
+
) -> "Request | GeminiResponse | None":
|
|
332
|
+
fingerprint = request.client_cert_fingerprint
|
|
333
|
+
if fingerprint:
|
|
334
|
+
if self._is_async:
|
|
335
|
+
request.state.user = await self._get_user_async(fingerprint)
|
|
336
|
+
else:
|
|
337
|
+
request.state.user = await self._get_user_sync(fingerprint)
|
|
338
|
+
else:
|
|
339
|
+
request.state.user = None
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def clear_cache(self) -> None:
|
|
343
|
+
"""Clear all cached users.
|
|
344
|
+
|
|
345
|
+
Call this after updating user data to ensure fresh lookups.
|
|
346
|
+
|
|
347
|
+
Example:
|
|
348
|
+
def update_user(user: User):
|
|
349
|
+
with Session(engine) as session:
|
|
350
|
+
session.add(user)
|
|
351
|
+
session.commit()
|
|
352
|
+
user_middleware.clear_cache()
|
|
353
|
+
"""
|
|
354
|
+
if self._is_async:
|
|
355
|
+
self._async_cache.clear()
|
|
356
|
+
self._cache_hits = 0
|
|
357
|
+
self._cache_misses = 0
|
|
358
|
+
else:
|
|
359
|
+
self._sync_cached_loader.cache_clear()
|
|
360
|
+
|
|
361
|
+
def cache_info(self) -> Any:
|
|
362
|
+
"""Return cache statistics.
|
|
363
|
+
|
|
364
|
+
Returns information about cache hits, misses, and size.
|
|
365
|
+
|
|
366
|
+
Example:
|
|
367
|
+
info = user_middleware.cache_info()
|
|
368
|
+
print(f"Cache hits: {info.hits}, misses: {info.misses}")
|
|
369
|
+
"""
|
|
370
|
+
if self._is_async:
|
|
371
|
+
from collections import namedtuple
|
|
372
|
+
|
|
373
|
+
CacheInfo = namedtuple(
|
|
374
|
+
"CacheInfo", ["hits", "misses", "maxsize", "currsize"]
|
|
375
|
+
)
|
|
376
|
+
return CacheInfo(
|
|
377
|
+
hits=self._cache_hits,
|
|
378
|
+
misses=self._cache_misses,
|
|
379
|
+
maxsize=self._cache_size,
|
|
380
|
+
currsize=len(self._async_cache),
|
|
381
|
+
)
|
|
382
|
+
return self._sync_cached_loader.cache_info()
|
xitzin/scgi.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""SCGI support for Xitzin applications.
|
|
2
|
+
|
|
3
|
+
This module provides SCGI (Simple Common Gateway Interface) client support
|
|
4
|
+
for proxying requests to persistent backend processes. Unlike CGI which
|
|
5
|
+
spawns a new process per request, SCGI connects to a running server process.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
from xitzin import Xitzin
|
|
9
|
+
from xitzin.scgi import SCGIHandler, SCGIConfig
|
|
10
|
+
|
|
11
|
+
app = Xitzin()
|
|
12
|
+
|
|
13
|
+
# Mount an SCGI backend via TCP
|
|
14
|
+
config = SCGIConfig(timeout=30)
|
|
15
|
+
app.mount("/dynamic", SCGIHandler("127.0.0.1", 4000, config=config))
|
|
16
|
+
|
|
17
|
+
# Or via Unix socket
|
|
18
|
+
app.mount("/api", SCGIApp("/tmp/scgi.sock", config=config))
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
29
|
+
|
|
30
|
+
from .cgi import build_cgi_env, parse_cgi_output
|
|
31
|
+
from .exceptions import CGIError, ProxyError
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from .requests import Request
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SCGIConfig:
|
|
39
|
+
"""Configuration for SCGI backend communication.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
timeout: Maximum time to wait for SCGI response in seconds.
|
|
43
|
+
max_response_size: Maximum response size in bytes (None = unlimited).
|
|
44
|
+
buffer_size: Read buffer size for streaming responses.
|
|
45
|
+
inherit_environment: Whether to inherit parent environment variables.
|
|
46
|
+
app_state_keys: App state keys to pass as XITZIN_* env vars.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
timeout: float = 30.0
|
|
50
|
+
max_response_size: int | None = 1048576 # 1MB default
|
|
51
|
+
buffer_size: int = 8192
|
|
52
|
+
inherit_environment: bool = True
|
|
53
|
+
app_state_keys: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def encode_netstring(data: bytes) -> bytes:
|
|
57
|
+
"""Encode data as a netstring.
|
|
58
|
+
|
|
59
|
+
Netstring format: <length>:<data>,
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: Bytes to encode.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Netstring-encoded bytes.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
>>> encode_netstring(b"hello")
|
|
69
|
+
b'5:hello,'
|
|
70
|
+
"""
|
|
71
|
+
length = str(len(data)).encode("ascii")
|
|
72
|
+
return length + b":" + data + b","
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def encode_scgi_headers(env: dict[str, str]) -> bytes:
|
|
76
|
+
"""Encode CGI environment as SCGI headers.
|
|
77
|
+
|
|
78
|
+
SCGI format is a netstring containing null-separated key-value pairs:
|
|
79
|
+
<key>\\0<value>\\0<key>\\0<value>\\0...
|
|
80
|
+
|
|
81
|
+
The CONTENT_LENGTH header must come first per SCGI spec.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
env: CGI environment dictionary.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Netstring-encoded headers ready for SCGI transmission.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> env = {"CONTENT_LENGTH": "0", "SCGI": "1", "PATH_INFO": "/test"}
|
|
91
|
+
>>> encode_scgi_headers(env) # Returns netstring with headers
|
|
92
|
+
"""
|
|
93
|
+
parts: list[bytes] = []
|
|
94
|
+
|
|
95
|
+
# CONTENT_LENGTH must be first per SCGI spec
|
|
96
|
+
content_length = env.get("CONTENT_LENGTH", "0")
|
|
97
|
+
parts.append(b"CONTENT_LENGTH\x00")
|
|
98
|
+
parts.append(content_length.encode("utf-8"))
|
|
99
|
+
parts.append(b"\x00")
|
|
100
|
+
|
|
101
|
+
# Add remaining headers
|
|
102
|
+
for key, value in env.items():
|
|
103
|
+
if key == "CONTENT_LENGTH":
|
|
104
|
+
continue # Already added first
|
|
105
|
+
parts.append(key.encode("utf-8"))
|
|
106
|
+
parts.append(b"\x00")
|
|
107
|
+
parts.append(value.encode("utf-8"))
|
|
108
|
+
parts.append(b"\x00")
|
|
109
|
+
|
|
110
|
+
headers = b"".join(parts)
|
|
111
|
+
return encode_netstring(headers)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SCGIHandler:
|
|
115
|
+
"""Proxy requests to an SCGI backend server via TCP socket.
|
|
116
|
+
|
|
117
|
+
This handler forwards requests to an SCGI application server
|
|
118
|
+
(like Python's flup, or custom SCGI servers) over a TCP connection.
|
|
119
|
+
|
|
120
|
+
Example:
|
|
121
|
+
from xitzin.scgi import SCGIHandler, SCGIConfig
|
|
122
|
+
|
|
123
|
+
config = SCGIConfig(timeout=30)
|
|
124
|
+
handler = SCGIHandler("127.0.0.1", 4000, config=config)
|
|
125
|
+
app.mount("/dynamic", handler)
|
|
126
|
+
|
|
127
|
+
# Requests to /dynamic/* are forwarded to 127.0.0.1:4000
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
host: str,
|
|
133
|
+
port: int,
|
|
134
|
+
*,
|
|
135
|
+
config: SCGIConfig | None = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Create an SCGI TCP handler.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
host: SCGI server hostname or IP.
|
|
141
|
+
port: SCGI server port.
|
|
142
|
+
config: SCGI communication configuration.
|
|
143
|
+
"""
|
|
144
|
+
self.host = host
|
|
145
|
+
self.port = port
|
|
146
|
+
self.config = config or SCGIConfig()
|
|
147
|
+
|
|
148
|
+
async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
|
|
149
|
+
"""Forward request to SCGI backend.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
request: The Gemini request.
|
|
153
|
+
path_info: Path after the mount prefix.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
GeminiResponse from the SCGI backend.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ProxyError: If connection or communication fails.
|
|
160
|
+
"""
|
|
161
|
+
# Build CGI environment (reuse from cgi.py)
|
|
162
|
+
app_state_vars = self._get_app_state_vars(request)
|
|
163
|
+
env = build_cgi_env(
|
|
164
|
+
request,
|
|
165
|
+
script_name="", # SCGI app handles routing internally
|
|
166
|
+
path_info=path_info,
|
|
167
|
+
app_state_vars=app_state_vars,
|
|
168
|
+
inherit_environment=self.config.inherit_environment,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Add SCGI-specific variables
|
|
172
|
+
env["SCGI"] = "1"
|
|
173
|
+
env["CONTENT_LENGTH"] = "0" # Gemini has no request body
|
|
174
|
+
|
|
175
|
+
# Connect to SCGI backend
|
|
176
|
+
try:
|
|
177
|
+
reader, writer = await asyncio.wait_for(
|
|
178
|
+
asyncio.open_connection(self.host, self.port),
|
|
179
|
+
timeout=self.config.timeout,
|
|
180
|
+
)
|
|
181
|
+
except asyncio.TimeoutError:
|
|
182
|
+
raise ProxyError(
|
|
183
|
+
f"SCGI connection timeout to {self.host}:{self.port}"
|
|
184
|
+
) from None
|
|
185
|
+
except OSError as e:
|
|
186
|
+
raise ProxyError(
|
|
187
|
+
f"Failed to connect to SCGI backend at {self.host}:{self.port}: {e}"
|
|
188
|
+
) from e
|
|
189
|
+
|
|
190
|
+
try:
|
|
191
|
+
# Send SCGI headers
|
|
192
|
+
headers = encode_scgi_headers(env)
|
|
193
|
+
writer.write(headers)
|
|
194
|
+
await writer.drain()
|
|
195
|
+
|
|
196
|
+
# Read response
|
|
197
|
+
response_data = await self._read_response(reader)
|
|
198
|
+
|
|
199
|
+
# Parse as CGI output (reuse from cgi.py)
|
|
200
|
+
cgi_response = parse_cgi_output(response_data, None)
|
|
201
|
+
|
|
202
|
+
return GeminiResponse(
|
|
203
|
+
status=cgi_response.status,
|
|
204
|
+
meta=cgi_response.meta,
|
|
205
|
+
body=cgi_response.body,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
except asyncio.TimeoutError:
|
|
209
|
+
raise ProxyError(
|
|
210
|
+
f"SCGI backend timeout after {self.config.timeout}s"
|
|
211
|
+
) from None
|
|
212
|
+
except CGIError as e:
|
|
213
|
+
# Re-raise as ProxyError (status 43 instead of 42)
|
|
214
|
+
raise ProxyError(f"SCGI backend error: {e.message}") from e
|
|
215
|
+
except ProxyError:
|
|
216
|
+
raise
|
|
217
|
+
except Exception as e:
|
|
218
|
+
raise ProxyError(f"SCGI communication error: {e}") from e
|
|
219
|
+
finally:
|
|
220
|
+
writer.close()
|
|
221
|
+
await writer.wait_closed()
|
|
222
|
+
|
|
223
|
+
async def _read_response(self, reader: asyncio.StreamReader) -> bytes:
|
|
224
|
+
"""Read full response from SCGI backend.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
reader: Stream reader connected to SCGI backend.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Complete response bytes.
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
ProxyError: If response exceeds max size or read fails.
|
|
234
|
+
"""
|
|
235
|
+
chunks: list[bytes] = []
|
|
236
|
+
total_size = 0
|
|
237
|
+
|
|
238
|
+
while True:
|
|
239
|
+
try:
|
|
240
|
+
chunk = await asyncio.wait_for(
|
|
241
|
+
reader.read(self.config.buffer_size),
|
|
242
|
+
timeout=self.config.timeout,
|
|
243
|
+
)
|
|
244
|
+
except asyncio.TimeoutError:
|
|
245
|
+
raise ProxyError("SCGI backend read timeout") from None
|
|
246
|
+
|
|
247
|
+
if not chunk:
|
|
248
|
+
break
|
|
249
|
+
|
|
250
|
+
chunks.append(chunk)
|
|
251
|
+
total_size += len(chunk)
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
self.config.max_response_size
|
|
255
|
+
and total_size > self.config.max_response_size
|
|
256
|
+
):
|
|
257
|
+
raise ProxyError(
|
|
258
|
+
f"SCGI response exceeds maximum size "
|
|
259
|
+
f"({self.config.max_response_size} bytes)"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
return b"".join(chunks)
|
|
263
|
+
|
|
264
|
+
def _get_app_state_vars(self, request: Request) -> dict[str, str]:
|
|
265
|
+
"""Extract app state variables to pass to SCGI backend."""
|
|
266
|
+
if not self.config.app_state_keys:
|
|
267
|
+
return {}
|
|
268
|
+
|
|
269
|
+
result: dict[str, str] = {}
|
|
270
|
+
try:
|
|
271
|
+
app_state = request.app.state
|
|
272
|
+
for key in self.config.app_state_keys:
|
|
273
|
+
try:
|
|
274
|
+
value = getattr(app_state, key)
|
|
275
|
+
result[key] = str(value)
|
|
276
|
+
except AttributeError:
|
|
277
|
+
pass
|
|
278
|
+
except RuntimeError:
|
|
279
|
+
# Request not bound to app
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
return result
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class SCGIApp:
|
|
286
|
+
"""Proxy requests to an SCGI backend server via Unix socket.
|
|
287
|
+
|
|
288
|
+
This handler forwards requests to an SCGI application server
|
|
289
|
+
over a Unix domain socket (more efficient for local communication).
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
from xitzin.scgi import SCGIApp, SCGIConfig
|
|
293
|
+
|
|
294
|
+
config = SCGIConfig(timeout=30)
|
|
295
|
+
handler = SCGIApp("/tmp/scgi.sock", config=config)
|
|
296
|
+
app.mount("/dynamic", handler)
|
|
297
|
+
|
|
298
|
+
# Requests to /dynamic/* are forwarded to /tmp/scgi.sock
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
def __init__(
|
|
302
|
+
self,
|
|
303
|
+
socket_path: Path | str,
|
|
304
|
+
*,
|
|
305
|
+
config: SCGIConfig | None = None,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Create an SCGI Unix socket handler.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
socket_path: Path to the Unix socket.
|
|
311
|
+
config: SCGI communication configuration.
|
|
312
|
+
"""
|
|
313
|
+
self.socket_path = Path(socket_path)
|
|
314
|
+
self.config = config or SCGIConfig()
|
|
315
|
+
|
|
316
|
+
async def __call__(self, request: Request, path_info: str) -> GeminiResponse:
|
|
317
|
+
"""Forward request to SCGI backend via Unix socket.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
request: The Gemini request.
|
|
321
|
+
path_info: Path after the mount prefix.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
GeminiResponse from the SCGI backend.
|
|
325
|
+
|
|
326
|
+
Raises:
|
|
327
|
+
ProxyError: If connection or communication fails.
|
|
328
|
+
"""
|
|
329
|
+
# Build CGI environment (reuse from cgi.py)
|
|
330
|
+
app_state_vars = self._get_app_state_vars(request)
|
|
331
|
+
env = build_cgi_env(
|
|
332
|
+
request,
|
|
333
|
+
script_name="",
|
|
334
|
+
path_info=path_info,
|
|
335
|
+
app_state_vars=app_state_vars,
|
|
336
|
+
inherit_environment=self.config.inherit_environment,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Add SCGI-specific variables
|
|
340
|
+
env["SCGI"] = "1"
|
|
341
|
+
env["CONTENT_LENGTH"] = "0"
|
|
342
|
+
|
|
343
|
+
# Connect via Unix socket
|
|
344
|
+
try:
|
|
345
|
+
reader, writer = await asyncio.wait_for(
|
|
346
|
+
asyncio.open_unix_connection(str(self.socket_path)),
|
|
347
|
+
timeout=self.config.timeout,
|
|
348
|
+
)
|
|
349
|
+
except FileNotFoundError:
|
|
350
|
+
raise ProxyError(f"SCGI socket not found: {self.socket_path}") from None
|
|
351
|
+
except asyncio.TimeoutError:
|
|
352
|
+
raise ProxyError(f"SCGI connection timeout to {self.socket_path}") from None
|
|
353
|
+
except OSError as e:
|
|
354
|
+
raise ProxyError(
|
|
355
|
+
f"Failed to connect to SCGI backend at {self.socket_path}: {e}"
|
|
356
|
+
) from e
|
|
357
|
+
|
|
358
|
+
try:
|
|
359
|
+
# Send SCGI headers
|
|
360
|
+
headers = encode_scgi_headers(env)
|
|
361
|
+
writer.write(headers)
|
|
362
|
+
await writer.drain()
|
|
363
|
+
|
|
364
|
+
# Read response
|
|
365
|
+
response_data = await self._read_response(reader)
|
|
366
|
+
|
|
367
|
+
# Parse as CGI output (reuse from cgi.py)
|
|
368
|
+
cgi_response = parse_cgi_output(response_data, None)
|
|
369
|
+
|
|
370
|
+
return GeminiResponse(
|
|
371
|
+
status=cgi_response.status,
|
|
372
|
+
meta=cgi_response.meta,
|
|
373
|
+
body=cgi_response.body,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
except asyncio.TimeoutError:
|
|
377
|
+
raise ProxyError(
|
|
378
|
+
f"SCGI backend timeout after {self.config.timeout}s"
|
|
379
|
+
) from None
|
|
380
|
+
except CGIError as e:
|
|
381
|
+
raise ProxyError(f"SCGI backend error: {e.message}") from e
|
|
382
|
+
except ProxyError:
|
|
383
|
+
raise
|
|
384
|
+
except Exception as e:
|
|
385
|
+
raise ProxyError(f"SCGI communication error: {e}") from e
|
|
386
|
+
finally:
|
|
387
|
+
writer.close()
|
|
388
|
+
await writer.wait_closed()
|
|
389
|
+
|
|
390
|
+
async def _read_response(self, reader: asyncio.StreamReader) -> bytes:
|
|
391
|
+
"""Read full response from SCGI backend."""
|
|
392
|
+
chunks: list[bytes] = []
|
|
393
|
+
total_size = 0
|
|
394
|
+
|
|
395
|
+
while True:
|
|
396
|
+
try:
|
|
397
|
+
chunk = await asyncio.wait_for(
|
|
398
|
+
reader.read(self.config.buffer_size),
|
|
399
|
+
timeout=self.config.timeout,
|
|
400
|
+
)
|
|
401
|
+
except asyncio.TimeoutError:
|
|
402
|
+
raise ProxyError("SCGI backend read timeout") from None
|
|
403
|
+
|
|
404
|
+
if not chunk:
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
chunks.append(chunk)
|
|
408
|
+
total_size += len(chunk)
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
self.config.max_response_size
|
|
412
|
+
and total_size > self.config.max_response_size
|
|
413
|
+
):
|
|
414
|
+
raise ProxyError(
|
|
415
|
+
f"SCGI response exceeds maximum size "
|
|
416
|
+
f"({self.config.max_response_size} bytes)"
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
return b"".join(chunks)
|
|
420
|
+
|
|
421
|
+
def _get_app_state_vars(self, request: Request) -> dict[str, str]:
|
|
422
|
+
"""Extract app state variables to pass to SCGI backend."""
|
|
423
|
+
if not self.config.app_state_keys:
|
|
424
|
+
return {}
|
|
425
|
+
|
|
426
|
+
result: dict[str, str] = {}
|
|
427
|
+
try:
|
|
428
|
+
app_state = request.app.state
|
|
429
|
+
for key in self.config.app_state_keys:
|
|
430
|
+
try:
|
|
431
|
+
value = getattr(app_state, key)
|
|
432
|
+
result[key] = str(value)
|
|
433
|
+
except AttributeError:
|
|
434
|
+
pass
|
|
435
|
+
except RuntimeError:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
return result
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
__all__ = [
|
|
442
|
+
"SCGIApp",
|
|
443
|
+
"SCGIConfig",
|
|
444
|
+
"SCGIHandler",
|
|
445
|
+
"encode_netstring",
|
|
446
|
+
"encode_scgi_headers",
|
|
447
|
+
]
|
xitzin/sqlmodel.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""SQLModel integration for Xitzin.
|
|
2
|
+
|
|
3
|
+
This module provides database session management through middleware
|
|
4
|
+
and helper functions. Requires the 'sqlmodel' optional dependency.
|
|
5
|
+
|
|
6
|
+
Install with: pip install xitzin[sqlmodel]
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
from sqlmodel import Field, select
|
|
10
|
+
from xitzin import Xitzin, Request
|
|
11
|
+
from xitzin.sqlmodel import (
|
|
12
|
+
SQLModel,
|
|
13
|
+
create_engine,
|
|
14
|
+
SessionMiddleware,
|
|
15
|
+
get_session,
|
|
16
|
+
init_db,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Define models
|
|
20
|
+
class Entry(SQLModel, table=True):
|
|
21
|
+
id: int | None = Field(default=None, primary_key=True)
|
|
22
|
+
content: str
|
|
23
|
+
|
|
24
|
+
# Setup app
|
|
25
|
+
app = Xitzin()
|
|
26
|
+
engine = create_engine("sqlite:///./database.db")
|
|
27
|
+
|
|
28
|
+
# Initialize database and add middleware
|
|
29
|
+
init_db(app, engine)
|
|
30
|
+
app.middleware(SessionMiddleware(engine))
|
|
31
|
+
|
|
32
|
+
# Use in routes
|
|
33
|
+
@app.gemini("/entries")
|
|
34
|
+
def list_entries(request: Request):
|
|
35
|
+
session = get_session(request)
|
|
36
|
+
entries = session.exec(select(Entry)).all()
|
|
37
|
+
return render_entries(entries)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from typing import TYPE_CHECKING, Awaitable, Callable
|
|
43
|
+
|
|
44
|
+
from sqlalchemy import Engine
|
|
45
|
+
from sqlmodel import Session, SQLModel, create_engine
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
49
|
+
|
|
50
|
+
from xitzin.application import Xitzin
|
|
51
|
+
from xitzin.requests import Request
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"SQLModel",
|
|
55
|
+
"Session",
|
|
56
|
+
"create_engine",
|
|
57
|
+
"SessionMiddleware",
|
|
58
|
+
"get_session",
|
|
59
|
+
"init_db",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def SessionMiddleware(
|
|
64
|
+
engine: Engine,
|
|
65
|
+
*,
|
|
66
|
+
autoflush: bool = True,
|
|
67
|
+
) -> Callable[
|
|
68
|
+
["Request", Callable[["Request"], Awaitable["GeminiResponse"]]],
|
|
69
|
+
Awaitable["GeminiResponse"],
|
|
70
|
+
]:
|
|
71
|
+
"""Create a middleware that manages database sessions per request.
|
|
72
|
+
|
|
73
|
+
The session is stored in request.state.db and automatically committed
|
|
74
|
+
on success or rolled back on error.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
engine: SQLAlchemy engine instance.
|
|
78
|
+
autoflush: If True, flush before queries. Defaults to True.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Middleware function compatible with @app.middleware.
|
|
82
|
+
|
|
83
|
+
Example:
|
|
84
|
+
engine = create_engine("sqlite:///./database.db")
|
|
85
|
+
app.middleware(SessionMiddleware(engine))
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
async def middleware(
|
|
89
|
+
request: "Request",
|
|
90
|
+
call_next: Callable[["Request"], Awaitable["GeminiResponse"]],
|
|
91
|
+
) -> "GeminiResponse":
|
|
92
|
+
session = Session(engine, autoflush=autoflush)
|
|
93
|
+
request.state.db = session
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
response = await call_next(request)
|
|
97
|
+
session.commit()
|
|
98
|
+
return response
|
|
99
|
+
except Exception:
|
|
100
|
+
session.rollback()
|
|
101
|
+
raise
|
|
102
|
+
finally:
|
|
103
|
+
session.close()
|
|
104
|
+
|
|
105
|
+
return middleware
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_session(request: "Request") -> Session:
|
|
109
|
+
"""Get the database session from the current request.
|
|
110
|
+
|
|
111
|
+
This helper retrieves the session created by SessionMiddleware.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
request: The current Xitzin request object.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
SQLModel Session instance.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
AttributeError: If SessionMiddleware is not configured.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
@app.gemini("/users/{user_id}")
|
|
124
|
+
def get_user(request: Request, user_id: int):
|
|
125
|
+
session = get_session(request)
|
|
126
|
+
user = session.get(User, user_id)
|
|
127
|
+
return f"# {user.name}"
|
|
128
|
+
"""
|
|
129
|
+
if not hasattr(request.state, "db"):
|
|
130
|
+
raise AttributeError(
|
|
131
|
+
"No database session found. Did you add SessionMiddleware?"
|
|
132
|
+
)
|
|
133
|
+
return request.state.db
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def init_db(
|
|
137
|
+
app: "Xitzin",
|
|
138
|
+
engine: Engine,
|
|
139
|
+
*,
|
|
140
|
+
create_tables: bool = True,
|
|
141
|
+
drop_all: bool = False,
|
|
142
|
+
) -> None:
|
|
143
|
+
"""Initialize database with lifecycle hooks.
|
|
144
|
+
|
|
145
|
+
This helper registers startup/shutdown hooks to manage table creation
|
|
146
|
+
and engine cleanup.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
app: Xitzin application instance.
|
|
150
|
+
engine: SQLAlchemy engine instance.
|
|
151
|
+
create_tables: Create all tables on startup. Defaults to True.
|
|
152
|
+
drop_all: Drop all tables before creating. Defaults to False.
|
|
153
|
+
|
|
154
|
+
Warning:
|
|
155
|
+
Setting drop_all=True will DELETE ALL DATA on startup!
|
|
156
|
+
|
|
157
|
+
Example:
|
|
158
|
+
app = Xitzin()
|
|
159
|
+
engine = create_engine("sqlite:///./database.db")
|
|
160
|
+
init_db(app, engine)
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
@app.on_startup
|
|
164
|
+
def create_db_tables() -> None:
|
|
165
|
+
if drop_all:
|
|
166
|
+
SQLModel.metadata.drop_all(engine)
|
|
167
|
+
if create_tables:
|
|
168
|
+
SQLModel.metadata.create_all(engine)
|
|
169
|
+
|
|
170
|
+
@app.on_shutdown
|
|
171
|
+
def dispose_engine() -> None:
|
|
172
|
+
engine.dispose()
|
xitzin/tasks.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Background task scheduling for Xitzin.
|
|
2
|
+
|
|
3
|
+
This module provides background task execution with interval-based
|
|
4
|
+
and cron-based scheduling.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
from xitzin import Xitzin
|
|
8
|
+
|
|
9
|
+
app = Xitzin()
|
|
10
|
+
|
|
11
|
+
@app.task(interval="1h")
|
|
12
|
+
async def hourly_cleanup():
|
|
13
|
+
await cleanup_old_records()
|
|
14
|
+
|
|
15
|
+
@app.task(cron="0 2 * * *") # 2 AM daily
|
|
16
|
+
def daily_backup():
|
|
17
|
+
backup_database()
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from datetime import datetime, timezone
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
|
|
28
|
+
import structlog
|
|
29
|
+
|
|
30
|
+
logger = structlog.get_logger("xitzin.tasks")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _execute_handler(handler: Callable[[], Any]) -> None:
|
|
34
|
+
"""Execute a task handler, wrapping sync handlers in executor.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
handler: The handler function to execute.
|
|
38
|
+
"""
|
|
39
|
+
if asyncio.iscoroutinefunction(handler):
|
|
40
|
+
await handler()
|
|
41
|
+
else:
|
|
42
|
+
loop = asyncio.get_running_loop()
|
|
43
|
+
await loop.run_in_executor(None, handler)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class BackgroundTask:
|
|
48
|
+
"""Configuration for a background task."""
|
|
49
|
+
|
|
50
|
+
handler: Callable[[], Any]
|
|
51
|
+
interval: float | None # Seconds
|
|
52
|
+
cron: str | None
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_interval(interval: str | int | float) -> float:
|
|
57
|
+
"""Parse interval string or int to seconds.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
interval: Either an integer/float (seconds) or string like "1h", "30m", "1d"
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Interval in seconds
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If format is invalid
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(interval, (int, float)):
|
|
69
|
+
if interval <= 0:
|
|
70
|
+
raise ValueError("Interval must be positive")
|
|
71
|
+
return float(interval)
|
|
72
|
+
|
|
73
|
+
# Parse duration strings
|
|
74
|
+
pattern = r"^(\d+)([smhd])$"
|
|
75
|
+
match = re.match(pattern, interval.lower().strip())
|
|
76
|
+
if not match:
|
|
77
|
+
raise ValueError(
|
|
78
|
+
f"Invalid interval format: {interval!r}. "
|
|
79
|
+
"Use integer seconds or format like '1h', '30m', '1d'"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
value, unit = match.groups()
|
|
83
|
+
value = int(value)
|
|
84
|
+
|
|
85
|
+
multipliers = {
|
|
86
|
+
"s": 1,
|
|
87
|
+
"m": 60,
|
|
88
|
+
"h": 3600,
|
|
89
|
+
"d": 86400,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return float(value * multipliers[unit])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def run_interval_task(task: BackgroundTask) -> None:
|
|
96
|
+
"""Run a task on a fixed interval.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
task: The task to run
|
|
100
|
+
"""
|
|
101
|
+
task_logger = logger.bind(task=task.name)
|
|
102
|
+
task_logger.info("task_started", interval=task.interval)
|
|
103
|
+
|
|
104
|
+
while True:
|
|
105
|
+
try:
|
|
106
|
+
# Wait first (standard behavior)
|
|
107
|
+
await asyncio.sleep(task.interval) # type: ignore[arg-type]
|
|
108
|
+
|
|
109
|
+
# Execute handler
|
|
110
|
+
await _execute_handler(task.handler)
|
|
111
|
+
task_logger.debug("task_executed")
|
|
112
|
+
|
|
113
|
+
except asyncio.CancelledError:
|
|
114
|
+
task_logger.info("task_cancelled")
|
|
115
|
+
raise
|
|
116
|
+
except Exception as e:
|
|
117
|
+
task_logger.error(
|
|
118
|
+
"task_failed",
|
|
119
|
+
error=str(e),
|
|
120
|
+
error_type=type(e).__name__,
|
|
121
|
+
)
|
|
122
|
+
# Continue running despite errors
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def run_cron_task(task: BackgroundTask) -> None:
|
|
126
|
+
"""Run a task on a cron schedule.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
task: The task to run
|
|
130
|
+
"""
|
|
131
|
+
from croniter import croniter
|
|
132
|
+
|
|
133
|
+
task_logger = logger.bind(task=task.name)
|
|
134
|
+
task_logger.info("task_started", cron=task.cron)
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
cron_iter = croniter(task.cron, datetime.now(timezone.utc))
|
|
138
|
+
except Exception as e:
|
|
139
|
+
task_logger.error(
|
|
140
|
+
"task_cron_invalid",
|
|
141
|
+
cron=task.cron,
|
|
142
|
+
error=str(e),
|
|
143
|
+
error_type=type(e).__name__,
|
|
144
|
+
)
|
|
145
|
+
raise
|
|
146
|
+
|
|
147
|
+
while True:
|
|
148
|
+
try:
|
|
149
|
+
# Calculate next run time
|
|
150
|
+
next_run = cron_iter.get_next(datetime)
|
|
151
|
+
now = datetime.now(timezone.utc)
|
|
152
|
+
|
|
153
|
+
# Handle timezone-naive datetime from croniter
|
|
154
|
+
if next_run.tzinfo is None:
|
|
155
|
+
next_run = next_run.replace(tzinfo=timezone.utc)
|
|
156
|
+
|
|
157
|
+
sleep_seconds = (next_run - now).total_seconds()
|
|
158
|
+
|
|
159
|
+
if sleep_seconds > 0:
|
|
160
|
+
task_logger.debug("task_waiting", next_run=next_run.isoformat())
|
|
161
|
+
await asyncio.sleep(sleep_seconds)
|
|
162
|
+
|
|
163
|
+
# Execute handler
|
|
164
|
+
await _execute_handler(task.handler)
|
|
165
|
+
task_logger.debug("task_executed")
|
|
166
|
+
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
task_logger.info("task_cancelled")
|
|
169
|
+
raise
|
|
170
|
+
except Exception as e:
|
|
171
|
+
task_logger.error(
|
|
172
|
+
"task_failed",
|
|
173
|
+
error=str(e),
|
|
174
|
+
error_type=type(e).__name__,
|
|
175
|
+
)
|
|
176
|
+
# Continue running despite errors
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xitzin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Gemini Application Framework
|
|
5
5
|
Keywords: gemini,protocol,framework,async,geminispace
|
|
6
6
|
Author: Alan Velasco
|
|
@@ -20,13 +20,18 @@ Classifier: Typing :: Typed
|
|
|
20
20
|
Requires-Dist: jinja2>=3.1.0
|
|
21
21
|
Requires-Dist: nauyaca>=0.3.2
|
|
22
22
|
Requires-Dist: rich>=14.2.0
|
|
23
|
+
Requires-Dist: structlog>=25.5.0
|
|
23
24
|
Requires-Dist: typing-extensions>=4.15.0
|
|
25
|
+
Requires-Dist: sqlmodel>=0.0.22 ; extra == 'sqlmodel'
|
|
26
|
+
Requires-Dist: croniter>=1.0.0 ; extra == 'tasks'
|
|
24
27
|
Requires-Python: >=3.10
|
|
25
28
|
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
26
29
|
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
27
30
|
Project-URL: Homepage, https://github.com/alanbato/xitzin
|
|
28
31
|
Project-URL: Issues, https://github.com/alanbato/xitzin/issues
|
|
29
32
|
Project-URL: Repository, https://github.com/alanbato/xitzin.git
|
|
33
|
+
Provides-Extra: sqlmodel
|
|
34
|
+
Provides-Extra: tasks
|
|
30
35
|
Description-Content-Type: text/markdown
|
|
31
36
|
|
|
32
37
|
# Xitzin
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
xitzin/__init__.py,sha256=NwOPmRS4lfOkkUyG1_RNjveqPL2p7pvQvnNyTPTWvdo,1817
|
|
2
|
+
xitzin/application.py,sha256=cHKfrGWPsbMHd445YIsXV6BBTtyK7-MnM2PD7JUNXqg,23834
|
|
3
|
+
xitzin/auth.py,sha256=KT1WprT4qF1u03T8lAGO_UzQBLQcg-OegIFubay7VlA,4511
|
|
4
|
+
xitzin/cgi.py,sha256=nmKaeLwYfk3esRnxQnn1Rx-6EHKq2zL-Xvpw5hdI-rk,17627
|
|
5
|
+
xitzin/exceptions.py,sha256=82z-CjyC0FwFbo9hGTjjmurlL_Vd4rTVdgkmQoFLXT0,3883
|
|
6
|
+
xitzin/middleware.py,sha256=q19ePBvIMgbR97qNud8NJj94yhZuwnJfwsR5PakQY0M,12499
|
|
7
|
+
xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
xitzin/requests.py,sha256=EeDqh0roz7eTItqarEDj53iFxMZlVsEIcimybKdqAHI,4612
|
|
9
|
+
xitzin/responses.py,sha256=N4HeP9Yy2PIHY0zsGa2pj8xvX2OFHAjtqR5nogNh8UQ,6545
|
|
10
|
+
xitzin/routing.py,sha256=SiT9J617GfQR_rPDD3Ivw0_N4CTh8-Jb_AZjWJnT5sw,12409
|
|
11
|
+
xitzin/scgi.py,sha256=PKpbIAsHs0iw998AwMvSPlHyoNjDDlxIHi5drOafiqo,13664
|
|
12
|
+
xitzin/sqlmodel.py,sha256=lZvDzYAgnG8S2K-EYnx6PkO7D68pjM06uQLSKUZ9yY4,4500
|
|
13
|
+
xitzin/tasks.py,sha256=_smEXy-THge8wmQqWDtX3iUmAmoHniw_qqZBlKjCdqA,4597
|
|
14
|
+
xitzin/templating.py,sha256=spjxb05wgwKA4txOMbWJuwEVMaoLovo13_ukbXAcBDY,6040
|
|
15
|
+
xitzin/testing.py,sha256=JO41TeIJeb1CqHVqBOjCVAvv9BOlvDJYzAeG83ZofdE,7572
|
|
16
|
+
xitzin-0.3.0.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
|
|
17
|
+
xitzin-0.3.0.dist-info/METADATA,sha256=S1yYzsGTGQOtg-FdeEcpllwiUGA5Wqt_XiXpcpu6sJU,3456
|
|
18
|
+
xitzin-0.3.0.dist-info/RECORD,,
|
xitzin-0.1.3.dist-info/RECORD
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
xitzin/__init__.py,sha256=9zCk_h4rI-QGLE3q95NSgPha-dtvSOEdwiZuTQmbBR4,1637
|
|
2
|
-
xitzin/application.py,sha256=GWcL-u1C6xiUwYwMxg60kQxprJ_KaLcJFPcIBf8xCzU,17909
|
|
3
|
-
xitzin/auth.py,sha256=KT1WprT4qF1u03T8lAGO_UzQBLQcg-OegIFubay7VlA,4511
|
|
4
|
-
xitzin/cgi.py,sha256=nmKaeLwYfk3esRnxQnn1Rx-6EHKq2zL-Xvpw5hdI-rk,17627
|
|
5
|
-
xitzin/exceptions.py,sha256=JdxAXHtnXMf6joXeFQMfxlcdbv_R5odb-qYPMbgRTZY,3382
|
|
6
|
-
xitzin/middleware.py,sha256=z2QJJjMEinz5e4NeZvjsCeyMLt8ZD2a3Ox2V2vuTnk8,6748
|
|
7
|
-
xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
xitzin/requests.py,sha256=EeDqh0roz7eTItqarEDj53iFxMZlVsEIcimybKdqAHI,4612
|
|
9
|
-
xitzin/responses.py,sha256=N4HeP9Yy2PIHY0zsGa2pj8xvX2OFHAjtqR5nogNh8UQ,6545
|
|
10
|
-
xitzin/routing.py,sha256=SiT9J617GfQR_rPDD3Ivw0_N4CTh8-Jb_AZjWJnT5sw,12409
|
|
11
|
-
xitzin/templating.py,sha256=spjxb05wgwKA4txOMbWJuwEVMaoLovo13_ukbXAcBDY,6040
|
|
12
|
-
xitzin/testing.py,sha256=JO41TeIJeb1CqHVqBOjCVAvv9BOlvDJYzAeG83ZofdE,7572
|
|
13
|
-
xitzin-0.1.3.dist-info/WHEEL,sha256=RRVLqVugUmFOqBedBFAmA4bsgFcROUBiSUKlERi0Hcg,79
|
|
14
|
-
xitzin-0.1.3.dist-info/METADATA,sha256=0K_lXt3a_Vy_itxhTC7hbbQPLEZ4dqMUhNhg3UqIDjs,3272
|
|
15
|
-
xitzin-0.1.3.dist-info/RECORD,,
|
|
File without changes
|