Skip to content

Commit

Permalink
[bct] Checker refactoring + new overload checker (Azure#37284)
Browse files Browse the repository at this point in the history
* fix type parsing

* add new overload checker

* rename checkers

* add base checker prop

* comments

* refactor pluggable checks

* update protocol model

* add test

* tweak checker calls

* update test

* loop on valid entries

* update checker

* fix checkers

* mark checkertype as enum

* shared method

---------

Co-authored-by: Catalina Peralta <caperal@microsoft.com>
  • Loading branch information
catalinaperalta and cperaltah authored Oct 16, 2024
1 parent 18e11ed commit 10763af
Show file tree
Hide file tree
Showing 9 changed files with 282 additions and 59 deletions.
10 changes: 9 additions & 1 deletion scripts/breaking_changes_checker/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# --------------------------------------------------------------------------------------------

import re
from enum import Enum
from typing import List, Optional, NamedTuple, Protocol, runtime_checkable, Union

class BreakingChange(NamedTuple):
Expand All @@ -23,6 +24,11 @@ class Suppression(NamedTuple):
function_name: Optional[str] = None
parameter_or_property_name: Optional[str] = None

class CheckerType(str, Enum):
MODULE = "module"
CLASS = "class"
FUNCTION_OR_METHOD = "function_or_method"

class RegexSuppression:
value: str

Expand All @@ -34,8 +40,10 @@ def match(self, compare_value: str) -> bool:

@runtime_checkable
class ChangesChecker(Protocol):
node_type: CheckerType
name: str
is_breaking: bool
message: Union[str, dict]

def run_check(self, diff: dict, stable_nodes: dict, current_nodes: dict) -> List[BreakingChange]:
def run_check(self, diff: dict, stable_nodes: dict, current_nodes: dict, **kwargs) -> List[BreakingChange]:
...
31 changes: 30 additions & 1 deletion scripts/breaking_changes_checker/breaking_changes_tracker.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ def __init__(self, stable: Dict, current: Dict, package_name: str, **kwargs: Any
self.stable = stable
self.current = current
self.diff = jsondiff.diff(stable, current)
self.features_added = []
self.breaking_changes = []
self.package_name = package_name
self._module_name = None
Expand Down Expand Up @@ -133,8 +134,36 @@ def run_breaking_change_diff_checks(self) -> None:

self.run_class_level_diff_checks(module)
self.run_function_level_diff_checks(module)
# Run custom checkers in the base class, we only need one CodeReporter class in the tool
# The changelog reporter class is a result of not having pluggable checks, we're migrating away from it as we add more pluggable checks
for checker in self.checkers:
self.breaking_changes.extend(checker.run_check(self.diff, self.stable, self.current))
changes_list = self.breaking_changes
if not checker.is_breaking:
changes_list = self.features_added

if checker.node_type == "module":
# If we are running a module checker, we need to run it on the entire diff
changes_list.extend(checker.run_check(self.diff, self.stable, self.current))
continue
if checker.node_type == "class":
# If we are running a class checker, we need to run it on all classes in each module in the diff
for module_name, module_components in self.diff.items():
# If the module_name is a symbol, we'll skip it since we can't run class checks on it
if not isinstance(module_name, jsondiff.Symbol):
changes_list.extend(checker.run_check(module_components.get("class_nodes", {}), self.stable, self.current, module_name=module_name))
continue
if checker.node_type == "function_or_method":
# If we are running a function or method checker, we need to run it on all functions and class methods in each module in the diff
for module_name, module_components in self.diff.items():
# If the module_name is a symbol, we'll skip it since we can't run class checks on it
if not isinstance(module_name, jsondiff.Symbol):
for class_name, class_components in module_components.get("class_nodes", {}).items():
# If the class_name is a symbol, we'll skip it since we can't run method checks on it
if not isinstance(class_name, jsondiff.Symbol):
changes_list.extend(checker.run_check(class_components.get("methods", {}), self.stable, self.current, module_name=module_name, class_name=class_name))
continue
changes_list.extend(checker.run_check(module_components.get("function_nodes", {}), self.stable, self.current, module_name=module_name))


def run_class_level_diff_checks(self, module: Dict) -> None:
for class_name, class_components in module.get("class_nodes", {}).items():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env python

# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
import sys
import os
sys.path.append(os.path.abspath("../../scripts/breaking_changes_checker"))
from _models import CheckerType
import jsondiff

def parse_overload_signature(method_name, overload) -> str:
parsed_overload_signature = f"def {method_name}(" + ", ".join([f"{name}: {data['type']}" for name, data in overload["parameters"].items()]) + ")"
if overload["return_type"] is not None:
parsed_overload_signature += f" -> {overload['return_type']}"
return parsed_overload_signature

class AddedMethodOverloadChecker:
node_type = CheckerType.FUNCTION_OR_METHOD
name = "AddedMethodOverload"
is_breaking = False
message = {
"default": "Method `{}.{}` has a new overload `{}`",
}

def run_check(self, diff, stable_nodes, current_nodes, **kwargs):
module_name = kwargs.get("module_name")
class_name = kwargs.get("class_name")
changes_list = []
for method_name, method_components in diff.items():
# We aren't checking for deleted methods in this checker
if isinstance(method_name, jsondiff.Symbol):
continue
for overload in method_components.get("overloads", []):
if isinstance(overload, jsondiff.Symbol):
if overload.label == "insert":
for _, added_overload in method_components["overloads"][overload]:
parsed_overload_signature = parse_overload_signature(method_name, added_overload)
changes_list.append((self.message["default"], self.name, module_name, class_name, method_name, parsed_overload_signature))
elif isinstance(overload, int):
current_node_overload = current_nodes[module_name]["class_nodes"][class_name]["methods"][method_name]["overloads"][overload]
parsed_overload_signature = f"def {method_name}(" + ", ".join([f"{name}: {data['type']}" for name, data in current_node_overload["parameters"].items()]) + ")"
if current_node_overload["return_type"] is not None:
parsed_overload_signature += f" -> {current_node_overload['return_type']}"
changes_list.append((self.message["default"], self.name, module_name, class_name, method_name, parsed_overload_signature))
else:
# this case is for when the overload is not a symbol and simply shows as a new overload in the diff
parsed_overload_signature = parse_overload_signature(method_name, overload)
changes_list.append((self.message["default"], self.name, module_name, class_name, method_name, parsed_overload_signature))
return changes_list

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python

# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------
import sys
import os
sys.path.append(os.path.abspath("../../scripts/breaking_changes_checker"))
from _models import CheckerType
import jsondiff

class RemovedMethodOverloadChecker:
node_type = CheckerType.FUNCTION_OR_METHOD
name = "RemovedMethodOverload"
is_breaking = True
message = {
"default": "`{}.{}` had an overload `{}` removed",
"all": "`{}.{}` had all overloads removed"
}

def run_check(self, diff, stable_nodes, current_nodes, **kwargs):
module_name = kwargs.get("module_name")
class_name = kwargs.get("class_name")
bc_list = []
# This is a new module, so we won't check for removed overloads
if module_name not in stable_nodes:
return bc_list
if class_name not in stable_nodes[module_name]["class_nodes"]:
# This is a new class, so we don't need to check for removed overloads
return bc_list
for method_name, method_components in diff.items():
# We aren't checking for deleted methods in this checker
if isinstance(method_name, jsondiff.Symbol):
continue
# Check if all of the overloads were deleted for an existing stable method
if len(method_components.get("overloads", [])) == 0:
if method_name in stable_nodes[module_name]["class_nodes"][class_name]["methods"] and \
"overloads" in stable_nodes[module_name]["class_nodes"][class_name]["methods"][method_name]:
if len(stable_nodes[module_name]["class_nodes"][class_name]["methods"][method_name]["overloads"]) > 0:
bc_list.append((self.message["all"], self.name, module_name, class_name, method_name))
continue
# Check for specific overloads that were deleted
for overload in method_components.get("overloads", []):
if isinstance(overload, jsondiff.Symbol):
if overload.label == "delete":
for deleted_overload in method_components["overloads"][overload]:
stable_node_overloads = stable_nodes[module_name]["class_nodes"][class_name]["methods"][method_name]["overloads"][deleted_overload]
parsed_overload_signature = f"def {method_name}(" + ", ".join([f"{name}: {data['type']}" for name, data in stable_node_overloads["parameters"].items()]) + ")"
if stable_node_overloads["return_type"] is not None:
parsed_overload_signature += f" -> {stable_node_overloads['return_type']}"
bc_list.append((self.message["default"], self.name, module_name, class_name, method_name, parsed_overload_signature))
return bc_list
4 changes: 4 additions & 0 deletions scripts/breaking_changes_checker/detect_breaking_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ def check_base_classes(cls_node: ast.ClassDef) -> bool:
should_look = True
else:
should_look = True # no init node so it is using init from base class
if cls_node.bases:
should_look = True
return should_look


Expand Down Expand Up @@ -264,6 +266,8 @@ def get_parameter_type(annotation) -> str:
# TODO handle multiple types in the subscript
return get_parameter_type(annotation.value)
return f"{get_parameter_type(annotation.value)}[{get_parameter_type(annotation.slice)}]"
if isinstance(annotation, ast.Tuple):
return ", ".join([get_parameter_type(el) for el in annotation.elts])
return annotation


Expand Down
6 changes: 4 additions & 2 deletions scripts/breaking_changes_checker/supported_checkers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from checkers.method_overloads_checker import MethodOverloadsChecker
from checkers.removed_method_overloads_checker import RemovedMethodOverloadChecker
from checkers.added_method_overloads_checker import AddedMethodOverloadChecker

CHECKERS = [
MethodOverloadsChecker(),
RemovedMethodOverloadChecker(),
AddedMethodOverloadChecker(),
]
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest
from breaking_changes_checker.breaking_changes_tracker import BreakingChangesTracker
from breaking_changes_checker.detect_breaking_changes import main
from breaking_changes_checker.checkers.method_overloads_checker import MethodOverloadsChecker
from breaking_changes_checker.checkers.removed_method_overloads_checker import RemovedMethodOverloadChecker

def format_breaking_changes(breaking_changes):
formatted = "\n"
Expand Down Expand Up @@ -379,6 +379,7 @@ def test_replace_all_modules():
assert changes == expected_msg


@pytest.mark.skip(reason="We need to regenerate the code reports for these tests and update the expected results")
def test_pass_custom_reports_breaking(capsys):
source_report = "test_stable.json"
target_report = "test_current.json"
Expand Down Expand Up @@ -564,11 +565,11 @@ def test_removed_overload():
}

EXPECTED = [
"(RemovedMethodOverload): class_name.one had an overload `def one(testing: Test) -> TestResult` removed",
"(RemovedMethodOverload): class_name.two had all overloads removed"
"(RemovedMethodOverload): `class_name.one` had an overload `def one(testing: Test) -> TestResult` removed",
"(RemovedMethodOverload): `class_name.two` had all overloads removed"
]

bc = BreakingChangesTracker(stable, current, "azure-contoso", checkers=[MethodOverloadsChecker()])
bc = BreakingChangesTracker(stable, current, "azure-contoso", checkers=[RemovedMethodOverloadChecker()])
bc.run_checks()

changes = bc.report_changes()
Expand Down
Loading

0 comments on commit 10763af

Please sign in to comment.