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/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)