Skip to content

Commit dd03b44

Browse files
[bct] Support class level properties (#36007)
* wip - support non-breaking changes * refactor to support changelog functionality * print changelog or breaking changes * support changelog flag * detect and register class level attributes * diff check for class properties and method params * revert temp changes * fix property checking logic * add some init handling * cleanup * add comment * add tests * rename added class method parameter * cleanup * revert init exception * update tests * comment --------- Co-authored-by: Catalina Peralta <[email protected]>
1 parent b7dd566 commit dd03b44

File tree

3 files changed

+365
-17
lines changed

3 files changed

+365
-17
lines changed

scripts/breaking_changes_checker/breaking_changes_tracker.py

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class ChangeType(str, Enum):
3535
ADDED_CLIENT_METHOD = "AddedClientMethod"
3636
ADDED_CLASS = "AddedClass"
3737
ADDED_CLASS_METHOD = "AddedClassMethod"
38+
ADDED_CLASS_METHOD_PARAMETER = "AddedClassMethodParameter"
39+
ADDED_CLASS_PROPERTY = "AddedClassProperty"
40+
ADDED_FUNCTION_PARAMETER = "AddedFunctionParameter"
3841

3942
class BreakingChangesTracker:
4043
REMOVED_OR_RENAMED_CLIENT_MSG = \
@@ -103,6 +106,12 @@ class BreakingChangesTracker:
103106
"The model or publicly exposed class '{}.{}' was added in the current version"
104107
ADDED_CLASS_METHOD_MSG = \
105108
"The '{}.{}' method '{}' was added in the current version"
109+
ADDED_CLASS_METHOD_PARAMETER_MSG = \
110+
"The model or publicly exposed class '{}.{}' had property '{}' added in the {} method in the current version"
111+
ADDED_FUNCTION_PARAMETER_MSG = \
112+
"The function '{}.{}' had parameter '{}' added in the current version"
113+
ADDED_CLASS_PROPERTY_MSG = \
114+
"The model or publicly exposed class '{}.{}' had property '{}' added in the current version"
106115

107116

108117
def __init__(self, stable: Dict, current: Dict, diff: Dict, package_name: str, **kwargs: Any) -> None:
@@ -183,24 +192,51 @@ def run_non_breaking_class_level_diff_checks(self, module: Dict) -> None:
183192
stable_methods_node = stable_class_nodes[self.class_name]["methods"]
184193
for method_name, method_components in class_components.get("methods", {}).items():
185194
self.function_name = method_name
186-
if self.function_name not in stable_methods_node and \
187-
not isinstance(self.function_name, jsondiff.Symbol):
188-
if self.class_name.endswith("Client"):
189-
# This is a new client method
190-
fa = (
191-
self.ADDED_CLIENT_METHOD_MSG,
192-
ChangeType.ADDED_CLIENT_METHOD,
193-
self.module_name, self.class_name, method_name
194-
)
195-
self.features_added.append(fa)
195+
if not isinstance(self.function_name, jsondiff.Symbol):
196+
if self.function_name not in stable_methods_node:
197+
if self.class_name.endswith("Client"):
198+
# This is a new client method
199+
fa = (
200+
self.ADDED_CLIENT_METHOD_MSG,
201+
ChangeType.ADDED_CLIENT_METHOD,
202+
self.module_name, self.class_name, method_name
203+
)
204+
self.features_added.append(fa)
205+
else:
206+
# This is a new class method
207+
fa = (
208+
self.ADDED_CLASS_METHOD_MSG,
209+
ChangeType.ADDED_CLASS_METHOD,
210+
self.module_name, class_name, method_name
211+
)
212+
self.features_added.append(fa)
196213
else:
197-
# This is a new class method
198-
fa = (
199-
self.ADDED_CLASS_METHOD_MSG,
200-
ChangeType.ADDED_CLASS_METHOD,
201-
self.module_name, class_name, method_name
202-
)
203-
self.features_added.append(fa)
214+
# Check existing methods for new parameters
215+
stable_parameters_node = stable_methods_node[self.function_name]["parameters"]
216+
current_parameters_node = self.current[self.module_name]["class_nodes"][self.class_name]["methods"][self.function_name]["parameters"]
217+
for param_name, param_components in method_components.get("parameters", {}).items():
218+
self.parameter_name = param_name
219+
if self.parameter_name not in stable_parameters_node and \
220+
not isinstance(self.parameter_name, jsondiff.Symbol):
221+
if self.function_name == "__init__":
222+
# If this is a new class property skip reporting it here and let the class properties check handle it.
223+
# This is because we'll get multiple reports for the same new property if it's a parameter in __init__
224+
# and a class level attribute.
225+
if self.parameter_name in class_components.get("properties", {}).keys():
226+
continue
227+
self.check_non_positional_parameter_added(
228+
current_parameters_node[param_name]
229+
)
230+
stable_property_node = stable_class_nodes[self.class_name]["properties"]
231+
for property_name, property_components in class_components.get("properties", {}).items():
232+
if property_name not in stable_property_node and \
233+
not isinstance(property_name, jsondiff.Symbol):
234+
fa = (
235+
self.ADDED_CLASS_PROPERTY_MSG,
236+
ChangeType.ADDED_CLASS_PROPERTY,
237+
self.module_name, class_name, property_name
238+
)
239+
self.features_added.append(fa)
204240

205241

206242
def run_class_level_diff_checks(self, module: Dict) -> None:
@@ -504,6 +540,23 @@ def check_positional_parameter_added(self, current_parameters_node: Dict) -> Non
504540
)
505541
)
506542

