takibiapi 0.1.0__cp313-cp313-win_amd64.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.
takibiapi/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ """takibi: REST APIs whose OpenAPI document, request type checks and handler types come from
2
+ Python's own types.
3
+
4
+ from takibiapi import Context, TakibiAPI, createRoute
5
+
6
+ Routes are declared with ``createRoute`` (dataclasses, ``TypedDict``, ``Literal``, ``Enum``,
7
+ ``Annotated``, ...) and registered with ``app.openapi(route, handler)``; ``app.doc("/doc", {...})``
8
+ serves the OpenAPI document. Request parts are checked against their types only and read with
9
+ ``c.req.valid(...)``; ``Annotated`` keywords are documentation and responses are not checked.
10
+ """
11
+
12
+ from takibiapi.app import Fallback, FallbackHandler, FallbackMiddleware, FallbackNext, Middleware, Next, TakibiAPI
13
+ from takibiapi.context import Context, Request, Response, ResponseValidationError, problem
14
+ from takibiapi.openapi import OpenAPIConfig
15
+ from takibiapi.problems import ValidationProblem
16
+ from takibiapi.route import RequestConfig, ResponseConfig, Route, RouteConfig, createRoute
17
+ from takibiapi.schema import ValidationError
18
+
19
+ __all__ = [
20
+ "Context",
21
+ "Fallback",
22
+ "FallbackHandler",
23
+ "FallbackMiddleware",
24
+ "FallbackNext",
25
+ "Middleware",
26
+ "Next",
27
+ "OpenAPIConfig",
28
+ "Request",
29
+ "RequestConfig",
30
+ "Response",
31
+ "ResponseConfig",
32
+ "ResponseValidationError",
33
+ "Route",
34
+ "RouteConfig",
35
+ "TakibiAPI",
36
+ "ValidationError",
37
+ "ValidationProblem",
38
+ "createRoute",
39
+ "problem",
40
+ ]
41
+ __version__ = "0.1.0"
Binary file
takibiapi/_core.pyi ADDED
@@ -0,0 +1,22 @@
1
+ # Types of the Rust extension (core/src/lib.rs). Functions return None for input whose result
2
+ # they cannot reproduce exactly; the caller then uses Python's json / urllib.parse.
3
+ from collections.abc import Callable
4
+ from typing import Any
5
+
6
+ class Validator:
7
+ """A type compiled from ``takibiapi.schema.plan(tp)``."""
8
+
9
+ def __init__(self, plan: dict[str, Any]) -> None: ...
10
+ def decode(self, value: Any, coerce: bool) -> tuple[bool, Any]: ...
11
+ def decode_json(self, data: bytes) -> tuple[bool, Any] | None: ...
12
+
13
+ class Router:
14
+ """takibiapi.router.Router in Rust."""
15
+
16
+ def add(self, method: str, path: str, entry: Any) -> None: ...
17
+ def match(self, method: str, path: str) -> tuple[Any, dict[str, str]] | None: ...
18
+ def allowed(self, path: str) -> list[str]: ...
19
+
20
+ def dumps(value: Any, default: Callable[[Any], Any] | None = None) -> bytes | None: ...
21
+ def loads(data: bytes) -> tuple[Any] | None: ...
22
+ def parse_query(qs: bytes) -> dict[str, list[str]] | None: ...
takibiapi/app.py ADDED
@@ -0,0 +1,451 @@
1
+ """``TakibiAPI``: a REST app on ASGI whose routes are OpenAPI operations described with Python types.
2
+
3
+ Run it with any ASGI server (``uvicorn module:app``), with Granian's faster RSGI interface
4
+ (``granian --interface rsgi module:app``), or call ``await app.request("/path")`` in tests. Request
5
+ parts are checked against their types before the handler runs (``c.req.valid``); ``Annotated``
6
+ keywords and responses are not checked.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import logging
13
+ import typing
14
+ from collections.abc import Awaitable, Callable, Mapping
15
+ from typing import Any, Self, TypeVar, get_args, get_origin
16
+
17
+ from .context import Context, MalformedJSONError, RawHeaders, Request, Response, problem
18
+ from .openapi import OpenAPIConfig, build_document, check_config
19
+ from .route import AnyRoute, route_class
20
+ from .router import new_router
21
+ from .schema import ValidationError, dumps
22
+ from .schemas import Checker, Encoder, Schemas, TypeSchemas
23
+
24
+ Handler = Callable[[Context[Any]], Response | Awaitable[Response]]
25
+ Next = Callable[[], Awaitable[Response]]
26
+ # ``async def mw(c, next): ...; res = await next(); ...; return res`` (return early to stop).
27
+ Middleware = Callable[[Context[Any], Next], Awaitable[Response]]
28
+ # ``onError`` / ``notFound`` take handlers ``def h(c)`` (the error is ``c.error``) and middleware
29
+ # ``async def mw(c, next)``; ``next()`` runs the rest of the chain and is ``None`` when it ends
30
+ # without an answer, which the built-in response then gives.
31
+ FallbackNext = Callable[[], Awaitable[Response | None]]
32
+ FallbackMiddleware = Callable[[Context[Any], FallbackNext], Awaitable[Response | None]]
33
+ FallbackHandler = Callable[[Context[Any]], Response | Awaitable[Response | None] | None]
34
+ Fallback = FallbackHandler | FallbackMiddleware
35
+
36
+ # The registered route; its handler takes ``c: Context[that route]``.
37
+ _TR = TypeVar("_TR", bound=AnyRoute)
38
+
39
+ # Where each checked part lives, as an OpenAPI parameter location ("in") for error reports.
40
+ _LOCATIONS = {"param": "path", "query": "query", "header": "header", "cookie": "cookie", "json": "body"}
41
+ VALIDATION_PROBLEM_TYPE = "/problems/validation-error"
42
+
43
+ log = logging.getLogger("takibiapi")
44
+
45
+
46
+ class _Entry:
47
+ __slots__ = ("checks", "handler", "is_async", "responses")
48
+
49
+ def __init__(
50
+ self,
51
+ handler: Callable[..., Any],
52
+ checks: list[tuple[str, Checker, bool]] | None = None,
53
+ responses: dict[int, dict[str, Encoder]] | None = None,
54
+ ) -> None:
55
+ self.handler = handler
56
+ self.is_async = inspect.iscoroutinefunction(handler)
57
+ self.checks = checks or [] # (target, checker, required) for c.req.valid
58
+ self.responses = responses # {status: {media type: encoder}} for c.json, or None
59
+
60
+
61
+ class _Fallback:
62
+ """One ``onError`` / ``notFound`` registration: runs for paths under ``prefix``; handlers of
63
+ more deeply mounted apps (``depth``) come first, then registration order."""
64
+
65
+ __slots__ = ("depth", "fn", "is_middleware", "prefix")
66
+
67
+ def __init__(self, fn: Fallback, prefix: str, depth: int = 0) -> None:
68
+ self.fn: Callable[..., Any] = fn # called as ``fn(c)`` or ``fn(c, next)``, by is_middleware
69
+ self.prefix = prefix
70
+ self.depth = depth
71
+ self.is_middleware = _arity(fn) >= 2
72
+
73
+ def mounted(self, prefix: str) -> _Fallback:
74
+ return _Fallback(self.fn, prefix + self.prefix, self.depth + 1)
75
+
76
+
77
+ class TakibiAPI:
78
+ """``app = TakibiAPI()``; ``app.openapi(route, handler)``; ``app.doc("/doc", {...})``."""
79
+
80
+ # How the types in routes become checks and OpenAPI schemas (see takibiapi.schemas).
81
+ schemas: Schemas = TypeSchemas()
82
+
83
+ def __init__(self) -> None:
84
+ self._router = new_router()
85
+ self._registrations: list[tuple[str, str, _Entry]] = []
86
+ self._routes: list[type[AnyRoute]] = []
87
+ self._middleware: list[tuple[str, Middleware]] = []
88
+ self._on_error_fallbacks: list[_Fallback] = []
89
+ self._not_found_fallbacks: list[_Fallback] = []
90
+ self._documents: dict[int, bytes] = {}
91
+
92
+ # -- routes ------------------------------------------------------------------------------- #
93
+ def openapi(self, route: type[_TR], handler: Callable[[Context[_TR]], Response | Awaitable[Response]]) -> Self:
94
+ """Register ``route`` with its handler.
95
+
96
+ The route must be declared as a class (``class getTodoRoute(createRoute({...}))``) and
97
+ the handler must take it as ``c: Context[getTodoRoute]``; anything else is a ``TypeError``.
98
+ The request parts the route declares are checked against their types before the handler
99
+ runs (422 when they do not match) and read with ``c.req.valid(...)``.
100
+ """
101
+ _check_handler(route, handler)
102
+ request = route.config.get("request") or {}
103
+ checks: list[tuple[str, Checker, bool]] = []
104
+ for key, target in (("params", "param"), ("query", "query"), ("headers", "header"), ("cookies", "cookie")):
105
+ if request.get(key) is not None:
106
+ checks.append((target, self.schemas.checker(request[key], target), True))
107
+ body = request.get("body") or {}
108
+ for media_type, media in (body.get("content") or {}).items():
109
+ if "json" in media_type and media.get("schema") is not None:
110
+ checks.append(
111
+ ("json", self.schemas.checker(media["schema"], "json"), bool(body.get("required", False)))
112
+ )
113
+ responses: dict[int, dict[str, Encoder]] = {}
114
+ for status, response in (route.config.get("responses") or {}).items():
115
+ for media_type, media in (response.get("content") or {}).items():
116
+ if media.get("schema") is None or not str(status).isdigit():
117
+ continue
118
+ encoder = self.schemas.response_encoder(media["schema"])
119
+ if encoder is not None:
120
+ responses.setdefault(int(status), {})[media_type.lower()] = encoder
121
+ self._add(route.method, route.path, _Entry(handler, checks, responses or None))
122
+ self._routes.append(route)
123
+ self._documents.clear()
124
+ return self
125
+
126
+ def route(self, prefix: str, app: TakibiAPI) -> Self:
127
+ """Mount ``app``'s routes and middleware under ``prefix`` (``app.route("/v1", v1)``)."""
128
+ if type(app.schemas) is not type(self.schemas):
129
+ raise TypeError(
130
+ f"cannot mount a {type(app).__name__} app with other schemas into a {type(self).__name__} app"
131
+ )
132
+ prefix = prefix.rstrip("/")
133
+ for method, path, entry in app._registrations:
134
+ self._add(method, prefix + path, entry)
135
+ for pattern, middleware in app._middleware:
136
+ self._middleware.append((prefix + pattern, middleware))
137
+ self._on_error_fallbacks.extend(f.mounted(prefix) for f in app._on_error_fallbacks)
138
+ self._not_found_fallbacks.extend(f.mounted(prefix) for f in app._not_found_fallbacks)
139
+ for route in app._routes:
140
+ self._routes.append(route_class({**route.config, "path": prefix + route.path}))
141
+ self._documents.clear()
142
+ return self
143
+
144
+ def use(self, middleware: Middleware, *, prefix: str = "") -> Self:
145
+ """Run ``middleware`` around the handlers (only under ``prefix`` if given)::
146
+
147
+ async def cors(c, next):
148
+ res = await next()
149
+ res.headers["access-control-allow-origin"] = "*"
150
+ return res
151
+ """
152
+ self._middleware.append((prefix.rstrip("/"), middleware))
153
+ return self
154
+
155
+ def onError(self, *handlers: Fallback, prefix: str = "") -> Self: # noqa: N802
156
+ """Answer for exceptions raised by handlers and middleware, as Hono's ``app.onError``::
157
+
158
+ app.onError(lambda c: c.json({"error": str(c.error)}, 500))
159
+
160
+ The exception is ``c.error``. Registrations compose rather than replace each other:
161
+ ``async def mw(c, next)`` runs around the ones registered after it (``await next()`` is
162
+ their answer, ``None`` when there is none), a ``def h(c)`` handler ends the chain by
163
+ returning a response, and when the chain ends without one the built-in 500 Problem
164
+ Details answers. ``prefix`` limits the handlers to paths under it. The handlers of a
165
+ mounted app (``app.route``) come first for its paths; at the same depth, registration
166
+ order. An exception from a handler here is logged and answered by the built-in response.
167
+ """
168
+ self._on_error_fallbacks.extend(_Fallback(fn, prefix.rstrip("/")) for fn in handlers)
169
+ return self
170
+
171
+ def notFound(self, *handlers: Fallback, prefix: str = "") -> Self: # noqa: N802
172
+ """Answer for paths with no route (default: 404 Problem Details; a known path with another
173
+ method answers 405 before this). Composes like :meth:`onError`::
174
+
175
+ app.notFound(lambda c: c.text("not here", 404))
176
+ app.notFound(mw, lambda c: c.text("no such API", 404), prefix="/api")
177
+ """
178
+ self._not_found_fallbacks.extend(_Fallback(fn, prefix.rstrip("/")) for fn in handlers)
179
+ return self
180
+
181
+ # -- OpenAPI documents ---------------------------------------------------------------------- #
182
+ def getOpenAPIDocument(self, config: OpenAPIConfig) -> dict[str, Any]: # noqa: N802
183
+ """The OpenAPI document for the ``openapi`` version given in ``config`` (3.0.x or 3.1.x)."""
184
+ return build_document(self._routes, config, self.schemas)
185
+
186
+ def doc(self, path: str, config: OpenAPIConfig) -> Self:
187
+ """Serve the OpenAPI document at ``GET path`` (generated once, after the routes are known)."""
188
+ check_config(config)
189
+ key = id(config)
190
+
191
+ def serve(c: Context[Any]) -> Response:
192
+ body = self._documents.get(key)
193
+ if body is None:
194
+ body = self._documents[key] = dumps(build_document(self._routes, config, self.schemas))
195
+ return Response(body, 200, {"content-type": "application/json"})
196
+
197
+ self._add("GET", path, _Entry(serve))
198
+ return self
199
+
200
+ # -- dispatch ------------------------------------------------------------------------------- #
201
+ def _add(self, method: str, path: str, entry: _Entry) -> None:
202
+ self._router.add(method, path, entry)
203
+ self._registrations.append((method.upper(), path, entry))
204
+
205
+ async def handle(
206
+ self,
207
+ method: str,
208
+ path: str,
209
+ query_string: bytes = b"",
210
+ raw_headers: RawHeaders | None = None,
211
+ receive: Callable[[], Awaitable[dict[str, Any]]] | None = None,
212
+ ) -> Response:
213
+ matched = self._router.match(method, path)
214
+ if matched is None and method == "HEAD":
215
+ matched = self._router.match("GET", path)
216
+ entry, params = matched if matched is not None else (None, {})
217
+ c: Context[Any] = Context(
218
+ Request(method, path, params, query_string, raw_headers or [], receive),
219
+ entry.responses if entry is not None else None,
220
+ )
221
+ if self._middleware:
222
+ chain = [m for prefix, m in self._middleware if _under(prefix, path)]
223
+ if chain:
224
+ return await self._through(chain, 0, entry, c)
225
+ return await self._dispatch(entry, c)
226
+
227
+ async def _through(self, chain: list[Middleware], i: int, entry: _Entry | None, c: Context[Any]) -> Response:
228
+ if i == len(chain):
229
+ return await self._dispatch(entry, c)
230
+ try:
231
+ return await chain[i](c, lambda: self._through(chain, i + 1, entry, c))
232
+ except Exception as exc:
233
+ return await self._answer_error(exc, c)
234
+
235
+ async def _dispatch(self, entry: _Entry | None, c: Context[Any]) -> Response:
236
+ try:
237
+ if entry is None:
238
+ return await self._not_found(c)
239
+ if entry.checks:
240
+ failure = await _check_request(entry, c)
241
+ if failure is not None:
242
+ return failure
243
+ out = entry.handler(c)
244
+ if entry.is_async:
245
+ out = await out
246
+ if not isinstance(out, Response):
247
+ raise TypeError(
248
+ f"{entry.handler.__qualname__} returned {type(out).__name__}, not a Response (use c.json(...))"
249
+ )
250
+ return out
251
+ except MalformedJSONError: # RFC 9110 15.5.1
252
+ return problem(400, "Bad Request", "The request body is not valid JSON.", c.req.path)
253
+ except Exception as exc:
254
+ return await self._answer_error(exc, c)
255
+
256
+ async def _not_found(self, c: Context[Any]) -> Response:
257
+ allowed = self._router.allowed(c.req.path)
258
+ if allowed: # RFC 9110 15.5.6
259
+ res = problem(405, "Method Not Allowed", f"{c.req.method} is not allowed here.", c.req.path)
260
+ res.headers["allow"] = ", ".join(allowed)
261
+ return res
262
+ out = await self._fallback(_chain(self._not_found_fallbacks, c.req.path), 0, c)
263
+ if out is None:
264
+ return problem(404, "Not Found", f"No route for {c.req.method} {c.req.path}.", c.req.path)
265
+ return out
266
+
267
+ async def _answer_error(self, exc: Exception, c: Context[Any]) -> Response:
268
+ log.exception("%s %s: unhandled error", c.req.method, c.req.path, exc_info=exc)
269
+ c.error = exc
270
+ chain = _chain(self._on_error_fallbacks, c.req.path)
271
+ if chain:
272
+ try:
273
+ out = await self._fallback(chain, 0, c)
274
+ if out is not None:
275
+ return out
276
+ except Exception as failure:
277
+ log.exception("%s %s: the error handler failed", c.req.method, c.req.path, exc_info=failure)
278
+ return problem(500, "Internal Server Error", "An unexpected error occurred.", c.req.path)
279
+
280
+ async def _fallback(self, chain: list[_Fallback], i: int, c: Context[Any]) -> Response | None:
281
+ """Runs the ``onError`` / ``notFound`` handlers in ``chain`` from ``i``; ``None`` past the end."""
282
+ if i == len(chain):
283
+ return None
284
+ fallback = chain[i]
285
+ out = fallback.fn(c, lambda: self._fallback(chain, i + 1, c)) if fallback.is_middleware else fallback.fn(c)
286
+ if inspect.isawaitable(out):
287
+ out = await out
288
+ if out is not None and not isinstance(out, Response):
289
+ raise TypeError(f"{fallback.fn.__qualname__} returned {type(out).__name__}, not a Response")
290
+ return out
291
+
292
+ # -- ASGI ------------------------------------------------------------------------------------ #
293
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
294
+ kind = scope["type"]
295
+ if kind == "http":
296
+ method = scope["method"]
297
+ res = await self.handle(
298
+ method, scope["path"], scope.get("query_string", b""), scope.get("headers"), receive
299
+ )
300
+ # Python's str.encode here is faster than a Rust helper (measured).
301
+ headers = [(k.encode("latin-1"), v.encode("latin-1")) for k, v in _header_items(res.headers)]
302
+ headers.append((b"content-length", str(len(res.body)).encode()))
303
+ await send({"type": "http.response.start", "status": res.status, "headers": headers})
304
+ await send({"type": "http.response.body", "body": b"" if method == "HEAD" else res.body})
305
+ elif kind == "lifespan":
306
+ while True:
307
+ message = await receive()
308
+ if message["type"] == "lifespan.startup":
309
+ await send({"type": "lifespan.startup.complete"})
310
+ elif message["type"] == "lifespan.shutdown":
311
+ await send({"type": "lifespan.shutdown.complete"})
312
+ return
313
+
314
+ async def __rsgi__(self, scope: Any, protocol: Any) -> None:
315
+ """Granian's RSGI interface (``granian --interface rsgi module:app``): no per-request ASGI
316
+ message dicts, and one call sends the whole response."""
317
+ if scope.proto != "http":
318
+ return
319
+ method = scope.method
320
+
321
+ async def receive() -> dict[str, Any]:
322
+ return {"type": "http.request", "body": await protocol(), "more_body": False}
323
+
324
+ res = await self.handle(method, scope.path, scope.query_string.encode("latin-1"), scope.headers, receive)
325
+ protocol.response_bytes(res.status, _header_items(res.headers), b"" if method == "HEAD" else res.body)
326
+
327
+ async def request(
328
+ self,
329
+ path: str,
330
+ *,
331
+ method: str = "GET",
332
+ json: Any = None,
333
+ body: bytes | str | None = None,
334
+ headers: Mapping[str, str] | None = None,
335
+ ) -> Response:
336
+ """Call the app without a server, e.g. in tests: ``await app.request("/todos/1")``."""
337
+ path, _, query = path.partition("?")
338
+ merged = {k.lower(): v for k, v in (headers or {}).items()}
339
+ if json is not None:
340
+ payload = dumps(json)
341
+ merged.setdefault("content-type", "application/json")
342
+ else:
343
+ payload = body.encode() if isinstance(body, str) else (body or b"")
344
+ raw_headers = [(k.encode("latin-1"), v.encode("latin-1")) for k, v in merged.items()]
345
+
346
+ async def receive() -> dict[str, Any]:
347
+ return {"type": "http.request", "body": payload, "more_body": False}
348
+
349
+ return await self.handle(method.upper(), path, query.encode(), raw_headers, receive)
350
+
351
+
352
+ def _header_items(headers: Mapping[str, str | list[str]]) -> list[tuple[str, str]]:
353
+ """The headers as (name, value) pairs: a list value (several ``Set-Cookie``) is one pair per item."""
354
+ items: list[tuple[str, str]] = []
355
+ for name, value in headers.items():
356
+ if isinstance(value, list):
357
+ items.extend((name, v) for v in value)
358
+ else:
359
+ items.append((name, value))
360
+ return items
361
+
362
+
363
+ async def _check_request(entry: _Entry, c: Context[Any]) -> Response | None:
364
+ """Fill ``c.req.valid(...)``; the error response when a part does not match its type."""
365
+ req = c.req
366
+ for target, checker, required in entry.checks:
367
+ try:
368
+ if target == "param":
369
+ data = checker.decode(req._params, coerce=True)
370
+ elif target == "query":
371
+ queries = req._queries()
372
+ raw = {name: v if repeats else v[0] for name, repeats in checker.names if (v := queries.get(name))}
373
+ data = checker.decode(raw, coerce=True)
374
+ elif target == "header":
375
+ headers = req.header()
376
+ raw = {name: headers[name.lower()] for name, _ in checker.names if name.lower() in headers}
377
+ data = checker.decode(raw, coerce=True)
378
+ elif target == "cookie":
379
+ data = checker.decode(req.cookie(), coerce=True)
380
+ else:
381
+ content_type = req.header("content-type") or ""
382
+ if "json" not in content_type:
383
+ if required: # RFC 9110 15.5.16
384
+ detail = f"Content-Type {content_type or '(none)'} is not supported here"
385
+ return problem(415, "Unsupported Media Type", detail, req.path)
386
+ continue
387
+ try: # parsed and checked in one pass
388
+ data = checker.decode_json(await req.body())
389
+ except ValueError: # RFC 9110 15.5.1 (ValidationError is not a ValueError)
390
+ return problem(400, "Bad Request", "The request body is not valid JSON.", req.path)
391
+ except ValidationError as exc: # well-formed but of the wrong type: RFC 9110 15.5.21
392
+ return _validation_problem(target, exc.issues, req.path)
393
+ req._valid[target] = data
394
+ return None
395
+
396
+
397
+ def _validation_problem(target: str, issues: list[dict[str, Any]], instance: str) -> Response:
398
+ """422 Problem Details with one entry per issue (``ValidationProblem`` in the document)."""
399
+ location = _LOCATIONS[target]
400
+ errors = [
401
+ {
402
+ "in": location,
403
+ "path": ".".join(str(p) for p in issue["path"]),
404
+ "code": issue["code"],
405
+ "message": issue["message"],
406
+ }
407
+ for issue in issues
408
+ ]
409
+ detail = f"The request {location} is invalid." if location == "body" else f"Invalid {location} parameters."
410
+ return problem(422, "Unprocessable Content", detail, instance, VALIDATION_PROBLEM_TYPE, errors=errors)
411
+
412
+
413
+ def _check_handler(route: type[AnyRoute], handler: Callable[..., Any]) -> None:
414
+ """Routes are named classes and handlers say which one they serve: ``c: Context[route]``."""
415
+ if route._generated:
416
+ raise TypeError(
417
+ f"declare the route as a class to name it in the handler: "
418
+ f"class getTodoRoute(createRoute({{...}})) (got {route.__name__})"
419
+ )
420
+ name = getattr(handler, "__qualname__", repr(handler))
421
+ expected = f"def {route.__name__}Handler(c: Context[{route.__name__}])"
422
+ params = list(inspect.signature(handler).parameters.values())
423
+ if not params:
424
+ raise TypeError(f"{name} takes no context; write it as {expected}")
425
+ try:
426
+ annotation = typing.get_type_hints(handler).get(params[0].name)
427
+ except Exception as exc:
428
+ raise TypeError(
429
+ f"cannot resolve the annotations of {name} ({exc}); define {route.__name__} at module level"
430
+ ) from exc
431
+ if get_origin(annotation) is not Context or get_args(annotation) != (route,):
432
+ raise TypeError(f"{name} must take the route's context: {expected}")
433
+
434
+
435
+ def _under(prefix: str, path: str) -> bool:
436
+ return not prefix or path == prefix or path.startswith(prefix + "/")
437
+
438
+
439
+ def _chain(fallbacks: list[_Fallback], path: str) -> list[_Fallback]:
440
+ """The handlers for ``path``: the more deeply mounted first, then in registration order."""
441
+ return sorted((f for f in fallbacks if _under(f.prefix, path)), key=lambda f: -f.depth)
442
+
443
+
444
+ def _arity(fn: Callable[..., Any]) -> int:
445
+ try:
446
+ return len(inspect.signature(fn).parameters)
447
+ except (TypeError, ValueError): # a builtin or a callable without a signature: ``h(c)``
448
+ return 1
449
+
450
+
451
+ __all__ = ["Fallback", "FallbackHandler", "FallbackMiddleware", "FallbackNext", "Middleware", "Next", "TakibiAPI"]