xitzin 0.1.2__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 +78 -0
- xitzin/application.py +548 -0
- xitzin/auth.py +152 -0
- xitzin/cgi.py +555 -0
- xitzin/exceptions.py +138 -0
- xitzin/middleware.py +219 -0
- xitzin/py.typed +0 -0
- xitzin/requests.py +150 -0
- xitzin/responses.py +235 -0
- xitzin/routing.py +381 -0
- xitzin/templating.py +222 -0
- xitzin/testing.py +267 -0
- xitzin-0.1.2.dist-info/METADATA +118 -0
- xitzin-0.1.2.dist-info/RECORD +15 -0
- xitzin-0.1.2.dist-info/WHEEL +4 -0
xitzin/responses.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Response classes for Xitzin handlers.
|
|
2
|
+
|
|
3
|
+
These classes provide a convenient way to return different types of Gemini responses.
|
|
4
|
+
Handlers can return these objects, and Xitzin will convert them to GeminiResponse.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
11
|
+
|
|
12
|
+
from nauyaca.protocol.response import GeminiResponse
|
|
13
|
+
from nauyaca.protocol.status import StatusCode
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from .application import Xitzin
|
|
17
|
+
from .requests import Request
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResponseConvertible(Protocol):
|
|
21
|
+
"""Protocol for objects that can be converted to GeminiResponse."""
|
|
22
|
+
|
|
23
|
+
def to_gemini_response(self) -> GeminiResponse: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Response:
|
|
28
|
+
"""Success response with a body.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
@app.gemini("/")
|
|
32
|
+
def home(request: Request):
|
|
33
|
+
return Response("# Welcome!", mime_type="text/gemini")
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
body: str
|
|
37
|
+
mime_type: str = "text/gemini"
|
|
38
|
+
|
|
39
|
+
def to_gemini_response(self) -> GeminiResponse:
|
|
40
|
+
return GeminiResponse(
|
|
41
|
+
status=StatusCode.SUCCESS,
|
|
42
|
+
meta=self.mime_type,
|
|
43
|
+
body=self.body,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Input:
|
|
49
|
+
"""Request input from the client (status 10/11).
|
|
50
|
+
|
|
51
|
+
When returned from a handler, the client will prompt the user for input
|
|
52
|
+
and re-request the same URL with the input as a query string.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
@app.gemini("/search")
|
|
56
|
+
def search(request: Request):
|
|
57
|
+
if not request.query:
|
|
58
|
+
return Input("Enter your search query:")
|
|
59
|
+
return f"# Results for: {request.query}"
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
prompt: str
|
|
63
|
+
sensitive: bool = False
|
|
64
|
+
|
|
65
|
+
def to_gemini_response(self) -> GeminiResponse:
|
|
66
|
+
status = StatusCode.SENSITIVE_INPUT if self.sensitive else StatusCode.INPUT
|
|
67
|
+
return GeminiResponse(status=status, meta=self.prompt)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Redirect:
|
|
72
|
+
"""Redirect to another URL (status 30/31).
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
@app.gemini("/old-page")
|
|
76
|
+
def old_page(request: Request):
|
|
77
|
+
return Redirect("/new-page", permanent=True)
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
url: str
|
|
81
|
+
permanent: bool = False
|
|
82
|
+
|
|
83
|
+
def to_gemini_response(self) -> GeminiResponse:
|
|
84
|
+
status = (
|
|
85
|
+
StatusCode.REDIRECT_PERMANENT
|
|
86
|
+
if self.permanent
|
|
87
|
+
else StatusCode.REDIRECT_TEMPORARY
|
|
88
|
+
)
|
|
89
|
+
return GeminiResponse(status=status, meta=self.url)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class Link:
|
|
94
|
+
"""Build Gemtext link lines.
|
|
95
|
+
|
|
96
|
+
Generates link lines in the format: => URL [LABEL]
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
# Basic link
|
|
100
|
+
link = Link("/about", "About Us")
|
|
101
|
+
str(link) # "=> /about About Us"
|
|
102
|
+
|
|
103
|
+
# Link without label
|
|
104
|
+
link = Link("/about")
|
|
105
|
+
str(link) # "=> /about"
|
|
106
|
+
|
|
107
|
+
# Using with app.reverse()
|
|
108
|
+
link = Link(app.reverse("user_profile", username="alice"), "Alice's Profile")
|
|
109
|
+
str(link) # "=> /user/alice Alice's Profile"
|
|
110
|
+
|
|
111
|
+
# Using to_route() classmethod
|
|
112
|
+
link = Link.to_route(
|
|
113
|
+
app, "user_profile", username="alice", label="Alice's Profile"
|
|
114
|
+
)
|
|
115
|
+
str(link) # "=> /user/alice Alice's Profile"
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
url: str
|
|
119
|
+
label: str | None = None
|
|
120
|
+
|
|
121
|
+
def to_gemtext(self) -> str:
|
|
122
|
+
"""Generate Gemtext link line."""
|
|
123
|
+
if self.label:
|
|
124
|
+
return f"=> {self.url} {self.label}"
|
|
125
|
+
return f"=> {self.url}"
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def to_route(
|
|
129
|
+
cls,
|
|
130
|
+
app: "Xitzin",
|
|
131
|
+
name: str,
|
|
132
|
+
*,
|
|
133
|
+
label: str | None = None,
|
|
134
|
+
**params: Any,
|
|
135
|
+
) -> "Link":
|
|
136
|
+
"""Create a link to a named route.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
app: Xitzin application instance.
|
|
140
|
+
name: Route name.
|
|
141
|
+
label: Optional link label text.
|
|
142
|
+
**params: Path parameters for URL building.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Link instance pointing to the route.
|
|
146
|
+
|
|
147
|
+
Example:
|
|
148
|
+
link = Link.to_route(app, "user_profile", username="alice", label="Profile")
|
|
149
|
+
str(link) # "=> /user/alice Profile"
|
|
150
|
+
"""
|
|
151
|
+
url = app.reverse(name, **params)
|
|
152
|
+
return cls(url, label)
|
|
153
|
+
|
|
154
|
+
def __str__(self) -> str:
|
|
155
|
+
"""Return Gemtext representation."""
|
|
156
|
+
return self.to_gemtext()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def convert_response(result: Any, request: Request | None = None) -> GeminiResponse:
|
|
160
|
+
"""Convert a handler return value to a GeminiResponse.
|
|
161
|
+
|
|
162
|
+
Handlers can return:
|
|
163
|
+
- str: Converted to success response with text/gemini MIME type
|
|
164
|
+
- Response, Input, Redirect: Converted via to_gemini_response()
|
|
165
|
+
- GeminiResponse: Returned as-is
|
|
166
|
+
- tuple: (body, status) or (body, status, meta)
|
|
167
|
+
- None: Empty success response
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
result: The return value from a handler.
|
|
171
|
+
request: The current request (for URL tracking).
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A GeminiResponse instance.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
TypeError: If the result cannot be converted.
|
|
178
|
+
"""
|
|
179
|
+
url = request._raw_request.normalized_url if request else None
|
|
180
|
+
|
|
181
|
+
# Already a GeminiResponse
|
|
182
|
+
if isinstance(result, GeminiResponse):
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
# Objects with to_gemini_response method
|
|
186
|
+
if hasattr(result, "to_gemini_response"):
|
|
187
|
+
response = result.to_gemini_response()
|
|
188
|
+
# Add URL tracking if not present
|
|
189
|
+
if response.url is None and url:
|
|
190
|
+
return GeminiResponse(
|
|
191
|
+
status=response.status,
|
|
192
|
+
meta=response.meta,
|
|
193
|
+
body=response.body,
|
|
194
|
+
url=url,
|
|
195
|
+
)
|
|
196
|
+
return response
|
|
197
|
+
|
|
198
|
+
# Plain string -> success with text/gemini
|
|
199
|
+
if isinstance(result, str):
|
|
200
|
+
return GeminiResponse(
|
|
201
|
+
status=StatusCode.SUCCESS,
|
|
202
|
+
meta="text/gemini",
|
|
203
|
+
body=result,
|
|
204
|
+
url=url,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Tuple: (body, status) or (body, status, meta)
|
|
208
|
+
if isinstance(result, tuple):
|
|
209
|
+
if len(result) == 2:
|
|
210
|
+
body, status = result
|
|
211
|
+
meta = "text/gemini" if status == StatusCode.SUCCESS else ""
|
|
212
|
+
elif len(result) == 3:
|
|
213
|
+
body, status, meta = result
|
|
214
|
+
else:
|
|
215
|
+
msg = f"Tuple must have 2 or 3 elements, got {len(result)}"
|
|
216
|
+
raise TypeError(msg)
|
|
217
|
+
|
|
218
|
+
return GeminiResponse(
|
|
219
|
+
status=status,
|
|
220
|
+
meta=meta,
|
|
221
|
+
body=body if 20 <= status < 30 else None,
|
|
222
|
+
url=url,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# None -> empty success
|
|
226
|
+
if result is None:
|
|
227
|
+
return GeminiResponse(
|
|
228
|
+
status=StatusCode.SUCCESS,
|
|
229
|
+
meta="text/gemini",
|
|
230
|
+
body="",
|
|
231
|
+
url=url,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
msg = f"Cannot convert {type(result).__name__} to GeminiResponse"
|
|
235
|
+
raise TypeError(msg)
|
xitzin/routing.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""Route decorator and path parameter handling.
|
|
2
|
+
|
|
3
|
+
This module provides the Route class and path parameter extraction logic.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Callable, get_type_hints
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .requests import Request
|
|
14
|
+
|
|
15
|
+
# Pattern to match path parameters like {name} or {name:path}
|
|
16
|
+
PATH_PARAM_PATTERN = re.compile(r"\{(\w+)(?::(\w+))?\}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Route:
|
|
20
|
+
"""Represents a registered route.
|
|
21
|
+
|
|
22
|
+
Routes match URL paths and extract parameters based on the path template.
|
|
23
|
+
|
|
24
|
+
Example:
|
|
25
|
+
route = Route("/user/{username}", handler_func)
|
|
26
|
+
if route.matches("/user/alice"):
|
|
27
|
+
params = route.extract_params("/user/alice")
|
|
28
|
+
# params = {"username": "alice"}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
path: str,
|
|
34
|
+
handler: Callable[..., Any],
|
|
35
|
+
*,
|
|
36
|
+
name: str | None = None,
|
|
37
|
+
input_prompt: str | None = None,
|
|
38
|
+
sensitive_input: bool = False,
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Create a new route.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
path: Path template with optional parameters (e.g., "/user/{id}").
|
|
44
|
+
handler: The handler function to call.
|
|
45
|
+
name: Route name for URL reversing. Defaults to handler function name.
|
|
46
|
+
input_prompt: If set, request input with this prompt before calling handler.
|
|
47
|
+
sensitive_input: If True, use status 11 (sensitive input) instead of 10.
|
|
48
|
+
"""
|
|
49
|
+
self.path = path
|
|
50
|
+
self.handler = handler
|
|
51
|
+
self.name = (
|
|
52
|
+
name if name is not None else getattr(handler, "__name__", "<anonymous>")
|
|
53
|
+
)
|
|
54
|
+
self.input_prompt = input_prompt
|
|
55
|
+
self.sensitive_input = sensitive_input
|
|
56
|
+
|
|
57
|
+
self._param_pattern, self._param_names = self._compile_path(path)
|
|
58
|
+
self._type_hints = self._get_handler_type_hints(handler)
|
|
59
|
+
self._is_async = asyncio.iscoroutinefunction(handler)
|
|
60
|
+
|
|
61
|
+
def _compile_path(self, path: str) -> tuple[re.Pattern[str], list[str]]:
|
|
62
|
+
"""Convert a path template to a regex pattern.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Path template like "/user/{id}" or "/files/{path:path}".
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Tuple of (compiled regex, list of parameter names).
|
|
69
|
+
"""
|
|
70
|
+
param_names: list[str] = []
|
|
71
|
+
|
|
72
|
+
def replace_param(match: re.Match[str]) -> str:
|
|
73
|
+
name = match.group(1)
|
|
74
|
+
param_type = match.group(2)
|
|
75
|
+
param_names.append(name)
|
|
76
|
+
|
|
77
|
+
# :path captures everything including slashes
|
|
78
|
+
if param_type == "path":
|
|
79
|
+
return f"(?P<{name}>.+)"
|
|
80
|
+
# Default: capture until next slash
|
|
81
|
+
return f"(?P<{name}>[^/]+)"
|
|
82
|
+
|
|
83
|
+
# Escape regex special chars except our parameter syntax
|
|
84
|
+
escaped = re.escape(path)
|
|
85
|
+
# Unescape our parameter syntax
|
|
86
|
+
escaped = escaped.replace(r"\{", "{").replace(r"\}", "}")
|
|
87
|
+
# Replace parameters with capture groups
|
|
88
|
+
regex_path = PATH_PARAM_PATTERN.sub(replace_param, escaped)
|
|
89
|
+
|
|
90
|
+
return re.compile(f"^{regex_path}$"), param_names
|
|
91
|
+
|
|
92
|
+
def _get_handler_type_hints(self, handler: Callable[..., Any]) -> dict[str, type]:
|
|
93
|
+
"""Extract type hints from handler function.
|
|
94
|
+
|
|
95
|
+
Excludes 'request' and 'return' from the hints.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
hints = get_type_hints(handler)
|
|
99
|
+
# Remove non-parameter hints
|
|
100
|
+
hints.pop("request", None)
|
|
101
|
+
hints.pop("return", None)
|
|
102
|
+
return hints
|
|
103
|
+
except Exception:
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
def matches(self, path: str) -> bool:
|
|
107
|
+
"""Check if this route matches the given path.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
path: URL path to match.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
True if the path matches this route's pattern.
|
|
114
|
+
"""
|
|
115
|
+
return self._param_pattern.match(path) is not None
|
|
116
|
+
|
|
117
|
+
def extract_params(self, path: str) -> dict[str, Any]:
|
|
118
|
+
"""Extract and type-convert path parameters.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: URL path to extract parameters from.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Dictionary of parameter names to values.
|
|
125
|
+
"""
|
|
126
|
+
match = self._param_pattern.match(path)
|
|
127
|
+
if not match:
|
|
128
|
+
return {}
|
|
129
|
+
|
|
130
|
+
params: dict[str, Any] = {}
|
|
131
|
+
for name, value in match.groupdict().items():
|
|
132
|
+
# Apply type conversion based on handler annotations
|
|
133
|
+
target_type = self._type_hints.get(name, str)
|
|
134
|
+
try:
|
|
135
|
+
if target_type is int:
|
|
136
|
+
params[name] = int(value)
|
|
137
|
+
elif target_type is float:
|
|
138
|
+
params[name] = float(value)
|
|
139
|
+
elif target_type is bool:
|
|
140
|
+
params[name] = value.lower() in ("true", "1", "yes")
|
|
141
|
+
else:
|
|
142
|
+
params[name] = value
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
# Keep as string if conversion fails
|
|
145
|
+
params[name] = value
|
|
146
|
+
|
|
147
|
+
return params
|
|
148
|
+
|
|
149
|
+
async def call_handler(self, request: Request, params: dict[str, Any]) -> Any:
|
|
150
|
+
"""Call the handler with the request and extracted parameters.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
request: The current request.
|
|
154
|
+
params: Extracted path parameters.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
The handler's return value.
|
|
158
|
+
"""
|
|
159
|
+
if self._is_async:
|
|
160
|
+
return await self.handler(request, **params)
|
|
161
|
+
# Wrap sync handler in executor to avoid blocking
|
|
162
|
+
loop = asyncio.get_event_loop()
|
|
163
|
+
return await loop.run_in_executor(None, lambda: self.handler(request, **params))
|
|
164
|
+
|
|
165
|
+
def reverse(self, **params: Any) -> str:
|
|
166
|
+
"""Build URL from this route's path template.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
**params: Path parameters to substitute.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
URL path string.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If required parameters are missing.
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
route = Route("/user/{username}", handler)
|
|
179
|
+
route.reverse(username="alice") # Returns "/user/alice"
|
|
180
|
+
"""
|
|
181
|
+
missing = set(self._param_names) - set(params.keys())
|
|
182
|
+
if missing:
|
|
183
|
+
missing_params = ", ".join(sorted(missing))
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Route '{self.name}' missing required parameters: {missing_params}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
url = self.path
|
|
189
|
+
for name in self._param_names:
|
|
190
|
+
value = str(params[name])
|
|
191
|
+
# Handle both {name} and {name:path} patterns
|
|
192
|
+
url = url.replace(f"{{{name}}}", value)
|
|
193
|
+
url = url.replace(f"{{{name}:path}}", value)
|
|
194
|
+
|
|
195
|
+
return url
|
|
196
|
+
|
|
197
|
+
def __repr__(self) -> str:
|
|
198
|
+
return f"Route({self.path!r}, name={self.name!r})"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class MountedRoute:
|
|
202
|
+
"""Route that delegates to a mounted handler at a path prefix.
|
|
203
|
+
|
|
204
|
+
Unlike regular Route, this matches path prefixes and passes the
|
|
205
|
+
remaining path to the handler, enabling directory-style mounting.
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
mounted = MountedRoute("/cgi-bin", cgi_handler)
|
|
209
|
+
if mounted.matches("/cgi-bin/script.py"):
|
|
210
|
+
# Calls handler with path_info="script.py"
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
path_prefix: str,
|
|
216
|
+
handler: Callable[..., Any],
|
|
217
|
+
*,
|
|
218
|
+
name: str | None = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Create a mounted route.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
path_prefix: Path prefix to match (e.g., "/cgi-bin").
|
|
224
|
+
handler: Handler that receives (request, path_info) where
|
|
225
|
+
path_info is the path after the prefix.
|
|
226
|
+
name: Optional name for the mount.
|
|
227
|
+
"""
|
|
228
|
+
# Normalize prefix: ensure it starts with / and doesn't end with /
|
|
229
|
+
self.path_prefix = "/" + path_prefix.strip("/")
|
|
230
|
+
self.handler = handler
|
|
231
|
+
self.name = name or getattr(handler, "__name__", "<mounted>")
|
|
232
|
+
self._is_async = asyncio.iscoroutinefunction(handler) or (
|
|
233
|
+
hasattr(handler, "__call__")
|
|
234
|
+
and asyncio.iscoroutinefunction(handler.__call__)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
def matches(self, path: str) -> bool:
|
|
238
|
+
"""Check if this mount matches the given path.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
path: URL path to match.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
True if path starts with this mount's prefix.
|
|
245
|
+
"""
|
|
246
|
+
# Exact match or prefix with /
|
|
247
|
+
return path == self.path_prefix or path.startswith(self.path_prefix + "/")
|
|
248
|
+
|
|
249
|
+
def extract_path_info(self, path: str) -> str:
|
|
250
|
+
"""Extract the path info (remaining path after prefix).
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
path: Full URL path.
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
The path after the mount prefix.
|
|
257
|
+
"""
|
|
258
|
+
if path == self.path_prefix:
|
|
259
|
+
return ""
|
|
260
|
+
# Remove prefix, keep the leading /
|
|
261
|
+
return path[len(self.path_prefix) :]
|
|
262
|
+
|
|
263
|
+
async def call_handler(self, request: Request, path_info: str) -> Any:
|
|
264
|
+
"""Call the handler with the request and path info.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
request: The current request.
|
|
268
|
+
path_info: Path after the mount prefix.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The handler's return value.
|
|
272
|
+
"""
|
|
273
|
+
if self._is_async:
|
|
274
|
+
return await self.handler(request, path_info)
|
|
275
|
+
# Wrap sync handler in executor to avoid blocking
|
|
276
|
+
loop = asyncio.get_running_loop()
|
|
277
|
+
return await loop.run_in_executor(
|
|
278
|
+
None, lambda: self.handler(request, path_info)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def __repr__(self) -> str:
|
|
282
|
+
return f"MountedRoute({self.path_prefix!r}, name={self.name!r})"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class Router:
|
|
286
|
+
"""Collection of routes with matching logic.
|
|
287
|
+
|
|
288
|
+
Routes are matched in registration order; first match wins.
|
|
289
|
+
Mounted routes are checked before regular routes.
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
def __init__(self) -> None:
|
|
293
|
+
self._routes: list[Route] = []
|
|
294
|
+
self._routes_by_name: dict[str, Route] = {}
|
|
295
|
+
self._mounted_routes: list[MountedRoute] = []
|
|
296
|
+
|
|
297
|
+
def add_route(self, route: Route) -> None:
|
|
298
|
+
"""Add a route to the router.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
ValueError: If a route with the same name already exists.
|
|
302
|
+
"""
|
|
303
|
+
if route.name in self._routes_by_name:
|
|
304
|
+
existing = self._routes_by_name[route.name]
|
|
305
|
+
msg = (
|
|
306
|
+
f"Route name '{route.name}' already registered "
|
|
307
|
+
f"for path '{existing.path}'. "
|
|
308
|
+
f"Use the name= parameter to provide a unique name."
|
|
309
|
+
)
|
|
310
|
+
raise ValueError(msg)
|
|
311
|
+
self._routes.append(route)
|
|
312
|
+
self._routes_by_name[route.name] = route
|
|
313
|
+
|
|
314
|
+
def add_mounted_route(self, route: MountedRoute) -> None:
|
|
315
|
+
"""Add a mounted route to the router.
|
|
316
|
+
|
|
317
|
+
Mounted routes are checked before regular routes.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
route: The mounted route to add.
|
|
321
|
+
"""
|
|
322
|
+
self._mounted_routes.append(route)
|
|
323
|
+
|
|
324
|
+
def match_mount(self, path: str) -> tuple[MountedRoute, str] | None:
|
|
325
|
+
"""Find a matching mounted route and extract path info.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
path: URL path to match.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
Tuple of (mounted_route, path_info) if found, None otherwise.
|
|
332
|
+
"""
|
|
333
|
+
for mounted in self._mounted_routes:
|
|
334
|
+
if mounted.matches(path):
|
|
335
|
+
path_info = mounted.extract_path_info(path)
|
|
336
|
+
return mounted, path_info
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
def match(self, path: str) -> tuple[Route, dict[str, Any]] | None:
|
|
340
|
+
"""Find a matching route and extract parameters.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
path: URL path to match.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
Tuple of (route, params) if found, None otherwise.
|
|
347
|
+
"""
|
|
348
|
+
for route in self._routes:
|
|
349
|
+
if route.matches(path):
|
|
350
|
+
params = route.extract_params(path)
|
|
351
|
+
return route, params
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
def reverse(self, name: str, **params: Any) -> str:
|
|
355
|
+
"""Build URL for a named route.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
name: Route name.
|
|
359
|
+
**params: Path parameters.
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
URL path string.
|
|
363
|
+
|
|
364
|
+
Raises:
|
|
365
|
+
ValueError: If route name not found or parameters missing.
|
|
366
|
+
|
|
367
|
+
Example:
|
|
368
|
+
router.reverse("user_profile", username="alice")
|
|
369
|
+
# Returns "/user/alice"
|
|
370
|
+
"""
|
|
371
|
+
if name not in self._routes_by_name:
|
|
372
|
+
available = ", ".join(sorted(self._routes_by_name.keys()))
|
|
373
|
+
raise ValueError(f"No route named '{name}'. Available routes: {available}")
|
|
374
|
+
route = self._routes_by_name[name]
|
|
375
|
+
return route.reverse(**params)
|
|
376
|
+
|
|
377
|
+
def __iter__(self):
|
|
378
|
+
return iter(self._routes)
|
|
379
|
+
|
|
380
|
+
def __len__(self):
|
|
381
|
+
return len(self._routes)
|