Skip to content

Commit e801b52

Browse files
kalzoodbanty
andauthored
Prepend a prefix to field names to allow use as Python identifiers (#206)
* Prepend a prefix to invalid field names (e.g. starting with number) * Make prefix configurable * Strip all invalid characters from identifiers * Prevent keywords in identifiers Co-authored-by: Dylan Anthony <[email protected]>
1 parent 461396d commit e801b52

File tree

11 files changed

+97
-34
lines changed

11 files changed

+97
-34
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## Unreleased
9+
10+
### Changes
11+
12+
### Fixes
13+
14+
- Prefix generated identifiers to allow leading digits in field names (#206 - @kalzoo).
15+
16+
### Additions
17+
818
## 0.6.1 - 2020-09-26
919

1020
### Changes

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +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+
attr_1_leading_digit: Optional[str] = None
2021

2122
def to_dict(self) -> Dict[str, Any]:
2223
an_enum_value = self.an_enum_value.value
@@ -44,12 +45,15 @@ def to_dict(self) -> Dict[str, Any]:
4445

4546
nested_list_of_enums.append(nested_list_of_enums_item)
4647

48+
attr_1_leading_digit = self.attr_1_leading_digit
49+
4750
return {
4851
"an_enum_value": an_enum_value,
4952
"some_dict": some_dict,
5053
"aCamelDateTime": a_camel_date_time,
5154
"a_date": a_date,
5255
"nested_list_of_enums": nested_list_of_enums,
56+
"1_leading_digit": attr_1_leading_digit,
5357
}
5458

5559
@staticmethod
@@ -84,10 +88,13 @@ def _parse_a_camel_date_time(data: Dict[str, Any]) -> Union[datetime.datetime, d
8488

8589
nested_list_of_enums.append(nested_list_of_enums_item)
8690

91+
attr_1_leading_digit = d.get("1_leading_digit")
92+
8793
return AModel(
8894
an_enum_value=an_enum_value,
8995
some_dict=some_dict,
9096
a_camel_date_time=a_camel_date_time,
9197
a_date=a_date,
9298
nested_list_of_enums=nested_list_of_enums,
99+
attr_1_leading_digit=attr_1_leading_digit,
93100
)

end_to_end_tests/openapi.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,10 @@
564564
"title": "A Date",
565565
"type": "string",
566566
"format": "date"
567+
},
568+
"1_leading_digit": {
569+
"title": "Leading Digit",
570+
"type": "string"
567571
}
568572
},
569573
"description": "A Model for testing all the ways custom objects can be used "

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/parser/properties.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class Property:
3636
python_name: str = field(init=False)
3737

3838
def __post_init__(self) -> None:
39-
self.python_name = utils.snake_case(self.name)
39+
self.python_name = utils.to_valid_python_identifier(utils.snake_case(self.name))
4040
if self.default is not None:
4141
self.default = self._validate_default(default=self.default)
4242

openapi_python_client/utils.py

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

@@ -34,3 +35,23 @@ def kebab_case(value: str) -> str:
3435

3536
def remove_string_escapes(value: str) -> str:
3637
return value.replace('"', r"\"")
38+
39+
40+
# This can be changed by config.Config.load_config
41+
FIELD_PREFIX = "field_"
42+
43+
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.
48+
49+
See:
50+
https://docs.python.org/3/reference/lexical_analysis.html#identifiers
51+
"""
52+
new_value = fix_keywords(sanitize(value))
53+
54+
if new_value.isidentifier():
55+
return new_value
56+
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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,10 @@ def test_no_string_escapes():
3434

3535
def test__fix_keywords():
3636
assert utils.fix_keywords("None") == "None_"
37+
38+
39+
def test_to_valid_python_identifier():
40+
assert utils.to_valid_python_identifier("valid") == "valid"
41+
assert utils.to_valid_python_identifier("1") == "field_1"
42+
assert utils.to_valid_python_identifier("$") == "field_"
43+
assert utils.to_valid_python_identifier("for") == "for_"

0 commit comments

Comments
 (0)