Skip to content

Commit 05bffac

Browse files
committed
NEW: support non-Pydantic arguments in Payload and FormData, resolves #77
1 parent 3d34a74 commit 05bffac

11 files changed

+268
-19
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ format: format/ruff
3232

3333
.PHONY: format/ruff
3434
format/ruff:
35-
poetry run ruff check --fix combadge tests
3635
poetry run ruff format combadge tests
36+
poetry run ruff check --fix combadge tests
3737

3838
.PHONY: test
3939
test:

combadge/support/http/abc/containers.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,4 @@ def append_form_field(self, name: str, value: Any) -> None: # noqa: D102
6767
class ContainsPayload(ABC):
6868
"""SOAP request payload."""
6969

70-
payload: Optional[dict] = None
71-
72-
def ensure_payload(self) -> dict:
73-
"""Ensure that the payload is initialized and return it."""
74-
if self.payload is None:
75-
self.payload = {}
76-
return self.payload
70+
payload: Optional[Any] = None

combadge/support/http/markers/request.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from inspect import BoundArguments
66
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar
77

8-
from pydantic import BaseModel
98
from typing_extensions import Annotated, TypeAlias, override
109

1110
from combadge.core.markers.method import MethodMarker
@@ -19,6 +18,7 @@
1918
ContainsQueryParams,
2019
ContainsUrlPath,
2120
)
21+
from combadge.support.shared.functools import get_type_adapter
2222

2323
_T = TypeVar("_T")
2424

@@ -144,8 +144,18 @@ class Payload(ParameterMarker[ContainsPayload]):
144144
by_alias: bool = False
145145

146146
@override
147-
def __call__(self, request: ContainsPayload, value: BaseModel) -> None: # noqa: D102
148-
request.ensure_payload().update(value.model_dump(by_alias=self.by_alias, exclude_unset=self.exclude_unset))
147+
def __call__(self, request: ContainsPayload, value: Any) -> None: # noqa: D102
148+
value = get_type_adapter(type(value)).dump_python(
149+
value,
150+
by_alias=self.by_alias,
151+
exclude_unset=self.exclude_unset,
152+
)
153+
if request.payload is None:
154+
request.payload = value
155+
elif isinstance(request.payload, dict):
156+
request.payload.update(value) # merge into the existing payload
157+
else:
158+
raise ValueError(f"attempting to merge {type(value)} into {type(request.payload)}")
149159

150160
def __class_getitem__(cls, item: type[Any]) -> Any:
151161
return Annotated[item, cls()]
@@ -178,7 +188,9 @@ class Field(ParameterMarker[ContainsPayload]):
178188

179189
@override
180190
def __call__(self, request: ContainsPayload, value: Any) -> None: # noqa: D102
181-
request.ensure_payload()[self.name] = value.value if isinstance(value, Enum) else value
191+
if request.payload is None:
192+
request.payload = {}
193+
request.payload[self.name] = value.value if isinstance(value, Enum) else value
182194

183195

184196
if not TYPE_CHECKING:
@@ -201,8 +213,11 @@ class FormData(ParameterMarker[ContainsFormData]):
201213
__slots__ = ()
202214

203215
@override
204-
def __call__(self, request: ContainsFormData, value: BaseModel) -> None: # noqa: D102
205-
for item_name, item_value in value.model_dump(by_alias=True).items():
216+
def __call__(self, request: ContainsFormData, value: Any) -> None: # noqa: D102
217+
value = get_type_adapter(type(value)).dump_python(value, by_alias=True)
218+
if not isinstance(value, dict):
219+
raise TypeError(f"form data requires a dictionary, got {type(value)}")
220+
for item_name, item_value in value.items():
206221
request.append_form_field(item_name, item_value)
207222

208223
def __class_getitem__(cls, item: type[Any]) -> Any:

