Skip to content

Commit 864e0f8

Browse files
committed
refactor: Improve performance of enum sub-type checking, add more test coverage.
1 parent a3623c2 commit 864e0f8

File tree

4 files changed

+150
-118
lines changed

4 files changed

+150
-118
lines changed

openapi_python_client/parser/properties/model_property.py

+54-52
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from itertools import chain
2-
from typing import ClassVar, Dict, List, NamedTuple, Optional, Set, Tuple, Union, cast
2+
from typing import ClassVar, Dict, List, NamedTuple, Optional, Set, Tuple, Union
33

44
import attr
55

@@ -50,56 +50,57 @@ def get_imports(self, *, prefix: str) -> Set[str]:
5050
return imports
5151

5252

53-
def _is_string_enum(prop: Property) -> bool:
54-
return isinstance(prop, EnumProperty) and prop.value_type == str
55-
56-
57-
def _is_int_enum(prop: Property) -> bool:
58-
return isinstance(prop, EnumProperty) and prop.value_type == int
59-
60-
61-
def values_are_subset(first: EnumProperty, second: EnumProperty) -> bool:
53+
def _values_are_subset(first: EnumProperty, second: EnumProperty) -> bool:
6254
return set(first.values.items()) <= set(second.values.items())
6355

6456

65-
def _is_subtype(first: Property, second: Property) -> bool:
57+
def _types_are_subset(first: EnumProperty, second: Property) -> bool:
6658
from . import IntProperty, StringProperty
6759

68-
return any(
69-
[
70-
_is_string_enum(first) and isinstance(second, StringProperty),
71-
_is_int_enum(first) and isinstance(second, IntProperty),
72-
_is_string_enum(first) and _is_string_enum(second)
73-
# cast because MyPy fails to deduce type
74-
and values_are_subset(cast(EnumProperty, first), cast(EnumProperty, second)),
75-
_is_int_enum(first) and _is_int_enum(second)
76-
# cast because MyPy fails to deduce type
77-
and values_are_subset(cast(EnumProperty, first), cast(EnumProperty, second)),
78-
]
79-
)
60+
if first.value_type == int and isinstance(second, IntProperty):
61+
return True
62+
if first.value_type == str and isinstance(second, StringProperty):
63+
return True
64+
return False
65+
66+
67+
def _enum_subset(first: Property, second: Property) -> Optional[EnumProperty]:
68+
"""Return the EnumProperty that is the subset of the other, if possible."""
69+
70+
if isinstance(first, EnumProperty):
71+
if isinstance(second, EnumProperty):
72+
if _values_are_subset(first, second):
73+
return first
74+
if _values_are_subset(second, first):
75+
return second
76+
return None
77+
return first if _types_are_subset(first, second) else None
78+
if isinstance(second, EnumProperty) and _types_are_subset(second, first):
79+
return second
80+
return None
8081

8182

8283
def _merge_properties(first: Property, second: Property) -> Union[Property, PropertyError]:
8384
nullable = first.nullable and second.nullable
8485
required = first.required or second.required
8586

86-
if _is_subtype(first, second):
87-
first = attr.evolve(first, nullable=nullable, required=required)
88-
return first
89-
elif _is_subtype(second, first):
90-
second = attr.evolve(second, nullable=nullable, required=required)
91-
return second
92-
elif first.__class__ == second.__class__:
87+
err = None
88+
89+
if first.__class__ == second.__class__:
9390
first = attr.evolve(first, nullable=nullable, required=required)
9491
second = attr.evolve(second, nullable=nullable, required=required)
95-
if first != second:
96-
return PropertyError(header="Cannot merge properties", detail="Properties has conflicting values")
97-
return first
98-
else:
99-
return PropertyError(
100-
header="Cannot merge properties",
101-
detail=f"{first.__class__}, {second.__class__}Properties have incompatible types",
102-
)
92+
if first == second:
93+
return first
94+
err = PropertyError(header="Cannot merge properties", detail="Properties has conflicting values")
95+
96+
enum_subset = _enum_subset(first, second)
97+
if enum_subset is not None:
98+
return attr.evolve(enum_subset, nullable=nullable, required=required)
99+
100+
return err or PropertyError(
101+
header="Cannot merge properties",
102+
detail=f"{first.__class__}, {second.__class__}Properties have incompatible types",
103+
)
103104

104105

