Inspecting un-imported modules using ast
or
Skipping modules in py.test using marks but no importing
As I've said before, py.test is my favourite testing tool. One if it's features is that it allows you to mark tests with
arbitrary "marks", built in ones are e.g. skipif
, xfail
etc. But py.test's extension mechanism allows you to easily add behaviour on any other marks you might want.
Recently I've been starting to write some testing code for a Django project (using the great-and-still-improving pytest-django plugin), but I have one strange requirement, on our CI hosts we only want to run the Django tests for a few platforms (We write monitoring software so support way more platforms then is sane). So the natural thing to was mark those test modules as needing Django so we can skip them.
This is how you mark a test module as such, each test written in this module will have the "django" mark applied:
# test_foo.py
import django
import pytest
import foo
pytestmark = pytest.mark.django
# Or, using multiple marks
pytestmark = [pytest.mark.django, py.test.mark.another_mark]
I've also imported django itself, usually test modules need to do this one way or another, at least indirectly via the module they are testing. Now we could easily write py.test conftest file to skip these tests:
# conftest.py
import pytest
ENABLE_DJANGO = True # imagine some complicated expression/function
def pytest_setup_item(item):
if 'django' in item.keywords and not ENABLE_DJANGO:
pytest.skip('Django tests are disabled')
But we have an additional problem, we don't even build Django for all platforms so can't import these test modules in the first place. This means py.test fails while collecting the test modules. There is another hook available in py.test which allows us to skip modules without importing them: pytest_ignore_collect()
. But we still need to be able to read the marker inside this module without
importing, time to enter ast.
The ast
module can parse python code into an abstract syntax tree, which represents the code in a tree of nodes according to the grammar. This is actually a part of the normal compilation process, but just as an internal step. Enough introduction, it was pleasantly easy to use it to find the marks in our test modules:
def _static_mark(path):
"""Return the pytestmark of a test file whithout executing it"""
module = ast.parse(path.read(), str(path))
marks = []
for node in module.body:
if not isinstance(node, ast.Assign):
continue
if len(node.targets) > 1:
continue
if not isinstance(node.targets[0], ast.Name):
continue
if node.targets[0].id != 'pytestmark':
continue
if isinstance(node.value, ast.Attribute):
marks.append(node.value.attr)
continue
if isinstance(node.value, ast.List):
for elem in node.value.elts:
if isinstance(elem, ast.Attribute):
marks.append(elem.attr)
return marks
That's it. Essentially we're looking for an ast.Assign
node which has a target of pytestmark
that is, something is being assigned to
pytestmark
. Once we find such a node we make sure to only accept a few right-hand-side expressions, namely either pytest.mark.the_mark
or [pytest.mark.mark0, pytest.mark.mark1]
.
Now this is obviously not bulletproof, but it does keep it nice and straight forward. And I thought is a nice example of how to the ast
module can be useful.
0 comments:
New comments are not allowed.