-
Notifications
You must be signed in to change notification settings - Fork 488
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feature/sg 1057 add breaking change tests (#1373)
* remove pram * wip * change * change * add default and non default * delete module * remove default * add x to method * remove default get_arch Signed-off-by: Louis Dupont <louis-dupont@live.fr> * wip * improve display * minor update * table content * add * remove unwanted remaining * improve robustness * adding test for class * add test to CI * cleanup * cleanup * add unittest * divide code to modules and import git only when required * fix ci * add gitpython to dev * improve doc * minor docstring change * install gitpython in CI * wip * set default value to false * fix test * replace verbose by silent * fix path * clean how we define root_dir * update CI test * try again * try again * try again * rename breaking-change-test * add breaking chnage to base workflow * remove from releast * fix sg isntall in CI * rename the check name * move main to test * add tests as unittests * fix test --------- Signed-off-by: Louis Dupont <louis-dupont@live.fr>
- Loading branch information
1 parent
7b182ff
commit f7d9909
Showing
8 changed files
with
674 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,4 @@ | ||
flake8==5.0.4 | ||
black==22.10.0 | ||
pre-commit==2.20.0 | ||
gitpython>=3.1.0 |
Empty file.
243 changes: 243 additions & 0 deletions
243
tests/breaking_change_tests/breaking_changes_detection.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,243 @@ | ||
import ast | ||
from abc import ABC | ||
from pathlib import Path | ||
from typing import List, Dict, Union | ||
|
||
from termcolor import colored | ||
from dataclasses import dataclass, field, asdict | ||
|
||
from .code_parser import parse_functions_signatures, parse_imports | ||
|
||
|
||
MODULE_PATH_COLOR = "yellow" | ||
SOURCE_CODE_COLOR = "blue" | ||
BREAKING_OBJECT_COLOR = "red" | ||
|
||
|
||
@dataclass | ||
class AbstractBreakingChange(ABC): | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
raise NotImplementedError() | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
raise NotImplementedError() | ||
|
||
|
||
@dataclass | ||
class ClassRemoved(AbstractBreakingChange): | ||
class_name: str | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
return f"{colored(self.class_name, SOURCE_CODE_COLOR)} -> {colored('X', BREAKING_OBJECT_COLOR)}" | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
return "CLASS REMOVED" | ||
|
||
|
||
@dataclass | ||
class ImportRemoved(AbstractBreakingChange): | ||
import_name: str | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
return f"{colored(self.import_name, SOURCE_CODE_COLOR)} -> {colored('X', BREAKING_OBJECT_COLOR)}" | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
return "IMPORT REMOVED" | ||
|
||
|
||
@dataclass | ||
class FunctionRemoved(AbstractBreakingChange): | ||
function_name: str | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
return f"{colored(self.function_name, SOURCE_CODE_COLOR)} -> {colored('X', BREAKING_OBJECT_COLOR)}" | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
return "FUNCTION REMOVED" | ||
|
||
|
||
@dataclass | ||
class ParameterRemoved(AbstractBreakingChange): | ||
parameter_name: str | ||
function_name: str | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
source_fn_colored = colored(self.function_name, SOURCE_CODE_COLOR) | ||
current_fn_colored = colored(self.function_name, "yellow") | ||
param_colored = colored(self.parameter_name, BREAKING_OBJECT_COLOR) | ||
return f"{source_fn_colored}(..., {param_colored}) -> {current_fn_colored}(...)" | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
return "FUNCTION PARAMETER REMOVED" | ||
|
||
|
||
@dataclass | ||
class RequiredParameterAdded(AbstractBreakingChange): | ||
parameter_name: str | ||
function_name: str | ||
line_num: int | ||
|
||
@property | ||
def description(self) -> str: | ||
source_fn_colored = colored(self.function_name, SOURCE_CODE_COLOR) | ||
current_fn_colored = colored(self.function_name, "yellow") | ||
param_colored = colored(self.parameter_name, BREAKING_OBJECT_COLOR) | ||
return f"{source_fn_colored}(...) -> {current_fn_colored}(..., {param_colored})" | ||
|
||
@property | ||
def breaking_type_name(self) -> str: | ||
return "FUNCTION PARAMETER ADDED" | ||
|
||
|
||
@dataclass | ||
class BreakingChanges: | ||
module_path: str | ||
classes_removed: List[ClassRemoved] = field(default_factory=list) | ||
imports_removed: List[ImportRemoved] = field(default_factory=list) | ||
functions_removed: List[FunctionRemoved] = field(default_factory=list) | ||
params_removed: List[ParameterRemoved] = field(default_factory=list) | ||
required_params_added: List[RequiredParameterAdded] = field(default_factory=list) | ||
|
||
def __str__(self) -> str: | ||
summary = "" | ||
module_path_colored = colored(self.module_path, MODULE_PATH_COLOR) | ||
|
||
breaking_changes: List[AbstractBreakingChange] = ( | ||
self.classes_removed + self.imports_removed + self.functions_removed + self.params_removed + self.required_params_added | ||
) | ||
for breaking_change in breaking_changes: | ||
|
||
summary += "{:<70} {:<8} {:<30} {}\n".format( | ||
module_path_colored, breaking_change.line_num, breaking_change.breaking_type_name, breaking_change.description | ||
) | ||
|
||
return summary | ||
|
||
def json(self) -> Dict[str, List[str]]: | ||
return asdict(self) | ||
|
||
@property | ||
def is_empty(self) -> bool: | ||
return len(self.classes_removed + self.imports_removed + self.functions_removed + self.params_removed + self.required_params_added) == 0 | ||
|
||
|
||
def extract_code_breaking_changes(module_path: str, source_code: str, current_code: str) -> BreakingChanges: | ||
"""Compares two versions of code to identify breaking changes. | ||
:param module_path: The path to the module being compared. | ||
:param source_code: The source version of the code. | ||
:param current_code: The modified version of the code. | ||
:return: A BreakingChanges object detailing the differences. | ||
""" | ||
breaking_changes = BreakingChanges(module_path=module_path) | ||
|
||
source_classes = {node.name: node for node in ast.walk(ast.parse(source_code)) if isinstance(node, ast.ClassDef)} | ||
current_classes = {node.name: node for node in ast.walk(ast.parse(current_code)) if isinstance(node, ast.ClassDef)} | ||
|
||
# ClassRemoved | ||
for class_name, source_class in source_classes.items(): | ||
if class_name not in current_classes: | ||
breaking_changes.classes_removed.append( | ||
ClassRemoved( | ||
class_name=class_name, | ||
line_num=source_class.lineno, | ||
) | ||
) | ||
|
||
# FUNCTION SIGNATURES | ||
source_functions_signatures = parse_functions_signatures(source_code) | ||
current_functions_signatures = parse_functions_signatures(current_code) | ||
for function_name, source_function_signature in source_functions_signatures.items(): | ||
|
||
if function_name in current_functions_signatures: | ||
current_function_signature = current_functions_signatures[function_name] | ||
|
||
# ParameterRemoved | ||
for source_param in source_function_signature.params.all: | ||
if source_param not in current_function_signature.params.all: | ||
breaking_changes.params_removed.append( | ||
ParameterRemoved( | ||
function_name=function_name, | ||
parameter_name=source_param, | ||
line_num=current_function_signature.line_num, | ||
) | ||
) | ||
|
||
# RequiredParameterAdded | ||
for current_param in current_function_signature.params.required: | ||
if current_param not in source_function_signature.params.required: | ||
breaking_changes.required_params_added.append( | ||
RequiredParameterAdded( | ||
function_name=function_name, | ||
parameter_name=current_param, | ||
line_num=current_function_signature.line_num, | ||
) | ||
) | ||
|
||
else: | ||
# FunctionRemoved | ||
breaking_changes.functions_removed.append( | ||
FunctionRemoved( | ||
function_name=function_name, | ||
line_num=source_function_signature.line_num, | ||
) | ||
) | ||
|
||
# Check import ONLY if __init__ file. | ||
if module_path.endswith("__init__.py"): | ||
source_imports = parse_imports(code=source_code) | ||
current_imports = parse_imports(code=current_code) | ||
breaking_changes.imports_removed = [ | ||
ImportRemoved(import_name=source_import, line_num=0) for source_import in source_imports if source_import not in current_imports | ||
] | ||
return breaking_changes | ||
|
||
|
||
def analyze_breaking_changes(verbose: bool = 1) -> List[Dict[str, Union[str, List]]]: | ||
"""Analyze changes between the current branch (HEAD) and the master branch. | ||
:param verbose: If True, print the summary of breaking changes in a nicely formatted way | ||
:return: List of changes, where each change is a dictionary listing each type of change for each module. | ||
""" | ||
# GitHelper requires `git` library which should NOT be required for the other functions | ||
from .git_utils import GitHelper | ||
|
||
root_dir = str(Path(__file__).resolve().parents[2]) | ||
git_explorer = GitHelper(git_path=root_dir) | ||
|
||
summary = "" | ||
breaking_changes_list = [] | ||
for module_path in git_explorer.diff_files(source_branch="master", current_branch="HEAD"): | ||
|
||
master_code = git_explorer.load_branch_file(branch="master", file_path=module_path) | ||
head_code = git_explorer.load_branch_file(branch="HEAD", file_path=module_path) | ||
breaking_changes = extract_code_breaking_changes(module_path=module_path, source_code=master_code, current_code=head_code) | ||
|
||
if not breaking_changes.is_empty: | ||
breaking_changes_list.append(breaking_changes.json()) | ||
summary += str(breaking_changes) | ||
|
||
if verbose: | ||
if summary: | ||
print("{:<60} {:<8} {:<30} {}\n".format("MODULE", "LINE NO", "BREAKING TYPE", "DESCRIPTION (Master -> HEAD)")) | ||
print("-" * 175 + "\n") | ||
print(summary) | ||
else: | ||
print(colored("NO BREAKING CHANGE DETECTED!", "green")) | ||
|
||
return breaking_changes_list |
Oops, something went wrong.