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.
@@ -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
+ ]
@@ -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