apiroute 1.0.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.
Files changed (65) hide show
  1. apiroute/__init__.py +57 -0
  2. apiroute/api_route_error.py +2 -0
  3. apiroute/argument_targets.py +33 -0
  4. apiroute/async_chunk_iterator.py +35 -0
  5. apiroute/async_retry_runner.py +26 -0
  6. apiroute/base_url_validator.py +15 -0
  7. apiroute/body_encoder.py +25 -0
  8. apiroute/chunk_iterator.py +36 -0
  9. apiroute/client.py +85 -0
  10. apiroute/client_config.py +95 -0
  11. apiroute/dataclass_converter.py +56 -0
  12. apiroute/deferred_converter.py +22 -0
  13. apiroute/from_client.py +9 -0
  14. apiroute/generated_signature.py +93 -0
  15. apiroute/http_method.py +44 -0
  16. apiroute/model_types.py +43 -0
  17. apiroute/namespace.py +34 -0
  18. apiroute/path_template.py +25 -0
  19. apiroute/placeholder_resolver.py +20 -0
  20. apiroute/py.typed +0 -0
  21. apiroute/pydantic_converter.py +15 -0
  22. apiroute/query_encoder.py +50 -0
  23. apiroute/request_builder.py +139 -0
  24. apiroute/request_options.py +56 -0
  25. apiroute/request_spec.py +13 -0
  26. apiroute/response_converter.py +22 -0
  27. apiroute/response_reader.py +58 -0
  28. apiroute/response_validation_error.py +13 -0
  29. apiroute/result.py +9 -0
  30. apiroute/retries.py +50 -0
  31. apiroute/retry_policy.py +66 -0
  32. apiroute/retry_runner.py +26 -0
  33. apiroute/route.py +42 -0
  34. apiroute/route_compiler.py +214 -0
  35. apiroute/route_decorator.py +23 -0
  36. apiroute/route_definition.py +48 -0
  37. apiroute/route_definition_error.py +5 -0
  38. apiroute/route_executor.py +113 -0
  39. apiroute/route_method.py +75 -0
  40. apiroute/route_spec.py +15 -0
  41. apiroute/router.py +52 -0
  42. apiroute/router_view.py +35 -0
  43. apiroute/section.py +27 -0
  44. apiroute/section_accessor.py +25 -0
  45. apiroute/stream_path_validator.py +16 -0
  46. apiroute/stream_reader.py +82 -0
  47. apiroute/transport_configuration.py +37 -0
  48. apiroute/transports/__init__.py +25 -0
  49. apiroute/transports/async_httpx_client_transport.py +25 -0
  50. apiroute/transports/async_one_shot_transport.py +33 -0
  51. apiroute/transports/async_pooled_transport.py +84 -0
  52. apiroute/transports/async_transport.py +18 -0
  53. apiroute/transports/httpx_client_factory.py +32 -0
  54. apiroute/transports/httpx_client_transport.py +33 -0
  55. apiroute/transports/one_shot_transport.py +34 -0
  56. apiroute/transports/pooled_transport.py +44 -0
  57. apiroute/transports/thread_local_transport.py +60 -0
  58. apiroute/transports/transport.py +22 -0
  59. apiroute/transports/transport_registry.py +52 -0
  60. apiroute/unexpected_response.py +18 -0
  61. apiroute/value_template.py +59 -0
  62. apiroute-1.0.0.dist-info/METADATA +379 -0
  63. apiroute-1.0.0.dist-info/RECORD +65 -0
  64. apiroute-1.0.0.dist-info/WHEEL +4 -0
  65. apiroute-1.0.0.dist-info/licenses/LICENSE +21 -0
