Skip to content

Commit 8a9556d

Browse files
committed
fix!: Turn all tags (endpoints) into valid Python identifiers [WIP]
1 parent f19b582 commit 8a9556d

File tree

13 files changed

+84
-76
lines changed

13 files changed

+84
-76
lines changed

openapi_python_client/config.py

Lines changed: 0 additions & 3 deletions
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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ..config import Config
1212
from .errors import GeneratorError, ParseError, PropertyError
1313
from .properties import Class, EnumProperty, ModelProperty, Property, Schemas, build_schemas, property_from_data
14+
from .properties.property import _PythonIdentifier, to_valid_python_identifier
1415
from .responses import Response, response_from_data
1516

1617

@@ -30,9 +31,9 @@ class EndpointCollection:
3031
@staticmethod
3132
def from_data(
3233
*, data: Dict[str, oai.PathItem], schemas: Schemas, config: Config
33-
) -> Tuple[Dict[str, "EndpointCollection"], Schemas]:
34+
) -> Tuple[Dict[_PythonIdentifier, "EndpointCollection"], Schemas]:
3435
"""Parse the openapi paths data to get EndpointCollections by tag"""
35-
endpoints_by_tag: Dict[str, EndpointCollection] = {}
36+
endpoints_by_tag: Dict[_PythonIdentifier, EndpointCollection] = {}
3637

3738
methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"]
3839

