Skip to content

Commit 457ebca

Browse files
authored
fix!: Normalize generated module names to allow more tags [#428 & #448]. Thanks @iamnoah & @forest-benchling!
* fix!: Turn all tags (endpoints) into valid Python identifiers [WIP] * test: Fix all failing unit tests * test: Add e2e test for tag beginning with number * chore(deps): Update deps, add dataclasses types for Python 3.6 * refactor: Switch _PythonIdentifier to a public custom class in `utils`
1 parent f19b582 commit 457ebca

File tree

24 files changed

+1141
-931
lines changed

24 files changed

+1141
-931
lines changed

end_to_end_tests/custom-templates-golden-record/my_test_api_client/api/__init__.py

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from my_test_api_client.api.default import DefaultEndpoints
66
from my_test_api_client.api.parameters import ParametersEndpoints
7+
from my_test_api_client.api.tag1 import Tag1Endpoints
78
from my_test_api_client.api.tests import TestsEndpoints
89

910

@@ -19,3 +20,7 @@ def default(cls) -> Type[DefaultEndpoints]:
1920
@classmethod
2021
def parameters(cls) -> Type[ParametersEndpoints]:
2122
return ParametersEndpoints
23+
24+
@classmethod
25+
def tag1(cls) -> Type[Tag1Endpoints]:
26+
return Tag1Endpoints
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
""" Contains methods for accessing the API Endpoints """
2+
3+
import types
4+
5+
from my_test_api_client.api.tag1 import get_tag_with_number
6+
7+
8+
class Tag1Endpoints:
9+
@classmethod
10+
def get_tag_with_number(cls) -> types.ModuleType:
11+
return get_tag_with_number

end_to_end_tests/golden-record/my_test_api_client/api/tag1/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import Any, Dict
2+
3+
import httpx
4+
5+
from ...client import Client
6+
from ...types import Response
7+
8+
9+
def _get_kwargs(
10+
*,
11+
client: Client,
12+
) -> Dict[str, Any]:
13+
url = "{}/tag_with_number".format(client.base_url)
14+
15+
headers: Dict[str, Any] = client.get_headers()
16+
cookies: Dict[str, Any] = client.get_cookies()
17+
18+
return {
19+
"url": url,
20+
"headers": headers,
21+
"cookies": cookies,
22+
"timeout": client.get_timeout(),
23+
}
24+
25+
26+
def _build_response(*, response: httpx.Response) -> Response[Any]:
27+
return Response(
28+
status_code=response.status_code,
29+
content=response.content,
30+
headers=response.headers,
31+
parsed=None,
32+
)
33+
34+
35+
def sync_detailed(
36+
*,
37+
client: Client,
38+
) -> Response[Any]:
39+
kwargs = _get_kwargs(
40+
client=client,
41+
)
42+
43+
response = httpx.get(
44+
**kwargs,
45+
)
46+
47+
return _build_response(response=response)
48+
49+
50+
async def asyncio_detailed(
51+
*,
52+
client: Client,
53+
) -> Response[Any]:
54+
kwargs = _get_kwargs(
55+
client=client,
56+
)
57+
58+
async with httpx.AsyncClient() as _client:
59+
response = await _client.get(**kwargs)
60+
61+
return _build_response(response=response)

end_to_end_tests/openapi.json

+10
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,16 @@
823823
}
824824
}
825825
}
826+
},
827+
"/tag_with_number": {
828+
"get": {
829+
"tags": [1],
830+
"responses": {
831+
"200": {
832+
"description": "Success"
833+
}
834+
}
835+
}
826836
}
827837
},
828838
"components": {

end_to_end_tests/test_end_to_end.py

+13-5
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import shutil
23
from filecmp import cmpfiles, dircmp
34
from pathlib import Path
@@ -101,18 +102,25 @@ def test_end_to_end():
101102

102103
def test_custom_templates():
103104
expected_differences = {} # key: path relative to generated directory, value: expected generated content
105+
api_dir = Path("my_test_api_client").joinpath("api")
106+
golden_tpls_root_dir = Path(__file__).parent.joinpath("custom-templates-golden-record")
107+
104108
expected_difference_paths = [
105109
Path("README.md"),
106-
Path("my_test_api_client").joinpath("api", "__init__.py"),
107-
Path("my_test_api_client").joinpath("api", "tests", "__init__.py"),
108-
Path("my_test_api_client").joinpath("api", "default", "__init__.py"),
109-
Path("my_test_api_client").joinpath("api", "parameters", "__init__.py"),
110+
api_dir.joinpath("__init__.py"),
110111
]
111112

112-
golden_tpls_root_dir = Path(__file__).parent.joinpath("custom-templates-golden-record")
113113
for expected_difference_path in expected_difference_paths:
114114
expected_differences[expected_difference_path] = (golden_tpls_root_dir / expected_difference_path).read_text()
115115

116+
# Each API module (defined by tag) has a custom __init__.py in it now.
117+
for endpoint_mod in golden_tpls_root_dir.joinpath(api_dir).iterdir():
118+
if not endpoint_mod.is_dir():
119+
continue
120+
relative_path = api_dir.joinpath(endpoint_mod.name, "__init__.py")
121+
expected_text = endpoint_mod.joinpath("__init__.py").read_text()
122+
expected_differences[relative_path] = expected_text
123+
116124
run_e2e_test(
117125
extra_args=["--custom-template-path=end_to_end_tests/test_custom_templates/"],
118126
expected_differences=expected_differences,

openapi_python_client/config.py

-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ class Config(BaseModel):
2020
@staticmethod
2121
def load_from_path(path: Path) -> "Config":
2222
"""Creates a Config from provided JSON or YAML file and sets a bunch of globals from it"""
23-
from . import utils
24-
2523
config_data = yaml.safe_load(path.read_text())
2624
config = Config(**config_data)
27-
utils.FIELD_PREFIX = config.field_prefix
2825
return config

openapi_python_client/parser/openapi.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ class EndpointCollection:
3030
@staticmethod
3131
def from_data(
3232
*, data: Dict[str, oai.PathItem], schemas: Schemas, config: Config
33-
) -> Tuple[Dict[str, "EndpointCollection"], Schemas]:
33+
) -> Tuple[Dict[utils.PythonIdentifier, "EndpointCollection"], Schemas]:
3434
"""Parse the openapi paths data to get EndpointCollections by tag"""
35-
endpoints_by_tag: Dict[str, EndpointCollection] = {}
35+
endpoints_by_tag: Dict[utils.PythonIdentifier, EndpointCollection] = {}
3636

3737
methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"]
3838

@@ -41,7 +41,7 @@ def from_data(
4141
operation: Optional[oai.Operation] = getattr(path_data, method)
4242
if operation is None:
4343
continue
44-
tag = utils.snake_case((operation.tags or ["default"])[0])
44+
tag = utils.PythonIdentifier(value=(operation.tags or ["default"])[0], prefix="tag")
4545
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
4646
endpoint, schemas = Endpoint.from_data(
4747
data=operation, path=path, method=method, tag=tag, schemas=schemas, config=config
@@ -261,8 +261,8 @@ def _add_parameters(
261261
if prop.python_name in used_python_names:
262262
duplicate, duplicate_location = used_python_names[prop.python_name]
263263
if duplicate.python_name == prop.python_name: # Existing should be converted too for consistency
264-
duplicate.set_python_name(f"{duplicate.python_name}_{duplicate_location}")
265-
prop.set_python_name(f"{prop.python_name}_{param.param_in}")
264+
duplicate.set_python_name(f"{duplicate.python_name}_{duplicate_location}", config=config)
265+
prop.set_python_name(f"{prop.python_name}_{param.param_in}", config=config)
266266
else:
267267
used_python_names[prop.python_name] = (prop, param.param_in)
268268

@@ -340,7 +340,7 @@ class GeneratorData:
340340
version: str
341341
models: Iterator[ModelProperty]
342342
errors: List[ParseError]
343-
endpoint_collections_by_tag: Dict[str, EndpointCollection]
343+
endpoint_collections_by_tag: Dict[utils.PythonIdentifier, EndpointCollection]
344344
enums: Iterator[EnumProperty]
345345

346346
@staticmethod

openapi_python_client/parser/properties/__init__.py

+34-7
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ class UnionProperty(Property):
177177
has_properties_without_templates: bool = attr.ib(init=False)
178178

179179
def __attrs_post_init__(self) -> None:
180-
super().__attrs_post_init__()
181180
object.__setattr__(
182181
self, "has_properties_without_templates", any(prop.template is None for prop in self.inner_properties)
183182
)
@@ -235,30 +234,34 @@ def inner_properties_with_template(self) -> Iterator[Property]:
235234

236235

237236
def _string_based_property(
238-
name: str, required: bool, data: oai.Schema
237+
name: str, required: bool, data: oai.Schema, config: Config
239238
) -> Union[StringProperty, DateProperty, DateTimeProperty, FileProperty]:
240239
"""Construct a Property from the type "string" """
241240
string_format = data.schema_format
241+
python_name = utils.PythonIdentifier(value=name, prefix=config.field_prefix)
242242
if string_format == "date-time":
243243
return DateTimeProperty(
244244
name=name,
245245
required=required,
246246
default=convert("datetime.datetime", data.default),
247247
nullable=data.nullable,
248+
python_name=python_name,
248249
)
249250
elif string_format == "date":
250251
return DateProperty(
251252
name=name,
252253
required=required,
253254
default=convert("datetime.date", data.default),
254255
nullable=data.nullable,
256+
python_name=python_name,
255257
)
256258
elif string_format == "binary":
257259
return FileProperty(
258260
name=name,
259261
required=required,
260262
default=None,
261263
nullable=data.nullable,
264+
python_name=python_name,
262265
)
263266
else:
264267
return StringProperty(
@@ -267,6 +270,7 @@ def _string_based_property(
267270
required=required,
268271
pattern=data.pattern,
269272
nullable=data.nullable,
273+
python_name=python_name,
270274
)
271275

272276

@@ -326,6 +330,7 @@ def build_enum_property(
326330
values=values,
327331
value_type=value_type,
328332
default=None,
333+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
329334
)
330335

331336
default = get_enum_default(prop, data)
@@ -373,6 +378,7 @@ def build_union_property(
373378
default=default,
374379
inner_properties=sub_properties,
375380
nullable=data.nullable,
381+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
376382
),
377383
schemas,
378384
)
@@ -395,6 +401,7 @@ def build_list_property(
395401
default=None,
396402
inner_property=inner_prop,
397403
nullable=data.nullable,
404+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
398405
),
399406
schemas,
400407
)
@@ -406,6 +413,7 @@ def _property_from_ref(
406413
parent: Union[oai.Schema, None],
407414
data: oai.Reference,
408415
schemas: Schemas,
416+
config: Config,
409417
) -> Tuple[Union[Property, PropertyError], Schemas]:
410418
ref_path = parse_reference_path(data.ref)
411419
if isinstance(ref_path, ParseError):
@@ -414,7 +422,12 @@ def _property_from_ref(
414422
if not existing:
415423
return PropertyError(data=data, detail="Could not find reference in parsed models or enums"), schemas
416424

417-
prop = attr.evolve(existing, required=required, name=name)
425+
prop = attr.evolve(
426+
existing,
427+
required=required,
428+
name=name,
429+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
430+
)
418431
if parent:
419432
prop = attr.evolve(prop, nullable=parent.nullable)
420433
if isinstance(prop, EnumProperty):
@@ -437,12 +450,14 @@ def _property_from_data(
437450
"""Generate a Property from the OpenAPI dictionary representation of it"""
438451
name = utils.remove_string_escapes(name)
439452
if isinstance(data, oai.Reference):
440-
return _property_from_ref(name=name, required=required, parent=None, data=data, schemas=schemas)
453+
return _property_from_ref(name=name, required=required, parent=None, data=data, schemas=schemas, config=config)
441454

442455
# A union of a single reference should just be passed through to that reference (don't create copy class)
443456
sub_data = (data.allOf or []) + data.anyOf + data.oneOf
444457
if len(sub_data) == 1 and isinstance(sub_data[0], oai.Reference):
445-
return _property_from_ref(name=name, required=required, parent=data, data=sub_data[0], schemas=schemas)
458+
return _property_from_ref(
459+
name=name, required=required, parent=data, data=sub_data[0], schemas=schemas, config=config
460+
)
446461

447462
if data.enum:
448463
return build_enum_property(
@@ -459,14 +474,15 @@ def _property_from_data(
459474
data=data, name=name, required=required, schemas=schemas, parent_name=parent_name, config=config
460475
)
461476
elif data.type == "string":
462-
return _string_based_property(name=name, required=required, data=data), schemas
477+
return _string_based_property(name=name, required=required, data=data, config=config), schemas
463478
elif data.type == "number":
464479
return (
465480
FloatProperty(
466481
name=name,
467482
default=convert("float", data.default),
468483
required=required,
469484
nullable=data.nullable,
485+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
470486
),
471487
schemas,
472488
)
@@ -477,6 +493,7 @@ def _property_from_data(
477493
default=convert("int", data.default),
478494
required=required,
479495
nullable=data.nullable,
496+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
480497
),
481498
schemas,
482499
)
@@ -487,6 +504,7 @@ def _property_from_data(
487504
required=required,
488505
default=convert("bool", data.default),
489506
nullable=data.nullable,
507+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
490508
),
491509
schemas,
492510
)
@@ -499,7 +517,16 @@ def _property_from_data(
499517
data=data, name=name, schemas=schemas, required=required, parent_name=parent_name, config=config
500518
)
501519
elif not data.type:
502-
return AnyProperty(name=name, required=required, nullable=False, default=None), schemas
520+
return (
521+
AnyProperty(
522+
name=name,
523+
required=required,
524+
nullable=False,
525+
default=None,
526+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
527+
),
528+
schemas,
529+
)
503530
return PropertyError(data=data, detail=f"unknown type {data.type}"), schemas
504531

505532

openapi_python_client/parser/properties/model_property.py

+1
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ def build_model_property(
210210
required=required,
211211
name=name,
212212
additional_properties=additional_properties,
213+
python_name=utils.PythonIdentifier(value=name, prefix=config.field_prefix),
213214
)
214215
if class_info.name in schemas.classes_by_name:
215216
error = PropertyError(data=data, detail=f'Attempted to generate duplicate models with name "{class_info.name}"')

0 commit comments

Comments
 (0)