Skip to content

Commit

Permalink
Add two module comparison script
Browse files Browse the repository at this point in the history
Its only functionality is currencly a feature to detect newly added
'from package import name' statements.
  • Loading branch information
petr-muller committed Feb 9, 2018
1 parent 7119c84 commit f1c3e47
Show file tree
Hide file tree
Showing 7 changed files with 128 additions and 15 deletions.
43 changes: 36 additions & 7 deletions pyff/pyff.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Functions for comparison of various Python entities"""

from ast import FunctionDef, parse
from ast import FunctionDef, parse, NodeVisitor, Module
from typing import cast
from pyff.pyfference import FunctionPyfference, ModulePyfference
from collections import defaultdict
from pyff.pyfference import FunctionPyfference, ModulePyfference, FromImportPyfference

def _pyff_function_ast(first: FunctionDef, second: FunctionDef) -> FunctionPyfference:
"""Return differences between two Python function ASTs, or None if they are identical"""
Expand All @@ -11,6 +12,36 @@ def _pyff_function_ast(first: FunctionDef, second: FunctionDef) -> FunctionPyffe

return FunctionPyfference(names=(first.name, second.name))

def _pyff_from_imports(first_ast: Module, second_ast: Module) -> FromImportPyfference:
"""Return differences in `from X import Y` statements in two modules"""
first_walker = ImportFromExtractor()
second_walker = ImportFromExtractor()

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

appeared = set(second_walker.modules.keys()) - set(first_walker.modules.keys())
new = {}
for module in appeared:
new[module] = second_walker.modules[module]

return FromImportPyfference(new) if new else None

def _pyff_modules(first_ast: Module, second_ast: Module) -> ModulePyfference:
from_imports = _pyff_from_imports(first_ast, second_ast)

return ModulePyfference(from_imports) if from_imports else None

class ImportFromExtractor(NodeVisitor):
"""Extracts information about `from x import y` statements"""
def __init__(self):
self.modules = defaultdict(set)
super(ImportFromExtractor, self).__init__()

def visit_ImportFrom(self, node): # pylint: disable=invalid-name
"""Save information about `from x import y` statements"""
self.modules[node.module].update([node.name for node in node.names])

def pyff_function(first: str, second: str) -> FunctionPyfference:
"""Return differences between two Python functions, or None if they are identical"""
first_ast = parse(first).body
Expand All @@ -21,13 +52,11 @@ def pyff_function(first: str, second: str) -> FunctionPyfference:
if len(second_ast) != 1 or not isinstance(second_ast[0], FunctionDef):
raise ValueError(f"Second argument does not seem to be a single Python function: {second}")


return _pyff_function_ast(cast(FunctionDef, first_ast[0]), cast(FunctionDef, second_ast[0]))

def pyff_module(first: str, second: str) -> ModulePyfference:
"""Return difference between two Python modules, or None if they are identical"""
# pylint: disable=unused-variable
first_ast = parse(first).body
second_ast = parse(second).body

return None
first_ast = parse(first)
second_ast = parse(second)
return _pyff_modules(first_ast, second_ast)
31 changes: 26 additions & 5 deletions pyff/pyfference.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Classes holding information about differences between individual Python elements"""
from collections import namedtuple
from typing import Tuple, List
from typing import Tuple, List, Dict

Change = namedtuple("Change", ["old", "new"])

Expand All @@ -17,10 +17,31 @@ def __init__(self, names: Tuple[str, str] = None) -> None:
def __len__(self):
return len(self.changes)

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:
self.new = new

def __str__(self):
template = "Added import of new names {names} from new package '{package}'"
lines = []
for package, values in self.new.items():
names = ", ".join([f"'{name}'" for name in values])
lines.append(template.format(names=names, package=package))

return "\n".join(lines)

class ModulePyfference: # pylint: disable=too-few-public-methods
"""Holds differences between two Python modules"""
def __init__(self) -> None:
self.changes: List[Change] = []
def __init__(self, from_imports: FromImportPyfference = None) -> None:
self.changes: List = []
self.from_imports: FromImportPyfference = None
if from_imports:
self.from_imports = from_imports
self.changes.append(self.from_imports)

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

def __iter__(self):
yield from self.changes
def __str__(self):
return "\n".join([str(change) for change in self.changes])
3 changes: 1 addition & 2 deletions pyff/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ def main() -> None: # pragma: no cover
print(f"Pyff did not detect any significant difference between {args.old} and {args.new}")
sys.exit(0)

for change in changes:
print(change)
print(changes)

if __name__ == "__main__": # pragma: no cover
main()
24 changes: 24 additions & 0 deletions tests/examples/01-run.new
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from argparse import Action, ArgumentParser

class ParsePlayerAction(Action):
def __init__(self, *args, **kwargs):
Action.__init__(self, *args, **kwargs)

def __call__(self, parser, namespace, values, option_string=None):
if 2 < len(values) < 7:
setattr(namespace, self.dest, values)
else:
raise ValueError("VtES expects three to six players")

def main():
parser = ArgumentParser()
subcommands = parser.add_subparsers()

add = subcommands.add_parser("add")
add.add_argument("players", action=ParsePlayerAction, nargs='*')

parser.parse_args()


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions tests/examples/01-run.old
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
def main():
print("Stuff")

if __name__ == "__main__":
main()
24 changes: 24 additions & 0 deletions tests/unit/test_modules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# pylint: disable=missing-docstring

from pyff.pyff import pyff_module

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

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


def test_trivial_module():
difference = pyff_module(TRIVIAL_MODULE, TRIVIAL_MODULE)
assert difference is None

def test_changed_module():
difference = pyff_module(TRIVIAL_MODULE, IMPORT_MODULE)
assert difference is not None
assert len(difference) == 1
13 changes: 12 additions & 1 deletion tests/unit/test_pyfference.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# pylint: disable=missing-docstring

from pyff.pyfference import FunctionPyfference
from pyff.pyfference import FunctionPyfference, FromImportPyfference, ModulePyfference

def test_function_name_changed():
fpyff = FunctionPyfference(names=("first", "second"))
Expand All @@ -12,3 +12,14 @@ def test_function_name_same():
fpyff = FunctionPyfference()
assert fpyff.name is None
assert len(fpyff) == 0 # pylint: disable=len-as-condition

def test_new_from_import():
mpyff = FromImportPyfference(new={'os': ['path', 'getenv']})
assert mpyff.new == {'os': ['path', 'getenv']}
assert str(mpyff) == "Added import of new names 'path', 'getenv' from new package 'os'"

def test_module_with_from_imports():
mpyff = ModulePyfference(from_imports=FromImportPyfference(new={'os': ['path', 'getenv']}))
assert mpyff.from_imports is not None
assert len(mpyff) == 1
assert str(mpyff) == "Added import of new names 'path', 'getenv' from new package 'os'"

0 comments on commit f1c3e47

Please sign in to comment.