Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 8de75d9

Browse files
committedNov 5, 2024·
add option to use dataclasses instead of attrs
1 parent 40d63f9 commit 8de75d9

File tree

26 files changed

+798
-38
lines changed

26 files changed

+798
-38
lines changed
 

‎.changeset/use_dataclasses.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
default: minor
3+
---
4+
5+
# Add `use_dataclasses` config setting
6+
7+
Instead of using the `attrs` package in the generated code, you can choose to use the built-in `dataclasses` module by setting `use_dataclasses: true` in your config file. This may be useful if you are trying to reduce external dependencies, or if your client package might be used in applications that require different versions of `attrs`.
8+
9+
The generated client code should behave exactly the same from an application's point of view except for the following differences:
10+
11+
- The generated project file does not have an `attrs` dependency.
12+
- If you were using `attrs.evolve` to create an updated instance of a model class, you should use `dataclasses.replace` instead.
13+
- Undocumented attributes of the `Client` class that had an underscore prefix in their names are no longer available.
14+
- The builder methods `with_cookies`, `with_headers`, and `with_timeout` do _not_ modify any Httpx client that may have been created from the previous Client instance; they affect only the new instance.

‎CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ If you think that some of the added code is not testable (or testing it would ad
5858

5959
This project aims to have all "happy paths" (types of code which _can_ be generated) covered by end to end tests (snapshot tests). In order to check code changes against the previous set of snapshots (called a "golden record" here), you can run `pdm e2e`. To regenerate the snapshots, run `pdm regen`.
6060

61-
There are 4 types of snapshots generated right now, you may have to update only some or all of these depending on the changes you're making. Within the `end_to_end_tets` directory:
61+
There are 4 types of snapshots generated right now, you may have to update only some or all of these depending on the changes you're making. Within the `end_to_end_tests` directory:
6262

6363
1. `baseline_openapi_3.0.json` creates `golden-record` for testing OpenAPI 3.0 features
6464
2. `baseline_openapi_3.1.yaml` is checked against `golden-record` for testing OpenAPI 3.1 features (and ensuring consistency with 3.0)

‎README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ post_hooks:
150150
- "ruff format ."
151151
```
152152

153+
### use_dataclasses
154+
155+
By default, `openapi-python-client` uses the `attrs` package when generating model classes (and the `Client` class). Setting `use_dataclasses` to `true` causes it to use the built-in `dataclasses` module instead. This may be useful if you are trying to reduce external dependencies, or if your client package might be used in applications that require different versions of `attrs`.
156+
157+
The generated client code should behave exactly the same from an application's point of view except for the following differences:
158+
159+
- The generated project file does not have an `attrs` dependency.
160+
- If you were using `attrs.evolve` to create an updated instance of a model class, you should use `dataclasses.replace` instead.
161+
- Undocumented attributes of the `Client` class that had an underscore prefix in their names are no longer available.
162+
- The builder methods `with_cookies`, `with_headers`, and `with_timeout` do _not_ modify any Httpx client that may have been created from the previous Client instance; they affect only the new instance.
163+
153164
### use_path_prefixes_for_title_model_names
154165

155166
By default, `openapi-python-client` generates class names which include the full path to the schema, including any parent-types. This can result in very long class names like `MyRouteSomeClassAnotherClassResponse`—which is very unique and unlikely to cause conflicts with future API additions, but also super verbose.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
use_dataclasses: true
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
__pycache__/
2+
build/
3+
dist/
4+
*.egg-info/
5+
.pytest_cache/
6+
7+
# pyenv
8+
.python-version
9+
10+
# Environments
11+
.env
12+
.venv
13+
14+
# mypy
15+
.mypy_cache/
16+
.dmypy.json
17+
dmypy.json
18+
19+
# JetBrains
20+
.idea/
21+
22+
/coverage.xml
23+
/.coverage
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# my-dataclasses-api-client
2+
A client library for accessing My Dataclasses API
3+
4+
## Usage
5+
First, create a client:
6+
7+
```python
8+
from my_dataclasses_api_client import Client
9+
10+
client = Client(base_url="https://api.example.com")
11+
```
12+
13+
If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead:
14+
15+
```python
16+
from my_dataclasses_api_client import AuthenticatedClient
17+
18+
client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken")
19+
```
20+
21+
Now call your endpoint and use your models:
22+
23+
```python
24+
from my_dataclasses_api_client.models import MyDataModel
25+
from my_dataclasses_api_client.api.my_tag import get_my_data_model
26+
from my_dataclasses_api_client.types import Response
27+
28+
with client as client:
29+
my_data: MyDataModel = get_my_data_model.sync(client=client)
30+
# or if you need more info (e.g. status_code)
31+
response: Response[MyDataModel] = get_my_data_model.sync_detailed(client=client)
32+
```
33+
34+
Or do the same thing with an async version:
35+
36+
```python
37+
from my_dataclasses_api_client.models import MyDataModel
38+
from my_dataclasses_api_client.api.my_tag import get_my_data_model
39+
from my_dataclasses_api_client.types import Response
40+
41+
async with client as client:
42+
my_data: MyDataModel = await get_my_data_model.asyncio(client=client)
43+
response: Response[MyDataModel] = await get_my_data_model.asyncio_detailed(client=client)
44+
```
45+
46+
By default, when you're calling an HTTPS API it will attempt to verify that SSL is working correctly. Using certificate verification is highly recommended most of the time, but sometimes you may need to authenticate to a server (especially an internal server) using a custom certificate bundle.
47+
48+
```python
49+
client = AuthenticatedClient(
50+
base_url="https://internal_api.example.com",
51+
token="SuperSecretToken",
52+
verify_ssl="/path/to/certificate_bundle.pem",
53+
)
54+
```
55+
56+
You can also disable certificate validation altogether, but beware that **this is a security risk**.
57+
58+
```python
59+
client = AuthenticatedClient(
60+
base_url="https://internal_api.example.com",
61+
token="SuperSecretToken",
62+
verify_ssl=False
63+
)
64+
```
65+
66+
Things to know:
67+
1. Every path/method combo becomes a Python module with four functions:
68+
1. `sync`: Blocking request that returns parsed data (if successful) or `None`
69+
1. `sync_detailed`: Blocking request that always returns a `Request`, optionally with `parsed` set if the request was successful.
70+
1. `asyncio`: Like `sync` but async instead of blocking
71+
1. `asyncio_detailed`: Like `sync_detailed` but async instead of blocking
72+
73+
1. All path/query params, and bodies become method arguments.
74+
1. If your endpoint had any tags on it, the first tag will be used as a module name for the function (my_tag above)
75+
1. Any endpoint which did not have a tag will be in `my_dataclasses_api_client.api.default`
76+
77+
## Advanced customizations
78+
79+
There are more settings on the generated `Client` class which let you control more runtime behavior, check out the docstring on that class for more info. You can also customize the underlying `httpx.Client` or `httpx.AsyncClient` (depending on your use-case):
80+
81+
```python
82+
from my_dataclasses_api_client import Client
83+
84+
def log_request(request):
85+
print(f"Request event hook: {request.method} {request.url} - Waiting for response")
86+
87+
def log_response(response):
88+
request = response.request
89+
print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}")
90+
91+
client = Client(
92+
base_url="https://api.example.com",
93+
httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}},
94+
)
95+
96+
# Or get the underlying httpx client to modify directly with client.get_httpx_client() or client.get_async_httpx_client()
97+
```
98+
99+
You can even set the httpx client directly, but beware that this will override any existing settings (e.g., base_url):
100+
101+
```python
102+
import httpx
103+
from my_dataclasses_api_client import Client
104+
105+
client = Client(
106+
base_url="https://api.example.com",
107+
)
108+
# Note that base_url needs to be re-set, as would any shared cookies, headers, etc.
109+
client.set_httpx_client(httpx.Client(base_url="https://api.example.com", proxies="http://localhost:8030"))
110+
```
111+
112+
## Building / publishing this package
113+
This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics:
114+
1. Update the metadata in pyproject.toml (e.g. authors, version)
115+
1. If you're using a private repository, configure it with Poetry
116+
1. `poetry config repositories.<your-repository-name> <url-to-your-repository>`
117+
1. `poetry config http-basic.<your-repository-name> <username> <password>`
118+
1. Publish the client with `poetry publish --build -r <your-repository-name>` or, if for public PyPI, just `poetry publish --build`
119+
120+
If you want to install this client into another project without publishing it (e.g. for development) then:
121+
1. If that project **is using Poetry**, you can simply do `poetry add <path-to-this-client>` from that project
122+
1. If that project is not using Poetry:
123+
1. Build a wheel with `poetry build -f wheel`
124+
1. Install that wheel from the other project `pip install <path-to-wheel>`
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""A client library for accessing My Dataclasses API"""
2+
3+
from .client import AuthenticatedClient, Client
4+
5+
__all__ = (
6+
"AuthenticatedClient",
7+
"Client",
8+
)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Contains methods for accessing the API"""
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import copy
2+
from dataclasses import dataclass, field, replace
3+
from typing import Any, Dict, Optional
4+
5+
import httpx
6+
7+
8+
@dataclass
9+
class _HttpxConfig:
10+
cookies: Dict[str, str] = field(default_factory=dict)
11+
headers: Dict[str, str] = field(default_factory=dict)
12+
timeout: Optional[httpx.Timeout] = None
13+
verify_ssl: bool = True
14+
follow_redirects: bool = False
15+
httpx_args: Dict[str, str] = field(default_factory=dict)
16+
17+
18+
class Client:
19+
"""A class for keeping track of data related to the API
20+
21+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
22+
23+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
24+
25+
``cookies``: A dictionary of cookies to be sent with every request
26+
27+
``headers``: A dictionary of headers to be sent with every request
28+
29+
``timeout``: The maximum amount of a time a request can take. API functions will raise
30+
httpx.TimeoutException if this is exceeded.
31+
32+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
33+
but can be set to False for testing purposes.
34+
35+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
36+
37+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
38+
39+
40+
Attributes:
41+
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
42+
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
43+
argument to the constructor.
44+
"""
45+
46+
_base_url: str
47+
raise_on_unexpected_status: bool
48+
_httpx_config: _HttpxConfig
49+
50+
def __init__(
51+
self,
52+
base_url: str,
53+
cookies: Dict[str, str] = {},
54+
headers: Dict[str, str] = {},
55+
timeout: Optional[httpx.Timeout] = None,
56+
verify_ssl: bool = True,
57+
follow_redirects: bool = False,
58+
httpx_args: Dict[str, Any] = {},
59+
raise_on_unexpected_status: bool = False,
60+
) -> None:
61+
self.raise_on_unexpected_status = raise_on_unexpected_status
62+
self._base_url = base_url
63+
self._httpx_config = _HttpxConfig(cookies, headers, timeout, verify_ssl, follow_redirects, httpx_args)
64+
65+
def _with_httpx_config(self, httpx_config: _HttpxConfig) -> "Client":
66+
ret = copy.copy(self)
67+
ret._httpx_config = httpx_config
68+
return ret
69+
70+
def with_headers(self, headers: Dict[str, str]) -> "Client":
71+
"""Get a new client matching this one with additional headers"""
72+
return self._with_httpx_config(
73+
replace(self._httpx_config, headers={**self._httpx_config.headers, **headers}),
74+
)
75+
76+
def with_cookies(self, cookies: Dict[str, str]) -> "Client":
77+
"""Get a new client matching this one with additional cookies"""
78+
return self._with_httpx_config(
79+
replace(self._httpx_config, cookies={**self._httpx_config.cookies, **cookies}),
80+
)
81+
82+
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
83+
"""Get a new client matching this one with a new timeout (in seconds)"""
84+
return self._with_httpx_config(replace(self._httpx_config, timeout=timeout))
85+
86+
def set_httpx_client(self, client: httpx.Client) -> "Client":
87+
"""Manually set the underlying httpx.Client
88+
89+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
90+
"""
91+
self._client = client
92+
return self
93+
94+
def get_httpx_client(self) -> httpx.Client:
95+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
96+
if self._client is None:
97+
self._client = httpx.Client(
98+
base_url=self._base_url,
99+
cookies=self._httpx_config.cookies,
100+
headers=self._httpx_config.headers,
101+
timeout=self._httpx_config.timeout,
102+
verify=self._httpx_config.verify_ssl,
103+
follow_redirects=self._httpx_config.follow_redirects,
104+
**self._httpx_config.httpx_args,
105+
)
106+
return self._client
107+
108+
def __enter__(self) -> "Client":
109+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
110+
self.get_httpx_client().__enter__()
111+
return self
112+
113+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
114+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
115+
self.get_httpx_client().__exit__(*args, **kwargs)
116+
117+
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
118+
"""Manually the underlying httpx.AsyncClient
119+
120+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
121+
"""
122+
self._async_client = async_client
123+
return self
124+
125+
def get_async_httpx_client(self) -> httpx.AsyncClient:
126+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
127+
if self._async_client is None:
128+
self._async_client = httpx.AsyncClient(
129+
base_url=self._httpx_config.base_url,
130+
cookies=self._httpx_config.cookies,
131+
headers=self._httpx_config.headers,
132+
timeout=self._httpx_config.timeout,
133+
verify=self._httpx_config.verify_ssl,
134+
follow_redirects=self._httpx_config.follow_redirects,
135+
**self._httpx_config.httpx_args,
136+
)
137+
return self._async_client
138+
139+
async def __aenter__(self) -> "Client":
140+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
141+
await self.get_async_httpx_client().__aenter__()
142+
return self
143+
144+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
145+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
146+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
147+
148+
149+
class AuthenticatedClient:
150+
"""A Client which has been authenticated for use on secured endpoints
151+
152+
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
153+
154+
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
155+
156+
``cookies``: A dictionary of cookies to be sent with every request
157+
158+
``headers``: A dictionary of headers to be sent with every request
159+
160+
``timeout``: The maximum amount of a time a request can take. API functions will raise
161+
httpx.TimeoutException if this is exceeded.
162+
163+
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
164+
but can be set to False for testing purposes.
165+
166+
``follow_redirects``: Whether or not to follow redirects. Default value is False.
167+
168+
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
169+
170+
171+
Attributes:
172+
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
173+
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
174+
argument to the constructor.
175+
token: The token to use for authentication
176+
prefix: The prefix to use for the Authorization header
177+
auth_header_name: The name of the Authorization header
178+
"""
179+
180+
_base_url: str
181+
raise_on_unexpected_status: bool
182+
token: str
183+
prefix: str = "Bearer"
184+
auth_header_name: str = "Authorization"
185+
_httpx_config: _HttpxConfig
186+
187+
def _with_httpx_config(self, httpx_config: _HttpxConfig) -> "AuthenticatedClient":
188+
ret = copy.copy(self)
189+
ret._httpx_config = httpx_config
190+
return ret
191+
192+
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
193+
"""Get a new client matching this one with additional headers"""
194+
return self._with_httpx_config(
195+
replace(self._httpx_config, headers={**self._httpx_config.headers, **headers}),
196+
)
197+
198+
def with_cookies(self, cookies: Dict[str, str]) -> "AuthenticatedClient":
199+
"""Get a new client matching this one with additional cookies"""
200+
return self._with_httpx_config(
201+
replace(self._httpx_config, cookies={**self._httpx_config.cookies, **cookies}),
202+
)
203+
204+
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
205+
"""Get a new client matching this one with a new timeout (in seconds)"""
206+
return self._with_httpx_config(replace(self._httpx_config, timeout=timeout))
207+
208+
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
209+
"""Manually set the underlying httpx.Client
210+
211+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
212+
"""
213+
self._client = client
214+
return self
215+
216+
def get_httpx_client(self) -> httpx.Client:
217+
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
218+
if self._client is None:
219+
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
220+
self._client = httpx.Client(
221+
base_url=self._base_url,
222+
cookies=self._httpx_config.cookies,
223+
headers=self._httpx_config.headers,
224+
timeout=self._httpx_config.timeout,
225+
verify=self._httpx_config.verify_ssl,
226+
follow_redirects=self._httpx_config.follow_redirects,
227+
**self._httpx_config.httpx_args,
228+
)
229+
return self._client
230+
231+
def __enter__(self) -> "AuthenticatedClient":
232+
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
233+
self.get_httpx_client().__enter__()
234+
return self
235+
236+
def __exit__(self, *args: Any, **kwargs: Any) -> None:
237+
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
238+
self.get_httpx_client().__exit__(*args, **kwargs)
239+
240+
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
241+
"""Manually the underlying httpx.AsyncClient
242+
243+
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
244+
"""
245+
self._async_client = async_client
246+
return self
247+
248+
def get_async_httpx_client(self) -> httpx.AsyncClient:
249+
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
250+
if self._async_client is None:
251+
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
252+
self._async_client = httpx.AsyncClient(
253+
base_url=self._httpx_config.base_url,
254+
cookies=self._httpx_config.cookies,
255+
headers=self._httpx_config.headers,
256+
timeout=self._httpx_config.timeout,
257+
verify=self._httpx_config.verify_ssl,
258+
follow_redirects=self._httpx_config.follow_redirects,
259+
**self._httpx_config.httpx_args,
260+
)
261+
return self._async_client
262+
263+
async def __aenter__(self) -> "AuthenticatedClient":
264+
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
265+
await self.get_async_httpx_client().__aenter__()
266+
return self
267+
268+
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
269+
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
270+
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Contains shared errors types that can be raised from API functions"""
2+
3+
4+
class UnexpectedStatus(Exception):
5+
"""Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True"""
6+
7+
def __init__(self, status_code: int, content: bytes):
8+
self.status_code = status_code
9+
self.content = content
10+
11+
super().__init__(
12+
f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}"
13+
)
14+
15+
16+
__all__ = ["UnexpectedStatus"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Contains all the data models used in inputs/outputs"""
2+
3+
from .a_model import AModel
4+
5+
__all__ = ("AModel",)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from dataclasses import dataclass as _dataclass
2+
from dataclasses import field as _dataclasses_field
3+
from typing import Any, Dict, List, Type, TypeVar, Union
4+
5+
from ..types import UNSET, Unset
6+
7+
T = TypeVar("T", bound="AModel")
8+
9+
10+
@_dataclass
11+
class AModel:
12+
"""
13+
Attributes:
14+
required_string (str):
15+
optional_string (Union[Unset, str]):
16+
string_with_default (Union[Unset, str]): Default: 'abc'.
17+
"""
18+
19+
required_string: str
20+
optional_string: Union[Unset, str] = UNSET
21+
string_with_default: Union[Unset, str] = "abc"
22+
additional_properties: Dict[str, Any] = _dataclasses_field(init=False, default_factory=dict)
23+
24+
def to_dict(self) -> Dict[str, Any]:
25+
required_string = self.required_string
26+
27+
optional_string = self.optional_string
28+
29+
string_with_default = self.string_with_default
30+
31+
field_dict: Dict[str, Any] = {}
32+
field_dict.update(self.additional_properties)
33+
field_dict.update(
34+
{
35+
"requiredString": required_string,
36+
}
37+
)
38+
if optional_string is not UNSET:
39+
field_dict["optionalString"] = optional_string
40+
if string_with_default is not UNSET:
41+
field_dict["stringWithDefault"] = string_with_default
42+
43+
return field_dict
44+
45+
@classmethod
46+
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
47+
d = src_dict.copy()
48+
required_string = d.pop("requiredString")
49+
50+
optional_string = d.pop("optionalString", UNSET)
51+
52+
string_with_default = d.pop("stringWithDefault", UNSET)
53+
54+
a_model = cls(
55+
required_string=required_string,
56+
optional_string=optional_string,
57+
string_with_default=string_with_default,
58+
)
59+
60+
a_model.additional_properties = d
61+
return a_model
62+
63+
@property
64+
def additional_keys(self) -> List[str]:
65+
return list(self.additional_properties.keys())
66+
67+
def __getitem__(self, key: str) -> Any:
68+
return self.additional_properties[key]
69+
70+
def __setitem__(self, key: str, value: Any) -> None:
71+
self.additional_properties[key] = value
72+
73+
def __delitem__(self, key: str) -> None:
74+
del self.additional_properties[key]
75+
76+
def __contains__(self, key: str) -> bool:
77+
return key in self.additional_properties
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Marker file for PEP 561
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Contains some shared types for properties"""
2+
3+
from http import HTTPStatus
4+
from typing import BinaryIO, Generic, Literal, MutableMapping, Optional, Tuple, TypeVar
5+
6+
from attrs import define
7+
8+
9+
class Unset:
10+
def __bool__(self) -> Literal[False]:
11+
return False
12+
13+
14+
UNSET: Unset = Unset()
15+
16+
FileJsonType = Tuple[Optional[str], BinaryIO, Optional[str]]
17+
18+
19+
@define
20+
class File:
21+
"""Contains information for file uploads"""
22+
23+
payload: BinaryIO
24+
file_name: Optional[str] = None
25+
mime_type: Optional[str] = None
26+
27+
def to_tuple(self) -> FileJsonType:
28+
"""Return a tuple representation that httpx will accept for multipart/form-data"""
29+
return self.file_name, self.payload, self.mime_type
30+
31+
32+
T = TypeVar("T")
33+
34+
35+
@define
36+
class Response(Generic[T]):
37+
"""A response from an endpoint"""
38+
39+
status_code: HTTPStatus
40+
content: bytes
41+
headers: MutableMapping[str, str]
42+
parsed: Optional[T]
43+
44+
45+
__all__ = ["File", "Response", "FileJsonType", "Unset", "UNSET"]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
[tool.poetry]
2+
name = "my-dataclasses-api-client"
3+
version = "0.1.0"
4+
description = "A client library for accessing My Dataclasses API"
5+
authors = []
6+
readme = "README.md"
7+
packages = [
8+
{include = "my_dataclasses_api_client"},
9+
]
10+
include = ["CHANGELOG.md", "my_dataclasses_api_client/py.typed"]
11+
12+
13+
[tool.poetry.dependencies]
14+
python = "^3.8"
15+
httpx = ">=0.20.0,<0.28.0"
16+
python-dateutil = "^2.8.0"
17+
18+
[build-system]
19+
requires = ["poetry-core>=1.0.0"]
20+
build-backend = "poetry.core.masonry.api"
21+
22+
[tool.ruff]
23+
line-length = 120
24+
25+
[tool.ruff.lint]
26+
select = ["F", "I", "UP"]

‎end_to_end_tests/golden-record/my_test_api_client/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class AuthenticatedClient:
166166

167167
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
168168
_base_url: str = field(alias="base_url")
169+
token: str
170+
prefix: str = "Bearer"
171+
auth_header_name: str = "Authorization"
169172
_cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
170173
_headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
171174
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
@@ -175,10 +178,6 @@ class AuthenticatedClient:
175178
_client: Optional[httpx.Client] = field(default=None, init=False)
176179
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
177180

178-
token: str
179-
prefix: str = "Bearer"
180-
auth_header_name: str = "Authorization"
181-
182181
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
183182
"""Get a new client matching this one with additional headers"""
184183
if self._client is not None:

‎end_to_end_tests/literal-enums-golden-record/my_enum_api_client/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class AuthenticatedClient:
166166

167167
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
168168
_base_url: str = field(alias="base_url")
169+
token: str
170+
prefix: str = "Bearer"
171+
auth_header_name: str = "Authorization"
169172
_cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
170173
_headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
171174
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
@@ -175,10 +178,6 @@ class AuthenticatedClient:
175178
_client: Optional[httpx.Client] = field(default=None, init=False)
176179
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
177180

178-
token: str
179-
prefix: str = "Bearer"
180-
auth_header_name: str = "Authorization"
181-
182181
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
183182
"""Get a new client matching this one with additional headers"""
184183
if self._client is not None:
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
openapi: 3.1.0
2+
info:
3+
title: My Dataclasses API
4+
description: An API for testing dataclasses in openapi-python-client
5+
version: 0.1.0
6+
paths: {}
7+
components:
8+
schemas:
9+
AModel:
10+
type: object
11+
properties:
12+
optionalString:
13+
type: string
14+
requiredString:
15+
type: string
16+
stringWithDefault:
17+
type: string
18+
default: abc
19+
required:
20+
- requiredString

‎end_to_end_tests/regen_golden_record.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,26 @@ def regen_golden_record_3_1_features():
5151
output_path.rename(gr_path)
5252

5353

54+
def regen_dataclasses_golden_record():
55+
runner = CliRunner()
56+
openapi_path = Path(__file__).parent / "openapi_3.1_dataclasses.yaml"
57+
58+
gr_path = Path(__file__).parent / "golden-record-dataclasses"
59+
output_path = Path.cwd() / "my-dataclasses-api-client"
60+
config_path = Path(__file__).parent / "config_dataclasses.yml"
61+
62+
shutil.rmtree(gr_path, ignore_errors=True)
63+
shutil.rmtree(output_path, ignore_errors=True)
64+
65+
result = runner.invoke(app, ["generate", f"--path={openapi_path}", f"--config={config_path}"])
66+
67+
if result.stdout:
68+
print(result.stdout)
69+
if result.exception:
70+
raise result.exception
71+
output_path.rename(gr_path)
72+
73+
5474
def regen_literal_enums_golden_record():
5575
runner = CliRunner()
5676
openapi_path = Path(__file__).parent / "openapi_3.1_enums.yaml"
@@ -144,4 +164,5 @@ def regen_custom_template_golden_record():
144164
regen_golden_record_3_1_features()
145165
regen_metadata_snapshots()
146166
regen_custom_template_golden_record()
167+
regen_dataclasses_golden_record()
147168
regen_literal_enums_golden_record()

‎end_to_end_tests/test-3-1-golden-record/test_3_1_features_client/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class AuthenticatedClient:
166166

167167
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
168168
_base_url: str = field(alias="base_url")
169+
token: str
170+
prefix: str = "Bearer"
171+
auth_header_name: str = "Authorization"
169172
_cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
170173
_headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
171174
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
@@ -175,10 +178,6 @@ class AuthenticatedClient:
175178
_client: Optional[httpx.Client] = field(default=None, init=False)
176179
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
177180

178-
token: str
179-
prefix: str = "Bearer"
180-
auth_header_name: str = "Authorization"
181-
182181
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
183182
"""Get a new client matching this one with additional headers"""
184183
if self._client is not None:

‎integration-tests/integration_tests/client.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,9 @@ class AuthenticatedClient:
166166

167167
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
168168
_base_url: str = field(alias="base_url")
169+
token: str
170+
prefix: str = "Bearer"
171+
auth_header_name: str = "Authorization"
169172
_cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
170173
_headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
171174
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
@@ -175,10 +178,6 @@ class AuthenticatedClient:
175178
_client: Optional[httpx.Client] = field(default=None, init=False)
176179
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
177180

178-
token: str
179-
prefix: str = "Bearer"
180-
auth_header_name: str = "Authorization"
181-
182181
def with_headers(self, headers: Dict[str, str]) -> "AuthenticatedClient":
183182
"""Get a new client matching this one with additional headers"""
184183
if self._client is not None:

‎openapi_python_client/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def __init__(
8989

9090
self.env.filters.update(TEMPLATE_FILTERS)
9191
self.env.globals.update(
92+
config=config,
9293
utils=utils,
9394
python_identifier=lambda x: utils.PythonIdentifier(x, config.field_prefix),
9495
class_name=lambda x: utils.ClassName(x, config.field_prefix),

‎openapi_python_client/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ class ConfigFile(BaseModel):
3939
project_name_override: Optional[str] = None
4040
package_name_override: Optional[str] = None
4141
package_version_override: Optional[str] = None
42+
use_dataclasses: bool = False
4243
use_path_prefixes_for_title_model_names: bool = True
4344
post_hooks: Optional[List[str]] = None
4445
field_prefix: str = "field_"
@@ -67,6 +68,7 @@ class Config:
6768
project_name_override: Optional[str]
6869
package_name_override: Optional[str]
6970
package_version_override: Optional[str]
71+
use_dataclasses: bool
7072
use_path_prefixes_for_title_model_names: bool
7173
post_hooks: List[str]
7274
field_prefix: str
@@ -107,6 +109,7 @@ def from_sources(
107109
project_name_override=config_file.project_name_override,
108110
package_name_override=config_file.package_name_override,
109111
package_version_override=config_file.package_version_override,
112+
use_dataclasses=config_file.use_dataclasses,
110113
use_path_prefixes_for_title_model_names=config_file.use_path_prefixes_for_title_model_names,
111114
post_hooks=post_hooks,
112115
field_prefix=config_file.field_prefix,

‎openapi_python_client/templates/client.py.jinja

Lines changed: 100 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,45 @@
11
import ssl
22
from typing import Any, Dict, Union, Optional
33

4-
from attrs import define, field, evolve
54
import httpx
65

6+
{% if config.use_dataclasses %}
7+
import copy
8+
from dataclasses import dataclass, field, replace
9+
{% else %}
10+
from attrs import define, field, evolve
11+
{% endif %}
12+
13+
{% if config.use_dataclasses %}
14+
@dataclass
15+
class _HttpxConfig:
16+
cookies: Dict[str, str] = field(default_factory=dict)
17+
headers: Dict[str, str] = field(default_factory=dict)
18+
timeout: Optional[httpx.Timeout] = None
19+
verify_ssl: bool = True
20+
follow_redirects: bool = False
21+
httpx_args: Dict[str, str] = field(default_factory=dict)
22+
23+
{% macro init_args() %}
24+
self,
25+
base_url: str,
26+
cookies: Dict[str, str] = {},
27+
headers: Dict[str, str] = {},
28+
timeout: Optional[httpx.Timeout] = None,
29+
verify_ssl: bool = True,
30+
follow_redirects: bool = False,
31+
httpx_args: Dict[str, Any] = {},
32+
raise_on_unexpected_status: bool = False,
33+
{% endmacro %}
34+
{% endif %}
735

36+
{% macro httpx_client_arg(name) -%}
37+
{% if config.use_dataclasses %}self._httpx_config.{{ name }}{% else %}self._{{ name }}{% endif %}
38+
{%- endmacro %}
39+
40+
{% if not config.use_dataclasses %}
841
@define
42+
{% endif %}
943
class Client:
1044
"""A class for keeping track of data related to the API
1145

@@ -35,9 +69,20 @@ class Client:
3569
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
3670
argument to the constructor.
3771
"""
38-
{% macro attributes() %}
72+
{% macro attributes(extra_attrs = []) %}
73+
{% if config.use_dataclasses %}
74+
_base_url: str
75+
raise_on_unexpected_status: bool
76+
{% for extra in extra_attrs %}
77+
{{ extra }}
78+
{% endfor %}
79+
_httpx_config: _HttpxConfig
80+
{% else %}
3981
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
4082
_base_url: str = field(alias="base_url")
83+
{% for extra in extra_attrs %}
84+
{{ extra }}
85+
{% endfor %}
4186
_cookies: Dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
4287
_headers: Dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
4388
_timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout")
@@ -46,8 +91,43 @@ class Client:
4691
_httpx_args: Dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
4792
_client: Optional[httpx.Client] = field(default=None, init=False)
4893
_async_client: Optional[httpx.AsyncClient] = field(default=None, init=False)
94+
{% endif %}
4995
{% endmacro %}{{ attributes() }}
96+
97+
{% if config.use_dataclasses %}
98+
def __init__(
99+
{{ init_args() }}
100+
) -> None:
101+
self.raise_on_unexpected_status = raise_on_unexpected_status
102+
self._base_url = base_url
103+
self._httpx_config = _HttpxConfig(
104+
cookies, headers, timeout, verify_ssl, follow_redirects, httpx_args
105+
)
106+
{% endif %}
107+
50108
{% macro builders(self) %}
109+
{% if config.use_dataclasses %}
110+
def _with_httpx_config(self, httpx_config: _HttpxConfig) -> "{{ self }}":
111+
ret = copy.copy(self)
112+
ret._httpx_config = httpx_config
113+
return ret
114+
115+
def with_headers(self, headers: Dict[str, str]) -> "{{ self }}":
116+
"""Get a new client matching this one with additional headers"""
117+
return self._with_httpx_config(
118+
replace(self._httpx_config, headers={**self._httpx_config.headers, **headers}),
119+
)
120+
121+
def with_cookies(self, cookies: Dict[str, str]) -> "{{ self }}":
122+
"""Get a new client matching this one with additional cookies"""
123+
return self._with_httpx_config(
124+
replace(self._httpx_config, cookies={**self._httpx_config.cookies, **cookies}),
125+
)
126+
127+
def with_timeout(self, timeout: httpx.Timeout) -> "{{ self }}":
128+
"""Get a new client matching this one with a new timeout (in seconds)"""
129+
return self._with_httpx_config(replace(self._httpx_config, timeout=timeout))
130+
{% else %}
51131
def with_headers(self, headers: Dict[str, str]) -> "{{ self }}":
52132
"""Get a new client matching this one with additional headers"""
53133
if self._client is not None:
@@ -71,7 +151,9 @@ class Client:
71151
if self._async_client is not None:
72152
self._async_client.timeout = timeout
73153
return evolve(self, timeout=timeout)
154+
{% endif %}
74155
{% endmacro %}{{ builders("Client") }}
156+
75157
{% macro httpx_stuff(name, custom_constructor=None) %}
76158
def set_httpx_client(self, client: httpx.Client) -> "{{ name }}":
77159
"""Manually set the underlying httpx.Client
@@ -89,12 +171,12 @@ class Client:
89171
{% endif %}
90172
self._client = httpx.Client(
91173
base_url=self._base_url,
92-
cookies=self._cookies,
93-
headers=self._headers,
94-
timeout=self._timeout,
95-
verify=self._verify_ssl,
96-
follow_redirects=self._follow_redirects,
97-
**self._httpx_args,
174+
cookies={{ httpx_client_arg("cookies") }},
175+
headers={{ httpx_client_arg("headers") }},
176+
timeout={{ httpx_client_arg("timeout") }},
177+
verify={{ httpx_client_arg("verify_ssl") }},
178+
follow_redirects={{ httpx_client_arg("follow_redirects") }},
179+
**{{ httpx_client_arg("httpx_args") }},
98180
)
99181
return self._client
100182

@@ -122,13 +204,13 @@ class Client:
122204
{{ custom_constructor | indent(12) }}
123205
{% endif %}
124206
self._async_client = httpx.AsyncClient(
125-
base_url=self._base_url,
126-
cookies=self._cookies,
127-
headers=self._headers,
128-
timeout=self._timeout,
129-
verify=self._verify_ssl,
130-
follow_redirects=self._follow_redirects,
131-
**self._httpx_args,
207+
base_url={{ httpx_client_arg("base_url") }},
208+
cookies={{ httpx_client_arg("cookies") }},
209+
headers={{ httpx_client_arg("headers") }},
210+
timeout={{ httpx_client_arg("timeout") }},
211+
verify={{ httpx_client_arg("verify_ssl") }},
212+
follow_redirects={{ httpx_client_arg("follow_redirects") }},
213+
**{{ httpx_client_arg("httpx_args") }},
132214
)
133215
return self._async_client
134216

@@ -142,7 +224,9 @@ class Client:
142224
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
143225
{% endmacro %}{{ httpx_stuff("Client") }}
144226

227+
{% if not config.use_dataclasses %}
145228
@define
229+
{% endif %}
146230
class AuthenticatedClient:
147231
"""A Client which has been authenticated for use on secured endpoints
148232

@@ -157,10 +241,7 @@ class AuthenticatedClient:
157241
auth_header_name: The name of the Authorization header
158242
"""
159243

160-
{{ attributes() }}
161-
token: str
162-
prefix: str = "Bearer"
163-
auth_header_name: str = "Authorization"
244+
{{ attributes(["token: str", 'prefix: str = "Bearer"', 'auth_header_name: str = "Authorization"']) }}
164245

165246
{{ builders("AuthenticatedClient") }}
166247
{{ httpx_stuff("AuthenticatedClient", "self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token") }}

‎openapi_python_client/templates/model.py.jinja

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ from typing import List
55

66
{% endif %}
77

8-
from attrs import define as _attrs_define
9-
from attrs import field as _attrs_field
8+
{% if config.use_dataclasses %}
9+
from dataclasses import dataclass as _dataclass, field as _dataclasses_field
10+
{% else %}
11+
from attrs import define as _attrs_define, field as _attrs_field
12+
{% endif %}
13+
1014
{% if model.is_multipart_body %}
1115
import json
1216
{% endif %}
@@ -59,7 +63,11 @@ T = TypeVar("T", bound="{{ class_name }}")
5963
{% endfor %}{% endif %}
6064
{% endmacro %}
6165

66+
{% if config.use_dataclasses %}
67+
@_dataclass
68+
{% else %}
6269
@_attrs_define
70+
{% endif %}
6371
class {{ class_name }}:
6472
{{ safe_docstring(class_docstring_content(model)) | indent(4) }}
6573

@@ -74,8 +82,12 @@ class {{ class_name }}:
7482
{% endif %}
7583
{% endfor %}
7684
{% if model.additional_properties %}
85+
{% if config.use_dataclasses %}
86+
additional_properties: Dict[str, {{ additional_property_type }}] = _dataclasses_field(init=False, default_factory=dict)
87+
{% else %}
7788
additional_properties: Dict[str, {{ additional_property_type }}] = _attrs_field(init=False, factory=dict)
7889
{% endif %}
90+
{% endif %}
7991

8092
{% macro _to_dict(multipart=False) %}
8193
{% for property in model.required_properties + model.optional_properties %}

‎openapi_python_client/templates/pyproject.toml.jinja

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ include = ["CHANGELOG.md", "{{ package_name }}/py.typed"]
2020
{% if pdm %}
2121
dependencies = [
2222
"httpx>=0.20.0,<0.28.0",
23+
{% if not config.use_dataclasses %}
2324
"attrs>=21.3.0",
25+
{% endif %}
2426
"python-dateutil>=2.8.0",
2527
]
2628

@@ -32,7 +34,9 @@ distribution = true
3234
[tool.poetry.dependencies]
3335
python = "^3.8"
3436
httpx = ">=0.20.0,<0.28.0"
37+
{% if not config.use_dataclasses -%}
3538
attrs = ">=21.3.0"
39+
{% endif %}
3640
python-dateutil = "^2.8.0"
3741
{% endif %}
3842

0 commit comments

Comments
 (0)
Please sign in to comment.