clientcraft 0.1.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.
- clientcraft/__init__.py +76 -0
- clientcraft/__init__.pyi +47 -0
- clientcraft/_base.py +300 -0
- clientcraft/_endpoints.py +194 -0
- clientcraft/_responses.py +32 -0
- clientcraft/_types.py +54 -0
- clientcraft/async_client.py +91 -0
- clientcraft/backends/__init__.py +55 -0
- clientcraft/backends/_protocols.py +119 -0
- clientcraft/backends/aiohttp.py +151 -0
- clientcraft/backends/httpx.py +251 -0
- clientcraft/backends/requests.py +144 -0
- clientcraft/backends/urllib.py +166 -0
- clientcraft/client.py +90 -0
- clientcraft/py.typed +0 -0
- clientcraft-0.1.0.dist-info/METADATA +181 -0
- clientcraft-0.1.0.dist-info/RECORD +19 -0
- clientcraft-0.1.0.dist-info/WHEEL +4 -0
- clientcraft-0.1.0.dist-info/licenses/LICENSE +21 -0
clientcraft/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
|
|
5
|
+
# Base utilities (useful for custom backends or extensions)
|
|
6
|
+
from ._base import HttpError, PreparedRequest, prepare_request
|
|
7
|
+
|
|
8
|
+
# Endpoint types (sync)
|
|
9
|
+
# Endpoint types (async) - same runtime behavior, different type stubs
|
|
10
|
+
# Extraction utility
|
|
11
|
+
from ._endpoints import (
|
|
12
|
+
AsyncDelete,
|
|
13
|
+
AsyncGet,
|
|
14
|
+
AsyncPatch,
|
|
15
|
+
AsyncPost,
|
|
16
|
+
AsyncPut,
|
|
17
|
+
Delete,
|
|
18
|
+
Get,
|
|
19
|
+
Patch,
|
|
20
|
+
Post,
|
|
21
|
+
Put,
|
|
22
|
+
extract_endpoint_info,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Response wrappers
|
|
26
|
+
from ._responses import BytesResponse, TextResponse
|
|
27
|
+
|
|
28
|
+
# Core types
|
|
29
|
+
from ._types import EndpointInfo, ExtractedEndpoint, RequestStyle, ResponseStyle
|
|
30
|
+
|
|
31
|
+
# Clients
|
|
32
|
+
from .async_client import AsyncAPIClient
|
|
33
|
+
|
|
34
|
+
# Backend protocols (for type annotations)
|
|
35
|
+
from .backends import AsyncHttpBackend, HttpBackend, HttpResponse
|
|
36
|
+
from .client import APIClient
|
|
37
|
+
|
|
38
|
+
__version__ = version("clientcraft")
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
# Version
|
|
42
|
+
"__version__",
|
|
43
|
+
# Core types
|
|
44
|
+
"EndpointInfo",
|
|
45
|
+
"ExtractedEndpoint",
|
|
46
|
+
"RequestStyle",
|
|
47
|
+
"ResponseStyle",
|
|
48
|
+
# Response wrappers
|
|
49
|
+
"BytesResponse",
|
|
50
|
+
"TextResponse",
|
|
51
|
+
# Sync endpoint types
|
|
52
|
+
"Delete",
|
|
53
|
+
"Get",
|
|
54
|
+
"Patch",
|
|
55
|
+
"Post",
|
|
56
|
+
"Put",
|
|
57
|
+
# Async endpoint types
|
|
58
|
+
"AsyncDelete",
|
|
59
|
+
"AsyncGet",
|
|
60
|
+
"AsyncPatch",
|
|
61
|
+
"AsyncPost",
|
|
62
|
+
"AsyncPut",
|
|
63
|
+
# Utilities
|
|
64
|
+
"extract_endpoint_info",
|
|
65
|
+
"prepare_request",
|
|
66
|
+
# Errors
|
|
67
|
+
"HttpError",
|
|
68
|
+
"PreparedRequest",
|
|
69
|
+
# Clients
|
|
70
|
+
"APIClient",
|
|
71
|
+
"AsyncAPIClient",
|
|
72
|
+
# Backend protocols
|
|
73
|
+
"HttpBackend",
|
|
74
|
+
"AsyncHttpBackend",
|
|
75
|
+
"HttpResponse",
|
|
76
|
+
]
|
clientcraft/__init__.pyi
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from collections.abc import Coroutine
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from ._base import HttpError as HttpError
|
|
7
|
+
from ._base import PreparedRequest as PreparedRequest
|
|
8
|
+
from ._base import prepare_request as prepare_request
|
|
9
|
+
from ._responses import BytesResponse as BytesResponse
|
|
10
|
+
from ._responses import TextResponse as TextResponse
|
|
11
|
+
from ._types import EndpointInfo as EndpointInfo
|
|
12
|
+
from ._types import ExtractedEndpoint as ExtractedEndpoint
|
|
13
|
+
from ._types import RequestStyle as RequestStyle
|
|
14
|
+
from ._types import ResponseStyle as ResponseStyle
|
|
15
|
+
from .async_client import AsyncAPIClient as AsyncAPIClient
|
|
16
|
+
from .backends import AsyncHttpBackend as AsyncHttpBackend
|
|
17
|
+
from .backends import HttpBackend as HttpBackend
|
|
18
|
+
from .backends import HttpResponse as HttpResponse
|
|
19
|
+
from .client import APIClient as APIClient
|
|
20
|
+
|
|
21
|
+
# Metaclass exposing runtime subscription: at runtime ``Get[Req, Resp, Path]`` is
|
|
22
|
+
# evaluated by ``_EndpointTypeMeta.__getitem__`` and returns an ``Annotated`` type.
|
|
23
|
+
# Declaring it on the base classes (subclasses inherit it) lets a value of type
|
|
24
|
+
# ``type[Get]`` be used as a subscriptable factory (e.g. in tests) without losing
|
|
25
|
+
# static typing on the generic-class annotation form.
|
|
26
|
+
class _EndpointTypeMeta(type):
|
|
27
|
+
def __getitem__(cls, params: tuple[type, type | None, object]) -> object: ...
|
|
28
|
+
|
|
29
|
+
class Endpoint[TRequest: BaseModel, TResponse: BaseModel | None](metaclass=_EndpointTypeMeta):
|
|
30
|
+
def __call__(self, request: TRequest) -> TResponse: ...
|
|
31
|
+
|
|
32
|
+
class Get[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](Endpoint[TRequest, TResponse]): ...
|
|
33
|
+
class Post[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](Endpoint[TRequest, TResponse]): ...
|
|
34
|
+
class Put[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](Endpoint[TRequest, TResponse]): ...
|
|
35
|
+
class Patch[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](Endpoint[TRequest, TResponse]): ...
|
|
36
|
+
class Delete[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](Endpoint[TRequest, TResponse]): ...
|
|
37
|
+
|
|
38
|
+
class AsyncEndpoint[TRequest: BaseModel, TResponse: BaseModel | None](metaclass=_EndpointTypeMeta):
|
|
39
|
+
def __call__(self, request: TRequest) -> Coroutine[Any, Any, TResponse]: ...
|
|
40
|
+
|
|
41
|
+
class AsyncGet[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](AsyncEndpoint[TRequest, TResponse]): ...
|
|
42
|
+
class AsyncPost[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](AsyncEndpoint[TRequest, TResponse]): ...
|
|
43
|
+
class AsyncPut[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](AsyncEndpoint[TRequest, TResponse]): ...
|
|
44
|
+
class AsyncPatch[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](AsyncEndpoint[TRequest, TResponse]): ...
|
|
45
|
+
class AsyncDelete[TRequest: BaseModel, TResponse: BaseModel | None, TPath: str](AsyncEndpoint[TRequest, TResponse]): ...
|
|
46
|
+
|
|
47
|
+
def extract_endpoint_info(hint: object) -> ExtractedEndpoint | None: ...
|
clientcraft/_base.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for sync and async API clients.
|
|
3
|
+
|
|
4
|
+
This module contains the common infrastructure that both sync and async clients share:
|
|
5
|
+
- Request/response handling utilities
|
|
6
|
+
- Generic base client class with __init_subclass__ processing
|
|
7
|
+
- Generic endpoint descriptor parameterized by bound endpoint type
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, get_type_hints
|
|
16
|
+
from urllib.parse import urlencode
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from ._endpoints import extract_endpoint_info
|
|
21
|
+
from ._responses import BytesResponse, TextResponse
|
|
22
|
+
from ._types import EndpointInfo, RequestStyle, ResponseStyle
|
|
23
|
+
from .backends import HttpResponse
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# HTTP Error
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HttpError(Exception):
|
|
31
|
+
"""HTTP error with status code and response content."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, status_code: int, content: bytes, headers: dict[str, str] | None = None) -> None:
|
|
34
|
+
self.status_code = status_code
|
|
35
|
+
self.content = content
|
|
36
|
+
self.headers = headers or {}
|
|
37
|
+
super().__init__(f"HTTP {status_code}: {content.decode('utf-8', errors='replace')}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Request Preparation
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PreparedRequest:
|
|
47
|
+
"""
|
|
48
|
+
A fully prepared HTTP request ready to be sent.
|
|
49
|
+
|
|
50
|
+
Separating preparation from execution enables:
|
|
51
|
+
- Easy testing (inspect the request before sending)
|
|
52
|
+
- Middleware/interceptors
|
|
53
|
+
- Retries with the same prepared request
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
method: str
|
|
57
|
+
url: str
|
|
58
|
+
content: bytes | None
|
|
59
|
+
headers: dict[str, str]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_path_params(path_template: str) -> set[str]:
|
|
63
|
+
"""Extract parameter names from a path template like '/users/{user_id}'."""
|
|
64
|
+
return set(re.findall(r"\{(\w+)\}", path_template))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def serialize_query_value(value: object) -> str:
|
|
68
|
+
"""Serialize a value for use in query string."""
|
|
69
|
+
if isinstance(value, bool):
|
|
70
|
+
return "true" if value else "false"
|
|
71
|
+
if isinstance(value, list):
|
|
72
|
+
return ",".join(str(item) for item in value)
|
|
73
|
+
return str(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def prepare_request(
|
|
77
|
+
request: BaseModel,
|
|
78
|
+
endpoint_info: EndpointInfo,
|
|
79
|
+
path_params: set[str],
|
|
80
|
+
base_url: str,
|
|
81
|
+
default_headers: dict[str, str],
|
|
82
|
+
) -> PreparedRequest:
|
|
83
|
+
"""Build a PreparedRequest from the endpoint info and request data."""
|
|
84
|
+
request_dict = request.model_dump(exclude_none=True)
|
|
85
|
+
|
|
86
|
+
# Build URL with path interpolation
|
|
87
|
+
url_path = endpoint_info.path
|
|
88
|
+
for param in path_params:
|
|
89
|
+
if param not in request_dict:
|
|
90
|
+
raise ValueError(f"Path parameter '{param}' not found in request")
|
|
91
|
+
url_path = url_path.replace(f"{{{param}}}", str(request_dict[param]))
|
|
92
|
+
|
|
93
|
+
url = f"{base_url.rstrip('/')}/{url_path.lstrip('/')}"
|
|
94
|
+
|
|
95
|
+
# Serialize based on request_style (from endpoint metadata, not HTTP method)
|
|
96
|
+
content: bytes | None = None
|
|
97
|
+
|
|
98
|
+
if endpoint_info.request_style == RequestStyle.QUERY:
|
|
99
|
+
# Add non-path params to query string
|
|
100
|
+
query_params = {k: serialize_query_value(v) for k, v in request_dict.items() if k not in path_params}
|
|
101
|
+
if query_params:
|
|
102
|
+
url = f"{url}?{urlencode(query_params)}"
|
|
103
|
+
|
|
104
|
+
elif endpoint_info.request_style == RequestStyle.BODY:
|
|
105
|
+
# Serialize non-path params to JSON body
|
|
106
|
+
body_dict = {k: v for k, v in request_dict.items() if k not in path_params}
|
|
107
|
+
if body_dict:
|
|
108
|
+
content = json.dumps(body_dict).encode("utf-8")
|
|
109
|
+
|
|
110
|
+
headers = {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"Accept": "application/json",
|
|
113
|
+
**default_headers,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return PreparedRequest(
|
|
117
|
+
method=endpoint_info.method.name,
|
|
118
|
+
url=url,
|
|
119
|
+
content=content,
|
|
120
|
+
headers=headers,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_response(
|
|
125
|
+
response: HttpResponse,
|
|
126
|
+
endpoint_info: EndpointInfo,
|
|
127
|
+
response_type: type[BaseModel] | None,
|
|
128
|
+
) -> BaseModel | dict[str, Any] | list[Any] | None:
|
|
129
|
+
"""Parse response based on response_style."""
|
|
130
|
+
style = endpoint_info.response_style
|
|
131
|
+
|
|
132
|
+
if style == ResponseStyle.NONE:
|
|
133
|
+
# No body expected - return None
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
if style == ResponseStyle.TEXT:
|
|
137
|
+
# Return text content
|
|
138
|
+
return TextResponse(content=response.content.decode("utf-8"))
|
|
139
|
+
|
|
140
|
+
if style == ResponseStyle.BYTES:
|
|
141
|
+
# Return raw bytes
|
|
142
|
+
return BytesResponse(content=response.content)
|
|
143
|
+
|
|
144
|
+
# Default: JSON
|
|
145
|
+
if response_type is not None:
|
|
146
|
+
# Handle empty body gracefully for JSON
|
|
147
|
+
if not response.content or response.content.strip() == b"":
|
|
148
|
+
# If response type has no required fields, return empty instance
|
|
149
|
+
# Otherwise this will raise a validation error (which is correct)
|
|
150
|
+
return response_type.model_validate({})
|
|
151
|
+
return response_type.model_validate_json(response.content)
|
|
152
|
+
|
|
153
|
+
# No response type specified - return raw dict/list
|
|
154
|
+
if not response.content or response.content.strip() == b"":
|
|
155
|
+
return None
|
|
156
|
+
result: dict[str, Any] | list[Any] = json.loads(response.content)
|
|
157
|
+
return result
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Generic Base Client
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class BaseAPIClient[Backend]:
|
|
166
|
+
"""
|
|
167
|
+
Generic base class for declarative API clients.
|
|
168
|
+
|
|
169
|
+
This handles all the shared logic for both sync and async clients:
|
|
170
|
+
- __init_subclass__ processing of type hints
|
|
171
|
+
- __init__ with base_url, backend, headers, timeout
|
|
172
|
+
|
|
173
|
+
The Backend type parameter allows subclasses to specify their backend type.
|
|
174
|
+
Concrete subclasses must define `_descriptor_factory` to create the appropriate
|
|
175
|
+
descriptor type (sync or async).
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
# Subclasses must set this to their descriptor factory
|
|
179
|
+
_descriptor_factory: type[EndpointDescriptor[Any]]
|
|
180
|
+
|
|
181
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
182
|
+
"""Process Annotated type hints and create endpoint descriptors."""
|
|
183
|
+
super().__init_subclass__(**kwargs)
|
|
184
|
+
|
|
185
|
+
# Skip processing if this is still an abstract base
|
|
186
|
+
if not hasattr(cls, "_descriptor_factory"):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
hints = get_type_hints(cls, include_extras=True)
|
|
191
|
+
except Exception:
|
|
192
|
+
hints = {}
|
|
193
|
+
|
|
194
|
+
for name, hint in hints.items():
|
|
195
|
+
extracted = extract_endpoint_info(hint)
|
|
196
|
+
|
|
197
|
+
if extracted is not None:
|
|
198
|
+
descriptor = cls._descriptor_factory(
|
|
199
|
+
endpoint_info=extracted.info,
|
|
200
|
+
request_type=extracted.request_type,
|
|
201
|
+
response_type=extracted.response_type,
|
|
202
|
+
)
|
|
203
|
+
setattr(cls, name, descriptor)
|
|
204
|
+
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
base_url: str,
|
|
208
|
+
backend: Backend,
|
|
209
|
+
*,
|
|
210
|
+
default_headers: dict[str, str] | None = None,
|
|
211
|
+
default_timeout: float | None = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._base_url = base_url.rstrip("/")
|
|
214
|
+
self._backend = backend
|
|
215
|
+
self._default_headers = default_headers or {}
|
|
216
|
+
self._default_timeout = default_timeout
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
# Generic Endpoint Descriptor
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class EndpointDescriptor[ClientT: BaseAPIClient[Any]]:
|
|
226
|
+
"""
|
|
227
|
+
Generic descriptor that creates bound endpoint callables.
|
|
228
|
+
|
|
229
|
+
Subclasses override `_create_bound_endpoint` to return the appropriate
|
|
230
|
+
sync or async bound endpoint type.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
endpoint_info: EndpointInfo
|
|
234
|
+
request_type: type[BaseModel] | None
|
|
235
|
+
response_type: type[BaseModel] | None
|
|
236
|
+
path_params: set[str] = field(init=False)
|
|
237
|
+
|
|
238
|
+
def __post_init__(self) -> None:
|
|
239
|
+
self.path_params = extract_path_params(self.endpoint_info.path)
|
|
240
|
+
|
|
241
|
+
def __get__(self, obj: ClientT | None, objtype: type) -> Any:
|
|
242
|
+
if obj is None:
|
|
243
|
+
return self
|
|
244
|
+
return self._create_bound_endpoint(obj)
|
|
245
|
+
|
|
246
|
+
def _create_bound_endpoint(self, client: ClientT) -> Any:
|
|
247
|
+
"""Create a bound endpoint for the given client. Override in subclasses."""
|
|
248
|
+
raise NotImplementedError("Subclasses must implement _create_bound_endpoint")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Base Bound Endpoint
|
|
253
|
+
# ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
# from abc import ABC, abstractmethod
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# class ParseStrategy[T](ABC):
|
|
259
|
+
# def __init__(self, response_type: type[T]) -> None:
|
|
260
|
+
# self.response_type: type[T] = response_type
|
|
261
|
+
|
|
262
|
+
# @abstractmethod
|
|
263
|
+
# def parse(self, response: HttpResponse) -> T:
|
|
264
|
+
# raise NotImplementedError
|
|
265
|
+
|
|
266
|
+
# class ParsePydanticModel[T: BaseModel](ParseStrategy[T]):
|
|
267
|
+
# def parse(self, response: HttpResponse) -> T:
|
|
268
|
+
# data = response.content
|
|
269
|
+
# return self.response_type.model_validate(data)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class BaseBoundEndpoint[ClientT: BaseAPIClient[Any]]:
|
|
274
|
+
"""
|
|
275
|
+
Base class for bound endpoints with shared preparation logic.
|
|
276
|
+
|
|
277
|
+
Subclasses implement __call__ (sync) or __call__ as async.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
client: ClientT
|
|
281
|
+
endpoint_info: EndpointInfo
|
|
282
|
+
response_type: type[BaseModel] | None
|
|
283
|
+
path_params: set[str]
|
|
284
|
+
# parse_strategy: ParseStrategy
|
|
285
|
+
|
|
286
|
+
def _prepare(self, request: BaseModel) -> PreparedRequest:
|
|
287
|
+
"""Build a PreparedRequest from the endpoint info and request data."""
|
|
288
|
+
return prepare_request(
|
|
289
|
+
request=request,
|
|
290
|
+
endpoint_info=self.endpoint_info,
|
|
291
|
+
path_params=self.path_params,
|
|
292
|
+
base_url=self.client._base_url,
|
|
293
|
+
default_headers=self.client._default_headers,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _handle_response(self, response: HttpResponse) -> BaseModel | dict[str, Any] | list[Any] | None:
|
|
297
|
+
"""Handle response: raise on error, parse on success."""
|
|
298
|
+
if response.status_code >= 400:
|
|
299
|
+
raise HttpError(response.status_code, response.content, dict(response.headers))
|
|
300
|
+
return parse_response(response, self.endpoint_info, self.response_type)
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Endpoint type construction.
|
|
3
|
+
|
|
4
|
+
This module defines the endpoint types (Get, Post, Put, Patch, Delete) and their
|
|
5
|
+
async variants (AsyncGet, AsyncPost, etc.) using a metaclass to transform
|
|
6
|
+
`Get[Request, Response, Literal["/path"]]` into an Annotated type with EndpointInfo.
|
|
7
|
+
|
|
8
|
+
The type stubs (.pyi files) tell type checkers what the return types are:
|
|
9
|
+
- Sync types (Get, Post, etc.) return TResponse directly
|
|
10
|
+
- Async types (AsyncGet, AsyncPost, etc.) return Coroutine[Any, Any, TResponse]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from http import HTTPMethod
|
|
16
|
+
from typing import Annotated, Any, Literal, get_args, get_origin
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel
|
|
19
|
+
|
|
20
|
+
from ._responses import BytesResponse, TextResponse
|
|
21
|
+
from ._types import EndpointInfo, ExtractedEndpoint, RequestStyle, ResponseStyle
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_response_style(response_type: type | None) -> ResponseStyle:
|
|
25
|
+
"""Determine response style based on response type."""
|
|
26
|
+
if response_type is None or response_type is type(None):
|
|
27
|
+
return ResponseStyle.NONE
|
|
28
|
+
if response_type is TextResponse:
|
|
29
|
+
return ResponseStyle.TEXT
|
|
30
|
+
if response_type is BytesResponse:
|
|
31
|
+
return ResponseStyle.BYTES
|
|
32
|
+
return ResponseStyle.JSON
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_literal(t: Any) -> str | None:
|
|
36
|
+
"""Extract string value from a Literal type."""
|
|
37
|
+
if get_origin(t) is Literal:
|
|
38
|
+
args = get_args(t)
|
|
39
|
+
if args and isinstance(args[0], str):
|
|
40
|
+
return args[0]
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Marker type for endpoints
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Endpoint[TRequest: BaseModel, TResponse: BaseModel]:
|
|
50
|
+
"""
|
|
51
|
+
Marker type for endpoints.
|
|
52
|
+
|
|
53
|
+
This class is never instantiated - it only exists to carry type parameters.
|
|
54
|
+
The actual callable behavior comes from the descriptor created by APIClient.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __call__(self, request: TRequest) -> TResponse:
|
|
58
|
+
"""Type signature for the endpoint call."""
|
|
59
|
+
raise NotImplementedError("Endpoint should be used via APIClient")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Metaclass for endpoint type construction
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _EndpointTypeMeta(type):
|
|
68
|
+
"""Metaclass that makes Get[Req, Resp, Literal["/path"]] return an Annotated type."""
|
|
69
|
+
|
|
70
|
+
_method: HTTPMethod
|
|
71
|
+
_request_style: RequestStyle
|
|
72
|
+
|
|
73
|
+
def __getitem__(cls, params: tuple[type, type, Any]) -> Any:
|
|
74
|
+
if not isinstance(params, tuple) or len(params) != 3:
|
|
75
|
+
raise TypeError(f"{cls.__name__} requires 3 type parameters: [Request, Response, Path]")
|
|
76
|
+
|
|
77
|
+
request_type, response_type, path_type = params
|
|
78
|
+
|
|
79
|
+
# Extract path from Literal
|
|
80
|
+
path = _extract_literal(path_type)
|
|
81
|
+
if path is None and isinstance(path_type, str):
|
|
82
|
+
path = path_type
|
|
83
|
+
|
|
84
|
+
if path is None:
|
|
85
|
+
raise TypeError(f"Path must be a Literal string, got {path_type}")
|
|
86
|
+
|
|
87
|
+
# Determine response style from response type
|
|
88
|
+
response_style = _get_response_style(response_type)
|
|
89
|
+
|
|
90
|
+
# Return Annotated type with endpoint info
|
|
91
|
+
# Type checkers can't understand runtime type construction - we use .pyi stubs instead
|
|
92
|
+
return Annotated[
|
|
93
|
+
Endpoint[request_type, response_type], # type: ignore[valid-type]
|
|
94
|
+
EndpointInfo(
|
|
95
|
+
method=cls._method,
|
|
96
|
+
path=path,
|
|
97
|
+
request_style=cls._request_style,
|
|
98
|
+
response_style=response_style,
|
|
99
|
+
),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Sync endpoint types
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Get(metaclass=_EndpointTypeMeta):
|
|
109
|
+
"""GET endpoint type - sends data as query parameters."""
|
|
110
|
+
|
|
111
|
+
_method = HTTPMethod.GET
|
|
112
|
+
_request_style = RequestStyle.QUERY
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Post(metaclass=_EndpointTypeMeta):
|
|
116
|
+
"""POST endpoint type - sends data as JSON body."""
|
|
117
|
+
|
|
118
|
+
_method = HTTPMethod.POST
|
|
119
|
+
_request_style = RequestStyle.BODY
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Put(metaclass=_EndpointTypeMeta):
|
|
123
|
+
"""PUT endpoint type - sends data as JSON body."""
|
|
124
|
+
|
|
125
|
+
_method = HTTPMethod.PUT
|
|
126
|
+
_request_style = RequestStyle.BODY
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Patch(metaclass=_EndpointTypeMeta):
|
|
130
|
+
"""PATCH endpoint type - sends data as JSON body."""
|
|
131
|
+
|
|
132
|
+
_method = HTTPMethod.PATCH
|
|
133
|
+
_request_style = RequestStyle.BODY
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Delete(metaclass=_EndpointTypeMeta):
|
|
137
|
+
"""DELETE endpoint type - sends data as query parameters."""
|
|
138
|
+
|
|
139
|
+
_method = HTTPMethod.DELETE
|
|
140
|
+
_request_style = RequestStyle.QUERY
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Async endpoint types
|
|
145
|
+
#
|
|
146
|
+
# At runtime, these behave identically to the sync versions.
|
|
147
|
+
# The type stubs (.pyi) tell type checkers that these return Coroutine.
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
AsyncGet = Get
|
|
151
|
+
AsyncPost = Post
|
|
152
|
+
AsyncPut = Put
|
|
153
|
+
AsyncPatch = Patch
|
|
154
|
+
AsyncDelete = Delete
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Extraction utility
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract_endpoint_info(hint: Any) -> ExtractedEndpoint | None:
|
|
163
|
+
"""
|
|
164
|
+
Extract endpoint info from an Annotated type hint.
|
|
165
|
+
|
|
166
|
+
Returns ExtractedEndpoint if the hint is a valid endpoint type, None otherwise.
|
|
167
|
+
"""
|
|
168
|
+
if get_origin(hint) is not Annotated:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
args = get_args(hint)
|
|
172
|
+
if len(args) < 2:
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
base_type, *annotations = args
|
|
176
|
+
base_args = get_args(base_type)
|
|
177
|
+
|
|
178
|
+
if len(base_args) < 2:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
request_type, response_type = base_args[0], base_args[1]
|
|
182
|
+
|
|
183
|
+
if not isinstance(request_type, type) or not isinstance(response_type, type):
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
endpoint_info = next((a for a in annotations if isinstance(a, EndpointInfo)), None)
|
|
187
|
+
if endpoint_info is None:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
return ExtractedEndpoint(
|
|
191
|
+
request_type=request_type,
|
|
192
|
+
response_type=response_type,
|
|
193
|
+
info=endpoint_info,
|
|
194
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Response wrapper types for non-JSON responses.
|
|
3
|
+
|
|
4
|
+
These Pydantic models wrap text and binary responses,
|
|
5
|
+
allowing the declarative API to handle all response types uniformly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TextResponse(BaseModel):
|
|
14
|
+
"""
|
|
15
|
+
Wrapper for text responses.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
get_health: Get[EmptyRequest, TextResponse, Literal["/health"]]
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
content: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BytesResponse(BaseModel):
|
|
25
|
+
"""
|
|
26
|
+
Wrapper for binary responses.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
download_file: Get[FileRequest, BytesResponse, Literal["/files/{id}"]]
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
content: bytes
|
clientcraft/_types.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core types for endpoint definitions.
|
|
3
|
+
|
|
4
|
+
This module contains the fundamental types used to describe endpoints:
|
|
5
|
+
- RequestStyle: How to serialize request data
|
|
6
|
+
- ResponseStyle: How to deserialize response data
|
|
7
|
+
- EndpointInfo: Metadata bundle stored in Annotated types
|
|
8
|
+
- ExtractedEndpoint: Result of extracting endpoint info from a type hint
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum, auto
|
|
15
|
+
from http import HTTPMethod
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RequestStyle(Enum):
|
|
23
|
+
"""How to serialize request data for the HTTP call."""
|
|
24
|
+
|
|
25
|
+
QUERY = auto() # Serialize to query string (GET, DELETE)
|
|
26
|
+
BODY = auto() # Serialize to JSON body (POST, PUT, PATCH)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ResponseStyle(Enum):
|
|
30
|
+
"""How to deserialize response data."""
|
|
31
|
+
|
|
32
|
+
JSON = auto() # Parse as JSON into Pydantic model (default)
|
|
33
|
+
TEXT = auto() # Return as string
|
|
34
|
+
BYTES = auto() # Return raw bytes
|
|
35
|
+
NONE = auto() # No response body expected (204 No Content, etc.)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class EndpointInfo:
|
|
40
|
+
"""Metadata about an endpoint, stored in Annotated type."""
|
|
41
|
+
|
|
42
|
+
method: HTTPMethod
|
|
43
|
+
path: str
|
|
44
|
+
request_style: RequestStyle
|
|
45
|
+
response_style: ResponseStyle
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ExtractedEndpoint:
|
|
50
|
+
"""Result of extracting endpoint info from a type hint."""
|
|
51
|
+
|
|
52
|
+
request_type: type[BaseModel]
|
|
53
|
+
response_type: type[BaseModel] | None
|
|
54
|
+
info: EndpointInfo
|