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.
- apiroute/__init__.py +57 -0
- apiroute/api_route_error.py +2 -0
- apiroute/argument_targets.py +33 -0
- apiroute/async_chunk_iterator.py +35 -0
- apiroute/async_retry_runner.py +26 -0
- apiroute/base_url_validator.py +15 -0
- apiroute/body_encoder.py +25 -0
- apiroute/chunk_iterator.py +36 -0
- apiroute/client.py +85 -0
- apiroute/client_config.py +95 -0
- apiroute/dataclass_converter.py +56 -0
- apiroute/deferred_converter.py +22 -0
- apiroute/from_client.py +9 -0
- apiroute/generated_signature.py +93 -0
- apiroute/http_method.py +44 -0
- apiroute/model_types.py +43 -0
- apiroute/namespace.py +34 -0
- apiroute/path_template.py +25 -0
- apiroute/placeholder_resolver.py +20 -0
- apiroute/py.typed +0 -0
- apiroute/pydantic_converter.py +15 -0
- apiroute/query_encoder.py +50 -0
- apiroute/request_builder.py +139 -0
- apiroute/request_options.py +56 -0
- apiroute/request_spec.py +13 -0
- apiroute/response_converter.py +22 -0
- apiroute/response_reader.py +58 -0
- apiroute/response_validation_error.py +13 -0
- apiroute/result.py +9 -0
- apiroute/retries.py +50 -0
- apiroute/retry_policy.py +66 -0
- apiroute/retry_runner.py +26 -0
- apiroute/route.py +42 -0
- apiroute/route_compiler.py +214 -0
- apiroute/route_decorator.py +23 -0
- apiroute/route_definition.py +48 -0
- apiroute/route_definition_error.py +5 -0
- apiroute/route_executor.py +113 -0
- apiroute/route_method.py +75 -0
- apiroute/route_spec.py +15 -0
- apiroute/router.py +52 -0
- apiroute/router_view.py +35 -0
- apiroute/section.py +27 -0
- apiroute/section_accessor.py +25 -0
- apiroute/stream_path_validator.py +16 -0
- apiroute/stream_reader.py +82 -0
- apiroute/transport_configuration.py +37 -0
- apiroute/transports/__init__.py +25 -0
- apiroute/transports/async_httpx_client_transport.py +25 -0
- apiroute/transports/async_one_shot_transport.py +33 -0
- apiroute/transports/async_pooled_transport.py +84 -0
- apiroute/transports/async_transport.py +18 -0
- apiroute/transports/httpx_client_factory.py +32 -0
- apiroute/transports/httpx_client_transport.py +33 -0
- apiroute/transports/one_shot_transport.py +34 -0
- apiroute/transports/pooled_transport.py +44 -0
- apiroute/transports/thread_local_transport.py +60 -0
- apiroute/transports/transport.py +22 -0
- apiroute/transports/transport_registry.py +52 -0
- apiroute/unexpected_response.py +18 -0
- apiroute/value_template.py +59 -0
- apiroute-1.0.0.dist-info/METADATA +379 -0
- apiroute-1.0.0.dist-info/RECORD +65 -0
- apiroute-1.0.0.dist-info/WHEEL +4 -0
- 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,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")
|
apiroute/body_encoder.py
ADDED
|
@@ -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)
|
apiroute/from_client.py
ADDED
|
@@ -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__", {})
|
apiroute/http_method.py
ADDED
|
@@ -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
|