Skip to content

Commit

Permalink
Add 'changed function implementation' detection
Browse files Browse the repository at this point in the history
  • Loading branch information
petr-muller committed Feb 15, 2018
1 parent ab911c5 commit 5487325
Show file tree
Hide file tree
Showing 4 changed files with 115 additions and 20 deletions.
60 changes: 53 additions & 7 deletions pyff/pyff.py
@@ -1,18 +1,29 @@
"""Functions for comparison of various Python entities"""

from ast import FunctionDef, parse, NodeVisitor, Module
from typing import cast, Set
from ast import FunctionDef, parse, NodeVisitor, Module, dump
from typing import cast, Set, Dict
from collections import defaultdict
from itertools import zip_longest

import pyff.pyfference as pf
from pyff.summary import ClassSummary

def _pyff_function_ast(first: FunctionDef, second: FunctionDef) -> pf.FunctionPyfference:
"""Return differences between two Python function ASTs, or None if they are identical"""
if first.name == second.name:
return None
names = None
if first.name != second.name:
names = (first.name, second.name)

return pf.FunctionPyfference(names=(first.name, second.name))
implementation = None
for old_statement, new_statement in zip_longest(first.body, second.body):
if dump(old_statement) != dump(new_statement):
implementation = True
break

if names or implementation:
return pf.FunctionPyfference(name=first.name, names=names, implementation=implementation)

return None

def _pyff_from_imports(first_ast: Module, second_ast: Module) -> pf.FromImportPyfference:
"""Return differences in `from X import Y` statements in two modules"""
Expand Down Expand Up @@ -61,12 +72,47 @@ def _pyff_classes(first_ast: Module, second_ast: Module) -> pf.ClassesPyfference

return pf.ClassesPyfference(appeared) if appeared else None

class MethodsExtractor(NodeVisitor):
"""Extract information about methods in a module"""
def __init__(self) -> None:
self.names: Set[str] = set()
self.functions: Dict[str, FunctionDef] = {}

def visit_ClassDef(self, node): # pylint: disable=invalid-name
"""Prevent this visitor from inspecting classes"""
pass

def visit_FunctionDef(self, node): # pylint: disable=invalid-name
"""Save top-level function definitions"""
self.names.add(node.name)
self.functions[node.name] = node

def _pyff_functions(first_ast: Module, second_ast: Module) -> pf.FunctionsPyfference:
"""Return differences in top-level functions in two modules"""
first_walker = MethodsExtractor()
second_walker = MethodsExtractor()

first_walker.visit(first_ast)
second_walker.visit(second_ast)

both = first_walker.names.intersection(second_walker.names)
differences = {}
for function in both:
difference = _pyff_function_ast(first_walker.functions[function],
second_walker.functions[function])
if difference:
differences[function] = difference

return pf.FunctionsPyfference(changed=differences) if differences else None


def _pyff_modules(first_ast: Module, second_ast: Module) -> pf.ModulePyfference:
from_imports = _pyff_from_imports(first_ast, second_ast)
classes = _pyff_classes(first_ast, second_ast)
functions = _pyff_functions(first_ast, second_ast)

if from_imports or classes:
return pf.ModulePyfference(from_imports, classes)
if from_imports or classes or functions:
return pf.ModulePyfference(from_imports, classes, functions)

return None

Expand Down
46 changes: 40 additions & 6 deletions pyff/pyfference.py
Expand Up @@ -7,17 +7,35 @@

class FunctionPyfference: # pylint: disable=too-few-public-methods
"""Holds differences between Python function definitions"""
def __init__(self, names: Tuple[str, str] = None) -> None:
self.name: Change = None
self.changes: List[Change] = []
def __init__(self, name: str, names: Tuple[str, str] = None,
implementation: bool = None) -> None:
self.name = name
self.names: Change = None
self.changes: List = []
self.implementation: bool = implementation

if names:
self.name = Change(names[0], names[1])
self.changes.append(self.name)
self.names = Change(names[0], names[1])
self.changes.append(self.names)

if implementation:
self.changes.append(f"Function '{self.name}' changed implementation")

def __len__(self):
return len(self.changes)

def __str__(self):
if self.names and self.implementation:
old = self.names.old
new = self.names.new
return f"Function '{old}' was renamed to '{new}' and its implementation changed"
elif self.names:
return f"Function '{self.names.old}' was renamed to '{self.names.new}'"
elif self.implementation:
return f"Function '{self.name}' changed implementation"