@@ -41,7 +42,7 @@ def from_data(
4142
operation: Optional[oai.Operation] = getattr(path_data, method)
4243
if operation is None:
4344
continue
44-
tag = utils.snake_case((operation.tags or ["default"])[0])
45+
tag = to_valid_python_identifier((operation.tags or ["default"])[0], prefix="tag")
4546
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
4647
endpoint, schemas = Endpoint.from_data(
4748
data=operation, path=path, method=method, tag=tag, schemas=schemas, config=config
@@ -261,8 +262,8 @@ def _add_parameters(
261262
if prop.python_name in used_python_names:
262263
duplicate, duplicate_location = used_python_names[prop.python_name]
263264
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}")
265+
duplicate.set_python_name(f"{duplicate.python_name}_{duplicate_location}", config=config)
266+
prop.set_python_name(f"{prop.python_name}_{param.param_in}", config=config)
266267
else:
267268
used_python_names[prop.python_name] = (prop, param.param_in)
268269

@@ -340,7 +341,7 @@ class GeneratorData:
340341
version: str
341342
models: Iterator[ModelProperty]
342343
errors: List[ParseError]
343-
endpoint_collections_by_tag: Dict[str, EndpointCollection]
344+
endpoint_collections_by_tag: Dict[_PythonIdentifier, EndpointCollection]
344345
enums: Iterator[EnumProperty]
345346

346347
@staticmethod

openapi_python_client/parser/properties/model_property.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from ... import schema as oai
88
from ... import utils
99
from ..errors import ParseError, PropertyError
10-
from .property import Property
10+
from .property import Property, to_valid_python_identifier
1111
from .schemas import Class, Schemas, parse_reference_path
1212

1313

@@ -210,6 +210,7 @@ def build_model_property(
210210
required=required,
211211
name=name,
212212
additional_properties=additional_properties,
213+
python_name=to_valid_python_identifier(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}"')

openapi_python_client/parser/properties/property.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
1-
from typing import ClassVar, Optional, Set
1+
from typing import ClassVar, NewType, Optional, Set
22

33
import attr
44

5-
from ... import utils
5+
from ... import Config
6+
from ...utils import fix_keywords, fix_reserved_words, sanitize, snake_case
7+
8+
_PythonIdentifier = NewType("_PythonIdentifier", str)
9+
10+
11+
def to_valid_python_identifier(value: str, prefix: str) -> _PythonIdentifier:
12+
"""
13+
Given a string, attempt to coerce it into a valid Python identifier by stripping out invalid characters and, if
14+
necessary, prepending a prefix.
15+
16+
See:
17+
https://docs.python.org/3/reference/lexical_analysis.html#identifiers
18+
"""
19+
new_value = fix_reserved_words(fix_keywords(snake_case(sanitize(value))))
20+
21+
if new_value.isidentifier():
22+
return _PythonIdentifier(new_value)
23+
24+
return _PythonIdentifier(f"{prefix}{new_value}")
625

726

827
@attr.s(auto_attribs=True, frozen=True)
@@ -26,16 +45,13 @@ class Property:
2645
_type_string: ClassVar[str] = ""
2746
_json_type_string: ClassVar[str] = "" # Type of the property after JSON serialization
2847
default: Optional[str] = attr.ib()
29-
python_name: str = attr.ib(init=False)
48+
python_name: _PythonIdentifier
3049

3150
template: ClassVar[Optional[str]] = None
3251
json_is_dict: ClassVar[bool] = False
3352

34-
def __attrs_post_init__(self) -> None:
35-
self.set_python_name(self.name)
36-
37-
def set_python_name(self, new_name: str) -> None:
38-
object.__setattr__(self, "python_name", utils.to_valid_python_identifier(utils.snake_case(new_name)))
53+
def set_python_name(self, new_name: str, config: Config) -> None:
54+
object.__setattr__(self, "python_name", to_valid_python_identifier(new_name, prefix=config.field_prefix))
3955

4056
def get_base_type_string(self) -> str:
4157
return self._type_string

openapi_python_client/utils.py

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -55,23 +55,3 @@ def kebab_case(value: str) -> str:
5555

5656
def remove_string_escapes(value: str) -> str:
5757
return value.replace('"', r"\"")
58-
59-
60-
# This can be changed by config.Config.load_config
61-
FIELD_PREFIX = "field_"
62-
63-
64-
def to_valid_python_identifier(value: str) -> str:
65-
"""
66-
Given a string, attempt to coerce it into a valid Python identifier by stripping out invalid characters and, if
67-
necessary, prepending a prefix.
68-
69-
See:
70-
https://docs.python.org/3/reference/lexical_analysis.html#identifiers
71-
"""
72-
new_value = fix_reserved_words(fix_keywords(sanitize(value)))
73-
74-
if new_value.isidentifier():
75-
return new_value
76-
77-
return f"{FIELD_PREFIX}{new_value}"

tests/conftest.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ def _factory(**kwargs):
2626
"optional_properties": [],
2727
"relative_imports": set(),
2828
"additional_properties": False,
29+
"python_name": "",
2930
**kwargs,
3031
}
32+
if not kwargs["python_name"]:
33+
kwargs["python_name"] = kwargs["name"]
3134
return ModelProperty(**kwargs)
3235

3336
return _factory
@@ -53,6 +56,8 @@ def _factory(**kwargs):
5356
"value_type": str,
5457
**kwargs,
5558
}
59+
if not kwargs.get("python_name"):
60+
kwargs["python_name"] = kwargs["name"]
5661
return EnumProperty(**kwargs)
5762

5863
return _factory

tests/test_config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,9 @@ def test_load_from_path(mocker):
2222

2323
config = Config.load_from_path(fake_path)
2424
safe_load.assert_called()
25-
assert utils.FIELD_PREFIX == "blah"
25+
assert config.field_prefix == "blah"
2626
assert config.class_overrides["Class1"] == override1
2727
assert config.class_overrides["Class2"] == override2
2828
assert config.project_name_override == "project-name"
2929
assert config.package_name_override == "package_name"
3030
assert config.package_version_override == "package_version"
31-
32-
utils.FIELD_PREFIX = "field_"

tests/test_parser/test_openapi.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -460,12 +460,12 @@ def test__add_responses(self, mocker):
460460
response_1 = Response(
461461
status_code=200,
462462
source="source",
463-
prop=DateTimeProperty(name="datetime", required=True, nullable=False, default=None),
463+
prop=DateTimeProperty(name="datetime", required=True, nullable=False, default=None, python_name="datetime"),
464464
)
465465
response_2 = Response(
466466
status_code=404,
467467
source="source",
468-
prop=DateProperty(name="date", required=True, nullable=False, default=None),
468+
prop=DateProperty(name="date", required=True, nullable=False, default=None, python_name="date"),
469469
)
470470
response_from_data = mocker.patch(
471471
f"{MODULE_NAME}.response_from_data", side_effect=[(response_1, schemas_1), (response_2, schemas_2)]
@@ -1004,7 +1004,7 @@ def test_from_data_tags_snake_case_sanitizer(self, mocker):
10041004

10051005
path_1_put = oai.Operation.construct()
10061006
path_1_post = oai.Operation.construct(tags=["AMF Subscription Info (Document)", "tag_3"])
1007-
path_2_get = oai.Operation.construct()
1007+
path_2_get = oai.Operation.construct(tags=["3. ABC"])
10081008
data = {
10091009
"path_1": oai.PathItem.construct(post=path_1_post, put=path_1_put),
10101010
"path_2": oai.PathItem.construct(get=path_2_get),
@@ -1039,16 +1039,17 @@ def test_from_data_tags_snake_case_sanitizer(self, mocker):
10391039
config=config,
10401040
),
10411041
mocker.call(
1042-
data=path_2_get, path="path_2", method="get", tag="default", schemas=schemas_2, config=config
1042+
data=path_2_get, path="path_2", method="get", tag="tag3_abc", schemas=schemas_2, config=config
10431043
),
10441044
],
10451045
)
10461046
assert result == (
10471047
{
1048-
"default": EndpointCollection("default", endpoints=[endpoint_1, endpoint_3]),
1048+
"default": EndpointCollection("default", endpoints=[endpoint_1]),
10491049
"amf_subscription_info_document": EndpointCollection(
10501050
"amf_subscription_info_document", endpoints=[endpoint_2]
10511051
),
1052+
"tag3_abc": EndpointCollection("tag3_abc", endpoints=[endpoint_3])
10521053
},
10531054
schemas_3,
10541055
)