combadge/support/shared/functools.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from functools import lru_cache
2+
from typing import Any
3+
4+
from pydantic import TypeAdapter
5+
6+
7+
@lru_cache(maxsize=None)
8+
def get_type_adapter(type_: Any) -> TypeAdapter[Any]:
9+
"""Get cached type adapter for the given type."""
10+
return TypeAdapter(type_)

docs/support/models.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Models
2+
3+
Combadge is built on top of [Pydantic](https://docs.pydantic.dev/), hence Pydantic models are natively supported in service protocols.
4+
5+
However, thanks to the Pydantic's [`TypeAdapter`](https://docs.pydantic.dev/latest/api/type_adapter/), Combadge automatically supports:
6+
7+
## Built-in Python types
8+
9+
```python title="builtin.py" hl_lines="12 17"
10+
from typing_extensions import Annotated, Protocol
11+
12+
from combadge.core.markers import Extract
13+
from combadge.support.httpx.backends.sync import HttpxBackend
14+
from combadge.support.http.markers import Payload, http_method, path
15+
from httpx import Client
16+
17+
18+
class Httpbin(Protocol):
19+
@http_method("POST")
20+
@path("/anything")
21+
def post_anything(self, foo: Payload[int]) -> Annotated[int, Extract("data")]:
22+
...
23+
24+
25+
backend = HttpxBackend(Client(base_url="https://httpbin.org"))
26+
assert backend[Httpbin].post_anything(42) == 42
27+
```
28+
29+
## Standard [dataclasses](https://docs.python.org/3/library/dataclasses.html)
30+
31+
```python title="dataclasses.py" hl_lines="10-12 15-17 23 28"
32+
from dataclasses import dataclass
33+
34+
from typing_extensions import Protocol
35+
36+
from combadge.support.httpx.backends.sync import HttpxBackend
37+
from combadge.support.http.markers import Payload, http_method, path
38+
from httpx import Client
39+
40+
41+
@dataclass
42+
class Request:
43+
foo: int
44+
45+
46+
@dataclass
47+
class Response:
48+
data: str
49+
50+
51+
class Httpbin(Protocol):
52+
@http_method("POST")
53+
@path("/anything")
54+
def post_anything(self, foo: Payload[Request]) -> Response:
55+
...
56+
57+
58+
backend = HttpxBackend(Client(base_url="https://httpbin.org"))
59+
assert backend[Httpbin].post_anything(Request(42)) == Response(data='{"foo": 42}')
60+
```
61+
62+
## [Typed dictionaries](https://docs.python.org/3/library/typing.html#typing.TypedDict)
63+
64+
```python title="typed_dict.py" hl_lines="8-9 12-13 19 24"
65+
from typing_extensions import Protocol, TypedDict
66+
67+
from combadge.support.httpx.backends.sync import HttpxBackend
68+
from combadge.support.http.markers import Payload, http_method, path
69+
from httpx import Client
70+
71+
72+
class Request(TypedDict):
73+
foo: int
74+
75+
76+
class Response(TypedDict):
77+
data: str
78+
79+
80+
class Httpbin(Protocol):
81+
@http_method("POST")
82+
@path("/anything")
83+
def post_anything(self, foo: Payload[Request]) -> Response:
84+
...
85+
86+
87+
backend = HttpxBackend(Client(base_url="https://httpbin.org"))
88+
assert backend[Httpbin].post_anything({"foo": 42}) == {"data": '{"foo": 42}'}
89+
```

mkdocs.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ nav:
1717
- Backends:
1818
- support/httpx.md
1919
- support/zeep.md
20+
- support/models.md
2021
- support/handling-errors.md
2122
- tags.md
2223
- Cookbook:
@@ -33,6 +34,7 @@ theme:
3334
- content.action.edit
3435
- content.code.annotate
3536
- content.code.copy
37+
- navigation.expand
3638
- navigation.footer
3739
- navigation.indexes
3840
- navigation.instant
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
interactions:
2+
- request:
3+
body: '42'
4+
headers:
5+
accept:
6+
- '*/*'
7+
accept-encoding:
8+
- gzip, deflate
9+
connection:
10+
- keep-alive
11+
content-length:
12+
- '2'
13+
content-type:
14+
- application/json
15+
host:
16+
- httpbin.org
17+
user-agent:
18+
- python-httpx/0.25.1
19+
method: POST
20+
uri: https://httpbin.org/anything
21+
response:
22+
content: "{\n \"args\": {}, \n \"data\": \"42\", \n \"files\": {}, \n \"form\":
23+
{}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\":
24+
\"gzip, deflate\", \n \"Content-Length\": \"2\", \n \"Content-Type\":
25+
\"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\":
26+
\"python-httpx/0.25.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-654e3c0b-67b7ec060f0795b446f2cac2\"\n
27+
\ }, \n \"json\": 42, \n \"method\": \"POST\", \n \"origin\": \"86.94.162.190\",
28+
\n \"url\": \"https://httpbin.org/anything\"\n}\n"
29+
headers:
30+
Access-Control-Allow-Credentials:
31+
- 'true'
32+
Access-Control-Allow-Origin:
33+
- '*'
34+
Connection:
35+
- keep-alive
36+
Content-Length:
37+
- '462'
38+
Content-Type:
39+
- application/json
40+
Date:
41+
- Fri, 10 Nov 2023 14:19:55 GMT
42+
Server:
43+
- gunicorn/19.9.0
44+
http_version: HTTP/1.1
45+
status_code: 200
46+
version: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
interactions:
2+
- request:
3+
body: '{"foo": 42}'
4+
headers:
5+
accept:
6+
- '*/*'
7+
accept-encoding:
8+
- gzip, deflate
9+
connection:
10+
- keep-alive
11+
content-length:
12+
- '11'
13+
content-type:
14+
- application/json
15+
host:
16+
- httpbin.org
17+
user-agent:
18+
- python-httpx/0.25.1
19+
method: POST
20+
uri: https://httpbin.org/anything
21+
response:
22+
content: "{\n \"args\": {}, \n \"data\": \"{\\\"foo\\\": 42}\", \n \"files\":
23+
{}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\":
24+
\"gzip, deflate\", \n \"Content-Length\": \"11\", \n \"Content-Type\":
25+
\"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\":
26+
\"python-httpx/0.25.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-654e3e5b-7a9b143d4b620fdc7f26dca5\"\n
27+
\ }, \n \"json\": {\n \"foo\": 42\n }, \n \"method\": \"POST\", \n \"origin\":
28+
\"86.94.162.190\", \n \"url\": \"https://httpbin.org/anything\"\n}\n"
29+
headers:
30+
Access-Control-Allow-Credentials:
31+
- 'true'
32+
Access-Control-Allow-Origin:
33+
- '*'
34+
Connection:
35+
- keep-alive
36+
Content-Length:
37+
- '491'
38+
Content-Type:
39+
- application/json
40+
Date:
41+
- Fri, 10 Nov 2023 14:29:47 GMT
42+
Server:
43+
- gunicorn/19.9.0
44+
http_version: HTTP/1.1
45+
status_code: 200
46+
version: 1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
interactions:
2+
- request:
3+
body: '{"foo": 42}'
4+
headers:
5+
accept:
6+
- '*/*'
7+
accept-encoding:
8+
- gzip, deflate
9+
connection:
10+
- keep-alive
11+
content-length:
12+
- '11'
13+
content-type:
14+
- application/json
15+
host:
16+
- httpbin.org
17+
user-agent:
18+
- python-httpx/0.25.1
19+
method: POST
20+
uri: https://httpbin.org/anything
21+
response:
22+
content: "{\n \"args\": {}, \n \"data\": \"{\\\"foo\\\": 42}\", \n \"files\":
23+
{}, \n \"form\": {}, \n \"headers\": {\n \"Accept\": \"*/*\", \n \"Accept-Encoding\":
24+
\"gzip, deflate\", \n \"Content-Length\": \"11\", \n \"Content-Type\":
25+
\"application/json\", \n \"Host\": \"httpbin.org\", \n \"User-Agent\":
26+
\"python-httpx/0.25.1\", \n \"X-Amzn-Trace-Id\": \"Root=1-654e3f28-6dc22fa87c99307a215093d4\"\n
27+
\ }, \n \"json\": {\n \"foo\": 42\n }, \n \"method\": \"POST\", \n \"origin\":
28+
\"86.94.162.190\", \n \"url\": \"https://httpbin.org/anything\"\n}\n"
29+
headers:
30+
Access-Control-Allow-Credentials:
31+
- 'true'
32+
Access-Control-Allow-Origin:
33+
- '*'
34+
Connection:
35+
- keep-alive
36+
Content-Length:
37+
- '491'
38+
Content-Type:
39+
- application/json
40+
Date:
41+
- Fri, 10 Nov 2023 14:33:12 GMT
42+
Server:
43+
- gunicorn/19.9.0
44+
http_version: HTTP/1.1
45+
status_code: 200
46+
version: 1