return ""

class FromImportPyfference: # pylint: disable=too-few-public-methods
"""Holds differences between from X import Y statements in a module"""
def __init__(self, new: Dict[str, List[str]]) -> None:
Expand All @@ -32,7 +50,17 @@ def __str__(self):

return "\n".join(lines)

class FunctionsPyfference: # pylint: disable=too-few-public-methods
"""Holds differences between top-level functions in a module"""
def __init__(self, changed: Dict[str, FunctionPyfference]) -> None:
self.changed = changed

def __str__(self) -> str:

return "\n".join([str(change) for change in self.changed.values()])

class ClassesPyfference: # pylint: disable=too-few-public-methods

"""Holds differences between classes defined in a module"""
def __init__(self, new: Iterable[ClassSummary]) -> None:
self.new: Iterable[ClassSummary] = new
Expand All @@ -43,10 +71,12 @@ def __str__(self):
class ModulePyfference: # pylint: disable=too-few-public-methods
"""Holds differences between two Python modules"""
def __init__(self, from_imports: FromImportPyfference = None,
classes: ClassesPyfference = None) -> None:
classes: ClassesPyfference = None,
functions: FunctionsPyfference = None) -> None:
self.changes: List = []
self.from_imports: FromImportPyfference = None
self.classes: ClassesPyfference = None
self.functions: FunctionsPyfference = None

if from_imports:
self.from_imports = from_imports
Expand All @@ -56,6 +86,10 @@ def __init__(self, from_imports: FromImportPyfference = None,
self.classes = classes
self.changes.append(self.classes)

if functions:
self.functions = functions
self.changes.append(self.functions)

def __len__(self):
return len(self.changes)

Expand Down
15 changes: 12 additions & 3 deletions tests/unit/test_functions.py
Expand Up @@ -6,6 +6,7 @@
TRIVIAL_FUNCTION = """def function(): pass"""
TRIVIAL_FUNCTION_2 = """def function2(): pass"""

IMPLEMENTED_FUNCTION = """def function(): return None"""

def test_trivial_function():
difference = pyff_function(TRIVIAL_FUNCTION, TRIVIAL_FUNCTION)
Expand All @@ -14,9 +15,10 @@ def test_trivial_function():
def test_name_change():
difference = pyff_function(TRIVIAL_FUNCTION, TRIVIAL_FUNCTION_2)
assert len(difference) == 1
assert difference.name is not None
assert difference.name.old == "function"
assert difference.name.new == "function2"
assert difference.name == "function"
assert difference.names is not None
assert difference.names.old == "function"
assert difference.names.new == "function2"

def test_not_functions():
no_func = "a = 1"
Expand All @@ -28,3 +30,10 @@ def g(): pass"""
pyff_function(TRIVIAL_FUNCTION, bad)
with raises(ValueError):
pyff_function(bad, TRIVIAL_FUNCTION)

def test_changed_implementation():
difference = pyff_function(TRIVIAL_FUNCTION, IMPLEMENTED_FUNCTION)
assert len(difference) == 1
assert difference.names is None
assert difference.implementation is not None
assert str(difference) == "Function 'function' changed implementation"
14 changes: 10 additions & 4 deletions tests/unit/test_modules.py
Expand Up @@ -3,27 +3,27 @@
from pyff.pyff import pyff_module

TRIVIAL_MODULE = """import sys
def func():
pass"""

IMPORT_MODULE = """import sys
from os import path
def func():
pass"""

CLASSES_MODULE = """import sys
class Klass:
def method(self):
pass
def _method(self):
pass
def func():
pass"""

CHANGED_FUNCTION_MODULE = """import sys
def func():
return None"""


def test_trivial_module():
difference = pyff_module(TRIVIAL_MODULE, TRIVIAL_MODULE)
Expand All @@ -40,3 +40,9 @@ def test_module_with_new_class():
assert difference is not None
assert len(difference) == 1
assert str(difference) == "New class 'Klass' with 1 public methods"

def test_module_with_changed_function(): # pylint: disable=invalid-name
difference = pyff_module(TRIVIAL_MODULE, CHANGED_FUNCTION_MODULE)
assert difference is not None
assert len(difference) == 1
assert str(difference) == "Function 'func' changed implementation"

0 comments on commit 5487325

Please sign in to comment.