xitzin 0.2.0__py3-none-any.whl → 0.4.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/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 = False
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/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