Skip to content

Commit 5127d7e

Browse files
Merge main-v.0.7.3"
2 parents cd9fcda + 0b9a99a commit 5127d7e

File tree

6 files changed

+304
-15
lines changed

6 files changed

+304
-15
lines changed

openapi_python_client/parser/properties/__init__.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,23 @@ def build_model_property(
272272
required_properties: List[Property] = []
273273
optional_properties: List[Property] = []
274274
relative_imports: Set[str] = set()
275+
references: List[oai.Reference] = []
275276

276277
class_name = data.title or name
277278
if parent_name:
278279
class_name = f"{utils.pascal_case(parent_name)}{utils.pascal_case(class_name)}"
279280
ref = Reference.from_ref(class_name)
280281

281-
for key, value in (data.properties or {}).items():
282+
all_props = data.properties or {}
283+
if not isinstance(data, oai.Reference) and data.allOf:
284+
for sub_prop in data.allOf:
285+
if isinstance(sub_prop, oai.Reference):
286+
references += [sub_prop]
287+
else:
288+
all_props.update(sub_prop.properties or {})
289+
required_set.update(sub_prop.required or [])
290+
291+
for key, value in all_props.items():
282292
prop_required = key in required_set
283293
prop, schemas = property_from_data(
284294
name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name
@@ -314,6 +324,7 @@ def build_model_property(
314324

315325
prop = ModelProperty(
316326
reference=ref,
327+
references=references,
317328
required_properties=required_properties,
318329
optional_properties=optional_properties,
319330
relative_imports=relative_imports,
@@ -471,9 +482,6 @@ def _property_from_data(
471482
)
472483
if data.anyOf or data.oneOf:
473484
return build_union_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name)
474-
if not data.type:
475-
return NoneProperty(name=name, required=required, nullable=False, default=None), schemas
476-
477485
if data.type == "string":
478486
return _string_based_property(name=name, required=required, data=data), schemas
479487
elif data.type == "number":
@@ -508,8 +516,10 @@ def _property_from_data(
508516
)
509517
elif data.type == "array":
510518
return build_list_property(data=data, name=name, required=required, schemas=schemas, parent_name=parent_name)
511-
elif data.type == "object":
519+
elif data.type == "object" or data.allOf:
512520
return build_model_property(data=data, name=name, schemas=schemas, required=required, parent_name=parent_name)
521+
elif not data.type:
522+
return NoneProperty(name=name, required=required, nullable=False, default=None), schemas
513523
return PropertyError(data=data, detail=f"unknown type {data.type}"), schemas
514524

515525

@@ -566,6 +576,16 @@ def build_schemas(*, components: Dict[str, Union[oai.Reference, oai.Schema]]) ->
566576
schemas = schemas_or_err
567577
processing = True # We made some progress this round, do another after it's done
568578
to_process = next_round
569-
schemas.errors.extend(errors)
570579

580+
resolve_errors: List[PropertyError] = []
581+
models = list(schemas.models.values())
582+
for model in models:
583+
schemas_or_err = model.resolve_references(components=components, schemas=schemas)
584+
if isinstance(schemas_or_err, PropertyError):
585+
resolve_errors.append(schemas_or_err)
586+
else:
587+
schemas = schemas_or_err
588+
589+
schemas.errors.extend(errors)
590+
schemas.errors.extend(resolve_errors)
571591
return schemas

openapi_python_client/parser/properties/model_property.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1-
from typing import ClassVar, List, Set, Union
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterable
4+
from typing import TYPE_CHECKING, ClassVar, Dict, List, Set, Union
25

36
import attr
47

8+
from ... import schema as oai
9+
from ..errors import PropertyError
510
from ..reference import Reference
611
from .property import Property
712

13+
if TYPE_CHECKING:
14+
from .schemas import Schemas
15+
816

917
@attr.s(auto_attribs=True, frozen=True)
1018
class ModelProperty(Property):
1119
""" A property which refers to another Schema """
1220

1321
reference: Reference
14-
22+
references: List[oai.Reference]
1523
required_properties: List[Property]
1624
optional_properties: List[Property]
1725
description: str
@@ -21,6 +29,49 @@ class ModelProperty(Property):
2129

2230
template: ClassVar[str] = "model_property.pyi"
2331

32+
def resolve_references(
33+
self, components: Dict[str, Union[oai.Reference, oai.Schema]], schemas: Schemas
34+
) -> Union[Schemas, PropertyError]:
35+
from ..properties import property_from_data
36+
37+
required_set = set()
38+
props = {}
39+
while self.references:
40+
reference = self.references.pop()
41+
source_name = Reference.from_ref(reference.ref).class_name
42+
referenced_prop = components[source_name]
43+
assert isinstance(referenced_prop, oai.Schema)
44+
for p, val in (referenced_prop.properties or {}).items():
45+
props[p] = (val, source_name)
46+
for sub_prop in referenced_prop.allOf or []:
47+
if isinstance(sub_prop, oai.Reference):
48+
self.references.append(sub_prop)
49+
else:
50+
for p, val in (sub_prop.properties or {}).items():
51+
props[p] = (val, source_name)
52+
if isinstance(referenced_prop.required, Iterable):
53+
for sub_prop_name in referenced_prop.required:
54+
required_set.add(sub_prop_name)
55+
56+
for key, (value, source_name) in (props or {}).items():
57+
required = key in required_set
58+
prop, schemas = property_from_data(
59+
name=key, required=required, data=value, schemas=schemas, parent_name=source_name
60+
)
61+
if isinstance(prop, PropertyError):
62+
return prop
63+
if required:
64+
self.required_properties.append(prop)
65+
# Remove the optional version
66+
new_optional_props = [op for op in self.optional_properties if op.name != prop.name]
67+
self.optional_properties.clear()
68+
self.optional_properties.extend(new_optional_props)
69+
elif not any(ep for ep in (self.optional_properties + self.required_properties) if ep.name == prop.name):
70+
self.optional_properties.append(prop)
71+
self.relative_imports.update(prop.get_imports(prefix=".."))
72+
73+
return schemas
74+
2475
def get_base_type_string(self) -> str:
2576
return self.reference.class_name
2677

pyproject.toml

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ task regen\
6767
&& task regen_custom\
6868
&& task e2e\
6969
"""
70-
docs = "typer openapi_python_client/cli.py utils docs > usage.md"
70+
gen-setuppy = """
71+
poetry build \
72+
&& tar --strip-components=1 -xvf "$(ls -1 dist/*tar.gz | tail -1)" '*/setup.py' \
73+
&& isort setup.py \
74+
&& black setup.py
75+
"""
7176

7277
[tool.black]
7378
line-length = 120
@@ -95,5 +100,8 @@ skip = [".venv", "tests/test_templates"]
95100
omit = ["openapi_python_client/templates/*"]
96101

97102
[build-system]
98-
requires = ["poetry>=1.0"]
103+
requires = [
104+
"setuptools>=30.3.0,<50",
105+
"poetry>=1.0"
106+
]
99107
build-backend = "poetry.masonry.api"

setup.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# -*- coding: utf-8 -*-
2+
from setuptools import setup
3+
4+
packages = [
5+
"openapi_python_client",
6+
"openapi_python_client.parser",
7+
"openapi_python_client.parser.properties",
8+
"openapi_python_client.schema",
9+
"openapi_python_client.templates",
10+
]
11+
12+
package_data = {"": ["*"], "openapi_python_client.templates": ["property_templates/*"]}
13+
14+
install_requires = [
15+
"attrs>=20.1.0,<21.0.0",
16+
"autoflake>=1.4,<2.0",
17+
"black>=20.8b1",
18+
"httpx>=0.15.4,<0.17.0",
19+
"isort>=5.0.5,<6.0.0",
20+
"jinja2>=2.11.1,<3.0.0",
21+
"pydantic>=1.6.1,<2.0.0",
22+
"python-dateutil>=2.8.1,<3.0.0",
23+
"pyyaml>=5.3.1,<6.0.0",
24+
"shellingham>=1.3.2,<2.0.0",
25+
"stringcase>=1.2.0,<2.0.0",
26+
"typer>=0.3,<0.4",
27+
]
28+
29+
extras_require = {
30+
':python_version < "3.8"': ["importlib_metadata>=2.0.0,<3.0.0"],
31+
':sys_platform == "win32"': ["colorama>=0.4.3,<0.5.0"],
32+
}
33+
34+
entry_points = {"console_scripts": ["openapi-python-client = openapi_python_client.cli:app"]}
35+
36+
setup_kwargs = {
37+
"name": "openapi-python-client",
38+
"version": "0.7.0",
39+
"description": "Generate modern Python clients from OpenAPI",
40+
"long_description": '[![triaxtec](https://circleci.com/gh/triaxtec/openapi-python-client.svg?style=svg)](https://circleci.com/gh/triaxtec/openapi-python-client)\n[![codecov](https://codecov.io/gh/triaxtec/openapi-python-client/branch/main/graph/badge.svg)](https://codecov.io/gh/triaxtec/openapi-python-client)\n[![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](https://lbesson.mit-license.org/)\n[![Generic badge](https://img.shields.io/badge/type_checked-mypy-informational.svg)](https://mypy.readthedocs.io/en/stable/introduction.html)\n[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black)\n[![PyPI version shields.io](https://img.shields.io/pypi/v/openapi-python-client.svg)](https://pypi.python.org/pypi/openapi-python-client/)\n[![Downloads](https://static.pepy.tech/personalized-badge/openapi-python-client?period=total&units=international_system&left_color=blue&right_color=green&left_text=Downloads)](https://pepy.tech/project/openapi-python-client)\n\n# openapi-python-client\n\nGenerate modern Python clients from OpenAPI 3.x documents.\n\n_This generator does not support OpenAPI 2.x FKA Swagger. If you need to use an older document, try upgrading it to\nversion 3 first with one of many available converters._\n\n**This project is still in development and does not support all OpenAPI features**\n\n## Why This?\n\nThe Python clients generated by openapi-generator support Python 2 and therefore come with a lot of baggage. This tool\naims to generate clients which:\n\n1. Use all the latest and greatest Python features like type annotations and dataclasses\n1. Don\'t carry around a bunch of compatibility code for older version of Python (e.g. the `six` package)\n1. Have better documentation and more obvious usage instructions\n\nAdditionally, because this generator is written in Python, it should be more accessible to contribution by the people\nusing it (Python developers).\n\n## Installation\n\nI recommend you install with [pipx](https://pipxproject.github.io/pipx/) so you don\'t conflict with any other packages\nyou might have: `pipx install openapi-python-client`.\n\nBetter yet, use `pipx run openapi-python-client <normal params / options>` to always use the latest version of the generator.\n\nYou can install with normal pip if you want to though: `pip install openapi-python-client`\n\nThen, if you want tab completion: `openapi-python-client --install-completion`\n\n## Usage\n\n### Create a new client\n\n`openapi-python-client generate --url https://my.api.com/openapi.json`\n\nThis will generate a new client library named based on the title in your OpenAPI spec. For example, if the title\nof your API is "My API", the expected output will be "my-api-client". If a folder already exists by that name, you\'ll\nget an error.\n\n### Update an existing client\n\n`openapi-python-client update --url https://my.api.com/openapi.json`\n\n> For more usage details run `openapi-python-client --help` or read [usage](usage.md)\n\n### Using custom templates\n\nThis feature leverages Jinja2\'s [ChoiceLoader](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.ChoiceLoader) and [FileSystemLoader](https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.FileSystemLoader). This means you do _not_ need to customize every template. Simply copy the template(s) you want to customize from [the default template directory](openapi_python_client/templates) to your own custom template directory (file names _must_ match exactly) and pass the template directory through the `custom-template-path` flag to the `generate` and `update` commands. For instance,\n\n```\nopenapi-python-client update \\\n --url https://my.api.com/openapi.json \\\n --custom-template-path=relative/path/to/mytemplates\n```\n\n_Be forewarned, this is a beta-level feature in the sense that the API exposed in the templates is undocumented and unstable._\n\n## What You Get\n\n1. A `pyproject.toml` file with some basic metadata intended to be used with [Poetry].\n1. A `README.md` you\'ll most definitely need to update with your project\'s details\n1. A Python module named just like the auto-generated project name (e.g. "my_api_client") which contains:\n 1. A `client` module which will have both a `Client` class and an `AuthenticatedClient` class. You\'ll need these\n for calling the functions in the `api` module.\n 1. An `api` module which will contain one module for each tag in your OpenAPI spec, as well as a `default` module\n for endpoints without a tag. Each of these modules in turn contains one function for calling each endpoint.\n 1. A `models` module which has all the classes defined by the various schemas in your OpenAPI spec\n\nFor a full example you can look at the `end_to_end_tests` directory which has an `openapi.json` file.\n"golden-record" in that same directory is the generated client from that OpenAPI document.\n\n## OpenAPI features supported\n\n1. All HTTP Methods\n1. JSON and form bodies, path and query parameters\n1. File uploads with multipart/form-data bodies\n1. float, string, int, date, datetime, string enums, and custom schemas or lists containing any of those\n1. html/text or application/json responses containing any of the previous types\n1. Bearer token security\n\n## Configuration\n\nYou can pass a YAML (or JSON) file to openapi-python-client with the `--config` option in order to change some behavior.\nThe following parameters are supported:\n\n### class_overrides\n\nUsed to change the name of generated model classes. This param should be a mapping of existing class name\n(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name. As an example, if the\nname of the a model in OpenAPI (and therefore the generated class name) was something like "\\_PrivateInternalLongName"\nand you want the generated client\'s model to be called "ShortName" in a module called "short_name" you could do this:\n\nExample:\n\n```yaml\nclass_overrides:\n _PrivateInternalLongName:\n class_name: ShortName\n module_name: short_name\n```\n\nThe easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the\nmodels folder.\n\n### project_name_override and package_name_override\n\nUsed to change the name of generated client library project/package. If the project name is changed but an override for the package name\nisn\'t provided, the package name will be converted from the project name using the standard convention (replacing `-`\'s with `_`\'s).\n\nExample:\n\n```yaml\nproject_name_override: my-special-project-name\npackage_name_override: my_extra_special_package_name\n```\n\n### field_prefix\n\nWhen generating properties, the `name` attribute of the OpenAPI schema will be used. When the `name` is not a valid\nPython identifier (e.g. begins with a number) this string will be prepended. Defaults to "field\\_".\n\nExample:\n\n```yaml\nfield_prefix: attr_\n```\n\n### package_version_override\n\nSpecify the package version of the generated client. If unset, the client will use the version of the OpenAPI spec.\n\nExample:\n\n```yaml\npackage_version_override: 1.2.3\n```\n\n[changelog.md]: CHANGELOG.md\n[poetry]: https://python-poetry.org/\n',
41+
"author": "Dylan Anthony",
42+
"author_email": "[email protected]",
43+
"maintainer": None,
44+
"maintainer_email": None,
45+
"url": "https://github.com/triaxtec/openapi-python-client",
46+
"packages": packages,
47+
"package_data": package_data,
48+
"install_requires": install_requires,
49+
"extras_require": extras_require,
50+
"entry_points": entry_points,
51+
"python_requires": ">=3.6,<4.0",
52+
}
53+
54+
55+
setup(**setup_kwargs)

tests/test_parser/test_properties/test_init.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ def test_property_from_data_ref_model(self):
584584
nullable=False,
585585
default=None,
586586
reference=Reference(class_name=class_name, module_name="my_model"),
587+
references=[],
587588
required_properties=[],
588589
optional_properties=[],
589590
description="",
@@ -600,6 +601,7 @@ def test_property_from_data_ref_model(self):
600601
nullable=False,
601602
default=None,
602603
reference=Reference(class_name=class_name, module_name="my_model"),
604+
references=[],
603605
required_properties=[],
604606
optional_properties=[],
605607
description="",
@@ -986,19 +988,25 @@ def test__string_based_property_unsupported_format(self, mocker):
986988
def test_build_schemas(mocker):
987989
build_model_property = mocker.patch(f"{MODULE_NAME}.build_model_property")
988990
in_data = {"1": mocker.MagicMock(enum=None), "2": mocker.MagicMock(enum=None), "3": mocker.MagicMock(enum=None)}
991+
989992
model_1 = mocker.MagicMock()
990993
schemas_1 = mocker.MagicMock()
991994
model_2 = mocker.MagicMock()
992995
schemas_2 = mocker.MagicMock(errors=[])
993-
error = PropertyError()
996+
schemas_2.models = {"1": model_1, "2": model_2}
997+
error_1 = PropertyError()
994998
schemas_3 = mocker.MagicMock()
999+
schemas_4 = mocker.MagicMock(errors=[])
1000+
model_1.resolve_references.return_value = schemas_4
1001+
error_2 = PropertyError()
1002+
model_2.resolve_references.return_value = error_2
9951003

9961004
# This loops through one for each, then again to retry the error
9971005
build_model_property.side_effect = [
9981006
(model_1, schemas_1),
9991007
(model_2, schemas_2),
1000-
(error, schemas_3),
1001-
(error, schemas_3),
1008+
(error_1, schemas_3),
1009+
(error_1, schemas_3),
10021010
]
10031011

10041012
from openapi_python_client.parser.properties import Schemas, build_schemas
@@ -1014,8 +1022,12 @@ def test_build_schemas(mocker):
10141022
]
10151023
)
10161024
# schemas_3 was the last to come back from build_model_property, but it should be ignored because it's an error
1017-
assert result == schemas_2
1018-
assert result.errors == [error]
1025+
model_1.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_2)
1026+
# schemas_4 came from resolving model_1
1027+
model_2.resolve_references.assert_called_once_with(components=in_data, schemas=schemas_4)
1028+
# resolving model_2 resulted in err, so no schemas_5
1029+
assert result == schemas_4
1030+
assert result.errors == [error_1, error_2]
10191031

10201032

10211033
def test_build_parse_error_on_reference():
@@ -1089,6 +1101,7 @@ def test_build_model_property(additional_properties_schema, expected_additional_
10891101
nullable=False,
10901102
default=None,
10911103
reference=Reference(class_name="ParentMyModel", module_name="parent_my_model"),
1104+
references=[],
10921105
required_properties=[StringProperty(name="req", required=True, nullable=False, default=None)],
10931106
optional_properties=[DateTimeProperty(name="opt", required=False, nullable=False, default=None)],
10941107
description=data.description,

0 commit comments

Comments
 (0)