xitzin 0.3.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 +2 -1
- xitzin/application.py +118 -9
- xitzin/auth.py +28 -2
- xitzin/cgi.py +21 -10
- xitzin/middleware.py +4 -2
- xitzin/requests.py +103 -0
- xitzin/responses.py +2 -3
- xitzin/routing.py +165 -0
- xitzin/scgi.py +1 -1
- xitzin/testing.py +111 -1
- {xitzin-0.3.0.dist-info → xitzin-0.4.0.dist-info}/METADATA +4 -4
- xitzin-0.4.0.dist-info/RECORD +18 -0
- {xitzin-0.3.0.dist-info → xitzin-0.4.0.dist-info}/WHEEL +2 -2
- xitzin-0.3.0.dist-info/RECORD +0 -18
xitzin/__init__.py
CHANGED
|
@@ -42,7 +42,7 @@ from .exceptions import (
|
|
|
42
42
|
TaskConfigurationError,
|
|
43
43
|
TemporaryFailure,
|
|
44
44
|
)
|
|
45
|
-
from .requests import Request
|
|
45
|
+
from .requests import Request, TitanRequest
|
|
46
46
|
from .responses import Input, Link, Redirect, Response
|
|
47
47
|
|
|
48
48
|
__all__ = [
|
|
@@ -50,6 +50,7 @@ __all__ = [
|
|
|
50
50
|
"Xitzin",
|
|
51
51
|
# Request/Response
|
|
52
52
|
"Request",
|
|
53
|
+
"TitanRequest",
|
|
53
54
|
"Response",
|
|
54
55
|
"Input",
|
|
55
56
|
"Redirect",
|
xitzin/application.py
CHANGED
|
@@ -11,13 +11,19 @@ from pathlib import Path
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Callable
|
|
12
12
|
|
|
13
13
|
from nauyaca.protocol.request import GeminiRequest
|
|
14
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
14
15
|
from nauyaca.protocol.response import GeminiResponse
|
|
15
16
|
from nauyaca.protocol.status import StatusCode
|
|
16
17
|
|
|
17
|
-
from .exceptions import
|
|
18
|
-
|
|
18
|
+
from .exceptions import (
|
|
19
|
+
CertificateRequired,
|
|
20
|
+
GeminiException,
|
|
21
|
+
NotFound,
|
|
22
|
+
TaskConfigurationError,
|
|
23
|
+
)
|
|
24
|
+
from .requests import Request, TitanRequest
|
|
19
25
|
from .responses import Input, Redirect, convert_response
|
|
20
|
-
from .routing import MountedRoute, Route, Router
|
|
26
|
+
from .routing import MountedRoute, Route, Router, TitanRoute
|
|
21
27
|
|
|
22
28
|
if TYPE_CHECKING:
|
|
23
29
|
from .tasks import BackgroundTask
|
|
@@ -227,6 +233,45 @@ class Xitzin:
|
|
|
227
233
|
|
|
228
234
|
return decorator
|
|
229
235
|
|
|
236
|
+
def titan(
|
|
237
|
+
self,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
name: str | None = None,
|
|
241
|
+
auth_tokens: list[str] | None = None,
|
|
242
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
243
|
+
"""Register a Titan upload handler.
|
|
244
|
+
|
|
245
|
+
Titan is the upload companion protocol to Gemini. This decorator
|
|
246
|
+
registers a handler for Titan upload requests.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
path: URL path pattern (e.g., "/upload/{filename}").
|
|
250
|
+
name: Optional route name. Defaults to function name.
|
|
251
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
252
|
+
requests without a valid token are rejected with status 60.
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Decorator function.
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret123"])
|
|
259
|
+
def upload(request: TitanRequest, content: bytes,
|
|
260
|
+
mime_type: str, token: str | None, filename: str):
|
|
261
|
+
if request.is_delete():
|
|
262
|
+
Path(f"./uploads/{filename}").unlink()
|
|
263
|
+
return "# Deleted"
|
|
264
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
265
|
+
return "# Upload successful"
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
269
|
+
route = TitanRoute(path, handler, name=name, auth_tokens=auth_tokens)
|
|
270
|
+
self._router.add_titan_route(route)
|
|
271
|
+
return handler
|
|
272
|
+
|
|
273
|
+
return decorator
|
|
274
|
+
|
|
230
275
|
def mount(
|
|
231
276
|
self,
|
|
232
277
|
path: str,
|
|
@@ -563,24 +608,69 @@ class Xitzin:
|
|
|
563
608
|
|
|
564
609
|
except GeminiException as e:
|
|
565
610
|
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
566
|
-
except Exception
|
|
611
|
+
except Exception:
|
|
567
612
|
# Log the error and return a generic failure
|
|
568
613
|
import traceback
|
|
569
614
|
|
|
570
615
|
traceback.print_exc()
|
|
571
616
|
return GeminiResponse(
|
|
572
617
|
status=StatusCode.TEMPORARY_FAILURE,
|
|
573
|
-
meta=
|
|
618
|
+
meta="Internal server error",
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
async def _handle_titan_request(
|
|
622
|
+
self, raw_request: NauyacaTitanRequest
|
|
623
|
+
) -> GeminiResponse:
|
|
624
|
+
"""Handle an incoming Titan upload request.
|
|
625
|
+
|
|
626
|
+
This is the Titan request processing logic, separate from Gemini.
|
|
627
|
+
"""
|
|
628
|
+
request = TitanRequest(raw_request, self)
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
# Match Titan route
|
|
632
|
+
match = self._router.match_titan(request.path)
|
|
633
|
+
if match is None:
|
|
634
|
+
raise NotFound(f"No Titan route matches: {request.path}")
|
|
635
|
+
|
|
636
|
+
route, params = match
|
|
637
|
+
|
|
638
|
+
# Validate auth token if required
|
|
639
|
+
if route.auth_tokens is not None:
|
|
640
|
+
if not request.token or request.token not in route.auth_tokens:
|
|
641
|
+
raise CertificateRequired("Valid authentication token required")
|
|
642
|
+
|
|
643
|
+
# Build middleware chain
|
|
644
|
+
async def call_handler(req: TitanRequest) -> GeminiResponse:
|
|
645
|
+
result = await route.call_handler(req, params)
|
|
646
|
+
return convert_response(result, req)
|
|
647
|
+
|
|
648
|
+
# Apply middleware (in reverse order so first registered runs first)
|
|
649
|
+
handler = call_handler
|
|
650
|
+
for mw in reversed(self._middleware):
|
|
651
|
+
handler = self._wrap_middleware(mw, handler)
|
|
652
|
+
|
|
653
|
+
return await handler(request)
|
|
654
|
+
|
|
655
|
+
except GeminiException as e:
|
|
656
|
+
return GeminiResponse(status=e.status_code, meta=e.message)
|
|
657
|
+
except Exception:
|
|
658
|
+
import traceback
|
|
659
|
+
|
|
660
|
+
traceback.print_exc()
|
|
661
|
+
return GeminiResponse(
|
|
662
|
+
status=StatusCode.TEMPORARY_FAILURE,
|
|
663
|
+
meta="Internal server error",
|
|
574
664
|
)
|
|
575
665
|
|
|
576
666
|
def _wrap_middleware(
|
|
577
667
|
self,
|
|
578
668
|
middleware: Callable[..., Any],
|
|
579
|
-
next_handler: Callable[
|
|
580
|
-
) -> Callable[
|
|
669
|
+
next_handler: Callable[..., Any],
|
|
670
|
+
) -> Callable[..., Any]:
|
|
581
671
|
"""Wrap a handler with middleware."""
|
|
582
672
|
|
|
583
|
-
async def wrapped(request:
|
|
673
|
+
async def wrapped(request: Any) -> GeminiResponse:
|
|
584
674
|
if asyncio.iscoroutinefunction(middleware):
|
|
585
675
|
return await middleware(request, next_handler)
|
|
586
676
|
return middleware(request, next_handler)
|
|
@@ -659,11 +749,30 @@ class Xitzin:
|
|
|
659
749
|
async def handle(request: GeminiRequest) -> GeminiResponse:
|
|
660
750
|
return await self._handle_request(request)
|
|
661
751
|
|
|
752
|
+
# Create Titan upload handler if Titan routes are registered
|
|
753
|
+
upload_handler = None
|
|
754
|
+
if self._router.has_titan_routes():
|
|
755
|
+
from nauyaca.server.handler import UploadHandler
|
|
756
|
+
|
|
757
|
+
class XitzinUploadHandler(UploadHandler):
|
|
758
|
+
"""Wrapper to route Titan uploads to Xitzin handlers."""
|
|
759
|
+
|
|
760
|
+
def __init__(self, app: "Xitzin") -> None:
|
|
761
|
+
self._app = app
|
|
762
|
+
|
|
763
|
+
async def handle_upload(
|
|
764
|
+
self, request: NauyacaTitanRequest
|
|
765
|
+
) -> GeminiResponse:
|
|
766
|
+
return await self._app._handle_titan_request(request)
|
|
767
|
+
|
|
768
|
+
upload_handler = XitzinUploadHandler(self)
|
|
769
|
+
print("[Xitzin] Titan upload support enabled")
|
|
770
|
+
|
|
662
771
|
# Use TLSServerProtocol for manual TLS handling
|
|
663
772
|
# (supports self-signed client certs)
|
|
664
773
|
def create_protocol() -> TLSServerProtocol:
|
|
665
774
|
return TLSServerProtocol(
|
|
666
|
-
lambda: GeminiServerProtocol(handle, None),
|
|
775
|
+
lambda: GeminiServerProtocol(handle, None, upload_handler),
|
|
667
776
|
ssl_context,
|
|
668
777
|
)
|
|
669
778
|
|
xitzin/auth.py
CHANGED
|
@@ -6,6 +6,7 @@ Gemini client certificate authentication.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import hmac
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
from functools import wraps
|
|
11
12
|
from typing import TYPE_CHECKING, Any, Callable
|
|
@@ -89,6 +90,27 @@ def require_certificate(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
89
90
|
return wrapper
|
|
90
91
|
|
|
91
92
|
|
|
93
|
+
def _timing_safe_fingerprint_check(fingerprint: str, allowed: list[str]) -> bool:
|
|
94
|
+
"""Check if fingerprint matches any allowed value using timing-safe comparison.
|
|
95
|
+
|
|
96
|
+
This prevents timing attacks that could reveal valid fingerprints.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
fingerprint: The fingerprint to check.
|
|
100
|
+
allowed: List of allowed fingerprints.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if fingerprint matches any allowed value.
|
|
104
|
+
"""
|
|
105
|
+
# Encode once for comparison
|
|
106
|
+
fp_bytes = fingerprint.encode("utf-8")
|
|
107
|
+
|
|
108
|
+
for allowed_fp in allowed:
|
|
109
|
+
if hmac.compare_digest(fp_bytes, allowed_fp.encode("utf-8")):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
92
114
|
def require_fingerprint(
|
|
93
115
|
*allowed_fingerprints: str,
|
|
94
116
|
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
@@ -97,6 +119,8 @@ def require_fingerprint(
|
|
|
97
119
|
If the client certificate fingerprint is not in the allowed list,
|
|
98
120
|
returns status 61 (certificate not authorized).
|
|
99
121
|
|
|
122
|
+
Uses timing-safe comparison to prevent fingerprint enumeration attacks.
|
|
123
|
+
|
|
100
124
|
Args:
|
|
101
125
|
*allowed_fingerprints: SHA-256 fingerprints that are allowed.
|
|
102
126
|
|
|
@@ -111,7 +135,7 @@ def require_fingerprint(
|
|
|
111
135
|
def admin_panel(request: Request):
|
|
112
136
|
return "# Admin Panel"
|
|
113
137
|
"""
|
|
114
|
-
|
|
138
|
+
allowed_list = list(allowed_fingerprints)
|
|
115
139
|
|
|
116
140
|
def decorator(handler: Callable[..., Any]) -> Callable[..., Any]:
|
|
117
141
|
@wraps(handler)
|
|
@@ -119,7 +143,9 @@ def require_fingerprint(
|
|
|
119
143
|
if not request.client_cert_fingerprint:
|
|
120
144
|
raise CertificateRequired("Client certificate required")
|
|
121
145
|
|
|
122
|
-
if
|
|
146
|
+
if not _timing_safe_fingerprint_check(
|
|
147
|
+
request.client_cert_fingerprint, allowed_list
|
|
148
|
+
):
|
|
123
149
|
raise CertificateNotAuthorized("Certificate not authorized")
|
|
124
150
|
|
|
125
151
|
return handler(request, *args, **kwargs)
|
xitzin/cgi.py
CHANGED
|
@@ -26,10 +26,13 @@ from dataclasses import dataclass, field
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import TYPE_CHECKING
|
|
28
28
|
|
|
29
|
+
import structlog
|
|
29
30
|
from nauyaca.protocol.response import GeminiResponse
|
|
30
31
|
|
|
31
32
|
from .exceptions import BadRequest, CGIError, NotFound
|
|
32
33
|
|
|
34
|
+
logger = structlog.get_logger("xitzin.cgi")
|
|
35
|
+
|
|
33
36
|
if TYPE_CHECKING:
|
|
34
37
|
from .requests import Request
|
|
35
38
|
|
|
@@ -56,7 +59,7 @@ class CGIConfig:
|
|
|
56
59
|
max_header_size: int = 8192
|
|
57
60
|
streaming: bool = False
|
|
58
61
|
check_execute_permission: bool = True
|
|
59
|
-
inherit_environment: bool =
|
|
62
|
+
inherit_environment: bool = False
|
|
60
63
|
app_state_keys: list[str] = field(default_factory=list)
|
|
61
64
|
|
|
62
65
|
|
|
@@ -379,10 +382,14 @@ class CGIHandler:
|
|
|
379
382
|
|
|
380
383
|
# Check exit code
|
|
381
384
|
if process.returncode != 0:
|
|
382
|
-
|
|
383
|
-
if
|
|
384
|
-
|
|
385
|
-
|
|
385
|
+
# Log stderr server-side but don't expose to client
|
|
386
|
+
if stderr:
|
|
387
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
388
|
+
logger.error(
|
|
389
|
+
"cgi_script_failed",
|
|
390
|
+
script=str(script_path),
|
|
391
|
+
returncode=process.returncode,
|
|
392
|
+
stderr=error_detail,
|
|
386
393
|
)
|
|
387
394
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
388
395
|
|
|
@@ -420,7 +427,7 @@ class CGIScript:
|
|
|
420
427
|
*,
|
|
421
428
|
timeout: float = 30.0,
|
|
422
429
|
check_execute_permission: bool = True,
|
|
423
|
-
inherit_environment: bool =
|
|
430
|
+
inherit_environment: bool = False,
|
|
424
431
|
app_state_keys: list[str] | None = None,
|
|
425
432
|
) -> None:
|
|
426
433
|
"""Create a single-script CGI handler.
|
|
@@ -530,10 +537,14 @@ class CGIScript:
|
|
|
530
537
|
) from None
|
|
531
538
|
|
|
532
539
|
if process.returncode != 0:
|
|
533
|
-
|
|
534
|
-
if
|
|
535
|
-
|
|
536
|
-
|
|
540
|
+
# Log stderr server-side but don't expose to client
|
|
541
|
+
if stderr:
|
|
542
|
+
error_detail = stderr.decode("utf-8", errors="replace")[:500]
|
|
543
|
+
logger.error(
|
|
544
|
+
"cgi_script_failed",
|
|
545
|
+
script=str(self.script_path),
|
|
546
|
+
returncode=process.returncode,
|
|
547
|
+
stderr=error_detail,
|
|
537
548
|
)
|
|
538
549
|
raise CGIError(f"CGI script exited with code {process.returncode}")
|
|
539
550
|
|
xitzin/middleware.py
CHANGED
|
@@ -202,7 +202,9 @@ class RateLimitMiddleware(BaseMiddleware):
|
|
|
202
202
|
"""Get a unique identifier for the client."""
|
|
203
203
|
if request.client_cert_fingerprint:
|
|
204
204
|
return f"cert:{request.client_cert_fingerprint}"
|
|
205
|
-
#
|
|
205
|
+
# Use IP address when available for anonymous clients
|
|
206
|
+
if request.remote_addr:
|
|
207
|
+
return f"ip:{request.remote_addr}"
|
|
206
208
|
return "unknown"
|
|
207
209
|
|
|
208
210
|
def _is_rate_limited(self, client_id: str) -> bool:
|
|
@@ -309,7 +311,7 @@ class UserSessionMiddleware(BaseMiddleware):
|
|
|
309
311
|
return self._async_cache[fingerprint]
|
|
310
312
|
|
|
311
313
|
self._cache_misses += 1
|
|
312
|
-
user = await self._user_loader(fingerprint)
|
|
314
|
+
user = await self._user_loader(fingerprint)
|
|
313
315
|
|
|
314
316
|
# Add to cache
|
|
315
317
|
self._async_cache[fingerprint] = user
|
xitzin/requests.py
CHANGED
|
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
9
9
|
from urllib.parse import unquote_plus
|
|
10
10
|
|
|
11
11
|
from nauyaca.protocol.request import GeminiRequest
|
|
12
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
14
15
|
from cryptography.x509 import Certificate
|
|
@@ -148,3 +149,105 @@ class Request:
|
|
|
148
149
|
|
|
149
150
|
def __repr__(self) -> str:
|
|
150
151
|
return f"Request({self._raw_request.raw_url!r})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class TitanRequest:
|
|
155
|
+
"""Wraps a Nauyaca TitanRequest for Titan upload handlers.
|
|
156
|
+
|
|
157
|
+
Handlers receive this object as their first argument for @app.titan routes.
|
|
158
|
+
|
|
159
|
+
Example:
|
|
160
|
+
@app.titan("/upload/{filename}", auth_tokens=["secret"])
|
|
161
|
+
def upload(request: TitanRequest, content: bytes,
|
|
162
|
+
mime_type: str, token: str | None, filename: str):
|
|
163
|
+
if request.is_delete():
|
|
164
|
+
return "# Deleted"
|
|
165
|
+
Path(f"./uploads/{filename}").write_bytes(content)
|
|
166
|
+
return "# Upload successful"
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
app: The Xitzin application instance.
|
|
170
|
+
state: Arbitrary state storage for this request.
|
|
171
|
+
path: The URL path component.
|
|
172
|
+
content: The uploaded content bytes.
|
|
173
|
+
mime_type: Content MIME type.
|
|
174
|
+
token: Authentication token (if provided).
|
|
175
|
+
size: Content size in bytes.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self, raw_request: NauyacaTitanRequest, app: Xitzin | None = None
|
|
180
|
+
) -> None:
|
|
181
|
+
self._raw_request = raw_request
|
|
182
|
+
self._app = app
|
|
183
|
+
self._state = RequestState()
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def app(self) -> Xitzin:
|
|
187
|
+
"""The Xitzin application handling this request."""
|
|
188
|
+
if self._app is None:
|
|
189
|
+
msg = "Request is not bound to an application"
|
|
190
|
+
raise RuntimeError(msg)
|
|
191
|
+
return self._app
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def state(self) -> RequestState:
|
|
195
|
+
"""Arbitrary state storage for this request."""
|
|
196
|
+
return self._state
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def path(self) -> str:
|
|
200
|
+
"""The URL path component."""
|
|
201
|
+
return self._raw_request.path
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def content(self) -> bytes:
|
|
205
|
+
"""The uploaded content bytes."""
|
|
206
|
+
return self._raw_request.content
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def mime_type(self) -> str:
|
|
210
|
+
"""Content MIME type."""
|
|
211
|
+
return self._raw_request.mime_type
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def token(self) -> str | None:
|
|
215
|
+
"""Authentication token (if provided)."""
|
|
216
|
+
return self._raw_request.token
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def size(self) -> int:
|
|
220
|
+
"""Content size in bytes."""
|
|
221
|
+
return self._raw_request.size
|
|
222
|
+
|
|
223
|
+
def is_delete(self) -> bool:
|
|
224
|
+
"""Check if this is a delete request (zero-byte upload)."""
|
|
225
|
+
return self._raw_request.is_delete()
|
|
226
|
+
|
|
227
|
+
@property
|
|
228
|
+
def hostname(self) -> str:
|
|
229
|
+
"""The server hostname from the URL."""
|
|
230
|
+
return self._raw_request.hostname
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def port(self) -> int:
|
|
234
|
+
"""The server port from the URL."""
|
|
235
|
+
return self._raw_request.port
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def client_cert(self) -> Certificate | None:
|
|
239
|
+
"""The client's TLS certificate, if provided."""
|
|
240
|
+
return self._raw_request.client_cert
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def client_cert_fingerprint(self) -> str | None:
|
|
244
|
+
"""SHA-256 fingerprint of the client certificate."""
|
|
245
|
+
return self._raw_request.client_cert_fingerprint
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def raw_url(self) -> str:
|
|
249
|
+
"""The original URL from the request."""
|
|
250
|
+
return self._raw_request.raw_url
|
|
251
|
+
|
|
252
|
+
def __repr__(self) -> str:
|
|
253
|
+
return f"TitanRequest({self._raw_request.raw_url!r})"
|
xitzin/responses.py
CHANGED
|
@@ -14,7 +14,6 @@ from nauyaca.protocol.status import StatusCode
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
16
16
|
from .application import Xitzin
|
|
17
|
-
from .requests import Request
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class ResponseConvertible(Protocol):
|
|
@@ -156,7 +155,7 @@ class Link:
|
|
|
156
155
|
return self.to_gemtext()
|
|
157
156
|
|
|
158
157
|
|
|
159
|
-
def convert_response(result: Any, request:
|
|
158
|
+
def convert_response(result: Any, request: Any = None) -> GeminiResponse:
|
|
160
159
|
"""Convert a handler return value to a GeminiResponse.
|
|
161
160
|
|
|
162
161
|
Handlers can return:
|
|
@@ -168,7 +167,7 @@ def convert_response(result: Any, request: Request | None = None) -> GeminiRespo
|
|
|
168
167
|
|
|
169
168
|
Args:
|
|
170
169
|
result: The return value from a handler.
|
|
171
|
-
request: The current request (for URL tracking).
|
|
170
|
+
request: The current request (Request or TitanRequest, for URL tracking).
|
|
172
171
|
|
|
173
172
|
Returns:
|
|
174
173
|
A GeminiResponse instance.
|
xitzin/routing.py
CHANGED
|
@@ -282,6 +282,143 @@ class MountedRoute:
|
|
|
282
282
|
return f"MountedRoute({self.path_prefix!r}, name={self.name!r})"
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
class TitanRoute:
|
|
286
|
+
"""Route for Titan upload handlers with integrated authentication.
|
|
287
|
+
|
|
288
|
+
Similar to Route, but designed for Titan uploads with:
|
|
289
|
+
- Token-based authentication
|
|
290
|
+
- Explicit content/mime_type/token parameters passed to handlers
|
|
291
|
+
|
|
292
|
+
Example:
|
|
293
|
+
route = TitanRoute(
|
|
294
|
+
"/upload/{filename}",
|
|
295
|
+
upload_handler,
|
|
296
|
+
auth_tokens=["secret123"]
|
|
297
|
+
)
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(
|
|
301
|
+
self,
|
|
302
|
+
path: str,
|
|
303
|
+
handler: Callable[..., Any],
|
|
304
|
+
*,
|
|
305
|
+
name: str | None = None,
|
|
306
|
+
auth_tokens: list[str] | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Create a new Titan route.
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
path: Path template with optional parameters (e.g., "/upload/{filename}").
|
|
312
|
+
handler: The handler function to call.
|
|
313
|
+
name: Route name for identification. Defaults to handler function name.
|
|
314
|
+
auth_tokens: List of valid authentication tokens. If provided,
|
|
315
|
+
requests without a valid token are rejected with status 60.
|
|
316
|
+
"""
|
|
317
|
+
self.path = path
|
|
318
|
+
self.handler = handler
|
|
319
|
+
self.name = (
|
|
320
|
+
name if name is not None else getattr(handler, "__name__", "<anonymous>")
|
|
321
|
+
)
|
|
322
|
+
self.auth_tokens = set(auth_tokens) if auth_tokens else None
|
|
323
|
+
|
|
324
|
+
self._param_pattern, self._param_names = self._compile_path(path)
|
|
325
|
+
self._type_hints = self._get_handler_type_hints(handler)
|
|
326
|
+
self._is_async = asyncio.iscoroutinefunction(handler)
|
|
327
|
+
|
|
328
|
+
def _compile_path(self, path: str) -> tuple[re.Pattern[str], list[str]]:
|
|
329
|
+
"""Convert a path template to a regex pattern.
|
|
330
|
+
|
|
331
|
+
Same logic as Route._compile_path().
|
|
332
|
+
"""
|
|
333
|
+
param_names: list[str] = []
|
|
334
|
+
|
|
335
|
+
def replace_param(match: re.Match[str]) -> str:
|
|
336
|
+
name = match.group(1)
|
|
337
|
+
param_type = match.group(2)
|
|
338
|
+
param_names.append(name)
|
|
339
|
+
|
|
340
|
+
if param_type == "path":
|
|
341
|
+
return f"(?P<{name}>.+)"
|
|
342
|
+
return f"(?P<{name}>[^/]+)"
|
|
343
|
+
|
|
344
|
+
escaped = re.escape(path)
|
|
345
|
+
escaped = escaped.replace(r"\{", "{").replace(r"\}", "}")
|
|
346
|
+
regex_path = PATH_PARAM_PATTERN.sub(replace_param, escaped)
|
|
347
|
+
|
|
348
|
+
return re.compile(f"^{regex_path}$"), param_names
|
|
349
|
+
|
|
350
|
+
def _get_handler_type_hints(self, handler: Callable[..., Any]) -> dict[str, type]:
|
|
351
|
+
"""Extract type hints from handler function.
|
|
352
|
+
|
|
353
|
+
Excludes 'request', 'content', 'mime_type', 'token', and 'return'.
|
|
354
|
+
"""
|
|
355
|
+
try:
|
|
356
|
+
hints = get_type_hints(handler)
|
|
357
|
+
# Remove non-path-parameter hints
|
|
358
|
+
hints.pop("request", None)
|
|
359
|
+
hints.pop("content", None)
|
|
360
|
+
hints.pop("mime_type", None)
|
|
361
|
+
hints.pop("token", None)
|
|
362
|
+
hints.pop("return", None)
|
|
363
|
+
return hints
|
|
364
|
+
except Exception:
|
|
365
|
+
return {}
|
|
366
|
+
|
|
367
|
+
def matches(self, path: str) -> bool:
|
|
368
|
+
"""Check if this route matches the given path."""
|
|
369
|
+
return self._param_pattern.match(path) is not None
|
|
370
|
+
|
|
371
|
+
def extract_params(self, path: str) -> dict[str, Any]:
|
|
372
|
+
"""Extract and type-convert path parameters."""
|
|
373
|
+
match = self._param_pattern.match(path)
|
|
374
|
+
if not match:
|
|
375
|
+
return {}
|
|
376
|
+
|
|
377
|
+
params: dict[str, Any] = {}
|
|
378
|
+
for name, value in match.groupdict().items():
|
|
379
|
+
target_type = self._type_hints.get(name, str)
|
|
380
|
+
try:
|
|
381
|
+
if target_type is int:
|
|
382
|
+
params[name] = int(value)
|
|
383
|
+
elif target_type is float:
|
|
384
|
+
params[name] = float(value)
|
|
385
|
+
elif target_type is bool:
|
|
386
|
+
params[name] = value.lower() in ("true", "1", "yes")
|
|
387
|
+
else:
|
|
388
|
+
params[name] = value
|
|
389
|
+
except (ValueError, TypeError):
|
|
390
|
+
params[name] = value
|
|
391
|
+
|
|
392
|
+
return params
|
|
393
|
+
|
|
394
|
+
async def call_handler(self, request: Any, params: dict[str, Any]) -> Any:
|
|
395
|
+
"""Call the handler with request and explicit Titan parameters.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
request: TitanRequest object (typed as Any to avoid circular imports).
|
|
399
|
+
params: Path parameters extracted from the URL.
|
|
400
|
+
|
|
401
|
+
Handler receives: (request, content, mime_type, token, **path_params)
|
|
402
|
+
"""
|
|
403
|
+
# Add Titan-specific parameters
|
|
404
|
+
handler_params = {
|
|
405
|
+
"content": request.content,
|
|
406
|
+
"mime_type": request.mime_type,
|
|
407
|
+
"token": request.token,
|
|
408
|
+
**params,
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if self._is_async:
|
|
412
|
+
return await self.handler(request, **handler_params)
|
|
413
|
+
loop = asyncio.get_event_loop()
|
|
414
|
+
return await loop.run_in_executor(
|
|
415
|
+
None, lambda: self.handler(request, **handler_params)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
def __repr__(self) -> str:
|
|
419
|
+
return f"TitanRoute({self.path!r}, name={self.name!r})"
|
|
420
|
+
|
|
421
|
+
|
|
285
422
|
class Router:
|
|
286
423
|
"""Collection of routes with matching logic.
|
|
287
424
|
|
|
@@ -293,6 +430,7 @@ class Router:
|
|
|
293
430
|
self._routes: list[Route] = []
|
|
294
431
|
self._routes_by_name: dict[str, Route] = {}
|
|
295
432
|
self._mounted_routes: list[MountedRoute] = []
|
|
433
|
+
self._titan_routes: list[TitanRoute] = []
|
|
296
434
|
|
|
297
435
|
def add_route(self, route: Route) -> None:
|
|
298
436
|
"""Add a route to the router.
|
|
@@ -351,6 +489,33 @@ class Router:
|
|
|
351
489
|
return route, params
|
|
352
490
|
return None
|
|
353
491
|
|
|
492
|
+
def add_titan_route(self, route: TitanRoute) -> None:
|
|
493
|
+
"""Add a Titan upload route to the router.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
route: The Titan route to add.
|
|
497
|
+
"""
|
|
498
|
+
self._titan_routes.append(route)
|
|
499
|
+
|
|
500
|
+
def match_titan(self, path: str) -> tuple[TitanRoute, dict[str, Any]] | None:
|
|
501
|
+
"""Find a matching Titan route and extract parameters.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
path: URL path to match.
|
|
505
|
+
|
|
506
|
+
Returns:
|
|
507
|
+
Tuple of (titan_route, params) if found, None otherwise.
|
|
508
|
+
"""
|
|
509
|
+
for route in self._titan_routes:
|
|
510
|
+
if route.matches(path):
|
|
511
|
+
params = route.extract_params(path)
|
|
512
|
+
return route, params
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
def has_titan_routes(self) -> bool:
|
|
516
|
+
"""Check if any Titan routes are registered."""
|
|
517
|
+
return len(self._titan_routes) > 0
|
|
518
|
+
|
|
354
519
|
def reverse(self, name: str, **params: Any) -> str:
|
|
355
520
|
"""Build URL for a named route.
|
|
356
521
|
|
xitzin/scgi.py
CHANGED
|
@@ -49,7 +49,7 @@ class SCGIConfig:
|
|
|
49
49
|
timeout: float = 30.0
|
|
50
50
|
max_response_size: int | None = 1048576 # 1MB default
|
|
51
51
|
buffer_size: int = 8192
|
|
52
|
-
inherit_environment: bool =
|
|
52
|
+
inherit_environment: bool = False
|
|
53
53
|
app_state_keys: list[str] = field(default_factory=list)
|
|
54
54
|
|
|
55
55
|
|
xitzin/testing.py
CHANGED
|
@@ -13,6 +13,7 @@ from typing import TYPE_CHECKING, Generator
|
|
|
13
13
|
from urllib.parse import quote_plus
|
|
14
14
|
|
|
15
15
|
from nauyaca.protocol.request import GeminiRequest
|
|
16
|
+
from nauyaca.protocol.request import TitanRequest as NauyacaTitanRequest
|
|
16
17
|
from nauyaca.protocol.response import GeminiResponse
|
|
17
18
|
|
|
18
19
|
if TYPE_CHECKING:
|
|
@@ -153,10 +154,15 @@ class TestClient:
|
|
|
153
154
|
# Handle request through the app
|
|
154
155
|
response = self._handle_sync(request)
|
|
155
156
|
|
|
157
|
+
# Convert bytes body to str for TestResponse
|
|
158
|
+
body = response.body
|
|
159
|
+
if isinstance(body, bytes):
|
|
160
|
+
body = body.decode("utf-8")
|
|
161
|
+
|
|
156
162
|
return TestResponse(
|
|
157
163
|
status=response.status,
|
|
158
164
|
meta=response.meta,
|
|
159
|
-
body=
|
|
165
|
+
body=body,
|
|
160
166
|
)
|
|
161
167
|
|
|
162
168
|
def get_input(
|
|
@@ -210,6 +216,100 @@ class TestClient:
|
|
|
210
216
|
new_client._default_fingerprint = fingerprint
|
|
211
217
|
return new_client
|
|
212
218
|
|
|
219
|
+
def upload(
|
|
220
|
+
self,
|
|
221
|
+
path: str,
|
|
222
|
+
content: bytes | str,
|
|
223
|
+
*,
|
|
224
|
+
mime_type: str = "text/gemini",
|
|
225
|
+
token: str | None = None,
|
|
226
|
+
cert_fingerprint: str | None = None,
|
|
227
|
+
) -> TestResponse:
|
|
228
|
+
"""Make a Titan upload request.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
path: The request path (e.g., "/upload/file.gmi").
|
|
232
|
+
content: Content to upload (str will be UTF-8 encoded).
|
|
233
|
+
mime_type: Content MIME type (default: text/gemini).
|
|
234
|
+
token: Authentication token (if required by route).
|
|
235
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
TestResponse with status, meta, and body.
|
|
239
|
+
|
|
240
|
+
Example:
|
|
241
|
+
response = client.upload(
|
|
242
|
+
"/files/test.gmi",
|
|
243
|
+
"# Hello World",
|
|
244
|
+
mime_type="text/gemini",
|
|
245
|
+
token="secret123"
|
|
246
|
+
)
|
|
247
|
+
assert response.is_success
|
|
248
|
+
"""
|
|
249
|
+
# Convert str to bytes
|
|
250
|
+
if isinstance(content, str):
|
|
251
|
+
content_bytes = content.encode("utf-8")
|
|
252
|
+
else:
|
|
253
|
+
content_bytes = content
|
|
254
|
+
|
|
255
|
+
# Build Titan URL
|
|
256
|
+
size = len(content_bytes)
|
|
257
|
+
url = f"titan://testserver{path};size={size};mime={mime_type}"
|
|
258
|
+
if token:
|
|
259
|
+
url += f";token={token}"
|
|
260
|
+
|
|
261
|
+
# Create TitanRequest
|
|
262
|
+
request = NauyacaTitanRequest.from_line(url)
|
|
263
|
+
request.content = content_bytes
|
|
264
|
+
|
|
265
|
+
# Set certificate info
|
|
266
|
+
fingerprint = cert_fingerprint or self._default_fingerprint
|
|
267
|
+
if fingerprint:
|
|
268
|
+
request.client_cert_fingerprint = fingerprint
|
|
269
|
+
|
|
270
|
+
# Handle through app
|
|
271
|
+
response = self._handle_titan_sync(request)
|
|
272
|
+
|
|
273
|
+
# Convert bytes body to str for TestResponse
|
|
274
|
+
body = response.body
|
|
275
|
+
if isinstance(body, bytes):
|
|
276
|
+
body = body.decode("utf-8")
|
|
277
|
+
|
|
278
|
+
return TestResponse(
|
|
279
|
+
status=response.status,
|
|
280
|
+
meta=response.meta,
|
|
281
|
+
body=body,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def delete(
|
|
285
|
+
self,
|
|
286
|
+
path: str,
|
|
287
|
+
*,
|
|
288
|
+
token: str | None = None,
|
|
289
|
+
cert_fingerprint: str | None = None,
|
|
290
|
+
) -> TestResponse:
|
|
291
|
+
"""Make a Titan delete request (zero-byte upload).
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
path: The request path to delete.
|
|
295
|
+
token: Authentication token (if required by route).
|
|
296
|
+
cert_fingerprint: Mock client certificate fingerprint.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
TestResponse with status, meta, and body.
|
|
300
|
+
|
|
301
|
+
Example:
|
|
302
|
+
response = client.delete("/files/old.gmi", token="secret123")
|
|
303
|
+
assert response.is_success
|
|
304
|
+
"""
|
|
305
|
+
return self.upload(
|
|
306
|
+
path,
|
|
307
|
+
b"",
|
|
308
|
+
mime_type="text/gemini",
|
|
309
|
+
token=token,
|
|
310
|
+
cert_fingerprint=cert_fingerprint,
|
|
311
|
+
)
|
|
312
|
+
|
|
213
313
|
def _handle_sync(self, request: GeminiRequest) -> GeminiResponse:
|
|
214
314
|
"""Handle a request synchronously."""
|
|
215
315
|
try:
|
|
@@ -220,6 +320,16 @@ class TestClient:
|
|
|
220
320
|
|
|
221
321
|
return loop.run_until_complete(self._app._handle_request(request))
|
|
222
322
|
|
|
323
|
+
def _handle_titan_sync(self, request: NauyacaTitanRequest) -> GeminiResponse:
|
|
324
|
+
"""Handle a Titan request synchronously."""
|
|
325
|
+
try:
|
|
326
|
+
loop = asyncio.get_event_loop()
|
|
327
|
+
except RuntimeError:
|
|
328
|
+
loop = asyncio.new_event_loop()
|
|
329
|
+
asyncio.set_event_loop(loop)
|
|
330
|
+
|
|
331
|
+
return loop.run_until_complete(self._app._handle_titan_request(request))
|
|
332
|
+
|
|
223
333
|
|
|
224
334
|
@contextmanager
|
|
225
335
|
def test_app(app: "Xitzin") -> Generator[TestClient, None, None]:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: xitzin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: A Gemini Application Framework
|
|
5
5
|
Keywords: gemini,protocol,framework,async,geminispace
|
|
6
6
|
Author: Alan Velasco
|
|
@@ -25,11 +25,11 @@ Requires-Dist: typing-extensions>=4.15.0
|
|
|
25
25
|
Requires-Dist: sqlmodel>=0.0.22 ; extra == 'sqlmodel'
|
|
26
26
|
Requires-Dist: croniter>=1.0.0 ; extra == 'tasks'
|
|
27
27
|
Requires-Python: >=3.10
|
|
28
|
-
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
29
|
-
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
30
28
|
Project-URL: Homepage, https://github.com/alanbato/xitzin
|
|
31
|
-
Project-URL:
|
|
29
|
+
Project-URL: Documentation, https://xitzin.readthedocs.io
|
|
32
30
|
Project-URL: Repository, https://github.com/alanbato/xitzin.git
|
|
31
|
+
Project-URL: Issues, https://github.com/alanbato/xitzin/issues
|
|
32
|
+
Project-URL: Changelog, https://xitzin.readthedocs.io/changelog/
|
|
33
33
|
Provides-Extra: sqlmodel
|
|
34
34
|
Provides-Extra: tasks
|
|
35
35
|
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
xitzin/__init__.py,sha256=GMfAmzBJ6TMZJWfF38r1k0y-vswOdLw9xfWpPfYMcqc,1851
|
|
2
|
+
xitzin/application.py,sha256=axL5G4lRz0KTNLsNQkCGxWD0MGDpIH6Xe9OKkxJlJw8,27851
|
|
3
|
+
xitzin/auth.py,sha256=hHhrP_fYToY6J6CCU8vgAsWbYn6K16LUxkeDMzZTF2Q,5304
|
|
4
|
+
xitzin/cgi.py,sha256=nggSuQ0gXIKdBKWHqH_CNZ6UlVGaB3VAOu3ZRPc43ek,18046
|
|
5
|
+
xitzin/exceptions.py,sha256=82z-CjyC0FwFbo9hGTjjmurlL_Vd4rTVdgkmQoFLXT0,3883
|
|
6
|
+
xitzin/middleware.py,sha256=dZhrbOJPCrrbMiwrbRzxElsC7UDrWc7lvnQ5c0xBHts,12542
|
|
7
|
+
xitzin/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
xitzin/requests.py,sha256=1mbm5F8oywLPNpSdh2inn7K1IEeI3H5meOwj8jldi1Q,7783
|
|
9
|
+
xitzin/responses.py,sha256=QVH4B-qVHP7hxi0hIrOJhdCiHDf4PDCpSZ1fAD8iNkk,6525
|
|
10
|
+
xitzin/routing.py,sha256=8Kl3FV6EWZ6nW0bDTqJ-qysNyJ1iSRtCKicCAai8Lmk,18093
|
|
11
|
+
xitzin/scgi.py,sha256=t-ixEwrQR-bIRj56DkzBsvGyN75E5R0gasIjMfq5LFY,13665
|
|
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=wMiToopD2TAqTsreVqOrIUNCayORNQQennxR_GwMBSY,10980
|
|
16
|
+
xitzin-0.4.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
|
|
17
|
+
xitzin-0.4.0.dist-info/METADATA,sha256=Zv-Rde7LWIcf_FQfclXu77BzGipWf6_n0fOXVzkvk0A,3456
|
|
18
|
+
xitzin-0.4.0.dist-info/RECORD,,
|
xitzin-0.3.0.dist-info/RECORD
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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,,
|