tests/integration/test_docs.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ def _generate_params(path: Path) -> Iterator[NamedTuple]:
2727
)
2828
@pytest.mark.vcr(decode_compressed_response=True)
2929
def test_documentation_snippet(snippet: str) -> None:
30+
__tracebackhide__ = True
3031
exec(dedent(snippet), {})

tests/integration/test_number_conversion.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class NumberTooLargeResponse(RootModel, ErrorResponse):
2929
root: Literal["number too large"]
3030

3131

32-
class TestFault(BaseSoapFault):
32+
class _TestFault(BaseSoapFault):
3333
code: Literal["SOAP-ENV:Server"]
3434
message: Literal["Test Fault"]
3535

@@ -40,7 +40,7 @@ class SupportsNumberConversion(SupportsService, Protocol):
4040
def number_to_words(
4141
self,
4242
request: Annotated[NumberToWordsRequest, Payload(by_alias=True)],
43-
) -> Union[NumberTooLargeResponse, NumberToWordsResponse, TestFault]:
43+
) -> Union[NumberTooLargeResponse, NumberToWordsResponse, _TestFault]:
4444
raise NotImplementedError
4545

4646

@@ -50,7 +50,7 @@ class SupportsNumberConversionAsync(SupportsService, Protocol):
5050
async def number_to_words(
5151
self,
5252
request: Annotated[NumberToWordsRequest, Payload(by_alias=True)],
53-
) -> Union[NumberTooLargeResponse, NumberToWordsResponse, TestFault]:
53+
) -> Union[NumberTooLargeResponse, NumberToWordsResponse, _TestFault]:
5454
raise NotImplementedError
5555

5656

@@ -91,14 +91,14 @@ def test_sad_path_scalar_response(number_conversion_service: SupportsNumberConve
9191
def test_sad_path_web_fault(number_conversion_service: SupportsNumberConversion) -> None:
9292
# Note: the cassette is manually patched to return the SOAP fault.
9393
response = number_conversion_service.number_to_words(NumberToWordsRequest(number=42))
94-
with pytest.raises(TestFault.Error):
94+
with pytest.raises(_TestFault.Error):
9595
response.raise_for_result()
9696

9797

9898
@pytest.mark.vcr()
9999
async def test_happy_path_scalar_response_async(number_conversion_service_async: SupportsNumberConversionAsync) -> None:
100100
response = await number_conversion_service_async.number_to_words(NumberToWordsRequest(number=42))
101-
assert_type(response, Union[NumberToWordsResponse, NumberTooLargeResponse, TestFault])
101+
assert_type(response, Union[NumberToWordsResponse, NumberTooLargeResponse, _TestFault])
102102

103103
response = response.unwrap()
104104
assert_type(response, NumberToWordsResponse)

0 commit comments

Comments
 (0)