pytest-testrex 0.1.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luna White
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-testrex
3
+ Version: 0.1.0
4
+ Summary: A pytest plugin that holds a test suite to one layout and one standard of test quality
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/NearlyTRex/TestRex
7
+ Project-URL: Issues, https://github.com/NearlyTRex/TestRex/issues
8
+ Classifier: Framework :: Pytest
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Software Development :: Testing
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pytest>=8
15
+ Requires-Dist: pylint>=3
16
+ Dynamic: license-file
17
+
18
+ # TestRex
19
+
20
+ [![CI](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
21
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
22
+ [![Security](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml)
23
+ [![PyPI](https://img.shields.io/pypi/v/pytest-testrex)](https://pypi.org/project/pytest-testrex/)
24
+ [![License](https://img.shields.io/github/license/NearlyTRex/TestRex)](LICENSE)
25
+
26
+ A pytest plugin that holds a test suite to one layout and one standard of test quality
27
+
28
+ My repos share one way of laying out and writing tests. This plugin carries the rules, so a repo
29
+ gets them, and every fix to them, through one dependency bump instead of a copied meta suite.
30
+
31
+ It does three things:
32
+
33
+ - **The meta suite.** Around thirty rules that read every test file and fail on tests that cannot
34
+ fail, files in the wrong place, duplicated definitions and helpers, undeclared programs, broken
35
+ override signatures and pylint findings. There are no exemption lists: a test that trips a rule
36
+ is fixed.
37
+ - **Required programs.** `requires_binary` (and any marker a repo adds) skips a test while
38
+ collecting, before any of its fixtures start, when a program it needs is missing.
39
+ - **Hermetic runs.** Outbound network is closed to tests unless they are marked `allow_network`,
40
+ and the programs a repo seals (`sudo`, `ssh`, a password manager) are refused however a test
41
+ starts them.
42
+
43
+ ## Setting up a repo
44
+
45
+ Install it with the test dependencies. With a hash-locked `requirements-dev.in`:
46
+
47
+ ```text
48
+ pytest-testrex
49
+ ```
50
+
51
+ Then opt in with a `[tool.testrex]` table in `pyproject.toml`. Without one the plugin does nothing,
52
+ so having it installed never changes another project's run:
53
+
54
+ ```toml
55
+ [tool.testrex]
56
+ suites = ["unit", "integration"]
57
+ ```
58
+
59
+ and run the meta suite by subclassing it in a test module under a meta directory, by default
60
+ `Tests/unit/meta/`:
61
+
62
+ ```python
63
+ # Tests/unit/meta/test_testrex.py
64
+ from pytest_testrex import suite
65
+
66
+
67
+ class TestRex(suite.TestRex):
68
+ """The suite's own rules, from pytest-testrex."""
69
+ ```
70
+
71
+ The plugin parametrizes each rule over the files it reads, so a failure names the file:
72
+
73
+ ```text
74
+ FAILED Tests/unit/meta/test_testrex.py::TestRex::test_every_test_checks_something[unit/pkg/test_mod.py]
75
+ asserts nothing: ['test_parse (line 12)']
76
+ ```
77
+
78
+ ## Layout
79
+
80
+ Each suite mirrors the repository: a test lives at the path of the code it covers.
81
+
82
+ ```text
83
+ pkg/strings.py → Tests/unit/pkg/test_strings.py
84
+ pkg/net/fetch.py → Tests/unit/pkg/net/test_fetch.py
85
+ scripts/setup.sh → Tests/unit/scripts/test_setup.py
86
+ .githooks/pre-commit → Tests/unit/githooks/test_pre_commit.py
87
+ pkg/net/fetch.py (real HTTP) → Tests/integration/pkg/net/test_fetch.py
88
+ ```
89
+
90
+ What decides the suite is whether a test needs a program outside the process, not whether it
91
+ touches real files. A test that works in `tmp_path` with the code and its Python dependencies alone
92
+ is a unit test; one that runs `git`, `docker` or `7zz` goes in `integration/` and declares it.
93
+
94
+ A test file that outgrows `max_lines` becomes a directory of the same name, `test_<module>/`, holding
95
+ `test_<area>.py` files, a `conftest.py` and a `<module>_helpers.py` they share. Because the tree
96
+ mirrors the source, two suites can hold files with the same basename, so run pytest with
97
+ `--import-mode=importlib`.
98
+
99
+ ## The rules
100
+
101
+ | Area | A test file fails when |
102
+ |---|---|
103
+ | Discovery | fewer than `min_test_files` were found, or a suite holds none, so the rules would be guarding nothing |
104
+ | Hygiene | a test, test method, helper or constant is defined twice; an import sits below the header; it is longer than `max_lines`; it holds no test |
105
+ | Quality | a test checks nothing; every check sits in a branch, loop or try; an assertion is true by construction; it only checks `is not None` or `isinstance`; an `except` swallows silently; a raising callable is built from `(_ for _ in ()).throw()`; a helper is copied between files |
106
+ | Layout | a split package holds no test modules, hides tests in a file pytest won't collect, covers no real module or has a single-file twin; a directory mirrors nothing in the repository |
107
+ | Programs | a program is checked for at run time instead of by a marker; a marker names a program by anything but a string literal; a test uses a program fixture without declaring its programs; `subprocess` runs a program the file never declares; an integration file declares no program at all |
108
+ | Signatures | an override in `signature_packages` renames, drops, reorders or changes the default of a parameter of the method it overrides |
109
+ | Pylint | pylint reports anything in the tests, with the shipped rcfile or `pylint_rcfile`; the library breaks a `library_rules` rule |
110
+
111
+ A check counts as an `assert`, a mock's `assert_*`, `pytest.fail`, a `pytest.raises` or
112
+ `pytest.warns` block, or a call to a helper that makes one, including helpers imported by name from
113
+ a `*_helpers.py` beside the test or above it.
114
+
115
+ ## Required programs
116
+
117
+ ```python
118
+ pytestmark = pytest.mark.requires_binary("docker") # whole module
119
+
120
+ @pytest.mark.requires_binary("gpg", "xorriso|mkisofs") # "a|b" takes either
121
+ def test_sign_image(): ...
122
+ ```
123
+
124
+ A repo with its own kind of requirement, such as tools it installs itself, names the marker in
125
+ `requirement_markers` and says how to check it from its `conftest.py`:
126
+
127
+ ```python
128
+ def pytest_testrex_requirement_checkers(config):
129
+ return {"requires_tool": lambda names: [f"{name} (tool)" for name in names if not installed(name)]}
130
+ ```
131
+
132
+ ## Settings
133
+
134
+ Every path is relative to the directory holding `pyproject.toml`. A setting the plugin doesn't know,
135
+ or one of the wrong type, stops the run.
136
+
137
+ | Setting | Default | What it does |
138
+ |---|---|---|
139
+ | `tests_dir` | `"Tests"` | Where the suites live |
140
+ | `suites` | `["unit", "integration"]` | The suites under `tests_dir` |
141
+ | `integration_suites` | `["integration"]` | Suites whose files must declare the programs they need |
142
+ | `meta_dirs` | `["unit/meta"]` | Directories that mirror nothing on purpose |
143
+ | `data_dirs` | `[]` | Directories under `tests_dir` holding data, never read as tests |
144
+ | `source_extensions` | `[".py", ".sh"]` | What a split `test_<name>/` package may cover besides a directory |
145
+ | `max_lines` | `900` | The longest a test file may be |
146
+ | `min_test_files` | `1` | Fewer test files means discovery is looking in the wrong place |
147
+ | `requirement_markers` | `["requires_binary"]` | Markers that declare a needed program; any beyond `requires_binary` need a hook |
148
+ | `fixture_programs` | `{}` | `{fixture: [programs]}` a test using the fixture must declare, beyond `shell_sandbox` and `shell_traced_env` |
149
+ | `pylint_rcfile` | the shipped one | Pylint config for the tests |
150
+ | `library_targets` | `[]` | Library paths held to `library_rules` |
151
+ | `library_rules` | `["unspecified-encoding"]` | Pylint rules the library's own linter lacks |
152
+ | `signature_packages` | `[]` | Packages whose overrides must keep their base method's signature |
153
+ | `sealed_programs` | `[]` | Programs a test may never start for real |
154
+ | `block_network` | `true` | Refuse in-process outbound connections unless a test is marked `allow_network` |
155
+
156
+ ## Developing
157
+
158
+ ```bash
159
+ python3 -m venv .venv && . .venv/bin/activate
160
+ pip install --require-hashes --no-deps -r requirements-dev.txt
161
+ pip install --no-deps --no-build-isolation -e .
162
+ pytest -q
163
+ ```
164
+
165
+ The library is held to its own rules: `Tests/unit/meta/test_testrex.py` runs the suite over these
166
+ tests, and `Tests/unit/pytest_testrex/test_suite.py` checks that a clean sample repository passes
167
+ every rule and a broken one fails every rule. CI runs through
168
+ [NearlyTRex/Workflows](https://github.com/NearlyTRex/Workflows) at 100% line and branch coverage.
169
+
170
+ ## Releasing
171
+
172
+ Run **Prepare Release** from the Actions tab with `patch`, `minor`, `major` or an exact version. It
173
+ opens a pull request bumping `pyproject.toml`; merging it tags the version, publishes a GitHub
174
+ Release with the sdist and wheel, and publishes them to PyPI through trusted publishing from the
175
+ `pypi` environment.
@@ -0,0 +1,158 @@
1
+ # TestRex
2
+
3
+ [![CI](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
4
+ [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
5
+ [![Security](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml)
6
+ [![PyPI](https://img.shields.io/pypi/v/pytest-testrex)](https://pypi.org/project/pytest-testrex/)
7
+ [![License](https://img.shields.io/github/license/NearlyTRex/TestRex)](LICENSE)
8
+
9
+ A pytest plugin that holds a test suite to one layout and one standard of test quality
10
+
11
+ My repos share one way of laying out and writing tests. This plugin carries the rules, so a repo
12
+ gets them, and every fix to them, through one dependency bump instead of a copied meta suite.
13
+
14
+ It does three things:
15
+
16
+ - **The meta suite.** Around thirty rules that read every test file and fail on tests that cannot
17
+ fail, files in the wrong place, duplicated definitions and helpers, undeclared programs, broken
18
+ override signatures and pylint findings. There are no exemption lists: a test that trips a rule
19
+ is fixed.
20
+ - **Required programs.** `requires_binary` (and any marker a repo adds) skips a test while
21
+ collecting, before any of its fixtures start, when a program it needs is missing.
22
+ - **Hermetic runs.** Outbound network is closed to tests unless they are marked `allow_network`,
23
+ and the programs a repo seals (`sudo`, `ssh`, a password manager) are refused however a test
24
+ starts them.
25
+
26
+ ## Setting up a repo
27
+
28
+ Install it with the test dependencies. With a hash-locked `requirements-dev.in`:
29
+
30
+ ```text
31
+ pytest-testrex
32
+ ```
33
+
34
+ Then opt in with a `[tool.testrex]` table in `pyproject.toml`. Without one the plugin does nothing,
35
+ so having it installed never changes another project's run:
36
+
37
+ ```toml
38
+ [tool.testrex]
39
+ suites = ["unit", "integration"]
40
+ ```
41
+
42
+ and run the meta suite by subclassing it in a test module under a meta directory, by default
43
+ `Tests/unit/meta/`:
44
+
45
+ ```python
46
+ # Tests/unit/meta/test_testrex.py
47
+ from pytest_testrex import suite
48
+
49
+
50
+ class TestRex(suite.TestRex):
51
+ """The suite's own rules, from pytest-testrex."""
52
+ ```
53
+
54
+ The plugin parametrizes each rule over the files it reads, so a failure names the file:
55
+
56
+ ```text
57
+ FAILED Tests/unit/meta/test_testrex.py::TestRex::test_every_test_checks_something[unit/pkg/test_mod.py]
58
+ asserts nothing: ['test_parse (line 12)']
59
+ ```
60
+
61
+ ## Layout
62
+
63
+ Each suite mirrors the repository: a test lives at the path of the code it covers.
64
+
65
+ ```text
66
+ pkg/strings.py → Tests/unit/pkg/test_strings.py
67
+ pkg/net/fetch.py → Tests/unit/pkg/net/test_fetch.py
68
+ scripts/setup.sh → Tests/unit/scripts/test_setup.py
69
+ .githooks/pre-commit → Tests/unit/githooks/test_pre_commit.py
70
+ pkg/net/fetch.py (real HTTP) → Tests/integration/pkg/net/test_fetch.py
71
+ ```
72
+
73
+ What decides the suite is whether a test needs a program outside the process, not whether it
74
+ touches real files. A test that works in `tmp_path` with the code and its Python dependencies alone
75
+ is a unit test; one that runs `git`, `docker` or `7zz` goes in `integration/` and declares it.
76
+
77
+ A test file that outgrows `max_lines` becomes a directory of the same name, `test_<module>/`, holding
78
+ `test_<area>.py` files, a `conftest.py` and a `<module>_helpers.py` they share. Because the tree
79
+ mirrors the source, two suites can hold files with the same basename, so run pytest with
80
+ `--import-mode=importlib`.
81
+
82
+ ## The rules
83
+
84
+ | Area | A test file fails when |
85
+ |---|---|
86
+ | Discovery | fewer than `min_test_files` were found, or a suite holds none, so the rules would be guarding nothing |
87
+ | Hygiene | a test, test method, helper or constant is defined twice; an import sits below the header; it is longer than `max_lines`; it holds no test |
88
+ | Quality | a test checks nothing; every check sits in a branch, loop or try; an assertion is true by construction; it only checks `is not None` or `isinstance`; an `except` swallows silently; a raising callable is built from `(_ for _ in ()).throw()`; a helper is copied between files |
89
+ | Layout | a split package holds no test modules, hides tests in a file pytest won't collect, covers no real module or has a single-file twin; a directory mirrors nothing in the repository |
90
+ | Programs | a program is checked for at run time instead of by a marker; a marker names a program by anything but a string literal; a test uses a program fixture without declaring its programs; `subprocess` runs a program the file never declares; an integration file declares no program at all |
91
+ | Signatures | an override in `signature_packages` renames, drops, reorders or changes the default of a parameter of the method it overrides |
92
+ | Pylint | pylint reports anything in the tests, with the shipped rcfile or `pylint_rcfile`; the library breaks a `library_rules` rule |
93
+
94
+ A check counts as an `assert`, a mock's `assert_*`, `pytest.fail`, a `pytest.raises` or
95
+ `pytest.warns` block, or a call to a helper that makes one, including helpers imported by name from
96
+ a `*_helpers.py` beside the test or above it.
97
+
98
+ ## Required programs
99
+
100
+ ```python
101
+ pytestmark = pytest.mark.requires_binary("docker") # whole module
102
+
103
+ @pytest.mark.requires_binary("gpg", "xorriso|mkisofs") # "a|b" takes either
104
+ def test_sign_image(): ...
105
+ ```
106
+
107
+ A repo with its own kind of requirement, such as tools it installs itself, names the marker in
108
+ `requirement_markers` and says how to check it from its `conftest.py`:
109
+
110
+ ```python
111
+ def pytest_testrex_requirement_checkers(config):
112
+ return {"requires_tool": lambda names: [f"{name} (tool)" for name in names if not installed(name)]}
113
+ ```
114
+
115
+ ## Settings
116
+
117
+ Every path is relative to the directory holding `pyproject.toml`. A setting the plugin doesn't know,
118
+ or one of the wrong type, stops the run.
119
+
120
+ | Setting | Default | What it does |
121
+ |---|---|---|
122
+ | `tests_dir` | `"Tests"` | Where the suites live |
123
+ | `suites` | `["unit", "integration"]` | The suites under `tests_dir` |
124
+ | `integration_suites` | `["integration"]` | Suites whose files must declare the programs they need |
125
+ | `meta_dirs` | `["unit/meta"]` | Directories that mirror nothing on purpose |
126
+ | `data_dirs` | `[]` | Directories under `tests_dir` holding data, never read as tests |
127
+ | `source_extensions` | `[".py", ".sh"]` | What a split `test_<name>/` package may cover besides a directory |
128
+ | `max_lines` | `900` | The longest a test file may be |
129
+ | `min_test_files` | `1` | Fewer test files means discovery is looking in the wrong place |
130
+ | `requirement_markers` | `["requires_binary"]` | Markers that declare a needed program; any beyond `requires_binary` need a hook |
131
+ | `fixture_programs` | `{}` | `{fixture: [programs]}` a test using the fixture must declare, beyond `shell_sandbox` and `shell_traced_env` |
132
+ | `pylint_rcfile` | the shipped one | Pylint config for the tests |
133
+ | `library_targets` | `[]` | Library paths held to `library_rules` |
134
+ | `library_rules` | `["unspecified-encoding"]` | Pylint rules the library's own linter lacks |
135
+ | `signature_packages` | `[]` | Packages whose overrides must keep their base method's signature |
136
+ | `sealed_programs` | `[]` | Programs a test may never start for real |
137
+ | `block_network` | `true` | Refuse in-process outbound connections unless a test is marked `allow_network` |
138
+
139
+ ## Developing
140
+
141
+ ```bash
142
+ python3 -m venv .venv && . .venv/bin/activate
143
+ pip install --require-hashes --no-deps -r requirements-dev.txt
144
+ pip install --no-deps --no-build-isolation -e .
145
+ pytest -q
146
+ ```
147
+
148
+ The library is held to its own rules: `Tests/unit/meta/test_testrex.py` runs the suite over these
149
+ tests, and `Tests/unit/pytest_testrex/test_suite.py` checks that a clean sample repository passes
150
+ every rule and a broken one fails every rule. CI runs through
151
+ [NearlyTRex/Workflows](https://github.com/NearlyTRex/Workflows) at 100% line and branch coverage.
152
+
153
+ ## Releasing
154
+
155
+ Run **Prepare Release** from the Actions tab with `patch`, `minor`, `major` or an exact version. It
156
+ opens a pull request bumping `pyproject.toml`; merging it tags the version, publishes a GitHub
157
+ Release with the sdist and wheel, and publishes them to PyPI through trusted publishing from the
158
+ `pypi` environment.
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytest-testrex"
7
+ version = "0.1.0"
8
+ description = "A pytest plugin that holds a test suite to one layout and one standard of test quality"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = [
14
+ "pytest>=8",
15
+ "pylint>=3",
16
+ ]
17
+ classifiers = [
18
+ "Framework :: Pytest",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Testing",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/NearlyTRex/TestRex"
25
+ Issues = "https://github.com/NearlyTRex/TestRex/issues"
26
+
27
+ [project.entry-points.pytest11]
28
+ testrex = "pytest_testrex.plugin"
29
+
30
+ [tool.setuptools]
31
+ packages = ["pytest_testrex"]
32
+
33
+ [tool.setuptools.package-data]
34
+ pytest_testrex = ["pylintrc"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["Tests"]
38
+ # The tree mirrors the source, so unit/ and any later suite can hold files
39
+ # with the same basename, which the default prepend mode cannot import apart
40
+ addopts = "--strict-markers -ra --import-mode=importlib -p pytester"
41
+
42
+ [tool.coverage.run]
43
+ branch = true
44
+ source = ["pytest_testrex"]
45
+
46
+ [tool.coverage.report]
47
+ show_missing = true
48
+
49
+ [tool.testrex]
50
+ suites = ["unit"]
51
+ integration_suites = []
52
+ meta_dirs = ["unit/meta"]
53
+ # The meta suite runs over sample trees written by the tests themselves
54
+ min_test_files = 10
55
+ signature_packages = ["pytest_testrex"]
56
+ library_targets = ["pytest_testrex"]
57
+ sealed_programs = ["sudo", "ssh"]
@@ -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)