Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add 'changed function implementation' detection #2

Merged
merged 1 commit into from Feb 15, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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"
45 changes: 39 additions & 6 deletions tests/unit/test_pyfference.py
Expand Up @@ -3,15 +3,34 @@
import pyff.pyfference as pf

def test_function_name_changed():
fpyff = pf.FunctionPyfference(names=("first", "second"))
assert fpyff.name.old == "first"
assert fpyff.name.new == "second"
fpyff = pf.FunctionPyfference(name="first", names=("first", "second"))
assert fpyff.name == "first"
assert fpyff.names.old == "first"
assert fpyff.names.new == "second"
assert fpyff.implementation is None
assert len(fpyff) == 1
assert str(fpyff) == "Function 'first' was renamed to 'second'"

def test_function_name_same():
fpyff = pf.FunctionPyfference()
assert fpyff.name is None
def test_function_same():
fpyff = pf.FunctionPyfference(name="func")
assert fpyff.name == "func"
assert fpyff.names is None
assert fpyff.implementation is None
assert len(fpyff) == 0 # pylint: disable=len-as-condition
assert str(fpyff) == ""

def test_function_implementation_changed(): # pylint: disable=invalid-name
fpyff = pf.FunctionPyfference(name="func", implementation=True)
assert fpyff.name == "func"
assert fpyff.names is None
assert fpyff.implementation is True
assert len(fpyff) == 1
assert str(fpyff) == "Function 'func' changed implementation"

def test_function_everything_changed(): # pylint: disable=invalid-name
fpyff = pf.FunctionPyfference(name="first", names=("first", "second"), implementation=True)
assert len(fpyff) == 2
assert str(fpyff) == "Function 'first' was renamed to 'second' and its implementation changed"

def test_new_from_import():
mpyff = pf.FromImportPyfference(new={'os': ['path', 'getenv']})
Expand All @@ -36,3 +55,17 @@ def test_module_with_new_classes():
assert mpyff.classes is not None
assert len(mpyff) == 1
assert str(mpyff) == "New NewClass"

def test_functions_changed():
fpyff = pf.FunctionPyfference(name='func', implementation=True)
fspyff = pf.FunctionsPyfference(changed={'func': fpyff})
assert fspyff.changed is not None
assert str(fspyff) == "Function 'func' changed implementation"

def test_module_with_changed_functions(): # pylint: disable=invalid-name
fpyff = pf.FunctionPyfference(name='func', implementation=True)
fspyff = pf.FunctionsPyfference(changed={'func': fpyff})
mpyff = pf.ModulePyfference(functions=fspyff)
assert mpyff.functions is not None
assert len(mpyff) == 1
assert str(mpyff) == "Function 'func' changed implementation"