Skip to content

fix: Prevent backslashes in descriptions from breaking docstrings [#735]. Thanks @robertschweizer & @bryan-hunt! #735

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from . import (
callback_test,
defaults_tests_defaults_post,
description_with_backslash,
get_basic_list_of_booleans,
get_basic_list_of_floats,
get_basic_list_of_integers,
Expand Down Expand Up @@ -158,3 +159,10 @@ def callback_test(cls) -> types.ModuleType:
Try sending a request related to a callback
"""
return callback_test

@classmethod
def description_with_backslash(cls) -> types.ModuleType:
"""
Test description with \
"""
return description_with_backslash
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from http import HTTPStatus
from typing import Any, Dict, Optional

import httpx

from ... import errors
from ...client import Client
from ...types import Response


def _get_kwargs(
*,
client: Client,
) -> Dict[str, Any]:
url = "{}/tests/description-with-backslash".format(client.base_url)

headers: Dict[str, str] = client.get_headers()
cookies: Dict[str, Any] = client.get_cookies()

return {
"method": "get",
"url": url,
"headers": headers,
"cookies": cookies,
"timeout": client.get_timeout(),
}


def _parse_response(*, client: Client, response: httpx.Response) -> Optional[Any]:
if response.status_code == HTTPStatus.OK:
return None
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(f"Unexpected status code: {response.status_code}")
else:
return None


def _build_response(*, client: Client, response: httpx.Response) -> Response[Any]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)


def sync_detailed(
*,
client: Client,
) -> Response[Any]:
r""" Test description with \

Test description with \

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
Response[Any]
"""

kwargs = _get_kwargs(
client=client,
)

response = httpx.request(
verify=client.verify_ssl,
**kwargs,
)

return _build_response(client=client, response=response)


async def asyncio_detailed(
*,
client: Client,
) -> Response[Any]:
r""" Test description with \

Test description with \

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
Response[Any]
"""

kwargs = _get_kwargs(
client=client,
)

async with httpx.AsyncClient(verify=client.verify_ssl) as _client:
response = await _client.request(**kwargs)

return _build_response(client=client, response=response)
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from .model_with_additional_properties_refed import ModelWithAdditionalPropertiesRefed
from .model_with_any_json_properties import ModelWithAnyJsonProperties
from .model_with_any_json_properties_additional_property_type_0 import ModelWithAnyJsonPropertiesAdditionalPropertyType0
from .model_with_backslash_in_description import ModelWithBackslashInDescription
from .model_with_circular_ref_a import ModelWithCircularRefA
from .model_with_circular_ref_b import ModelWithCircularRefB
from .model_with_circular_ref_in_additional_properties_a import ModelWithCircularRefInAdditionalPropertiesA
Expand Down Expand Up @@ -111,6 +112,7 @@
"ModelWithAdditionalPropertiesRefed",
"ModelWithAnyJsonProperties",
"ModelWithAnyJsonPropertiesAdditionalPropertyType0",
"ModelWithBackslashInDescription",
"ModelWithCircularRefA",
"ModelWithCircularRefB",
"ModelWithCircularRefInAdditionalPropertiesA",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Any, Dict, List, Type, TypeVar

import attr

T = TypeVar("T", bound="ModelWithBackslashInDescription")


@attr.s(auto_attribs=True)
class ModelWithBackslashInDescription:
r""" Description with special character: \

"""

additional_properties: Dict[str, Any] = attr.ib(init=False, factory=dict)

def to_dict(self) -> Dict[str, Any]:

field_dict: Dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update({})

return field_dict

@classmethod
def from_dict(cls: Type[T], src_dict: Dict[str, Any]) -> T:
d = src_dict.copy()
model_with_backslash_in_description = cls()

model_with_backslash_in_description.additional_properties = d
return model_with_backslash_in_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
19 changes: 19 additions & 0 deletions end_to_end_tests/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,21 @@
}
}
}
},
"/tests/description-with-backslash": {
"get": {
"tags": [
"tests"
],
"summary": "Test description with \\",
"description": "Test description with \\",
"operationId": "description_with_backslash",
"responses": {
"200": {
"description": "Successful response"
}
}
}
}
},
"components": {
Expand Down Expand Up @@ -2223,6 +2238,10 @@
"$ref": "#/components/schemas/AnArrayWithACircularRefInItemsObjectAdditionalPropertiesA"
}
}
},
"ModelWithBackslashInDescription": {
"type": "object",
"description": "Description with special character: \\"
}
},
"parameters": {
Expand Down
10 changes: 7 additions & 3 deletions openapi_python_client/templates/endpoint_macros.py.jinja
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{% from "property_templates/helpers.jinja" import guarded_statement %}
{% from "helpers.jinja" import safe_docstring %}

{% macro header_params(endpoint) %}
{% if endpoint.header_parameters %}
Expand Down Expand Up @@ -140,8 +141,8 @@ json_body=json_body,
{% endfor %}
{% endmacro %}

{% macro docstring(endpoint, return_string) %}
"""{% if endpoint.summary %}{{ endpoint.summary | wordwrap(100)}}
{% macro docstring_content(endpoint, return_string) %}
{% if endpoint.summary %}{{ endpoint.summary | wordwrap(100)}}

{% endif -%}
{%- if endpoint.description %} {{ endpoint.description | wordwrap(100) }}
Expand All @@ -165,5 +166,8 @@ Raises:

Returns:
Response[{{ return_string }}]
"""
{% endmacro %}

{% macro docstring(endpoint, return_string) %}
{{ safe_docstring(docstring_content(endpoint, return_string)) }}
{% endmacro %}
8 changes: 8 additions & 0 deletions openapi_python_client/templates/helpers.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{% macro safe_docstring(content) %}
{# This macro returns the provided content as a docstring, set to a raw string if it contains a backslash #}
{% if '\\' in content -%}
r""" {{ content }} """
{%- else -%}
""" {{ content }} """
{%- endif -%}
{% endmacro %}
13 changes: 9 additions & 4 deletions openapi_python_client/templates/model.py.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ if TYPE_CHECKING:
{% set class_name = model.class_info.name %}
{% set module_name = model.class_info.module_name %}

{% from "helpers.jinja" import safe_docstring %}

T = TypeVar("T", bound="{{ class_name }}")

@attr.s(auto_attribs=True)
class {{ class_name }}:
"""{% if model.title %}{{ model.title | wordwrap(116) }}
{% macro class_docstring_content(model) %}
{% if model.title %}{{ model.title | wordwrap(116) }}

{% endif -%}
{%- if model.description %}{{ model.description | wordwrap(116) }}
Expand All @@ -55,7 +56,11 @@ class {{ class_name }}:
{% for property in model.required_properties + model.optional_properties %}
{{ property.to_docstring() | wordwrap(112) | indent(12) }}
{% endfor %}{% endif %}
"""
{% endmacro %}

@attr.s(auto_attribs=True)
class {{ class_name }}:
{{ safe_docstring(class_docstring_content(model)) | indent(4) }}

{% for property in model.required_properties + model.optional_properties %}
{% if property.default is none and property.required %}
Expand Down
4 changes: 3 additions & 1 deletion openapi_python_client/templates/package_init.py.jinja
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
""" {{ package_description }} """
{% from "helpers.jinja" import safe_docstring %}

{{ safe_docstring(package_description) }}
from .client import AuthenticatedClient, Client

__all__ = (
Expand Down