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 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 typing import TYPE_CHECKING, Awaitable, Callable
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
- app.add_middleware(LoggingMiddleware())
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
- app.add_middleware(TimingMiddleware())
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
- app.add_middleware(LoggingMiddleware())
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
- app.add_middleware(RateLimitMiddleware(max_requests=10, window_seconds=60))
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.1.3
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,,
@@ -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