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.
@@ -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)
@@ -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)]
@@ -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)
@@ -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)