543+
def check_non_positional_parameter_added(self, current_parameters_node: Dict) -> None:
544+
if current_parameters_node["param_type"] != "positional_or_keyword":
545+
if self.class_name:
546+
self.features_added.append(
547+
(
548+
self.ADDED_CLASS_METHOD_PARAMETER_MSG, ChangeType.ADDED_CLASS_METHOD_PARAMETER,
549+
self.module_name, self.class_name, self.parameter_name, self.function_name
550+
)
551+
)
552+
else:
553+
self.features_added.append(
554+
(
555+
self.ADDED_FUNCTION_PARAMETER_MSG, ChangeType.ADDED_FUNCTION_PARAMETER,
556+
self.module_name, self.function_name, self.parameter_name
557+
)
558+
)
559+
507560
def check_positional_parameter_removed_or_renamed(
508561
self, param_type: str,
509562
deleted: str,

scripts/breaking_changes_checker/detect_breaking_changes.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ def get_parameter_default(param: inspect.Parameter) -> None:
101101

102102

103103
def get_property_names(node: ast.AST, attribute_names: Dict) -> None:
104+
assign_nodes = [node for node in node.body if isinstance(node, ast.AnnAssign)]
105+
# Check for class level attributes that follow the pattern: foo: List["_models.FooItem"] = rest_field(name="foo")
106+
for assign in assign_nodes:
107+
if hasattr(assign, "target"):
108+
if hasattr(assign.target, "id") and not assign.target.id.startswith("_"):
109+
attr = assign.target.id
110+
attr_type = None
111+
# FIXME: This can get the type hint for a limited set attributes. We need to address more complex
112+
# type hints in the future.
113+
# Build type hint for the attribute
114+
if hasattr(assign.annotation, "value") and isinstance(assign.annotation.value, ast.Name):
115+
attr_type = assign.annotation.value.id
116+
if attr_type == "List" and hasattr(assign.annotation, "slice"):
117+
if isinstance(assign.annotation.slice, ast.Constant):
118+
attr_type = f"{attr_type}[{assign.annotation.slice.value}]"
119+
attribute_names.update({attr: attr_type})
120+
104121
func_nodes = [node for node in node.body if isinstance(node, ast.FunctionDef)]
105122
if func_nodes:
106123
assigns = [node for node in func_nodes[0].body if isinstance(node, (ast.Assign, ast.AnnAssign))]

0 commit comments

Comments
 (0)