Skip to content

Commit bbaba54

Browse files
committed
Add additional handling to python identifier sanitization. Make field prefix configurable.
1 parent 0f423b0 commit bbaba54

File tree

8 files changed

+64
-55
lines changed

8 files changed

+64
-55
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,16 @@ project_name_override: my-special-project-name
112112
package_name_override: my_extra_special_package_name
113113
```
114114

115+
### field_prefix
116+
117+
When generating properties, the `name` attribute of the OpenAPI schema will be used. When the `name` is not a valid
118+
Python identifier (e.g. begins with a number) this string will be prepended. Defaults to "field".
119+
120+
Example:
121+
122+
```yaml
123+
field_prefix: attr
124+
```
125+
115126
[changelog.md]: CHANGELOG.md
116127
[poetry]: https://python-poetry.org/

end_to_end_tests/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ class_overrides:
88
NestedListOfEnumsItemItem:
99
class_name: AnEnumValue
1010
module_name: an_enum_value
11+
field_prefix: attr

end_to_end_tests/golden-record/my_test_api_client/models/a_model.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class AModel:
1717
a_camel_date_time: Union[datetime.datetime, datetime.date]
1818
a_date: datetime.date
1919
nested_list_of_enums: Optional[List[List[DifferentEnum]]] = None
20-
field_1_leading_digit: Optional[str] = None
20+
attr_1_leading_digit: Optional[str] = None
2121

2222
def to_dict(self) -> Dict[str, Any]:
2323
an_enum_value = self.an_enum_value.value
@@ -45,15 +45,15 @@ def to_dict(self) -> Dict[str, Any]:
4545

4646
nested_list_of_enums.append(nested_list_of_enums_item)
4747

48-
field_1_leading_digit = self.field_1_leading_digit
48+
attr_1_leading_digit = self.attr_1_leading_digit
4949

5050
return {
5151
"an_enum_value": an_enum_value,
5252
"some_dict": some_dict,
5353
"aCamelDateTime": a_camel_date_time,
5454
"a_date": a_date,
5555
"nested_list_of_enums": nested_list_of_enums,
56-
"1_leading_digit": field_1_leading_digit,
56+
"1_leading_digit": attr_1_leading_digit,
5757
}
5858

5959
@staticmethod
@@ -88,13 +88,13 @@ def _parse_a_camel_date_time(data: Dict[str, Any]) -> Union[datetime.datetime, d
8888

8989
nested_list_of_enums.append(nested_list_of_enums_item)
9090

91-
field_1_leading_digit = d.get("1_leading_digit")
91+
attr_1_leading_digit = d.get("1_leading_digit")
9292

9393
return AModel(
9494
an_enum_value=an_enum_value,
9595
some_dict=some_dict,
9696
a_camel_date_time=a_camel_date_time,
9797
a_date=a_date,
9898
nested_list_of_enums=nested_list_of_enums,
99-
field_1_leading_digit=field_1_leading_digit,
99+
attr_1_leading_digit=attr_1_leading_digit,
100100
)

openapi_python_client/config.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,22 +14,27 @@ class Config(BaseModel):
1414
class_overrides: Optional[Dict[str, ClassOverride]]
1515
project_name_override: Optional[str]
1616
package_name_override: Optional[str]
17+
field_prefix: Optional[str]
1718

1819
def load_config(self) -> None:
19-
""" Loads config from provided Path """
20+
""" Sets globals based on Config """
21+
from openapi_python_client import Project
2022

21-
if self.class_overrides is not None:
22-
from .parser import reference
23+
from . import utils
24+
from .parser import reference
2325

26+
if self.class_overrides is not None:
2427
for class_name, class_data in self.class_overrides.items():
2528
reference.class_overrides[class_name] = reference.Reference(**dict(class_data))
2629

27-
from openapi_python_client import Project
28-
2930
Project.project_name_override = self.project_name_override
3031
Project.package_name_override = self.package_name_override
3132

33+
if self.field_prefix is not None:
34+
utils.FIELD_PREFIX = self.field_prefix
35+
3236
@staticmethod
3337
def load_from_path(path: Path) -> None:
38+
""" Creates a Config from provided JSON or YAML file and sets a bunch of globals from it """
3439
config_data = yaml.safe_load(path.read_text())
3540
Config(**config_data).load_config()

openapi_python_client/utils.py

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
def sanitize(value: str) -> str:
8+
""" Removes every character that isn't 0-9, A-Z, a-z, ' ', -, or _ """
89
return re.sub(r"[^\w _\-]+", "", value)
910

1011

@@ -36,27 +37,21 @@ def remove_string_escapes(value: str) -> str:
3637
return value.replace('"', r"\"")
3738

3839

39-
def to_valid_python_identifier(value: str) -> str:
40-
"""
41-
Given a string, attempt to coerce it into a valid Python identifier.
42-
43-
If valid, return it unmodified.
40+
# This can be changed by config.Config.load_config
41+
FIELD_PREFIX = "field"
4442

45-
If invalid, prepend a fixed prefix. This resolves some problems caused by the string's leading
46-
character.
4743

48-
If that prefix does not make it a valid identifier - there are unsupported non-leading
49-
characters - raise a ValueError.
44+
def to_valid_python_identifier(value: str) -> str:
45+
"""
46+
Given a string, attempt to coerce it into a valid Python identifier by stripping out invalid characters and, if
47+
necessary, prepending a prefix.
5048
5149
See:
5250
https://docs.python.org/3/reference/lexical_analysis.html#identifiers
5351
"""
54-
if value.isidentifier():
55-
return value
56-
57-
new_value = f"field_{value}"
52+
new_value = fix_keywords(sanitize(value))
5853

5954
if new_value.isidentifier():
6055
return new_value
6156

62-
raise ValueError(f"Cannot convert {value} to a valid python identifier")
57+
return f"{FIELD_PREFIX}_{new_value}"

tests/test_config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,12 @@ def test_project_and_package_name_overrides(self):
3333

3434
assert Project.project_name_override == "project-name"
3535
assert Project.package_name_override == "package_name"
36+
37+
def test_field_prefix(self):
38+
Config(field_prefix="blah").load_config()
39+
40+
from openapi_python_client import utils
41+
42+
assert utils.FIELD_PREFIX == "blah"
43+
44+
utils.FIELD_PREFIX = "field"

tests/test_openapi_parser/test_properties.py

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,21 @@ def test_get_type_string(self):
3636
def test_to_string(self, mocker):
3737
from openapi_python_client.parser.properties import Property
3838

39-
name = mocker.MagicMock()
40-
snake_case = mocker.patch("openapi_python_client.utils.snake_case")
39+
name = "test"
4140
p = Property(name=name, required=True, default=None, nullable=False)
4241
get_type_string = mocker.patch.object(p, "get_type_string")
4342

44-
assert p.to_string() == f"{snake_case(name)}: {get_type_string()}"
43+
assert p.to_string() == f"{name}: {get_type_string()}"
4544
p.required = False
46-
assert p.to_string() == f"{snake_case(name)}: {get_type_string()} = None"
45+
assert p.to_string() == f"{name}: {get_type_string()} = None"
4746

4847
p.default = "TEST"
49-
assert p.to_string() == f"{snake_case(name)}: {get_type_string()} = TEST"
48+
assert p.to_string() == f"{name}: {get_type_string()} = TEST"
5049

51-
def test_get_imports(self, mocker):
50+
def test_get_imports(self):
5251
from openapi_python_client.parser.properties import Property
5352

54-
name = mocker.MagicMock()
55-
mocker.patch("openapi_python_client.utils.snake_case")
56-
p = Property(name=name, required=True, default=None, nullable=False)
53+
p = Property(name="test", required=True, default=None, nullable=False)
5754
assert p.get_imports(prefix="") == set()
5855

5956
p.required = False
@@ -90,12 +87,10 @@ def test__validate_default(self):
9087

9188

9289
class TestDateTimeProperty:
93-
def test_get_imports(self, mocker):
90+
def test_get_imports(self):
9491
from openapi_python_client.parser.properties import DateTimeProperty
9592

96-
name = mocker.MagicMock()
97-
mocker.patch("openapi_python_client.utils.snake_case")
98-
p = DateTimeProperty(name=name, required=True, default=None, nullable=False)
93+
p = DateTimeProperty(name="test", required=True, default=None, nullable=False)
9994
assert p.get_imports(prefix="...") == {
10095
"import datetime",
10196
"from typing import cast",
@@ -121,12 +116,10 @@ def test__validate_default(self):
121116

122117

123118
class TestDateProperty:
124-
def test_get_imports(self, mocker):
119+
def test_get_imports(self):
125120
from openapi_python_client.parser.properties import DateProperty
126121

127-
name = mocker.MagicMock()
128-
mocker.patch("openapi_python_client.utils.snake_case")
129-
p = DateProperty(name=name, required=True, default=None, nullable=False)
122+
p = DateProperty(name="test", required=True, default=None, nullable=False)
130123
assert p.get_imports(prefix="...") == {
131124
"import datetime",
132125
"from typing import cast",
@@ -152,13 +145,11 @@ def test__validate_default(self):
152145

153146

154147
class TestFileProperty:
155-
def test_get_imports(self, mocker):
148+
def test_get_imports(self):
156149
from openapi_python_client.parser.properties import FileProperty
157150

158-
name = mocker.MagicMock()
159-
mocker.patch("openapi_python_client.utils.snake_case")
160151
prefix = ".."
161-
p = FileProperty(name=name, required=True, default=None, nullable=False)
152+
p = FileProperty(name="test", required=True, default=None, nullable=False)
162153
assert p.get_imports(prefix=prefix) == {"from ..types import File"}
163154

164155
p.required = False
@@ -342,9 +333,8 @@ def test__validate_default(self, mocker):
342333

343334
class TestEnumProperty:
344335
def test___post_init__(self, mocker):
345-
name = mocker.MagicMock()
336+
name = "test"
346337

347-
snake_case = mocker.patch("openapi_python_client.utils.snake_case")
348338
fake_reference = mocker.MagicMock(class_name="MyTestEnum")
349339
deduped_reference = mocker.MagicMock(class_name="Deduped")
350340
from_ref = mocker.patch(
@@ -361,7 +351,7 @@ def test___post_init__(self, mocker):
361351
)
362352

363353
assert enum_property.default == "Deduped.SECOND"
364-
assert enum_property.python_name == snake_case(name)
354+
assert enum_property.python_name == name
365355
from_ref.assert_has_calls([mocker.call("a_title"), mocker.call("MyTestEnum1")])
366356
assert enum_property.reference == deduped_reference
367357
assert properties._existing_enums == {"MyTestEnum": fake_dup_enum, "Deduped": enum_property}
@@ -383,7 +373,7 @@ def test___post_init__(self, mocker):
383373
name=name, required=True, default="second", values=values, title="a_title", nullable=False
384374
)
385375
assert enum_property.default == "MyTestEnum.SECOND"
386-
assert enum_property.python_name == snake_case(name)
376+
assert enum_property.python_name == name
387377
from_ref.assert_called_once_with("a_title")
388378
assert enum_property.reference == fake_reference
389379
assert len(properties._existing_enums) == 2
@@ -558,10 +548,8 @@ class TestDictProperty:
558548
def test_get_imports(self, mocker):
559549
from openapi_python_client.parser.properties import DictProperty
560550

561-
name = mocker.MagicMock()
562-
mocker.patch("openapi_python_client.utils.snake_case")
563551
prefix = mocker.MagicMock()
564-
p = DictProperty(name=name, required=True, default=None, nullable=False)
552+
p = DictProperty(name="test", required=True, default=None, nullable=False)
565553
assert p.get_imports(prefix=prefix) == {
566554
"from typing import Dict",
567555
}

tests/test_utils.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def test__fix_keywords():
3838
assert utils.fix_keywords("None") == "None_"
3939

4040

41-
def to_valid_python_identifier():
41+
def test_to_valid_python_identifier():
4242
assert utils.to_valid_python_identifier("valid") == "valid"
4343
assert utils.to_valid_python_identifier("1") == "field_1"
44-
with pytest.raises(ValueError):
45-
utils.to_valid_python_identifier("&")
44+
assert utils.to_valid_python_identifier("$") == "field_"
45+
assert utils.to_valid_python_identifier("for") == "for_"

0 commit comments

Comments
 (0)