apiroute/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Build API clients declaratively: one @route per endpoint, safe defaults, httpx underneath."""
2
+
3
+ from apiroute.api_route_error import ApiRouteError
4
+ from apiroute.argument_targets import Body, Header, Query
5
+ from apiroute.client import Client
6
+ from apiroute.from_client import FROM_CLIENT
7
+ from apiroute.namespace import namespace
8
+ from apiroute.request_spec import RequestSpec
9
+ from apiroute.response_validation_error import ResponseValidationError
10
+ from apiroute.result import Result
11
+ from apiroute.retries import Retries
12
+ from apiroute.route import route
13
+ from apiroute.route_definition_error import RouteDefinitionError
14
+ from apiroute.section import Section
15
+ from apiroute.transport_configuration import aclose_transports, close_transports, use_transport
16
+ from apiroute.transports import (
17
+ AsyncHttpxClientTransport,
18
+ AsyncOneShotTransport,
19
+ AsyncPooledTransport,
20
+ AsyncTransport,
21
+ HttpxClientTransport,
22
+ OneShotTransport,
23
+ PooledTransport,
24
+ ThreadLocalTransport,
25
+ Transport,
26
+ )
27
+ from apiroute.unexpected_response import UnexpectedResponse
28
+
29
+ __all__ = [
30
+ "AsyncHttpxClientTransport",
31
+ "AsyncOneShotTransport",
32
+ "AsyncPooledTransport",
33
+ "AsyncTransport",
34
+ "Body",
35
+ "Client",
36
+ "FROM_CLIENT",
37
+ "ApiRouteError",
38
+ "Header",
39
+ "HttpxClientTransport",
40
+ "OneShotTransport",
41
+ "PooledTransport",
42
+ "Query",
43
+ "RequestSpec",
44
+ "ResponseValidationError",
45
+ "Result",
46
+ "Retries",
47
+ "RouteDefinitionError",
48
+ "Section",
49
+ "ThreadLocalTransport",
50
+ "Transport",
51
+ "UnexpectedResponse",
52
+ "aclose_transports",
53
+ "close_transports",
54
+ "namespace",
55
+ "route",
56
+ "use_transport",
57
+ ]
@@ -0,0 +1,2 @@
1
+ class ApiRouteError(Exception):
2
+ """Base class for every error this library raises."""
@@ -0,0 +1,33 @@
1
+ class ArgumentTarget:
2
+ """Marks a route parameter that no {placeholder} names, saying where its value goes.
3
+
4
+ Use the class (`state: Query`) or an instance with a name (`sort: Query("sort_by")`),
5
+ or inside Annotated (`state: Annotated[str | None, Query()] = None`).
6
+ """
7
+
8
+ location = ""
9
+
10
+ def __init__(self, name: str | None = None):
11
+ self.name = name
12
+
13
+ def key_for(self, param_name: str) -> str:
14
+ return self.name or param_name
15
+
16
+ def __repr__(self) -> str:
17
+ return f"{type(self).__name__}({self.name!r})"
18
+
19
+
20
+ class Query(ArgumentTarget):
21
+ location = "query"
22
+
23
+
24
+ class Header(ArgumentTarget):
25
+ location = "header"
26
+
27
+ def key_for(self, param_name: str) -> str:
28
+ """trace_id becomes Trace-Id unless a header name was given."""
29
+ return self.name or "-".join(part.capitalize() for part in param_name.split("_"))
30
+
31
+
32
+ class Body(ArgumentTarget):
33
+ location = "body"
@@ -0,0 +1,35 @@
1
+ from contextlib import AsyncExitStack
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+
7
+ class AsyncChunkIterator:
8
+ """The async counterpart of ChunkIterator, returned by stream="iter" routes through .aio.
9
+
10
+ Use it in an async with block to be sure the connection closes when you stop early.
11
+ """
12
+
13
+ def __init__(self, response: httpx.Response, stack: AsyncExitStack):
14
+ self.response = response
15
+ self._chunks = response.aiter_bytes()
16
+ self._stack = stack
17
+
18
+ def __aiter__(self) -> "AsyncChunkIterator":
19
+ return self
20
+
21
+ async def __anext__(self) -> bytes:
22
+ try:
23
+ return await self._chunks.__anext__()
24
+ except BaseException:
25
+ await self.aclose()
26
+ raise
27
+
28
+ async def aclose(self) -> None:
29
+ await self._stack.aclose()
30
+
31
+ async def __aenter__(self) -> "AsyncChunkIterator":
32
+ return self
33
+
34
+ async def __aexit__(self, *exc_info: Any) -> None:
35
+ await self.aclose()
@@ -0,0 +1,26 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import Any, TypeVar
3
+
4
+ from apiroute.retry_policy import RetryPolicy
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ class AsyncRetryRunner:
10
+ """The async retry loop: awaits each attempt and sleeps without blocking the event loop."""
11
+
12
+ def __init__(self, policy: RetryPolicy, sleep: Callable[[float], Awaitable[Any]]):
13
+ self._policy = policy
14
+ self._sleep = sleep
15
+
16
+ async def run(self, attempt: Callable[[], Awaitable[T]]) -> T:
17
+ number = 1
18
+ while True:
19
+ try:
20
+ return await attempt()
21
+ except Exception as error:
22
+ delay = self._policy.delay_before_retry(error, number)
23
+ if delay is None:
24
+ raise
25
+ await self._sleep(delay)
26
+ number += 1
@@ -0,0 +1,15 @@
1
+ from urllib.parse import urlsplit
2
+
3
+ # Only web URLs, to keep a misconfigured base_url from reaching file:, gopher: and the like
4
+ ALLOWED_URL_SCHEMES = ("http", "https")
5
+
6
+
7
+ def validate_base_url(url: str) -> None:
8
+ parts = urlsplit(str(url))
9
+ if parts.scheme.lower() not in ALLOWED_URL_SCHEMES:
10
+ raise ValueError(
11
+ f"invalid base_url scheme {parts.scheme!r} in {url!r}; "
12
+ f"allowed: {', '.join(ALLOWED_URL_SCHEMES)}"
13
+ )
14
+ if not parts.hostname:
15
+ raise ValueError(f"invalid base_url {url!r}: it has no host")
@@ -0,0 +1,25 @@
1
+ import json
2
+ from typing import Any
3
+ from urllib.parse import urlencode
4
+
5
+ from apiroute.query_encoder import QueryEncoder
6
+
7
+ BODY_FORMATS = ("json", "form")
8
+
9
+
10
+ class BodyEncoder:
11
+ """Serializes a route's body data according to the class's body_format."""
12
+
13
+ def __init__(self, body_format: Any):
14
+ self._format = body_format
15
+
16
+ def encode(self, client: Any, data: Any) -> tuple[bytes | str, str | None]:
17
+ """Returns the content and the Content-Type it implies (None when it implies none)."""
18
+ if isinstance(data, (str, bytes)):
19
+ return data, None
20
+ if callable(self._format):
21
+ return self._format(client, data), None
22
+ if self._format == "form":
23
+ pairs = QueryEncoder("nested").encode(data)
24
+ return urlencode(pairs), "application/x-www-form-urlencoded"
25
+ return json.dumps(data).encode(), "application/json"
@@ -0,0 +1,36 @@
1
+ from contextlib import ExitStack
2
+ from typing import Any
3
+
4
+ import httpx
5
+
6
+
7
+ class ChunkIterator:
8
+ """A streamed response body, chunk by chunk (what a stream="iter" route returns).
9
+
10
+ The connection stays open until the chunks run out or close() is called; use it in a
11
+ with block to be sure it closes when you stop early.
12
+ """
13
+
14
+ def __init__(self, response: httpx.Response, stack: ExitStack):
15
+ self.response = response
16
+ self._chunks = response.iter_bytes()
17
+ self._stack = stack
18
+
19
+ def __iter__(self) -> "ChunkIterator":
20
+ return self
21
+
22
+ def __next__(self) -> bytes:
23
+ try:
24
+ return next(self._chunks)
25
+ except BaseException:
26
+ self.close()
27
+ raise
28
+
29
+ def close(self) -> None:
30
+ self._stack.close()
31
+
32
+ def __enter__(self) -> "ChunkIterator":
33
+ return self
34
+
35
+ def __exit__(self, *exc_info: Any) -> None:
36
+ self.close()
apiroute/client.py ADDED
@@ -0,0 +1,85 @@
1
+ import asyncio
2
+ import time
3
+ from dataclasses import replace
4
+ from typing import Any
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from apiroute.base_url_validator import validate_base_url
10
+ from apiroute.client_config import ClientConfig
11
+ from apiroute.request_spec import RequestSpec
12
+ from apiroute.router import Router
13
+ from apiroute.transport_configuration import ASYNC_TRANSPORTS, TRANSPORTS, is_async_transport
14
+ from apiroute.transports.async_transport import AsyncTransport
15
+ from apiroute.transports.transport import Transport
16
+
17
+ # Network failures worth another attempt; other errors (bad URL, TLS) won't fix themselves
18
+ RETRYABLE_ERRORS = (httpx.TimeoutException, httpx.NetworkError, httpx.RemoteProtocolError)
19
+
20
+
21
+ class Client(Router):
22
+ """Base class for an API client. Configure it with class attributes and @route methods.
23
+
24
+ The class describes the API only. How requests are sent is a Transport, chosen for the
25
+ environment with apiroute.use_transport() or, for one instance, the transport and
26
+ async_transport arguments. Async calls go through client.aio.
27
+ """
28
+
29
+ last_response: httpx.Response | None = None
30
+
31
+ # Class-level defaults, so a subclass that skips super().__init__() still works
32
+ _base_url: str | None = None
33
+ _transport: Transport | None = None
34
+ _async_transport: AsyncTransport | None = None
35
+ _sleep = staticmethod(time.sleep)
36
+ _async_sleep = staticmethod(asyncio.sleep)
37
+
38
+ def __init__(
39
+ self,
40
+ *,
41
+ base_url: str | None = None,
42
+ transport: Transport | None = None,
43
+ async_transport: AsyncTransport | None = None,
44
+ ):
45
+ if base_url is not None:
46
+ validate_base_url(base_url)
47
+ if transport is not None and is_async_transport(transport):
48
+ raise TypeError("transport is async; pass it as async_transport")
49
+ if async_transport is not None and not is_async_transport(async_transport):
50
+ raise TypeError("async_transport is not async; pass it as transport")
51
+ self._base_url = base_url
52
+ self._transport = transport
53
+ self._async_transport = async_transport
54
+
55
+ def should_retry(self, error: Exception, attempt: int) -> bool:
56
+ """Override to retry other errors, e.g. an UnexpectedResponse with status 503."""
57
+ return isinstance(error, RETRYABLE_ERRORS)
58
+
59
+ def on_request(self, request: RequestSpec) -> RequestSpec | None:
60
+ """Runs before every attempt, sync or async. Return a changed request, e.g. one with a
61
+ signature header added through dataclasses.replace, or None to send it as is."""
62
+ return None
63
+
64
+ def on_response(self, request: RequestSpec, response: httpx.Response, elapsed: float) -> None:
65
+ """Runs after every response, whatever its status, with the seconds it took; for a
66
+ stream, once the status is in and before the body is read. Use it for metrics or
67
+ logging. Network errors don't reach it; they are logged by the retry policy."""
68
+
69
+ def escape_path(self, value: Any) -> str:
70
+ """Every path value is escaped so it stays one segment; override to allow '/'."""
71
+ return quote(str(value), safe="")
72
+
73
+ @property
74
+ def _root(self) -> "Client":
75
+ return self
76
+
77
+ def _request_config(self) -> ClientConfig:
78
+ config = type(self)._config
79
+ return config if self._base_url is None else replace(config, base_url=self._base_url)
80
+
81
+ def _http_transport(self) -> Transport:
82
+ return self._transport or TRANSPORTS.for_class(type(self))
83
+
84
+ def _async_http_transport(self) -> AsyncTransport:
85
+ return self._async_transport or ASYNC_TRANSPORTS.for_class(type(self))
@@ -0,0 +1,95 @@
1
+ from collections.abc import Mapping
2
+ from dataclasses import dataclass, field, replace
3
+ from typing import Any
4
+
5
+ from apiroute.base_url_validator import validate_base_url
6
+ from apiroute.body_encoder import BODY_FORMATS
7
+ from apiroute.query_encoder import QUERY_FORMATS
8
+ from apiroute.retries import Retries
9
+
10
+ # Class attributes a client or section reads its configuration from
11
+ CONFIG_ATTRIBUTES = (
12
+ "base_url",
13
+ "headers",
14
+ "query_params",
15
+ "timeout",
16
+ "retries",
17
+ "body_format",
18
+ "query_format",
19
+ "inherit",
20
+ )
21
+
22
+ # These merge down the class hierarchy, as the gem's header and query_param calls do;
23
+ # the rest are replaced by a subclass
24
+ MERGED_ATTRIBUTES = ("headers", "query_params")
25
+
26
+ INHERITABLE = frozenset({"headers", "query_params"})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ClientConfig:
31
+ base_url: str | None = None
32
+ headers: Mapping[str, Any] = field(default_factory=dict)
33
+ query_params: Mapping[str, Any] = field(default_factory=dict)
34
+ # httpx defaults to 5 seconds, which is short for API calls that do real work
35
+ timeout: float | None = 30.0
36
+ retries: Retries = field(default_factory=Retries)
37
+ body_format: Any = "json"
38
+ query_format: Any = "flat"
39
+ inherit: frozenset[str] = frozenset()
40
+
41
+ @classmethod
42
+ def from_class(cls, router_class: type) -> "ClientConfig":
43
+ own = {
44
+ name: vars(router_class)[name]
45
+ for name in CONFIG_ATTRIBUTES
46
+ if name in vars(router_class)
47
+ }
48
+ return _parent_config(router_class)._extended(own, router_class.__qualname__)
49
+
50
+ def for_section(self, parent: "ClientConfig", root: "ClientConfig") -> "ClientConfig":
51
+ """This (a section's) config, with its parent's base_url and what it inherits from root."""
52
+ changes: dict[str, Any] = {"base_url": self.base_url or parent.base_url}
53
+ for name in self.inherit:
54
+ changes[name] = {**getattr(root, name), **getattr(self, name)}
55
+ return replace(self, **changes)
56
+
57
+ def _extended(self, own: dict[str, Any], owner: str) -> "ClientConfig":
58
+ changes = dict(own)
59
+ for name in MERGED_ATTRIBUTES:
60
+ if name in own:
61
+ changes[name] = _merged_dict(getattr(self, name), own[name])
62
+ if "inherit" in own:
63
+ changes["inherit"] = frozenset(own["inherit"])
64
+ config = replace(self, **changes)
65
+ try:
66
+ config._validate()
67
+ except ValueError as error:
68
+ raise ValueError(f"{owner}: {error}") from error
69
+ return config
70
+
71
+ def _validate(self) -> None:
72
+ if self.base_url is not None:
73
+ validate_base_url(self.base_url)
74
+ if not callable(self.body_format) and self.body_format not in BODY_FORMATS:
75
+ raise ValueError(f"body_format must be one of {BODY_FORMATS} or a callable")
76
+ if not callable(self.query_format) and self.query_format not in QUERY_FORMATS:
77
+ raise ValueError(f"query_format must be one of {QUERY_FORMATS} or a callable")
78
+ if not isinstance(self.retries, Retries):
79
+ raise ValueError("retries must be a Retries")
80
+ if not self.inherit <= INHERITABLE:
81
+ raise ValueError(f"inherit may only list {sorted(INHERITABLE)}")
82
+
83
+
84
+ def _parent_config(router_class: type) -> ClientConfig:
85
+ for base in router_class.__mro__[1:]:
86
+ config = vars(base).get("_config")
87
+ if config is not None:
88
+ return config
89
+ return ClientConfig()
90
+
91
+
92
+ def _merged_dict(base: Mapping[str, Any], own: Mapping[str, Any]) -> dict[str, Any]:
93
+ """A None value in the subclass removes that key."""
94
+ merged = {**base, **own}
95
+ return {name: value for name, value in merged.items() if value is not None}
@@ -0,0 +1,56 @@
1
+ import dataclasses
2
+ from typing import Any, get_args, get_origin, get_type_hints
3
+
4
+ from apiroute.model_types import is_dataclass_type, is_union, strip_annotated
5
+
6
+
7
+ class DataclassConverter:
8
+ """Builds dataclasses (inside lists, dicts and unions too) from parsed JSON, without pydantic.
9
+
10
+ Keys the dataclass doesn't declare are ignored, since APIs add fields over time; a
11
+ missing required field raises TypeError. Values aren't type-checked beyond that.
12
+ """
13
+
14
+ def __init__(self, model: Any):
15
+ self._model = model
16
+
17
+ def convert(self, data: Any) -> Any:
18
+ return self._convert(self._model, data)
19
+
20
+ def _convert(self, tp: Any, data: Any) -> Any:
21
+ tp = strip_annotated(tp)
22
+ if is_union(tp):
23
+ return self._convert_union(tp, data)
24
+ if is_dataclass_type(tp):
25
+ return self._convert_dataclass(tp, data)
26
+ origin = get_origin(tp)
27
+ if origin in (list, tuple, set, frozenset) and get_args(tp):
28
+ item = get_args(tp)[0]
29
+ return origin(self._convert(item, value) for value in data)
30
+ if origin is dict and get_args(tp):
31
+ value_type = get_args(tp)[1]
32
+ return {key: self._convert(value_type, value) for key, value in data.items()}
33
+ return data
34
+
35
+ def _convert_union(self, tp: Any, data: Any) -> Any:
36
+ options = [arg for arg in get_args(tp) if arg is not type(None)]
37
+ if data is None:
38
+ return None
39
+ errors = []
40
+ for option in options:
41
+ try:
42
+ return self._convert(option, data)
43
+ except (TypeError, KeyError, AttributeError) as error:
44
+ errors.append(error)
45
+ raise TypeError(f"no type in {tp} fits the data: {errors}")
46
+
47
+ def _convert_dataclass(self, tp: type, data: Any) -> Any:
48
+ if not isinstance(data, dict):
49
+ raise TypeError(f"{tp.__name__} needs an object, got {type(data).__name__}")
50
+ hints = get_type_hints(tp, include_extras=True)
51
+ values = {
52
+ field.name: self._convert(hints[field.name], data[field.name])
53
+ for field in dataclasses.fields(tp)
54
+ if field.init and field.name in data
55
+ }
56
+ return tp(**values)
@@ -0,0 +1,22 @@
1
+ from typing import Any
2
+
3
+ from apiroute.response_converter import converter_for
4
+
5
+
6
+ class DeferredConverter:
7
+ """Converts with a model type named by a string annotation that couldn't be evaluated
8
+ when the class was created, e.g. `-> "User"` with User defined further down the module.
9
+ The name is looked up on first use."""
10
+
11
+ def __init__(self, annotation: str, namespace: dict[str, Any]):
12
+ self._annotation = annotation
13
+ self._namespace = namespace
14
+ self._converter: Any = None
15
+ self._resolved = False
16
+
17
+ def convert(self, data: Any) -> Any:
18
+ if not self._resolved:
19
+ model = eval(self._annotation, self._namespace) # noqa: S307
20
+ self._converter = converter_for(model)
21
+ self._resolved = True
22
+ return data if self._converter is None else self._converter.convert(data)
@@ -0,0 +1,9 @@
1
+ class _FromClient:
2
+ """The default of a route parameter that, when left out, comes from the client instead."""
3
+
4
+ def __repr__(self) -> str:
5
+ return "FROM_CLIENT"
6
+
7
+
8
+ # Usable in a def too: def list_repos(self, org: str = FROM_CLIENT, page: int = 1)
9
+ FROM_CLIENT = _FromClient()
@@ -0,0 +1,93 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from typing import Any
4
+
5
+ from apiroute.from_client import FROM_CLIENT
6
+ from apiroute.http_method import infer_http_method, normalize_http_method
7
+ from apiroute.path_template import PathTemplate
8
+ from apiroute.route_compiler import STREAM_PARAMS
9
+ from apiroute.route_definition_error import RouteDefinitionError
10
+ from apiroute.route_spec import RouteSpec
11
+ from apiroute.value_template import ValueTemplate
12
+
13
+ _BODY_METHODS = frozenset({"POST", "PUT", "PATCH"})
14
+ _KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY
15
+
16
+
17
+ class GeneratedSignature:
18
+ """Builds the stub function for a route written without a def.
19
+
20
+ Every placeholder becomes a keyword-only parameter, in path, query, body order. One the
21
+ class declares (a class attribute, method, property or annotation such as `org: str`)
22
+ is optional and falls back to the client's value. A POST, PUT or PATCH route without a
23
+ body template also takes an optional `body`, and a stream="file" or "io" route a
24
+ required `file` or `io`.
25
+ """
26
+
27
+ def __init__(self, owner: type, name: str, spec: RouteSpec):
28
+ self._owner = owner
29
+ self._name = name
30
+ self._spec = spec
31
+
32
+ def function(self) -> Callable:
33
+ try:
34
+ signature = self._signature()
35
+ except (TypeError, ValueError) as error:
36
+ raise RouteDefinitionError(f"{self._label()}: {error}") from error
37
+ return self._stub(signature)
38
+
39
+ def _signature(self) -> inspect.Signature:
40
+ parameters = [inspect.Parameter("self", inspect.Parameter.POSITIONAL_OR_KEYWORD)]
41
+ parameters += [self._parameter(name) for name in self._placeholder_names()]
42
+ if self._takes_body():
43
+ parameters.append(inspect.Parameter("body", _KEYWORD_ONLY, default=None))
44
+ stream_param = STREAM_PARAMS.get(self._spec.stream)
45
+ if stream_param is not None:
46
+ parameters.append(inspect.Parameter(stream_param, _KEYWORD_ONLY))
47
+ return inspect.Signature(parameters)
48
+
49
+ def _placeholder_names(self) -> list[str]:
50
+ names = list(PathTemplate(self._spec.path).names)
51
+ for template in (self._spec.query, self._spec.body):
52
+ if template is not None and not isinstance(template, (str, bytes)):
53
+ names += ValueTemplate(template).names
54
+ return list(dict.fromkeys(names))
55
+
56
+ def _parameter(self, name: str) -> inspect.Parameter:
57
+ if self._declared_on_class(name):
58
+ return inspect.Parameter(name, _KEYWORD_ONLY, default=FROM_CLIENT)
59
+ return inspect.Parameter(name, _KEYWORD_ONLY)
60
+
61
+ def _declared_on_class(self, name: str) -> bool:
62
+ return hasattr(self._owner, name) or any(
63
+ name in _annotations(base) for base in self._owner.__mro__
64
+ )
65
+
66
+ def _takes_body(self) -> bool:
67
+ if self._spec.body is not None:
68
+ return False
69
+ method = self._spec.method
70
+ http_method = infer_http_method(self._name) if method is None else method
71
+ return normalize_http_method(http_method) in _BODY_METHODS
72
+
73
+ def _stub(self, signature: inspect.Signature) -> Callable:
74
+ def stub(*args: Any, **kwargs: Any) -> Any:
75
+ raise NotImplementedError("generated routes are called through the client")
76
+
77
+ stub.__name__ = self._name
78
+ stub.__qualname__ = self._label()
79
+ stub.__module__ = self._owner.__module__
80
+ stub.__doc__ = f"{self._spec.method or infer_http_method(self._name)} {self._spec.path}"
81
+ stub.__signature__ = signature
82
+ return stub
83
+
84
+ def _label(self) -> str:
85
+ return f"{self._owner.__qualname__}.{self._name}"
86
+
87
+
88
+ def _annotations(cls: type) -> dict[str, Any]:
89
+ """A class's own annotations; ones that can't be evaluated still count by name."""
90
+ try:
91
+ return inspect.get_annotations(cls)
92
+ except Exception:
93
+ return vars(cls).get("__annotations__", {})
@@ -0,0 +1,44 @@
1
+ import re
2
+
3
+ HTTP_METHODS = frozenset(
4
+ {
5
+ "GET",
6
+ "POST",
7
+ "PUT",
8
+ "PATCH",
9
+ "DELETE",
10
+ "HEAD",
11
+ "OPTIONS",
12
+ "TRACE",
13
+ "COPY",
14
+ "LOCK",
15
+ "UNLOCK",
16
+ "MKCOL",
17
+ "MOVE",
18
+ "PROPFIND",
19
+ "PROPPATCH",
20
+ }
21
+ )
22
+
23
+ # The verb must be the whole name or be followed by '_', so create_user is a POST
24
+ # but names such as posts or deleted_users stay GET.
25
+ _INFERRED_METHODS = (
26
+ (re.compile(r"(?:post|create|add|insert)(?:_|$)", re.IGNORECASE), "POST"),
27
+ (re.compile(r"(?:put|update|modify|change)(?:_|$)", re.IGNORECASE), "PUT"),
28
+ (re.compile(r"patch(?:_|$)", re.IGNORECASE), "PATCH"),
29
+ (re.compile(r"(?:delete|remove|destroy)(?:_|$)", re.IGNORECASE), "DELETE"),
30
+ )
31
+
32
+
33
+ def infer_http_method(route_name: str) -> str:
34
+ for pattern, method in _INFERRED_METHODS:
35
+ if pattern.match(route_name):
36
+ return method
37
+ return "GET"
38
+
39
+
40
+ def normalize_http_method(method: str) -> str:
41
+ upper = method.upper()
42
+ if upper not in HTTP_METHODS:
43
+ raise ValueError(f"unknown HTTP method {method!r}")
44
+ return upper