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.
- pytest_testrex-0.1.0/LICENSE +21 -0
- pytest_testrex-0.1.0/PKG-INFO +175 -0
- pytest_testrex-0.1.0/README.md +158 -0
- pytest_testrex-0.1.0/pyproject.toml +57 -0
- pytest_testrex-0.1.0/pytest_testrex/__init__.py +1 -0
- pytest_testrex-0.1.0/pytest_testrex/discovery.py +151 -0
- pytest_testrex-0.1.0/pytest_testrex/hookspecs.py +15 -0
- pytest_testrex-0.1.0/pytest_testrex/hygiene.py +59 -0
- pytest_testrex-0.1.0/pytest_testrex/layout.py +94 -0
- pytest_testrex-0.1.0/pytest_testrex/lint.py +41 -0
- pytest_testrex-0.1.0/pytest_testrex/plugin.py +135 -0
- pytest_testrex-0.1.0/pytest_testrex/pylintrc +45 -0
- pytest_testrex-0.1.0/pytest_testrex/quality.py +136 -0
- pytest_testrex-0.1.0/pytest_testrex/requirements.py +192 -0
- pytest_testrex-0.1.0/pytest_testrex/seal.py +86 -0
- pytest_testrex-0.1.0/pytest_testrex/settings.py +159 -0
- pytest_testrex-0.1.0/pytest_testrex/signatures.py +64 -0
- pytest_testrex-0.1.0/pytest_testrex/suite.py +295 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/PKG-INFO +175 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/SOURCES.txt +23 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/dependency_links.txt +1 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/entry_points.txt +2 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/requires.txt +2 -0
- pytest_testrex-0.1.0/pytest_testrex.egg-info/top_level.txt +1 -0
- pytest_testrex-0.1.0/setup.cfg +4 -0
|
@@ -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
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
|
|
21
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
|
|
22
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml)
|
|
23
|
+
[](https://pypi.org/project/pytest-testrex/)
|
|
24
|
+
[](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
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/NearlyTRex/TestRex/actions/workflows/security.yml)
|
|
6
|
+
[](https://pypi.org/project/pytest-testrex/)
|
|
7
|
+
[](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)
|