diff --git a/.changeset/config-in-templates.md b/.changeset/config-in-templates.md new file mode 100644 index 000000000..7b34f7497 --- /dev/null +++ b/.changeset/config-in-templates.md @@ -0,0 +1,7 @@ +--- +default: minor +--- + +# Make `config` available in custom templates + +The configuration options object is now exposed as a variable called `config` in Jinja2 templates. diff --git a/.changeset/docstrings-on-attributes.md b/.changeset/docstrings-on-attributes.md new file mode 100644 index 000000000..b87bcee11 --- /dev/null +++ b/.changeset/docstrings-on-attributes.md @@ -0,0 +1,7 @@ +--- +default: minor +--- + +# Add `docstrings_on_attributes` config setting + +Setting this option to `true` changes the docstring behavior in model classes: for any attribute that have a non-empty `description`, instead of describing the attribute as part of the class's docstring, the description will appear in an individual docstring for that attribute. diff --git a/README.md b/README.md index a184be377..b07e7b29b 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,16 @@ class_overrides: The easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the `models` folder. +### docstrings_on_attributes + +By default, when `openapi-python-client` generates a model class, it includes a list of attributes and their +descriptions in the docstring for the class. If you set this option to `true`, then the attribute descriptions +will be put in docstrings for the attributes themselves, and will not be in the class docstring. + +```yaml +docstrings_on_attributes: true +``` + ### literal_enums By default, `openapi-python-client` generates classes inheriting for `Enum` for enums. It can instead use `Literal` diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/.gitignore b/end_to_end_tests/docstrings-on-attributes-golden-record/.gitignore new file mode 100644 index 000000000..79a2c3d73 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/.gitignore @@ -0,0 +1,23 @@ +__pycache__/ +build/ +dist/ +*.egg-info/ +.pytest_cache/ + +# pyenv +.python-version + +# Environments +.env +.venv + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# JetBrains +.idea/ + +/coverage.xml +/.coverage diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/README.md b/end_to_end_tests/docstrings-on-attributes-golden-record/README.md new file mode 100644 index 000000000..79b20f411 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/README.md @@ -0,0 +1,124 @@ +# my-test-api-client +A client library for accessing My Test API + +## Usage +First, create a client: + +```python +from my_test_api_client import Client + +client = Client(base_url="https://api.example.com") +``` + +If the endpoints you're going to hit require authentication, use `AuthenticatedClient` instead: + +```python +from my_test_api_client import AuthenticatedClient + +client = AuthenticatedClient(base_url="https://api.example.com", token="SuperSecretToken") +``` + +Now call your endpoint and use your models: + +```python +from my_test_api_client.models import MyDataModel +from my_test_api_client.api.my_tag import get_my_data_model +from my_test_api_client.types import Response + +with client as client: + my_data: MyDataModel = get_my_data_model.sync(client=client) + # or if you need more info (e.g. status_code) + response: Response[MyDataModel] = get_my_data_model.sync_detailed(client=client) +``` + +Or do the same thing with an async version: + +```python +from my_test_api_client.models import MyDataModel +from my_test_api_client.api.my_tag import get_my_data_model +from my_test_api_client.types import Response + +async with client as client: + my_data: MyDataModel = await get_my_data_model.asyncio(client=client) + response: Response[MyDataModel] = await get_my_data_model.asyncio_detailed(client=client) +``` + +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. + +```python +client = AuthenticatedClient( + base_url="https://internal_api.example.com", + token="SuperSecretToken", + verify_ssl="/path/to/certificate_bundle.pem", +) +``` + +You can also disable certificate validation altogether, but beware that **this is a security risk**. + +```python +client = AuthenticatedClient( + base_url="https://internal_api.example.com", + token="SuperSecretToken", + verify_ssl=False +) +``` + +Things to know: +1. Every path/method combo becomes a Python module with four functions: + 1. `sync`: Blocking request that returns parsed data (if successful) or `None` + 1. `sync_detailed`: Blocking request that always returns a `Request`, optionally with `parsed` set if the request was successful. + 1. `asyncio`: Like `sync` but async instead of blocking + 1. `asyncio_detailed`: Like `sync_detailed` but async instead of blocking + +1. All path/query params, and bodies become method arguments. +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) +1. Any endpoint which did not have a tag will be in `my_test_api_client.api.default` + +## Advanced customizations + +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): + +```python +from my_test_api_client import Client + +def log_request(request): + print(f"Request event hook: {request.method} {request.url} - Waiting for response") + +def log_response(response): + request = response.request + print(f"Response event hook: {request.method} {request.url} - Status {response.status_code}") + +client = Client( + base_url="https://api.example.com", + httpx_args={"event_hooks": {"request": [log_request], "response": [log_response]}}, +) + +# Or get the underlying httpx client to modify directly with client.get_httpx_client() or client.get_async_httpx_client() +``` + +You can even set the httpx client directly, but beware that this will override any existing settings (e.g., base_url): + +```python +import httpx +from my_test_api_client import Client + +client = Client( + base_url="https://api.example.com", +) +# Note that base_url needs to be re-set, as would any shared cookies, headers, etc. +client.set_httpx_client(httpx.Client(base_url="https://api.example.com", proxies="http://localhost:8030")) +``` + +## Building / publishing this package +This project uses [Poetry](https://python-poetry.org/) to manage dependencies and packaging. Here are the basics: +1. Update the metadata in pyproject.toml (e.g. authors, version) +1. If you're using a private repository, configure it with Poetry + 1. `poetry config repositories. ` + 1. `poetry config http-basic. ` +1. Publish the client with `poetry publish --build -r ` or, if for public PyPI, just `poetry publish --build` + +If you want to install this client into another project without publishing it (e.g. for development) then: +1. If that project **is using Poetry**, you can simply do `poetry add ` from that project +1. If that project is not using Poetry: + 1. Build a wheel with `poetry build -f wheel` + 1. Install that wheel from the other project `pip install ` diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/__init__.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/__init__.py new file mode 100644 index 000000000..3747245da --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/__init__.py @@ -0,0 +1,8 @@ +"""A client library for accessing My Test API""" + +from .client import AuthenticatedClient, Client + +__all__ = ( + "AuthenticatedClient", + "Client", +) diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/api/__init__.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/api/__init__.py new file mode 100644 index 000000000..81f9fa241 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/api/__init__.py @@ -0,0 +1 @@ +"""Contains methods for accessing the API""" diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/client.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/client.py new file mode 100644 index 000000000..e05334a5f --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/client.py @@ -0,0 +1,260 @@ +import ssl +from typing import Any, Optional, Union + +import httpx +from attrs import define, evolve, field + + +@define +class Client: + """A class for keeping track of data related to the API + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + """Whether or not to raise an errors.UnexpectedStatus if the API returns a status code that was not documented in the source OpenAPI document. Can also be provided as a keyword argument to the constructor.""" + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + def with_headers(self, headers: dict[str, str]) -> "Client": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "Client": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "Client": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "Client": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "Client": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "Client": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) + + +@define +class AuthenticatedClient: + """A Client which has been authenticated for use on secured endpoints + + The following are accepted as keyword arguments and will be used to construct httpx Clients internally: + + ``base_url``: The base URL for the API, all requests are made to a relative path to this URL + + ``cookies``: A dictionary of cookies to be sent with every request + + ``headers``: A dictionary of headers to be sent with every request + + ``timeout``: The maximum amount of a time a request can take. API functions will raise + httpx.TimeoutException if this is exceeded. + + ``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production, + but can be set to False for testing purposes. + + ``follow_redirects``: Whether or not to follow redirects. Default value is False. + + ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. + + """ + + raise_on_unexpected_status: bool = field(default=False, kw_only=True) + """Whether or not to raise an errors.UnexpectedStatus if the API returns a status code that was not documented in the source OpenAPI document. Can also be provided as a keyword argument to the constructor.""" + _base_url: str = field(alias="base_url") + _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") + _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") + _timeout: Optional[httpx.Timeout] = field(default=None, kw_only=True, alias="timeout") + _verify_ssl: Union[str, bool, ssl.SSLContext] = field(default=True, kw_only=True, alias="verify_ssl") + _follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects") + _httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args") + _client: Optional[httpx.Client] = field(default=None, init=False) + _async_client: Optional[httpx.AsyncClient] = field(default=None, init=False) + + token: str + """The token to use for authentication""" + prefix: str = "Bearer" + """The prefix to use for the Authorization header""" + auth_header_name: str = "Authorization" + """The name of the Authorization header""" + + def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional headers""" + if self._client is not None: + self._client.headers.update(headers) + if self._async_client is not None: + self._async_client.headers.update(headers) + return evolve(self, headers={**self._headers, **headers}) + + def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient": + """Get a new client matching this one with additional cookies""" + if self._client is not None: + self._client.cookies.update(cookies) + if self._async_client is not None: + self._async_client.cookies.update(cookies) + return evolve(self, cookies={**self._cookies, **cookies}) + + def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient": + """Get a new client matching this one with a new timeout (in seconds)""" + if self._client is not None: + self._client.timeout = timeout + if self._async_client is not None: + self._async_client.timeout = timeout + return evolve(self, timeout=timeout) + + def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient": + """Manually set the underlying httpx.Client + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._client = client + return self + + def get_httpx_client(self) -> httpx.Client: + """Get the underlying httpx.Client, constructing a new one if not previously set""" + if self._client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._client = httpx.Client( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._client + + def __enter__(self) -> "AuthenticatedClient": + """Enter a context manager for self.client—you cannot enter twice (see httpx docs)""" + self.get_httpx_client().__enter__() + return self + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for internal httpx.Client (see httpx docs)""" + self.get_httpx_client().__exit__(*args, **kwargs) + + def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient": + """Manually the underlying httpx.AsyncClient + + **NOTE**: This will override any other settings on the client, including cookies, headers, and timeout. + """ + self._async_client = async_client + return self + + def get_async_httpx_client(self) -> httpx.AsyncClient: + """Get the underlying httpx.AsyncClient, constructing a new one if not previously set""" + if self._async_client is None: + self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token + self._async_client = httpx.AsyncClient( + base_url=self._base_url, + cookies=self._cookies, + headers=self._headers, + timeout=self._timeout, + verify=self._verify_ssl, + follow_redirects=self._follow_redirects, + **self._httpx_args, + ) + return self._async_client + + async def __aenter__(self) -> "AuthenticatedClient": + """Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)""" + await self.get_async_httpx_client().__aenter__() + return self + + async def __aexit__(self, *args: Any, **kwargs: Any) -> None: + """Exit a context manager for underlying httpx.AsyncClient (see httpx docs)""" + await self.get_async_httpx_client().__aexit__(*args, **kwargs) diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/errors.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/errors.py new file mode 100644 index 000000000..5f92e76ac --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/errors.py @@ -0,0 +1,16 @@ +"""Contains shared errors types that can be raised from API functions""" + + +class UnexpectedStatus(Exception): + """Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True""" + + def __init__(self, status_code: int, content: bytes): + self.status_code = status_code + self.content = content + + super().__init__( + f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}" + ) + + +__all__ = ["UnexpectedStatus"] diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/__init__.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/__init__.py new file mode 100644 index 000000000..3f5aca91f --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/__init__.py @@ -0,0 +1,9 @@ +"""Contains all the data models used in inputs/outputs""" + +from .model_with_description import ModelWithDescription +from .model_with_no_description import ModelWithNoDescription + +__all__ = ( + "ModelWithDescription", + "ModelWithNoDescription", +) diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_description.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_description.py new file mode 100644 index 000000000..dde413e66 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_description.py @@ -0,0 +1,77 @@ +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ModelWithDescription") + + +@_attrs_define +class ModelWithDescription: + """This is a nice model.""" + + prop_with_no_desc: Union[Unset, str] = UNSET + prop_with_desc: Union[Unset, str] = UNSET + """ This is a nice property. """ + prop_with_long_desc: Union[Unset, str] = UNSET + """ It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of + foolishness, + it was the epoch of belief, it was the epoch of incredulity, it was the season of light, it was the season of + darkness, it was the spring of hope, it was the winter of despair. + """ + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + prop_with_no_desc = self.prop_with_no_desc + + prop_with_desc = self.prop_with_desc + + prop_with_long_desc = self.prop_with_long_desc + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if prop_with_no_desc is not UNSET: + field_dict["propWithNoDesc"] = prop_with_no_desc + if prop_with_desc is not UNSET: + field_dict["propWithDesc"] = prop_with_desc + if prop_with_long_desc is not UNSET: + field_dict["propWithLongDesc"] = prop_with_long_desc + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: + d = src_dict.copy() + prop_with_no_desc = d.pop("propWithNoDesc", UNSET) + + prop_with_desc = d.pop("propWithDesc", UNSET) + + prop_with_long_desc = d.pop("propWithLongDesc", UNSET) + + model_with_description = cls( + prop_with_no_desc=prop_with_no_desc, + prop_with_desc=prop_with_desc, + prop_with_long_desc=prop_with_long_desc, + ) + + model_with_description.additional_properties = d + return model_with_description + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_no_description.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_no_description.py new file mode 100644 index 000000000..374db16a6 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/models/model_with_no_description.py @@ -0,0 +1,62 @@ +from typing import Any, TypeVar, Union + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +from ..types import UNSET, Unset + +T = TypeVar("T", bound="ModelWithNoDescription") + + +@_attrs_define +class ModelWithNoDescription: + prop_with_no_desc: Union[Unset, str] = UNSET + prop_with_desc: Union[Unset, str] = UNSET + """ This is a nice property. """ + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + prop_with_no_desc = self.prop_with_no_desc + + prop_with_desc = self.prop_with_desc + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({}) + if prop_with_no_desc is not UNSET: + field_dict["propWithNoDesc"] = prop_with_no_desc + if prop_with_desc is not UNSET: + field_dict["propWithDesc"] = prop_with_desc + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T: + d = src_dict.copy() + prop_with_no_desc = d.pop("propWithNoDesc", UNSET) + + prop_with_desc = d.pop("propWithDesc", UNSET) + + model_with_no_description = cls( + prop_with_no_desc=prop_with_no_desc, + prop_with_desc=prop_with_desc, + ) + + model_with_no_description.additional_properties = d + return model_with_no_description + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/py.typed b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/py.typed new file mode 100644 index 000000000..1aad32711 --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561 \ No newline at end of file diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/types.py b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/types.py new file mode 100644 index 000000000..b9ed58b8a --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/my_test_api_client/types.py @@ -0,0 +1,46 @@ +"""Contains some shared types for properties""" + +from collections.abc import MutableMapping +from http import HTTPStatus +from typing import BinaryIO, Generic, Literal, Optional, TypeVar + +from attrs import define + + +class Unset: + def __bool__(self) -> Literal[False]: + return False + + +UNSET: Unset = Unset() + +FileJsonType = tuple[Optional[str], BinaryIO, Optional[str]] + + +@define +class File: + """Contains information for file uploads""" + + payload: BinaryIO + file_name: Optional[str] = None + mime_type: Optional[str] = None + + def to_tuple(self) -> FileJsonType: + """Return a tuple representation that httpx will accept for multipart/form-data""" + return self.file_name, self.payload, self.mime_type + + +T = TypeVar("T") + + +@define +class Response(Generic[T]): + """A response from an endpoint""" + + status_code: HTTPStatus + content: bytes + headers: MutableMapping[str, str] + parsed: Optional[T] + + +__all__ = ["UNSET", "File", "FileJsonType", "Response", "Unset"] diff --git a/end_to_end_tests/docstrings-on-attributes-golden-record/pyproject.toml b/end_to_end_tests/docstrings-on-attributes-golden-record/pyproject.toml new file mode 100644 index 000000000..feca06dbd --- /dev/null +++ b/end_to_end_tests/docstrings-on-attributes-golden-record/pyproject.toml @@ -0,0 +1,27 @@ +[tool.poetry] +name = "my-test-api-client" +version = "0.1.0" +description = "A client library for accessing My Test API" +authors = [] +readme = "README.md" +packages = [ + {include = "my_test_api_client"}, +] +include = ["CHANGELOG.md", "my_test_api_client/py.typed"] + + +[tool.poetry.dependencies] +python = "^3.9" +httpx = ">=0.20.0,<0.29.0" +attrs = ">=22.2.0" +python-dateutil = "^2.8.0" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +select = ["F", "I", "UP"] diff --git a/end_to_end_tests/docstrings_on_attributes.config.yml b/end_to_end_tests/docstrings_on_attributes.config.yml new file mode 100644 index 000000000..0b21ad0b5 --- /dev/null +++ b/end_to_end_tests/docstrings_on_attributes.config.yml @@ -0,0 +1 @@ +docstrings_on_attributes: true diff --git a/end_to_end_tests/docstrings_on_attributes.yml b/end_to_end_tests/docstrings_on_attributes.yml new file mode 100644 index 000000000..22e6e227d --- /dev/null +++ b/end_to_end_tests/docstrings_on_attributes.yml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: My Test API + description: An API for testing docstrings_on_attributes behavior + version: 0.1.0 +paths: + {} +components: + schemas: + ModelWithDescription: + type: object + description: This is a nice model. + properties: + propWithNoDesc: + type: string + propWithDesc: + type: string + description: This is a nice property. + propWithLongDesc: + type: string + description: | + It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, + it was the epoch of belief, it was the epoch of incredulity, it was the season of light, it was the season of + darkness, it was the spring of hope, it was the winter of despair. + ModelWithNoDescription: + type: object + properties: + propWithNoDesc: + type: string + propWithDesc: + type: string + description: This is a nice property. diff --git a/end_to_end_tests/regen_golden_record.py b/end_to_end_tests/regen_golden_record.py index 2471e1340..ddac817ca 100644 --- a/end_to_end_tests/regen_golden_record.py +++ b/end_to_end_tests/regen_golden_record.py @@ -1,96 +1,97 @@ """ Regenerate golden-record """ import filecmp -import os import shutil -import tempfile from pathlib import Path +from typing import Optional from typer.testing import CliRunner from openapi_python_client.cli import app -def regen_golden_record(): +def _regenerate( + *, + spec_file_name: str, + output_dir: str = "my-test-api-client", + golden_record_dir: Optional[str] = None, + config_file_name: str = "config.yml", + extra_args: Optional[list[str]] = None +) -> None: + end_to_end_tests_base_path = Path(__file__).parent + project_base_path = end_to_end_tests_base_path.parent runner = CliRunner() - openapi_path = Path(__file__).parent / "baseline_openapi_3.0.json" - - gr_path = Path(__file__).parent / "golden-record" - output_path = Path.cwd() / "my-test-api-client" - config_path = Path(__file__).parent / "config.yml" + openapi_path = end_to_end_tests_base_path / spec_file_name - shutil.rmtree(gr_path, ignore_errors=True) + output_path = project_base_path / output_dir shutil.rmtree(output_path, ignore_errors=True) - result = runner.invoke( - app, ["generate", f"--config={config_path}", f"--path={openapi_path}"] - ) - - if result.stdout: - print(result.stdout) - if result.exception: - raise result.exception - output_path.rename(gr_path) - - -def regen_golden_record_3_1_features(): - runner = CliRunner() - openapi_path = Path(__file__).parent / "3.1_specific.openapi.yaml" - - gr_path = Path(__file__).parent / "test-3-1-golden-record" - output_path = Path.cwd() / "test-3-1-features-client" - - shutil.rmtree(gr_path, ignore_errors=True) - shutil.rmtree(output_path, ignore_errors=True) + args = ["generate", f"--path={openapi_path}"] + if config_file_name: + config_path = end_to_end_tests_base_path / config_file_name + args.append(f"--config={config_path}") + if extra_args: + args.extend(extra_args) + print(f"Using {spec_file_name}{f' and {config_file_name}' if config_file_name else ''}") - result = runner.invoke(app, ["generate", f"--path={openapi_path}"]) + result = runner.invoke(app, args) if result.stdout: print(result.stdout) if result.exception: raise result.exception - output_path.rename(gr_path) + if golden_record_dir: + gr_path = end_to_end_tests_base_path / golden_record_dir + shutil.rmtree(gr_path, ignore_errors=True) + output_path.rename(gr_path) -def regen_literal_enums_golden_record(): - runner = CliRunner() - openapi_path = Path(__file__).parent / "openapi_3.1_enums.yaml" +def regen_golden_record(): + _regenerate( + spec_file_name="baseline_openapi_3.0.json", + golden_record_dir="golden-record", + ) - gr_path = Path(__file__).parent / "literal-enums-golden-record" - output_path = Path.cwd() / "my-enum-api-client" - config_path = Path(__file__).parent / "literal_enums.config.yml" - shutil.rmtree(gr_path, ignore_errors=True) - shutil.rmtree(output_path, ignore_errors=True) +def regen_golden_record_3_1_features(): + _regenerate( + spec_file_name="3.1_specific.openapi.yaml", + output_dir="test-3-1-features-client", + golden_record_dir="test-3-1-golden-record", + ) - result = runner.invoke(app, ["generate", f"--path={openapi_path}", f"--config={config_path}"]) - if result.stdout: - print(result.stdout) - if result.exception: - raise result.exception - output_path.rename(gr_path) +def regen_literal_enums_golden_record(): + _regenerate( + spec_file_name="openapi_3.1_enums.yaml", + output_dir="my-enum-api-client", + golden_record_dir="literal-enums-golden-record", + config_file_name="literal_enums.config.yml", + ) def regen_metadata_snapshots(): - runner = CliRunner() - openapi_path = Path(__file__).parent / "3.1_specific.openapi.yaml" output_path = Path.cwd() / "test-3-1-features-client" snapshots_dir = Path(__file__).parent / "metadata_snapshots" for (meta, file, rename_to) in (("setup", "setup.py", "setup.py"), ("pdm", "pyproject.toml", "pdm.pyproject.toml"), ("poetry", "pyproject.toml", "poetry.pyproject.toml")): - shutil.rmtree(output_path, ignore_errors=True) - result = runner.invoke(app, ["generate", f"--path={openapi_path}", f"--meta={meta}"]) - - if result.stdout: - print(result.stdout) - if result.exception: - raise result.exception - + _regenerate( + spec_file_name="3.1_specific.openapi.yaml", + output_dir="test-3-1-features-client", + extra_args=[f"--meta={meta}"], + ) (output_path / file).rename(snapshots_dir / rename_to) shutil.rmtree(output_path, ignore_errors=True) +def regen_docstrings_on_attributes_golden_record(): + _regenerate( + spec_file_name="docstrings_on_attributes.yml", + golden_record_dir="docstrings-on-attributes-golden-record", + config_file_name="docstrings_on_attributes.config.yml", + ) + + def regen_custom_template_golden_record(): runner = CliRunner() openapi_path = Path(__file__).parent / "baseline_openapi_3.0.json" @@ -143,5 +144,6 @@ def regen_custom_template_golden_record(): regen_golden_record() regen_golden_record_3_1_features() regen_metadata_snapshots() + regen_docstrings_on_attributes_golden_record() regen_custom_template_golden_record() regen_literal_enums_golden_record() diff --git a/end_to_end_tests/test_end_to_end.py b/end_to_end_tests/test_end_to_end.py index 124b801d2..2d7238a2c 100644 --- a/end_to_end_tests/test_end_to_end.py +++ b/end_to_end_tests/test_end_to_end.py @@ -192,6 +192,16 @@ def test_none_meta(): ) +def test_docstrings_on_attributes(): + config_path = Path(__file__).parent / "docstrings_on_attributes.config.yml" + run_e2e_test( + "docstrings_on_attributes.yml", + [f"--config={config_path}"], + {}, + "docstrings-on-attributes-golden-record", + ) + + def test_custom_templates(): expected_differences = ( {} diff --git a/openapi_python_client/__init__.py b/openapi_python_client/__init__.py index af6944ae4..ba6380cd5 100644 --- a/openapi_python_client/__init__.py +++ b/openapi_python_client/__init__.py @@ -90,6 +90,7 @@ def __init__( self.env.filters.update(TEMPLATE_FILTERS) self.env.globals.update( + config=config, utils=utils, python_identifier=lambda x: utils.PythonIdentifier(x, config.field_prefix), class_name=lambda x: utils.ClassName(x, config.field_prefix), diff --git a/openapi_python_client/config.py b/openapi_python_client/config.py index 9cc002d12..1616ac785 100644 --- a/openapi_python_client/config.py +++ b/openapi_python_client/config.py @@ -41,6 +41,7 @@ class ConfigFile(BaseModel): package_version_override: Optional[str] = None use_path_prefixes_for_title_model_names: bool = True post_hooks: Optional[list[str]] = None + docstrings_on_attributes: bool = False field_prefix: str = "field_" generate_all_tags: bool = False http_timeout: int = 5 @@ -70,6 +71,7 @@ class Config: package_version_override: Optional[str] use_path_prefixes_for_title_model_names: bool post_hooks: list[str] + docstrings_on_attributes: bool field_prefix: str generate_all_tags: bool http_timeout: int @@ -111,6 +113,7 @@ def from_sources( package_version_override=config_file.package_version_override, use_path_prefixes_for_title_model_names=config_file.use_path_prefixes_for_title_model_names, post_hooks=post_hooks, + docstrings_on_attributes=config_file.docstrings_on_attributes, field_prefix=config_file.field_prefix, generate_all_tags=config_file.generate_all_tags, http_timeout=config_file.http_timeout, diff --git a/openapi_python_client/templates/client.py.jinja b/openapi_python_client/templates/client.py.jinja index aee6096e9..cf0301a9a 100644 --- a/openapi_python_client/templates/client.py.jinja +++ b/openapi_python_client/templates/client.py.jinja @@ -5,6 +5,31 @@ from attrs import define, field, evolve import httpx +{% set attrs_info = { + "raise_on_unexpected_status": namespace( + type="bool", + default="field(default=False, kw_only=True)", + docstring="Whether or not to raise an errors.UnexpectedStatus if the API returns a status code" + " that was not documented in the source OpenAPI document. Can also be provided as a keyword" + " argument to the constructor." + ), + "token": namespace(type="str", default="", docstring="The token to use for authentication"), + "prefix": namespace(type="str", default='"Bearer"', docstring="The prefix to use for the Authorization header"), + "auth_header_name": namespace(type="str", default='"Authorization"', docstring="The name of the Authorization header"), +} %} + +{% macro attr_in_class_docstring(name) %} +{{ name }}: {{ attrs_info[name].docstring }} +{%- endmacro %} + +{% macro declare_attr(name) %} +{% set attr = attrs_info[name] %} +{{ name }}: {{ attr.type }}{% if attr.default %} = {{ attr.default }}{% endif %} +{% if attr.docstring and config.docstrings_on_attributes +%} +"""{{ attr.docstring }}""" +{%- endif %} +{% endmacro %} + @define class Client: """A class for keeping track of data related to the API @@ -29,14 +54,14 @@ class Client: ``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor. {% endmacro %} {{ httpx_args_docstring() }} +{% if not config.docstrings_on_attributes %} Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. + {{ attr_in_class_docstring("raise_on_unexpected_status") | wordwrap(101) | indent(12) }} +{% endif %} """ {% macro attributes() %} - raise_on_unexpected_status: bool = field(default=False, kw_only=True) + {{ declare_attr("raise_on_unexpected_status") | indent(4) }} _base_url: str = field(alias="base_url") _cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies") _headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers") @@ -147,20 +172,20 @@ class AuthenticatedClient: """A Client which has been authenticated for use on secured endpoints {{ httpx_args_docstring() }} +{% if not config.docstrings_on_attributes %} Attributes: - raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a - status code that was not documented in the source OpenAPI document. Can also be provided as a keyword - argument to the constructor. - token: The token to use for authentication - prefix: The prefix to use for the Authorization header - auth_header_name: The name of the Authorization header + {{ attr_in_class_docstring("raise_on_unexpected_status") | wordwrap(101) | indent(12) }} + {{ attr_in_class_docstring("token") | indent(8) }} + {{ attr_in_class_docstring("prefix") | indent(8) }} + {{ attr_in_class_docstring("auth_header_name") | indent(8) }} +{% endif %} """ {{ attributes() }} - token: str - prefix: str = "Bearer" - auth_header_name: str = "Authorization" + {{ declare_attr("token") | indent(4) }} + {{ declare_attr("prefix") | indent(4) }} + {{ declare_attr("auth_header_name") | indent(4) }} {{ builders("AuthenticatedClient") }} {{ httpx_stuff("AuthenticatedClient", "self._headers[self.auth_header_name] = f\"{self.prefix} {self.token}\" if self.prefix else self.token") }} diff --git a/openapi_python_client/templates/helpers.jinja b/openapi_python_client/templates/helpers.jinja index 180613c02..fd5c3ec86 100644 --- a/openapi_python_client/templates/helpers.jinja +++ b/openapi_python_client/templates/helpers.jinja @@ -1,8 +1,10 @@ -{% macro safe_docstring(content) %} +{% macro safe_docstring(content, omit_if_empty=False) %} {# This macro returns the provided content as a docstring, set to a raw string if it contains a backslash #} +{% if (not omit_if_empty) or (content | trim) %} {% if '\\' in content -%} r""" {{ content }} """ {%- else -%} """ {{ content }} """ {%- endif -%} +{% endif %} {% endmacro %} \ No newline at end of file diff --git a/openapi_python_client/templates/model.py.jinja b/openapi_python_client/templates/model.py.jinja index 739f68962..6cd3fca5e 100644 --- a/openapi_python_client/templates/model.py.jinja +++ b/openapi_python_client/templates/model.py.jinja @@ -47,25 +47,34 @@ T = TypeVar("T", bound="{{ class_name }}") {{ model.example | string | wordwrap(112) | indent(12) }} {% endif %} - {% if model.required_properties or model.optional_properties %} + {% if (not config.docstrings_on_attributes) and (model.required_properties or model.optional_properties) %} Attributes: {% for property in model.required_properties + model.optional_properties %} {{ property.to_docstring() | wordwrap(112) | indent(12) }} {% endfor %}{% endif %} {% endmacro %} +{% macro declare_property(property) %} +{%- if config.docstrings_on_attributes and property.description -%} +{{ property.to_string() }} +{{ safe_docstring(property.description, omit_if_empty=True) | wordwrap(112) }} +{%- else -%} +{{ property.to_string() }} +{%- endif -%} +{% endmacro %} + @_attrs_define class {{ class_name }}: - {{ safe_docstring(class_docstring_content(model)) | indent(4) }} + {{ safe_docstring(class_docstring_content(model), omit_if_empty=config.docstrings_on_attributes) | indent(4) }} {% for property in model.required_properties + model.optional_properties %} {% if property.default is none and property.required %} - {{ property.to_string() }} + {{ declare_property(property) | indent(4) }} {% endif %} {% endfor %} {% for property in model.required_properties + model.optional_properties %} {% if property.default is not none or not property.required %} - {{ property.to_string() }} + {{ declare_property(property) | indent(4) }} {% endif %} {% endfor %} {% if model.additional_properties %}