105106
class _PropertyData(NamedTuple):
@@ -118,16 +119,18 @@ def _process_properties(
118119
relative_imports: Set[str] = set()
119120
required_set = set(data.required or [])
120121

121-
def _check_existing(prop: Property) -> Union[Property, PropertyError]:
122+
def _add_if_no_conflict(new_prop: Property) -> Optional[PropertyError]:
122123
nonlocal properties
123124

124-
existing = properties.get(prop.name)
125-
prop_or_error = _merge_properties(existing, prop) if existing else prop
126-
if isinstance(prop_or_error, PropertyError):
127-
prop_or_error.header = f"Found conflicting properties named {prop.name} when creating {class_name}"
128-
return prop_or_error
129-
properties[prop_or_error.name] = prop_or_error
130-
return prop_or_error
125+
existing = properties.get(new_prop.name)
126+
merged_prop_or_error = _merge_properties(existing, new_prop) if existing else new_prop
127+
if isinstance(merged_prop_or_error, PropertyError):
128+
merged_prop_or_error.header = (
129+
f"Found conflicting properties named {new_prop.name} when creating {class_name}"
130+
)
131+
return merged_prop_or_error
132+
properties[merged_prop_or_error.name] = merged_prop_or_error
133+
return None
131134

132135
unprocessed_props = data.properties or {}
133136
for sub_prop in data.allOf or []:
@@ -141,25 +144,24 @@ def _check_existing(prop: Property) -> Union[Property, PropertyError]:
141144
if not isinstance(sub_model, ModelProperty):
142145
return PropertyError("Cannot take allOf a non-object")
143146
for prop in chain(sub_model.required_properties, sub_model.optional_properties):
144-
prop_or_error = _check_existing(prop)
145-
if isinstance(prop_or_error, PropertyError):
146-
return prop_or_error
147+
err = _add_if_no_conflict(prop)
148+
if err is not None:
149+
return err
147150
else:
148151
unprocessed_props.update(sub_prop.properties or {})
149152
required_set.update(sub_prop.required or [])
150153

151154
for key, value in unprocessed_props.items():
152155
prop_required = key in required_set
156+
prop_or_error: Union[Property, PropertyError, None]
153157
prop_or_error, schemas = property_from_data(
154158
name=key, required=prop_required, data=value, schemas=schemas, parent_name=class_name, config=config
155159
)
156160
if isinstance(prop_or_error, Property):
157-
prop_or_error = _check_existing(prop_or_error)
161+
prop_or_error = _add_if_no_conflict(prop_or_error)
158162
if isinstance(prop_or_error, PropertyError):
159163
return prop_or_error
160164

161-
properties[prop_or_error.name] = prop_or_error
162-
163165
required_properties = []
164166
optional_properties = []
165167
for prop in properties.values():

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ check = """
5757
isort .\
5858
&& black .\
5959
&& flake8 openapi_python_client\
60-
&& safety check --bare\
60+
&& poetry export -f requirements.txt | poetry run safety check --bare --stdin\
6161
&& mypy openapi_python_client\
6262
&& pytest --cov openapi_python_client tests --cov-report=term-missing\
6363
"""

tests/conftest.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
DateTimeProperty,
99
EnumProperty,
1010
FileProperty,
11+
IntProperty,
1112
ListProperty,
1213
ModelProperty,
1314
Property,
@@ -54,7 +55,7 @@ def enum_property_factory() -> Callable[..., EnumProperty]:
5455
def _factory(**kwargs):
5556
kwargs = _common_kwargs(kwargs)
5657
kwargs = {
57-
"class_info": Class(name="", module_name=""),
58+
"class_info": Class(name=kwargs["name"], module_name=kwargs["name"]),
5859
"values": {},
5960
"value_type": str,
6061
**kwargs,
@@ -109,6 +110,21 @@ def _factory(**kwargs):
109110
return _factory
110111

111112

113+
@pytest.fixture
114+
def int_property_factory() -> Callable[..., IntProperty]:
115+
"""
116+
This fixture surfaces in the test as a function which manufactures StringProperties with defaults.
117+
118+
You can pass the same params into this as the StringProperty constructor to override defaults.
119+
"""
120+
121+
def _factory(**kwargs):
122+
kwargs = _common_kwargs(kwargs)
123+
return IntProperty(**kwargs)
124+
125+
return _factory
126+
127+
112128
@pytest.fixture
113129
def date_time_property_factory() -> Callable[..., DateTimeProperty]:
114130
"""

0 commit comments

Comments
 (0)