avocado/core/safeloader/core.py
import ast
import collections
import sys
from importlib.machinery import PathFinder
from avocado.core.safeloader.docstring import (
check_docstring_directive,
get_docstring_directives,
get_docstring_directives_dependencies,
get_docstring_directives_tags,
)
from avocado.core.safeloader.module import PythonModule
def get_methods_info(statement_body, class_tags, class_dependencies):
"""Returns information on test methods.
:param statement_body: the body of a "class" statement
:param class_tags: the tags at the class level, to be combined with the
tags at the method level.
:param class_dependencies: the dependencies at the class level, to be
combined with the dependencies at the method
level.
"""
methods_info = []
for st in statement_body:
if not (isinstance(st, (ast.FunctionDef, ast.AsyncFunctionDef))):
continue
decorators = getattr(st, "decorator_list", [])
decorator_names = [getattr(_, "id", None) for _ in decorators]
if "property" in decorator_names:
continue
if not st.name.startswith("test"):
continue
docstring = ast.get_docstring(st)
mt_tags = get_docstring_directives_tags(docstring)
mt_tags.update(class_tags)
mt_dependencies = get_docstring_directives_dependencies(docstring)
mt_dependencies.extend(class_dependencies)
methods = [method for method, _, _ in methods_info]
if st.name not in methods:
methods_info.append((st.name, mt_tags, mt_dependencies))
return methods_info
def _extend_test_list(current, new):
for test in new:
test_method_name = test[0]
found = False
for current_test in current:
if test_method_name == current_test[0]:
_exted_tests_tags([current_test], test[1])
found = True
break
if not found:
current.append(test)
def _exted_tests_tags(tests, tags, force_update=False):
for test in tests:
for tag, value in tags.items():
if force_update:
test[1][tag] = value
else:
test[1].setdefault(tag, value)
def _examine_same_module(
parents,
info,
disabled,
match,
module,
target_module,
target_class,
determine_match,
info_class_tags,
):
# Searching the parents in the same module
for parent in parents[:]:
# Looking for a 'class FooTest(Parent)'
if not isinstance(parent, ast.Name):
# 'class FooTest(bar.Bar)' not supported within
# a module
continue
parent_class = parent.id
# From this point we use `_$variable` to name temporary returns
# from method calls that are to-be-assigned/combined with the
# existing `$variable`.
_info, _disable, parent_tags, _match = _examine_class(
target_module,
target_class,
determine_match,
module.path,
parent_class,
match,
)
if _info:
parents.remove(parent)
_exted_tests_tags(info, parent_tags)
_exted_tests_tags(_info, info_class_tags, True)
_extend_test_list(info, _info)
disabled.update(_disable)
if _match is not match:
match = _match
return match
class ClassNotSuitable(Exception):
"""Exception raised when examination of a class should not proceed."""
def _get_attributes_for_further_examination(parent, module):
"""Returns path, module and class for further examination.
This looks at one of the parents of an interesting class, so for the
example class Test below:
>>> class Test(unittest.TestCase, MixIn):
>>> pass
This function should be called twice: once for unittest.TestCase,
and once for MixIn.
:param parent: parent is one of the possibly many parents from
which the class being examined inherits from.
:type parent: :class:`ast.Attribute`
:param module: PythonModule instance with information about the
module being inspected
:type module: :class:`avocado.core.safeloader.module.PythonModule`
:raises: ClassNotSuitable
:returns: a tuple with three values: the class name, the imported
symbol instance matching the further examination step,
and a hint about the symbol name also being a module.
:rtype: tuple of (str,
:class:`avocado.core.safeloader.imported.ImportedSymbol`,
bool)
"""
if hasattr(parent, "value"):
# A "value" in an "attribute" in this context means that
# there's a "module.class" notation. It may be called that
# way, because it resembles "class" being an attribute of the
# "module" object. In short, if "parent" has a "value"
# attribute, it means that this is given as a
# "module.parent_class" notation, meaning that:
# - parent is a module
# - parent.value *should* be a class, because there's
# currently no support for the "module.module.parent_class"
# notation. See issue #4706.
klass = parent.value
if not hasattr(klass, "id"):
# We don't support multi-level 'parent.parent.Class'
raise ClassNotSuitable
else:
# We know 'parent.Class' or 'asparent.Class' and need
# to get path and original_module_name. Class is given
# by parent definition.
imported_symbol = module.imported_symbols.get(klass.id)
if imported_symbol is None:
# We can't examine this parent (probably broken module)
raise ClassNotSuitable
# We currently don't support classes whose parents are generics
if isinstance(parent, ast.Subscript):
raise ClassNotSuitable
parent_class = parent.attr
# Special situation: in this case, because we know the parent
# class is given as, module.class notation, we know what the
# module name is. The imported symbol, because of its knowledge
# *only* about the imports, and not about the class definitions,
# can not tell if an import is a "from module import other_module"
# or a "from module import class"
symbol_is_module = klass.id == imported_symbol.symbol_name
else:
# We only know 'Class' or 'AsClass' and need to get
# path, module and original class_name
klass = parent.id
imported_symbol = module.imported_symbols.get(klass)
if imported_symbol is None:
# We can't examine this parent (probably broken module)
raise ClassNotSuitable
parent_class = imported_symbol.symbol
symbol_is_module = False
return parent_class, imported_symbol, symbol_is_module
def _find_import_match(parent_path, parent_module):
"""Attempts to find an importable module."""
modules_paths = [parent_path] + sys.path
found_spec = PathFinder.find_spec(parent_module, modules_paths)
if found_spec is None:
raise ClassNotSuitable
return found_spec
def _examine_class(
target_module, target_class, determine_match, path, class_name, match
):
"""
Examine a class from a given path
:param target_module: the name of the module from which a class should
have come from. When attempting to find a Python
unittest, the target_module will most probably
be "unittest", as per the standard library module
name. When attempting to find Avocado tests, the
target_module will most probably be "avocado".
:type target_module: str
:param target_class: the name of the class that is considered to contain
test methods. When attempting to find Python
unittests, the target_class will most probably be
"TestCase". When attempting to find Avocado tests,
the target_class will most probably be "Test".
:type target_class: str
:param determine_match: a callable that will determine if a match has
occurred or not
:type determine_match: function
:param path: path to a Python source code file
:type path: str
:param class_name: the specific class to be found
:type path: str
:param match: whether the inheritance from <target_module.target_class> has
been determined or not
:type match: bool
:returns: tuple where first item is a list of test methods detected
for given class; second item is set of class names which
look like avocado tests but are force-disabled;
third is dict of class tags.
:rtype: tuple
"""
module = PythonModule(path, target_module, target_class)
info = []
class_tags = {}
disabled = set()
for klass in module.iter_classes(class_name):
if class_name != klass.name:
continue
docstring = ast.get_docstring(klass)
if match is False:
match = module.is_matching_klass(klass)
class_tags = get_docstring_directives_tags(docstring)
info = get_methods_info(
klass.body,
class_tags,
get_docstring_directives_dependencies(docstring),
)
# Getting the list of parents of the current class
parents = klass.bases
match = _examine_same_module(
parents,
info,
disabled,
match,
module,
target_module,
target_class,
determine_match,
class_tags,
)
# If there are parents left to be discovered, they
# might be in a different module.
for parent in parents:
try:
(
parent_class,
imported_symbol,
symbol_is_module,
) = _get_attributes_for_further_examination(parent, module)
found_spec = imported_symbol.get_importable_spec(symbol_is_module)
if found_spec is None:
continue
except ClassNotSuitable:
continue
_info, _disabled, parent_tags, _match = _examine_class(
target_module,
target_class,
determine_match,
found_spec.origin,
parent_class,
match,
)
if _info:
_exted_tests_tags(info, parent_tags)
_exted_tests_tags(_info, class_tags, True)
_extend_test_list(info, _info)
disabled.update(_disabled)
if _match is not match:
match = _match
if not match and module.interesting_klass_found:
imported_symbol = module.imported_symbols[class_name]
if imported_symbol:
found_spec = imported_symbol.get_importable_spec()
if found_spec:
_info, _disabled, _class_tags, _match = _examine_class(
target_module,
target_class,
determine_match,
found_spec.origin,
class_name,
match,
)
if _info:
_exted_tests_tags(info, _class_tags)
_exted_tests_tags(_info, class_tags, True)
_extend_test_list(info, _info)
_class_tags.update(class_tags)
class_tags = _class_tags
disabled.update(_disabled)
if _match is not match:
match = _match
return info, disabled, class_tags, match
def find_python_tests(target_module, target_class, determine_match, path):
"""
Attempts to find Python tests from source files
A Python test in this context is a method within a specific type
of class (or that inherits from a specific class).
:param target_module: the name of the module from which a class should
have come from. When attempting to find a Python
unittest, the target_module will most probably
be "unittest", as per the standard library module
name. When attempting to find Avocado tests, the
target_module will most probably be "avocado".
:type target_module: str
:param target_class: the name of the class that is considered to contain
test methods. When attempting to find Python
unittests, the target_class will most probably be
"TestCase". When attempting to find Avocado tests,
the target_class will most probably be "Test".
:type target_class: str
:type determine_match: a callable that will determine if a given module
and class is contains valid Python tests
:type determine_match: function
:param path: path to a Python source code file
:type path: str
:returns: tuple where first item is dict with class name and additional
info such as method names and tags; the second item is
set of class names which look like Python tests but have been
forcefully disabled.
:rtype: tuple
"""
module = PythonModule(path, target_module, target_class)
# The resulting test classes
result = collections.OrderedDict()
disabled = set()
for klass in module.iter_classes():
docstring = ast.get_docstring(klass)
# Looking for a class that has in the docstring either
# ":avocado: enable" or ":avocado: disable
if check_docstring_directive(docstring, "disable"):
disabled.add(klass.name)
continue
if check_docstring_directive(docstring, "enable"):
info = get_methods_info(
klass.body,
get_docstring_directives_tags(docstring),
get_docstring_directives_dependencies(docstring),
)
result[klass.name] = info
continue
# From this point onwards we want to do recursive discovery, but
# for now we don't know whether it is avocado.Test inherited
# (Ifs are optimized for readability, not speed)
# If "recursive" tag is specified, it is forced as test
if check_docstring_directive(docstring, "recursive"):
match = True
else:
match = module.is_matching_klass(klass)
class_tags = get_docstring_directives_tags(docstring)
info = get_methods_info(
klass.body,
class_tags,
get_docstring_directives_dependencies(docstring),
)
# Getting the list of parents of the current class
parents = klass.bases
match = _examine_same_module(
parents,
info,
disabled,
match,
module,
target_module,
target_class,
determine_match,
class_tags,
)
# If there are parents left to be discovered, they
# might be in a different module.
for parent in parents:
try:
(
parent_class,
imported_symbol,
symbol_is_module,
) = _get_attributes_for_further_examination(parent, module)
found_spec = imported_symbol.get_importable_spec(symbol_is_module)
if found_spec is None:
continue
except ClassNotSuitable:
continue
_info, _dis, parent_tags, _match = _examine_class(
target_module,
target_class,
determine_match,
found_spec.origin,
parent_class,
match,
)
if _info:
_exted_tests_tags(info, parent_tags)
_exted_tests_tags(_info, class_tags, True)
_extend_test_list(info, _info)
disabled.update(_dis)
if _match is not match:
match = _match
# Only update the results if this was detected as 'avocado.Test'
if match:
result[klass.name] = info
return result, disabled
def _determine_match_python(module, klass, docstring):
"""
Implements the match check for all Python based test classes
Meaning that the enable/disabled/recursive tags are respected for
Avocado Instrumented Tests and Python unittests.
"""
directives = get_docstring_directives(docstring)
if "disable" in directives:
return False
if "enable" in directives:
return True
if "recursive" in directives:
return True
# Still not decided, try inheritance
return module.is_matching_klass(klass)
def find_avocado_tests(path):
return find_python_tests("avocado", "Test", _determine_match_python, path)
def find_python_unittests(path):
found, _ = find_python_tests("unittest", "TestCase", _determine_match_python, path)
return found