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/__init__.py +9 -1
- xitzin/application.py +278 -9
- xitzin/auth.py +28 -2
- xitzin/cgi.py +21 -10
- xitzin/exceptions.py +18 -0
- xitzin/middleware.py +171 -6
- xitzin/requests.py +103 -0
- xitzin/responses.py +2 -3
- xitzin/routing.py +165 -0
- xitzin/scgi.py +447 -0
- xitzin/tasks.py +176 -0
- xitzin/testing.py +111 -1
- {xitzin-0.2.0.dist-info → xitzin-0.4.0.dist-info}/METADATA +7 -4
- xitzin-0.4.0.dist-info/RECORD +18 -0
- {xitzin-0.2.0.dist-info → xitzin-0.4.0.dist-info}/WHEEL +2 -2
- xitzin-0.2.0.dist-info/RECORD +0 -16
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
|