How to replicate py.test magic in lest than 100 lines of code. Demo code available here: https://github.com/oinopion/dispel
Dispelling py.testmagicTomek PaczkowskiPyCon UK September 2015
View Slide
py.test is awesomeTest discoveryNo boilerplatePluginsAsserting using assert statement
def double(x):return x * 2def test_doubling():expected = 5assert double(2) == expected
def test_doubling():expected = 5> assert double(2) == expectedE assert 4 == 5E + where 4 = double(2)sample_test.py:6: AssertionError
How to replicate this?
Benjamin PetersonBehind the scenes of py.test'snew assertion rewritingbit.ly/pytest-ast
Step oneimport ast
assert double(2) == expectedassert==func callvariable constantvariable
ast.parse('assert double(2) == expected')bit.ly/docs-ast
Assert(test=Compare(left=Call(func=Name(id='double'),args=[Num(n=2)]),ops=[Eq()],comparators=[Name(id='expected')]))
Assert(test=Compare(left=Call(func=Name(id='double'),args=[Num(n=2)]),ops=[Eq()],comparators=[Name(id='expected')]))bit.ly/better-docs-ast
Goal oneModify the assert statement to call a function with both sides of the comparison
assert double(2) == expectedassert_equals(double(2), expected)
Node transformerAllows AST tree modificationImplements visitor pattern
class AssertRewrite(NodeTransformer): def visit_Assert(self, node): call = Call( func=Name( id='assert_equals', ctx=Load() ), args=[ node.test.left, node.test.comparators[0] ], keywords=[] ) new_node = Expr(value=call) copy_location(new_node, node) fix_missing_locations(new_node) return new_node
Where is assert_equalscoming from?
def transform(module): import_node = ImportFrom( module='test_utils', names=[alias('assert_equals', None)], lineno=0, col_offset=0, ) module.body[0:0] = [import_node] transformer = AssertRewrite() return transformer.visit(module)
import_node = ImportFrom( module='test_utils', names=[alias('assert_equals', None)], lineno=0, col_offset=0, )
import_node = ImportFrom( module='test_utils', names=[alias('assert_equals', '#eq')], lineno=0, col_offset=0, )Call( func=Name(id='assert_equals', ctx=Load()), args=[...], keywords=[] )
import_node = ImportFrom( module='test_utils', names=[alias('assert_equals', '#eq')], lineno=0, col_offset=0, )Call( func=Name(id='#eq', ctx=Load()), args=[...], keywords=[] )
Step twoimport sys
Import path hookssys.path_hooksFactory functions for finderssys.path_importer_cachebit.ly/docs-import
Goal twoWrite an import hook that uses our transformer to modify imported code
def import_hook(path):if os.path.abspath('') == path:return Finder()else:raise ImportErrorsys.path_hooks.insert(0, import_hook)sys.path_importer_cache.clear()
FinderDefines one method: find_specMethod returns a ModuleSpec
from importlib.util import spec_from_file_location class Finder: def find_spec(self, module, target=None): file_name = module + '.py' if not os.path.exists(file_name): return None return spec_from_file_location( name=module, location=file_name, loader=Loader() )
LoaderDefines one method: exec_moduleExecutes module codePopulates module namespace
class Loader:def exec_module(self, module):with open(module.__file__, 'rb') as fp:source = fp.read()tree = ast.parse(source, module.__file__)tree = transform(tree)code = compile(tree, module.__file__, 'exec')exec(code, module.__dict__)
class Loader:def exec_module(self, module):with open(module.__file__, 'rb') as f:source = f.read()tree = ast.parse(source, module.__file__)tree = transform(tree)code = compile(tree, module.__file__, 'exec')exec(code, module.__dict__)
class Loader: def exec_module(self, module): with open(module.__file__, 'rb') as fp: source = fp.read() tree = ast.parse(source, module.__file__) tree = transform(tree) code = compile(tree, module.__file__, 'exec') module.__dict__['#eq'] = assert_equals exec(code, module.__dict__)Bonus
Step threeTest discovery
import sample_testsample_test.test_with_assert()
Demo
SummaryThis is probably a giant foot gunCorner cases left for the readerPython is awesome
This presentation bit.ly/dispel-pytestDemo code github.com/oinopion/dispel
Thanks!Tomek Paczkowski@oinopion