tests/test_parser/test_properties/test_init.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ def test_get_type_string(self, mocker, nullable, required, no_optional, json, ex
3838

3939
mocker.patch.object(Property, "_type_string", "TestType")
4040
mocker.patch.object(Property, "_json_type_string", "str")
41-
p = Property(name="test", required=required, default=None, nullable=nullable)
41+
p = Property(name="test", required=required, default=None, nullable=nullable, python_name="test")
4242
assert p.get_type_string(no_optional=no_optional, json=json) == expected
4343

4444
@pytest.mark.parametrize(

tests/test_parser/test_properties/test_model_property.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,13 @@ class TestBuildModelProperty:
8484
(False, False),
8585
(
8686
oai.Schema.construct(type="string"),
87-
StringProperty(name="AdditionalProperty", required=True, nullable=False, default=None),
87+
StringProperty(
88+
name="AdditionalProperty",
89+
required=True,
90+
nullable=False,
91+
default=None,
92+
python_name="additional_property",
93+
),
8894
),
8995
],
9096
)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from openapi_python_client.parser.properties.property import to_valid_python_identifier
2+
3+
4+
class TestToValidPythonIdentifier:
5+
def test_valid_identifier_is_not_changed(self):
6+
assert to_valid_python_identifier("valid_field", prefix="field") == "valid_field"
7+
8+
def test_numbers_are_prefixed(self):
9+
assert to_valid_python_identifier("1", prefix="field") == "field1"
10+
11+
def test_invalid_symbols_are_stripped(self):
12+
assert to_valid_python_identifier("$abc", "prefix") == "abc"
13+
14+
def test_keywords_are_postfixed(self):
15+
assert to_valid_python_identifier("for", "prefix") == "for_"
16+
17+
def test_empty_is_prefixed(self):
18+
assert to_valid_python_identifier("", "something") == "something"

tests/test_templates/test_property_templates/test_date_property/test_date_property.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,21 @@
22

33
import jinja2
44

5+
from openapi_python_client.parser.properties import DateProperty
56

6-
def test_required_not_nullable():
7-
from openapi_python_client.parser.properties import DateProperty
87

9-
prop = DateProperty(
8+
def date_property(required=True, nullable=True, default=None) -> DateProperty:
9+
return DateProperty(
1010
name="a_prop",
11-
required=True,
12-
nullable=False,
13-
default=None,
11+
required=required,
12+
nullable=nullable,
13+
default=default,
14+
python_name="a_prop",
1415
)
16+
17+
18+
def test_required_not_nullable():
19+
prop = date_property(nullable=False)
1520
here = Path(__file__).parent
1621
templates_dir = here.parent.parent.parent.parent / "openapi_python_client" / "templates"
1722

@@ -28,14 +33,8 @@ def test_required_not_nullable():
2833

2934

3035
def test_required_nullable():
31-
from openapi_python_client.parser.properties import DateProperty
3236

33-
prop = DateProperty(
34-
name="a_prop",
35-
required=True,
36-
nullable=True,
37-
default=None,
38-
)
37+
prop = date_property()
3938
here = Path(__file__).parent
4039
templates_dir = here.parent.parent.parent.parent / "openapi_python_client" / "templates"
4140

@@ -52,14 +51,7 @@ def test_required_nullable():
5251

5352

5453
def test_optional_nullable():
55-
from openapi_python_client.parser.properties import DateProperty
56-
57-
prop = DateProperty(
58-
name="a_prop",
59-
required=False,
60-
nullable=True,
61-
default=None,
62-
)
54+
prop = date_property(required=False)
6355
here = Path(__file__).parent
6456
templates_dir = here.parent.parent.parent.parent / "openapi_python_client" / "templates"
6557

tests/test_utils.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,6 @@ def test__fix_reserved_words(reserved_word: str, expected: str):
7676
assert utils.fix_reserved_words(reserved_word) == expected
7777

7878

79-
def test_to_valid_python_identifier():
80-
assert utils.to_valid_python_identifier("valid") == "valid"
81-
assert utils.to_valid_python_identifier("1") == "field_1"
82-
assert utils.to_valid_python_identifier("$") == "field_"
83-
assert utils.to_valid_python_identifier("for") == "for_"
84-
85-
8679
@pytest.mark.parametrize(
8780
"before, after",
8881
[

0 commit comments

Comments
 (0)