pytest-testrex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_testrex/__init__.py +1 -0
- pytest_testrex/discovery.py +151 -0
- pytest_testrex/hookspecs.py +15 -0
- pytest_testrex/hygiene.py +59 -0
- pytest_testrex/layout.py +94 -0
- pytest_testrex/lint.py +41 -0
- pytest_testrex/plugin.py +135 -0
- pytest_testrex/pylintrc +45 -0
- pytest_testrex/quality.py +136 -0
- pytest_testrex/requirements.py +192 -0
- pytest_testrex/seal.py +86 -0
- pytest_testrex/settings.py +159 -0
- pytest_testrex/signatures.py +64 -0
- pytest_testrex/suite.py +295 -0
- pytest_testrex-0.1.0.dist-info/METADATA +175 -0
- pytest_testrex-0.1.0.dist-info/RECORD +20 -0
- pytest_testrex-0.1.0.dist-info/WHEEL +5 -0
- pytest_testrex-0.1.0.dist-info/entry_points.txt +2 -0
- pytest_testrex-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytest_testrex-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Hold a test suite to one layout and one standard of test quality."""
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import ast
|
|
3
|
+
import functools
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
###########################################################
|
|
7
|
+
# The suites under test
|
|
8
|
+
#
|
|
9
|
+
# The meta suite reads every file under the suites and holds it to one
|
|
10
|
+
# standard. It only parses; nothing here imports a test.
|
|
11
|
+
###########################################################
|
|
12
|
+
|
|
13
|
+
FUNCTION_DEFS = (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_ignored(path, ignored_dirs):
|
|
17
|
+
return any(path == directory or path.startswith(directory + os.sep) for directory in ignored_dirs)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Every test_*.py under the suites, or with test_modules_only off every .py
|
|
21
|
+
def discover_python_files(suite_dirs, test_modules_only, ignored_dirs = ()):
|
|
22
|
+
found = []
|
|
23
|
+
for suite_dir in suite_dirs:
|
|
24
|
+
for root, dirs, files in os.walk(suite_dir):
|
|
25
|
+
dirs[:] = [name for name in dirs if name != "__pycache__" and not is_ignored(os.path.join(root, name), ignored_dirs)]
|
|
26
|
+
for name in files:
|
|
27
|
+
if name.endswith(".py") and (not test_modules_only or name.startswith("test_")):
|
|
28
|
+
found.append(os.path.join(root, name))
|
|
29
|
+
return sorted(found)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@functools.lru_cache(maxsize = None)
|
|
33
|
+
def parsed(path):
|
|
34
|
+
with open(path, "r", encoding = "utf-8") as handle:
|
|
35
|
+
return ast.parse(handle.read())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_test_function(node):
|
|
39
|
+
return isinstance(node, FUNCTION_DEFS) and node.name.startswith("test_")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_test_class(node):
|
|
43
|
+
return isinstance(node, ast.ClassDef) and node.name.startswith("Test")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Every test pytest would collect from this module's own definitions: module
|
|
47
|
+
# level, or a method of a Test class
|
|
48
|
+
def collected_tests(tree):
|
|
49
|
+
found = [node for node in tree.body if is_test_function(node)]
|
|
50
|
+
for node in tree.body:
|
|
51
|
+
if is_test_class(node):
|
|
52
|
+
found.extend(member for member in node.body if is_test_function(member))
|
|
53
|
+
return found
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Whether a module defines tests, including a Test class that only inherits them
|
|
57
|
+
def defines_tests(tree):
|
|
58
|
+
return any(is_test_function(node) or is_test_class(node) for node in tree.body)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parameter_names(function):
|
|
62
|
+
arguments = function.args
|
|
63
|
+
return [argument.arg for argument in arguments.posonlyargs + arguments.args + arguments.kwonlyargs]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_fixture(function):
|
|
67
|
+
return any("fixture" in ast.unparse(decorator) for decorator in function.decorator_list)
|
|
68
|
+
|
|
69
|
+
###########################################################
|
|
70
|
+
# Checks
|
|
71
|
+
#
|
|
72
|
+
# What makes a test fail when its expectation is not met: an assert, a mock's
|
|
73
|
+
# or recorder's assert_*, pytest.fail, a pytest.raises / warns block, or a
|
|
74
|
+
# call to a helper that itself makes checks. Helpers are often imported by
|
|
75
|
+
# name from a *_helpers.py beside the test, so those are followed too.
|
|
76
|
+
###########################################################
|
|
77
|
+
|
|
78
|
+
def is_raises_block(node):
|
|
79
|
+
return isinstance(node, ast.With) and any(
|
|
80
|
+
any(word in ast.unparse(item.context_expr) for word in ("raises", "warns"))
|
|
81
|
+
for item in node.items)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_check_call(node, checkers = frozenset()):
|
|
85
|
+
if not isinstance(node, ast.Call):
|
|
86
|
+
return False
|
|
87
|
+
name = ast.unparse(node.func)
|
|
88
|
+
last = name.rsplit(".", maxsplit = 1)[-1]
|
|
89
|
+
return last.startswith("assert") or name.endswith("pytest.fail") or last in checkers
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Checks reachable in statements without entering a node of a type in stop_at.
|
|
93
|
+
# A try/finally with no handler runs its body straight through, so it is not a branch.
|
|
94
|
+
def checks_in(statements, stop_at = (), checkers = frozenset()):
|
|
95
|
+
found = []
|
|
96
|
+
|
|
97
|
+
def visit(node):
|
|
98
|
+
if isinstance(node, stop_at) and not (isinstance(node, ast.Try) and not node.handlers):
|
|
99
|
+
return
|
|
100
|
+
if isinstance(node, ast.Assert) or is_check_call(node, checkers) or is_raises_block(node):
|
|
101
|
+
found.append(node)
|
|
102
|
+
for child in ast.iter_child_nodes(node):
|
|
103
|
+
visit(child)
|
|
104
|
+
for statement in statements:
|
|
105
|
+
visit(statement)
|
|
106
|
+
return found
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Where a module imported by name from path lives: beside it, in a directory
|
|
110
|
+
# above it within tests_dir, or at the top of tests_dir
|
|
111
|
+
def find_helper_module(path, module_name, tests_dir):
|
|
112
|
+
directory = os.path.dirname(path)
|
|
113
|
+
while True:
|
|
114
|
+
candidate = os.path.join(directory, module_name.replace(".", os.sep) + ".py")
|
|
115
|
+
if os.path.isfile(candidate):
|
|
116
|
+
return candidate
|
|
117
|
+
if directory == tests_dir or not directory.startswith(tests_dir):
|
|
118
|
+
return None
|
|
119
|
+
directory = os.path.dirname(directory)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Names of the functions defined in tree that make checks, directly or through
|
|
123
|
+
# another such function, given the checker names it can already see
|
|
124
|
+
def local_checkers(tree, known = frozenset()):
|
|
125
|
+
functions = [node for node in ast.walk(tree) if isinstance(node, FUNCTION_DEFS) and not node.name.startswith("test_")]
|
|
126
|
+
checkers = set()
|
|
127
|
+
while True:
|
|
128
|
+
visible = frozenset(known | checkers)
|
|
129
|
+
found = {function.name for function in functions if checks_in(function.body, (), visible)}
|
|
130
|
+
if found <= checkers:
|
|
131
|
+
return frozenset(checkers)
|
|
132
|
+
checkers |= found
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Every name a call in path can use to reach a checking helper
|
|
136
|
+
@functools.lru_cache(maxsize = None)
|
|
137
|
+
def checker_names(path, tests_dir, seen = frozenset()):
|
|
138
|
+
tree = parsed(path)
|
|
139
|
+
imported = set()
|
|
140
|
+
for node in tree.body:
|
|
141
|
+
if isinstance(node, ast.ImportFrom) and node.module and not node.level:
|
|
142
|
+
helper = find_helper_module(path, node.module, tests_dir)
|
|
143
|
+
if helper and helper not in seen:
|
|
144
|
+
theirs = checker_names(helper, tests_dir, seen | {path})
|
|
145
|
+
imported.update(alias.asname or alias.name for alias in node.names if alias.name in theirs)
|
|
146
|
+
elif isinstance(node, ast.Import):
|
|
147
|
+
for alias in node.names:
|
|
148
|
+
helper = find_helper_module(path, alias.name, tests_dir)
|
|
149
|
+
if helper and helper not in seen:
|
|
150
|
+
imported.update(checker_names(helper, tests_dir, seen | {path}))
|
|
151
|
+
return local_checkers(tree, frozenset(imported)) | frozenset(imported)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Third-party imports
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
# Hooks a repository's conftest.py can implement
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@pytest.hookspec
|
|
8
|
+
def pytest_testrex_requirement_checkers(config):
|
|
9
|
+
"""Return {marker name: check} for requirement markers beyond requires_binary.
|
|
10
|
+
|
|
11
|
+
Each check takes the names a test's markers list and returns a label for
|
|
12
|
+
each one that is unavailable, such as "XorrISO (JoyBox tool)". A test with
|
|
13
|
+
any unavailable name is skipped while collecting, before its fixtures run.
|
|
14
|
+
Every marker in [tool.testrex] requirement_markers needs a check.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import ast
|
|
3
|
+
import collections
|
|
4
|
+
|
|
5
|
+
# Local imports
|
|
6
|
+
from pytest_testrex.discovery import FUNCTION_DEFS, is_test_function
|
|
7
|
+
|
|
8
|
+
###########################################################
|
|
9
|
+
# File hygiene
|
|
10
|
+
#
|
|
11
|
+
# A test defined twice in one file silently replaces the first, which then
|
|
12
|
+
# never runs again and reports nothing when it would have failed. Large files
|
|
13
|
+
# are where this happens, so they are also kept to a size where it stays
|
|
14
|
+
# visible.
|
|
15
|
+
###########################################################
|
|
16
|
+
|
|
17
|
+
def duplicated(names):
|
|
18
|
+
return sorted(name for name, count in collections.Counter(names).items() if count > 1)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def duplicate_tests(tree):
|
|
22
|
+
return duplicated(node.name for node in tree.body if is_test_function(node))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# {class: names} for test methods defined twice within one class
|
|
26
|
+
def duplicate_test_methods(tree):
|
|
27
|
+
return {
|
|
28
|
+
node.name: names for node in tree.body if isinstance(node, ast.ClassDef)
|
|
29
|
+
for names in [duplicated(member.name for member in node.body if is_test_function(member))]
|
|
30
|
+
if names}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def duplicate_definitions(tree):
|
|
34
|
+
return duplicated(node.name for node in tree.body if isinstance(node, FUNCTION_DEFS + (ast.ClassDef,)))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def duplicate_constants(tree):
|
|
38
|
+
return duplicated(
|
|
39
|
+
target.id for node in tree.body if isinstance(node, ast.Assign)
|
|
40
|
+
for target in node.targets if isinstance(target, ast.Name) and target.id.isupper())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Lines of imports that come after the first real statement
|
|
44
|
+
def late_imports(tree):
|
|
45
|
+
first_statement = None
|
|
46
|
+
late = []
|
|
47
|
+
for node in tree.body:
|
|
48
|
+
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
|
49
|
+
if first_statement is not None:
|
|
50
|
+
late.append(node.lineno)
|
|
51
|
+
elif not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)):
|
|
52
|
+
if first_statement is None:
|
|
53
|
+
first_statement = node.lineno
|
|
54
|
+
return late
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def line_count(path):
|
|
58
|
+
with open(path, "r", encoding = "utf-8") as handle:
|
|
59
|
+
return sum(1 for _ in handle)
|
pytest_testrex/layout.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
# Local imports
|
|
5
|
+
from pytest_testrex.discovery import defines_tests, is_ignored, parsed
|
|
6
|
+
|
|
7
|
+
###########################################################
|
|
8
|
+
# Layout
|
|
9
|
+
#
|
|
10
|
+
# Each suite mirrors the repository: a test lives at the path of the code it
|
|
11
|
+
# covers. A test module that outgrows the size limit becomes a directory of
|
|
12
|
+
# the same name, test_<module>/, holding test_<area>.py files, a conftest.py
|
|
13
|
+
# and a <module>_helpers.py they share. The hazards are quiet: a file that
|
|
14
|
+
# holds tests but is not named test_*.py is never collected, and a directory
|
|
15
|
+
# that mirrors nothing puts tests where nobody looks for them.
|
|
16
|
+
###########################################################
|
|
17
|
+
|
|
18
|
+
# Files in a split package that are not test modules by design
|
|
19
|
+
SUPPORT_FILES = {"conftest.py"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def walk_directories(suite_dirs, ignored_dirs = ()):
|
|
23
|
+
for suite_dir in suite_dirs:
|
|
24
|
+
for root, dirs, _ in os.walk(suite_dir):
|
|
25
|
+
dirs[:] = sorted(
|
|
26
|
+
name for name in dirs if name != "__pycache__" and not name.startswith(".")
|
|
27
|
+
and not is_ignored(os.path.join(root, name), ignored_dirs))
|
|
28
|
+
yield root, dirs
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Every test_<module> directory under the suites
|
|
32
|
+
def discover_split_packages(suite_dirs, ignored_dirs = ()):
|
|
33
|
+
return sorted(os.path.join(root, name) for root, dirs in walk_directories(suite_dirs, ignored_dirs)
|
|
34
|
+
for name in dirs if name.startswith("test_"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def holds_python(directory):
|
|
38
|
+
return any(name.endswith(".py") for _, _, files in os.walk(directory) for name in files)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Every directory that must mirror the repository: neither a split package nor
|
|
42
|
+
# inside one, and holding Python (a directory of fixture data mirrors nothing)
|
|
43
|
+
def discover_mirror_directories(suite_dirs, ignored_dirs = ()):
|
|
44
|
+
found = []
|
|
45
|
+
for root, dirs in walk_directories(suite_dirs, ignored_dirs):
|
|
46
|
+
dirs[:] = [name for name in dirs if not name.startswith("test_")]
|
|
47
|
+
found.extend(os.path.join(root, name) for name in dirs if holds_python(os.path.join(root, name)))
|
|
48
|
+
return sorted(found)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def suite_of(path, suite_dirs):
|
|
52
|
+
return next(suite for suite in suite_dirs if path.startswith(suite + os.sep))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def python_files(directory):
|
|
56
|
+
return sorted(name for name in os.listdir(directory)
|
|
57
|
+
if name.endswith(".py") and os.path.isfile(os.path.join(directory, name)))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_test_module_name(name):
|
|
61
|
+
return name.startswith("test_") and name.endswith(".py")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Files that define tests but are not named test_*.py, so pytest skips them
|
|
65
|
+
def uncollected_test_files(directory):
|
|
66
|
+
return [name for name in python_files(directory)
|
|
67
|
+
if name not in SUPPORT_FILES and not is_test_module_name(name)
|
|
68
|
+
and defines_tests(parsed(os.path.join(directory, name)))]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Where the code a split package covers would live: <repo>/<path>/<name> with
|
|
72
|
+
# one of the source extensions, or a package directory
|
|
73
|
+
def source_candidates(package, suite_dir, repo_root, extensions):
|
|
74
|
+
relative_parts = os.path.relpath(package, suite_dir).split(os.sep)
|
|
75
|
+
base = os.path.join(repo_root, *relative_parts[:-1], relative_parts[-1][len("test_"):])
|
|
76
|
+
return [base + extension for extension in extensions] + [base]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# The repository directories a test directory may mirror; a hidden one, such
|
|
80
|
+
# as .githooks, is mirrored without its dot
|
|
81
|
+
def mirror_targets(directory, suite_dir, repo_root):
|
|
82
|
+
relative_path = os.path.relpath(directory, suite_dir)
|
|
83
|
+
return [os.path.join(repo_root, relative_path), os.path.join(repo_root, "." + relative_path)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def single_file_twin(package):
|
|
87
|
+
twin = package + ".py"
|
|
88
|
+
return twin if os.path.exists(twin) else None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# Whether directory sits at or under one of the meta directories, relative to tests_dir
|
|
92
|
+
def is_meta(directory, tests_dir, meta_dirs):
|
|
93
|
+
relative_path = os.path.relpath(directory, tests_dir)
|
|
94
|
+
return any(relative_path == meta or relative_path.startswith(meta + os.sep) for meta in meta_dirs)
|
pytest_testrex/lint.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
###########################################################
|
|
7
|
+
# Pylint
|
|
8
|
+
#
|
|
9
|
+
# Pylint runs over every test file with the rcfile, and any message fails.
|
|
10
|
+
# The shipped rcfile only turns off rules that are pytest idioms rather than
|
|
11
|
+
# defects; everything else, such as unused variables, undefined names, calls
|
|
12
|
+
# that do not match a signature and files opened without an encoding, is
|
|
13
|
+
# enforced. Library code gets only the few rules its own linter lacks.
|
|
14
|
+
###########################################################
|
|
15
|
+
|
|
16
|
+
# Tests import the helpers beside them by name, through each conftest's
|
|
17
|
+
# sys.path entry, which pylint has to be told about
|
|
18
|
+
def import_roots(tests_dir, all_files):
|
|
19
|
+
roots = {tests_dir}
|
|
20
|
+
roots.update(os.path.dirname(path) for path in all_files if os.path.basename(path) == "conftest.py")
|
|
21
|
+
return sorted(roots)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Every file in the suites, and the .py files at the top of tests_dir that
|
|
25
|
+
# they share. Files, not directories: given a directory, pylint names its
|
|
26
|
+
# modules after it, and a test directory mirroring a package, such as
|
|
27
|
+
# Tests/unit/mypackage, would then shadow the package itself.
|
|
28
|
+
def pylint_targets(tests_dir, suite_files):
|
|
29
|
+
return list(suite_files) + [
|
|
30
|
+
os.path.join(tests_dir, name) for name in sorted(os.listdir(tests_dir)) if name.endswith(".py")]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_pylint(rcfile, cwd, roots, *arguments):
|
|
34
|
+
env = dict(os.environ, PYTHONPATH = os.pathsep.join(roots))
|
|
35
|
+
return subprocess.run(
|
|
36
|
+
[sys.executable, "-m", "pylint", "--rcfile=" + rcfile, "--score=no", *arguments],
|
|
37
|
+
cwd = cwd, env = env, capture_output = True, text = True, check = False)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def only_rules(rules):
|
|
41
|
+
return ["--disable=all", "--enable=" + ",".join(rules)]
|
pytest_testrex/plugin.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import shutil
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
# Third-party imports
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
# Local imports
|
|
9
|
+
from pytest_testrex import hookspecs, seal, settings as settings_module, suite
|
|
10
|
+
|
|
11
|
+
###########################################################
|
|
12
|
+
# The plugin
|
|
13
|
+
#
|
|
14
|
+
# Loaded by pytest from the pytest11 entry point. It does nothing until the
|
|
15
|
+
# repository has a [tool.testrex] table; then it registers the markers, seals
|
|
16
|
+
# the programs tests may not start, closes outbound network, skips tests whose
|
|
17
|
+
# required programs are missing, and parametrizes the meta suite.
|
|
18
|
+
###########################################################
|
|
19
|
+
|
|
20
|
+
SETTINGS_KEY = pytest.StashKey()
|
|
21
|
+
SURVEY_KEY = pytest.StashKey()
|
|
22
|
+
SEALED_KEY = pytest.StashKey()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def pytest_addhooks(pluginmanager):
|
|
26
|
+
pluginmanager.add_hookspecs(hookspecs)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_settings(config):
|
|
30
|
+
return config.stash.get(SETTINGS_KEY, None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def pytest_configure(config):
|
|
34
|
+
try:
|
|
35
|
+
loaded = settings_module.load(str(config.rootpath))
|
|
36
|
+
except settings_module.SettingsError as error:
|
|
37
|
+
raise pytest.UsageError(str(error)) from error
|
|
38
|
+
config.stash[SETTINGS_KEY] = loaded
|
|
39
|
+
if loaded is None:
|
|
40
|
+
return
|
|
41
|
+
config.addinivalue_line(
|
|
42
|
+
"markers", 'requires_binary(*names): skips, before its fixtures run, unless every named program is on PATH ("a|b" takes either)')
|
|
43
|
+
for marker in loaded.requirement_markers[1:]:
|
|
44
|
+
config.addinivalue_line("markers", "%s(*names): skips, before its fixtures run, unless every name is available" % marker)
|
|
45
|
+
config.addinivalue_line("markers", "allow_network: may open outbound connections")
|
|
46
|
+
# A pytest run inside a test, such as pytester's, finds the seal in place
|
|
47
|
+
# and must leave it for the session that applied it to remove
|
|
48
|
+
config.stash[SEALED_KEY] = bool(loaded.sealed_programs) and seal.seal(loaded.sealed_programs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pytest_unconfigure(config):
|
|
52
|
+
if config.stash.get(SEALED_KEY, False):
|
|
53
|
+
seal.unseal()
|
|
54
|
+
|
|
55
|
+
###########################################################
|
|
56
|
+
# Required programs
|
|
57
|
+
###########################################################
|
|
58
|
+
|
|
59
|
+
def missing_binaries(names):
|
|
60
|
+
return [name for name in names if not any(shutil.which(option) for option in name.split("|"))]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def requirement_checkers(config):
|
|
64
|
+
checkers = {"requires_binary": missing_binaries}
|
|
65
|
+
for result in config.hook.pytest_testrex_requirement_checkers(config = config):
|
|
66
|
+
checkers.update(result)
|
|
67
|
+
return checkers
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def pytest_collection_modifyitems(config, items):
|
|
71
|
+
loaded = get_settings(config)
|
|
72
|
+
if loaded is None:
|
|
73
|
+
return
|
|
74
|
+
checkers = requirement_checkers(config)
|
|
75
|
+
unchecked = [marker for marker in loaded.requirement_markers if marker not in checkers]
|
|
76
|
+
if unchecked:
|
|
77
|
+
raise pytest.UsageError(
|
|
78
|
+
"requirement markers with no pytest_testrex_requirement_checkers hook to enforce them: %s" % unchecked)
|
|
79
|
+
for item in items:
|
|
80
|
+
missing = []
|
|
81
|
+
for marker in loaded.requirement_markers:
|
|
82
|
+
names = [name for mark in item.iter_markers(marker) for name in mark.args]
|
|
83
|
+
if names:
|
|
84
|
+
missing.extend(checkers[marker](names))
|
|
85
|
+
if missing:
|
|
86
|
+
item.add_marker(pytest.mark.skip(reason = "%s not installed" % ", ".join(missing)))
|
|
87
|
+
|
|
88
|
+
###########################################################
|
|
89
|
+
# Network
|
|
90
|
+
#
|
|
91
|
+
# A test that quietly reaches the internet passes on a connected machine and
|
|
92
|
+
# fails in CI, and its result depends on a third party. Subprocess based tests
|
|
93
|
+
# are unaffected; this only closes the in-process path.
|
|
94
|
+
###########################################################
|
|
95
|
+
|
|
96
|
+
def refuse_connection(*args, **kwargs):
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"outbound network access is not available to tests; "
|
|
99
|
+
"mark the test with @pytest.mark.allow_network if it truly needs it")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture(autouse = True)
|
|
103
|
+
def no_outbound_network(request):
|
|
104
|
+
loaded = get_settings(request.config)
|
|
105
|
+
if loaded is None or not loaded.block_network or request.node.get_closest_marker("allow_network"):
|
|
106
|
+
return
|
|
107
|
+
monkeypatch = request.getfixturevalue("monkeypatch")
|
|
108
|
+
monkeypatch.setattr(socket.socket, "connect", refuse_connection)
|
|
109
|
+
monkeypatch.setattr(socket.socket, "connect_ex", refuse_connection)
|
|
110
|
+
monkeypatch.setattr(socket, "create_connection", refuse_connection)
|
|
111
|
+
|
|
112
|
+
###########################################################
|
|
113
|
+
# The meta suite
|
|
114
|
+
###########################################################
|
|
115
|
+
|
|
116
|
+
def get_survey(config):
|
|
117
|
+
if SURVEY_KEY not in config.stash:
|
|
118
|
+
loaded = get_settings(config)
|
|
119
|
+
if loaded is None:
|
|
120
|
+
raise pytest.UsageError("the TestRex suite needs a [tool.testrex] table in pyproject.toml")
|
|
121
|
+
config.stash[SURVEY_KEY] = suite.survey(loaded)
|
|
122
|
+
return config.stash[SURVEY_KEY]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pytest_generate_tests(metafunc):
|
|
126
|
+
if metafunc.cls is None or not issubclass(metafunc.cls, suite.TestRex):
|
|
127
|
+
return
|
|
128
|
+
for argument, (values, ids) in get_survey(metafunc.config).parameters().items():
|
|
129
|
+
if argument in metafunc.fixturenames:
|
|
130
|
+
metafunc.parametrize(argument, values, ids = ids)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.fixture(scope = "session")
|
|
134
|
+
def testrex_survey(request):
|
|
135
|
+
return get_survey(request.config)
|
pytest_testrex/pylintrc
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Pylint for the tests; the meta suite runs it and fails on any message.
|
|
2
|
+
#
|
|
3
|
+
# Tests get pylint's deeper checks (fakes whose calls no longer match the real
|
|
4
|
+
# signature, names a fixture never sets, unused results), minus the rules
|
|
5
|
+
# that are pytest idioms rather than defects: a fixture is requested by naming
|
|
6
|
+
# it as a parameter (redefined-outer-name, unused-argument), tests are
|
|
7
|
+
# described by their names (docstrings, invalid-name), and a test reaches into
|
|
8
|
+
# the code under test on purpose (protected-access). Line length is left to
|
|
9
|
+
# the repository's formatter. A repository with its own
|
|
10
|
+
# conventions points [tool.testrex] pylint_rcfile at a copy of this file.
|
|
11
|
+
|
|
12
|
+
[MAIN]
|
|
13
|
+
jobs = 0
|
|
14
|
+
|
|
15
|
+
[TYPECHECK]
|
|
16
|
+
# Attributes of the MagicMocks tests swap in, which pylint may infer as the real object
|
|
17
|
+
generated-members = assert_.*,return_value,side_effect,call_args,call_args_list,call_count,called,mock_calls,method_calls,reset_mock
|
|
18
|
+
|
|
19
|
+
[MESSAGES CONTROL]
|
|
20
|
+
disable =
|
|
21
|
+
too-many-arguments,
|
|
22
|
+
too-many-positional-arguments,
|
|
23
|
+
too-many-instance-attributes,
|
|
24
|
+
too-many-locals,
|
|
25
|
+
too-many-branches,
|
|
26
|
+
too-many-statements,
|
|
27
|
+
too-many-return-statements,
|
|
28
|
+
too-many-nested-blocks,
|
|
29
|
+
too-many-public-methods,
|
|
30
|
+
too-many-lines,
|
|
31
|
+
too-few-public-methods,
|
|
32
|
+
duplicate-code,
|
|
33
|
+
import-outside-toplevel,
|
|
34
|
+
broad-exception-caught,
|
|
35
|
+
broad-exception-raised,
|
|
36
|
+
missing-module-docstring,
|
|
37
|
+
missing-class-docstring,
|
|
38
|
+
missing-function-docstring,
|
|
39
|
+
redefined-outer-name,
|
|
40
|
+
unused-argument,
|
|
41
|
+
protected-access,
|
|
42
|
+
invalid-name,
|
|
43
|
+
attribute-defined-outside-init,
|
|
44
|
+
use-implicit-booleaness-not-comparison,
|
|
45
|
+
line-too-long,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Imports
|
|
2
|
+
import ast
|
|
3
|
+
import collections
|
|
4
|
+
import hashlib
|
|
5
|
+
|
|
6
|
+
# Local imports
|
|
7
|
+
from pytest_testrex.discovery import FUNCTION_DEFS, checks_in, collected_tests
|
|
8
|
+
|
|
9
|
+
###########################################################
|
|
10
|
+
# Test quality
|
|
11
|
+
#
|
|
12
|
+
# A test that cannot fail reads as coverage and protects nothing. These catch
|
|
13
|
+
# the shapes that let a test pass whatever the code does: asserting nothing,
|
|
14
|
+
# asserting only inside a branch that may not run, swallowing the exception it
|
|
15
|
+
# exists to see, an assertion true by construction, checking only that a value
|
|
16
|
+
# exists or has a type, and a helper pasted into several files that then drift
|
|
17
|
+
# apart. There are no exemption lists: a test that trips one is fixed.
|
|
18
|
+
###########################################################
|
|
19
|
+
|
|
20
|
+
CONDITIONAL = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.Try)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def has_any_check(function, checkers = frozenset()):
|
|
24
|
+
return bool(checks_in(function.body, (), checkers))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# A check that runs whenever the test does: not inside a branch, loop or try
|
|
28
|
+
def has_unconditional_check(function, checkers = frozenset()):
|
|
29
|
+
return bool(checks_in(function.body, CONDITIONAL, checkers))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def label(function):
|
|
33
|
+
return "%s (line %d)" % (function.name, function.lineno)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Tests that make no check at all
|
|
37
|
+
def unchecked_tests(tree, checkers = frozenset()):
|
|
38
|
+
return [label(function) for function in collected_tests(tree) if not has_any_check(function, checkers)]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Tests whose every check sits in a branch, loop or try
|
|
42
|
+
def conditional_tests(tree, checkers = frozenset()):
|
|
43
|
+
return [
|
|
44
|
+
label(function) for function in collected_tests(tree)
|
|
45
|
+
if has_any_check(function, checkers) and not has_unconditional_check(function, checkers)]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Lines of except handlers whose whole body is pass (or a bare string)
|
|
49
|
+
def swallowed_handlers(tree):
|
|
50
|
+
lines = []
|
|
51
|
+
for node in ast.walk(tree):
|
|
52
|
+
if isinstance(node, ast.Try):
|
|
53
|
+
for handler in node.handlers:
|
|
54
|
+
body = [statement for statement in handler.body
|
|
55
|
+
if not (isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Constant))]
|
|
56
|
+
if all(isinstance(statement, ast.Pass) for statement in body):
|
|
57
|
+
lines.append(handler.lineno)
|
|
58
|
+
return sorted(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Why an assertion cannot fail, or None
|
|
62
|
+
def tautology(assertion):
|
|
63
|
+
test = assertion.test
|
|
64
|
+
if isinstance(test, ast.Constant) and test.value:
|
|
65
|
+
return "a constant that is always true"
|
|
66
|
+
if isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], (ast.Eq, ast.Is)):
|
|
67
|
+
left, right = test.left, test.comparators[0]
|
|
68
|
+
if ast.unparse(left) == ast.unparse(right):
|
|
69
|
+
return "compares a value with itself"
|
|
70
|
+
if isinstance(right, ast.BoolOp) and isinstance(right.op, ast.Or) and ast.unparse(right.values[0]) == ast.unparse(left):
|
|
71
|
+
return "compares a value with itself or a fallback"
|
|
72
|
+
if isinstance(test, ast.BoolOp) and isinstance(test.op, ast.Or):
|
|
73
|
+
if any(isinstance(value, ast.Constant) and value.value for value in test.values):
|
|
74
|
+
return "has an or branch that is always true"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# "test line N: why" for each assertion in a test that cannot fail
|
|
79
|
+
def tautologies(tree):
|
|
80
|
+
return [
|
|
81
|
+
"%s line %d: %s" % (function.name, node.lineno, tautology(node))
|
|
82
|
+
for function in collected_tests(tree) for node in ast.walk(function)
|
|
83
|
+
if isinstance(node, ast.Assert) and tautology(node)]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# An assert that only says a value exists or has a type
|
|
87
|
+
def is_weak_assertion(assertion):
|
|
88
|
+
test = assertion.test
|
|
89
|
+
if isinstance(test, ast.Call):
|
|
90
|
+
return ast.unparse(test.func) == "isinstance"
|
|
91
|
+
return (isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], ast.IsNot)
|
|
92
|
+
and isinstance(test.comparators[0], ast.Constant) and test.comparators[0].value is None)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Tests whose only checks are weak, so they pass for almost any value
|
|
96
|
+
def weak_only_tests(tree, checkers = frozenset()):
|
|
97
|
+
weak = []
|
|
98
|
+
for function in collected_tests(tree):
|
|
99
|
+
checks = checks_in(function.body, (), checkers)
|
|
100
|
+
if checks and all(isinstance(check, ast.Assert) and is_weak_assertion(check) for check in checks):
|
|
101
|
+
weak.append(label(function))
|
|
102
|
+
return weak
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Lines that build a raising callable as (_ for _ in ()).throw(...), which
|
|
106
|
+
# reads as a puzzle; a def that raises says the same thing
|
|
107
|
+
def generator_throws(tree):
|
|
108
|
+
return sorted(
|
|
109
|
+
node.lineno for node in ast.walk(tree)
|
|
110
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "throw"
|
|
111
|
+
and isinstance(node.func.value, ast.GeneratorExp))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# (name, source) of every module level function that is not a test. The source
|
|
115
|
+
# includes the module constants it reads, so the same function over a
|
|
116
|
+
# different table is not mistaken for a copy.
|
|
117
|
+
def module_helpers(tree):
|
|
118
|
+
constants = {
|
|
119
|
+
target.id: ast.unparse(node) for node in tree.body if isinstance(node, ast.Assign)
|
|
120
|
+
for target in node.targets if isinstance(target, ast.Name)}
|
|
121
|
+
found = []
|
|
122
|
+
for node in tree.body:
|
|
123
|
+
if isinstance(node, FUNCTION_DEFS) and not node.name.startswith("test_") and not node.name.startswith("__"):
|
|
124
|
+
names = sorted({name.id for name in ast.walk(node) if isinstance(name, ast.Name)})
|
|
125
|
+
found.append((node.name, ast.unparse(node) + "".join("\n" + constants[name] for name in names if name in constants)))
|
|
126
|
+
return found
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# (name, files) for each helper whose identical source is in several files;
|
|
130
|
+
# two different helpers sharing a name are reported apart
|
|
131
|
+
def copied_helpers(sources_by_file):
|
|
132
|
+
where = collections.defaultdict(set)
|
|
133
|
+
for path, helpers in sources_by_file.items():
|
|
134
|
+
for name, source in helpers:
|
|
135
|
+
where[(name, hashlib.sha256(source.encode()).hexdigest())].add(path)
|
|
136
|
+
return sorted((name, sorted(files)) for (name, _), files in where.items() if len(files) > 1)
|