skippy-cov 0.2.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.
- skippy_cov-0.2.0/LICENSE +21 -0
- skippy_cov-0.2.0/PKG-INFO +113 -0
- skippy_cov-0.2.0/README.md +88 -0
- skippy_cov-0.2.0/pyproject.toml +128 -0
- skippy_cov-0.2.0/skippy_cov/__init__.py +120 -0
- skippy_cov-0.2.0/skippy_cov/__main__.py +156 -0
- skippy_cov-0.2.0/skippy_cov/config_handler.py +138 -0
- skippy_cov-0.2.0/skippy_cov/diff_handler.py +63 -0
- skippy_cov-0.2.0/skippy_cov/plugin.py +80 -0
- skippy_cov-0.2.0/skippy_cov/tests_finder.py +53 -0
- skippy_cov-0.2.0/skippy_cov/utils.py +124 -0
skippy_cov-0.2.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mariano Garcia Berrotaran
|
|
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,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skippy-cov
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Selectively run tests based on the current git diff and the collected data from previous tests runs
|
|
5
|
+
Keywords: python
|
|
6
|
+
Author-email: Mariano Garcia Berrotaran <mariano.garciaberrotaran@shiphero.com>, Martín Gaitán <marting@shiphero.com>
|
|
7
|
+
Requires-Python: >=3.8,<4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: unidiff >=0.7.5
|
|
21
|
+
Requires-Dist: coverage>=7.6
|
|
22
|
+
Requires-Dist: tomli; python_version < '3.11'
|
|
23
|
+
Project-URL: Repository, https://github.com/shiphero/skippy-cov
|
|
24
|
+
|
|
25
|
+
# skippy-cov
|
|
26
|
+
|
|
27
|
+
## Overview
|
|
28
|
+
|
|
29
|
+
`skippy-cov` is a tool that optimizes your Python testing workflow by filtering tests based on code changes. It takes a diff file from git and a `.coverage` database (generated by [`coverage.py`](https://coverage.readthedocs.io/)) to identify and run only the tests relevant to the modified code. This significantly reduces testing time and resources.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
To use this, you first need to generate the following required files:
|
|
35
|
+
|
|
36
|
+
* **Run Coverage Analysis:** Generate a `.coverage` database using `coverage.py`.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
coverage run -m pytest
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You can provide the diff to `skippy-cov` in two ways:
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
* **Option 1: Use a Git Branch or Ref**
|
|
46
|
+
You can specify a branch or ref to diff against (e.g., the main branch):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
skippy-cov --diff main --coverage-file .coverage
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
* **Option 2: Use a Diff File**
|
|
53
|
+
Generate a diff file using Git:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
git diff > changes.diff
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Then run:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
skippy-cov --diff changes.diff --coverage-file .coverage
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
* **Option 1: Use a Git Branch or Ref**
|
|
66
|
+
You can specify a branch or ref to diff against (e.g., the main branch):
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
skippy-cov --diff main --coverage-file .coverage
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If you omit the `--diff` argument, it will default to the main branch as determined by your git remote (usually "main" or "master"):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
skippy-cov --coverage-file .coverage
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
But it can also be used as a `pytest` plugin:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pytest --skippy-cov --skippy-cov-diff changes.diff --skippy-cov-coverage-file .coverage
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
which would be the equivalent of doing this:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pytest $(skippy-cov --diff changes.diff --coverage-file .coverage)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
`skippy-cov` can be configured using command-line arguments. See `skippy-cov --help` for more information.
|
|
93
|
+
|
|
94
|
+
### Diff Argument
|
|
95
|
+
|
|
96
|
+
- `--diff`: Path to a diff file, or a git branch/ref to diff against.
|
|
97
|
+
- If the argument is a path to an existing file, its contents are used as the diff.
|
|
98
|
+
- Otherwise, it is passed to `git diff <arg>` and the output is used.
|
|
99
|
+
- If omitted, defaults to the main branch as determined by `git remote show origin`.
|
|
100
|
+
|
|
101
|
+
Example usages:
|
|
102
|
+
- `skippy-cov --diff changes.diff --coverage-file .coverage`
|
|
103
|
+
- `skippy-cov --diff main --coverage-file .coverage`
|
|
104
|
+
- `skippy-cov --coverage-file .coverage` (defaults to main branch)
|
|
105
|
+
|
|
106
|
+
## Contributing
|
|
107
|
+
|
|
108
|
+
See `CONTRIBUTING.md` for information on how to contribute to the project.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
See `LICENSE` for licensing information.
|
|
113
|
+
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# skippy-cov
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`skippy-cov` is a tool that optimizes your Python testing workflow by filtering tests based on code changes. It takes a diff file from git and a `.coverage` database (generated by [`coverage.py`](https://coverage.readthedocs.io/)) to identify and run only the tests relevant to the modified code. This significantly reduces testing time and resources.
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
To use this, you first need to generate the following required files:
|
|
11
|
+
|
|
12
|
+
* **Run Coverage Analysis:** Generate a `.coverage` database using `coverage.py`.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
coverage run -m pytest
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
You can provide the diff to `skippy-cov` in two ways:
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
* **Option 1: Use a Git Branch or Ref**
|
|
22
|
+
You can specify a branch or ref to diff against (e.g., the main branch):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
skippy-cov --diff main --coverage-file .coverage
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
* **Option 2: Use a Diff File**
|
|
29
|
+
Generate a diff file using Git:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git diff > changes.diff
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Then run:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
skippy-cov --diff changes.diff --coverage-file .coverage
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
* **Option 1: Use a Git Branch or Ref**
|
|
42
|
+
You can specify a branch or ref to diff against (e.g., the main branch):
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
skippy-cov --diff main --coverage-file .coverage
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
If you omit the `--diff` argument, it will default to the main branch as determined by your git remote (usually "main" or "master"):
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
skippy-cov --coverage-file .coverage
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
But it can also be used as a `pytest` plugin:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pytest --skippy-cov --skippy-cov-diff changes.diff --skippy-cov-coverage-file .coverage
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
which would be the equivalent of doing this:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pytest $(skippy-cov --diff changes.diff --coverage-file .coverage)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Configuration
|
|
67
|
+
|
|
68
|
+
`skippy-cov` can be configured using command-line arguments. See `skippy-cov --help` for more information.
|
|
69
|
+
|
|
70
|
+
### Diff Argument
|
|
71
|
+
|
|
72
|
+
- `--diff`: Path to a diff file, or a git branch/ref to diff against.
|
|
73
|
+
- If the argument is a path to an existing file, its contents are used as the diff.
|
|
74
|
+
- Otherwise, it is passed to `git diff <arg>` and the output is used.
|
|
75
|
+
- If omitted, defaults to the main branch as determined by `git remote show origin`.
|
|
76
|
+
|
|
77
|
+
Example usages:
|
|
78
|
+
- `skippy-cov --diff changes.diff --coverage-file .coverage`
|
|
79
|
+
- `skippy-cov --diff main --coverage-file .coverage`
|
|
80
|
+
- `skippy-cov --coverage-file .coverage` (defaults to main branch)
|
|
81
|
+
|
|
82
|
+
## Contributing
|
|
83
|
+
|
|
84
|
+
See `CONTRIBUTING.md` for information on how to contribute to the project.
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
See `LICENSE` for licensing information.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "skippy-cov"
|
|
3
|
+
description = "Selectively run tests based on the current git diff and the collected data from previous tests runs"
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Mariano Garcia Berrotaran", email = "mariano.garciaberrotaran@shiphero.com" },
|
|
6
|
+
{ name = "Martín Gaitán", email = "marting@shiphero.com" },
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
keywords = ['python']
|
|
10
|
+
requires-python = ">=3.8,<4.0"
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.8",
|
|
16
|
+
"Programming Language :: Python :: 3.9",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"unidiff >=0.7.5",
|
|
25
|
+
"coverage>=7.6",
|
|
26
|
+
"tomli; python_version < '3.11'",
|
|
27
|
+
]
|
|
28
|
+
scripts = { "skippy-cov" = "skippy_cov.__main__:main" }
|
|
29
|
+
dynamic = ["version"]
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/shiphero/skippy-cov"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.2.0",
|
|
37
|
+
"pre-commit>=2.20.0",
|
|
38
|
+
"pytest-mock>=3.14",
|
|
39
|
+
"mypy>=0.991",
|
|
40
|
+
"pytest-cov>=4.0.0",
|
|
41
|
+
"ruff>=0.9.2",
|
|
42
|
+
"types-unidiff",
|
|
43
|
+
"doc901",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["flit_core >=3.6,<4"]
|
|
48
|
+
build-backend = "flit_core.buildapi"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools]
|
|
51
|
+
py-modules = ["skippy_cov"]
|
|
52
|
+
|
|
53
|
+
[tool.mypy]
|
|
54
|
+
files = ["skippy_cov"]
|
|
55
|
+
disallow_untyped_defs = true
|
|
56
|
+
disallow_any_unimported = true
|
|
57
|
+
no_implicit_optional = true
|
|
58
|
+
check_untyped_defs = true
|
|
59
|
+
warn_return_any = true
|
|
60
|
+
warn_unused_ignores = true
|
|
61
|
+
show_error_codes = true
|
|
62
|
+
|
|
63
|
+
[tool.pytest.ini_options]
|
|
64
|
+
testpaths = ["tests"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff]
|
|
67
|
+
target-version = "py38"
|
|
68
|
+
line-length = 89
|
|
69
|
+
fix = true
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
select = [
|
|
73
|
+
# flake8-2020
|
|
74
|
+
"YTT",
|
|
75
|
+
# flake8-bandit
|
|
76
|
+
"S",
|
|
77
|
+
# flake8-bugbear
|
|
78
|
+
"B",
|
|
79
|
+
# flake8-builtins
|
|
80
|
+
"A",
|
|
81
|
+
# flake8-comprehensions
|
|
82
|
+
"C4",
|
|
83
|
+
# flake8-debugger
|
|
84
|
+
"T10",
|
|
85
|
+
# flake8-simplify
|
|
86
|
+
"SIM",
|
|
87
|
+
# isort
|
|
88
|
+
"I",
|
|
89
|
+
# mccabe
|
|
90
|
+
"C90",
|
|
91
|
+
# pycodestyle
|
|
92
|
+
"E",
|
|
93
|
+
"W",
|
|
94
|
+
# pyflakes
|
|
95
|
+
"F",
|
|
96
|
+
# pygrep-hooks
|
|
97
|
+
"PGH",
|
|
98
|
+
# pyupgrade
|
|
99
|
+
"UP",
|
|
100
|
+
# ruff
|
|
101
|
+
"RUF",
|
|
102
|
+
# tryceratops
|
|
103
|
+
"TRY",
|
|
104
|
+
]
|
|
105
|
+
ignore = [
|
|
106
|
+
# LineTooLong
|
|
107
|
+
"E501",
|
|
108
|
+
# DoNotAssignLambda
|
|
109
|
+
"E731",
|
|
110
|
+
"S607",
|
|
111
|
+
"S603",
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.lint.per-file-ignores]
|
|
115
|
+
"tests/*" = ["S101"]
|
|
116
|
+
|
|
117
|
+
[tool.ruff.format]
|
|
118
|
+
preview = true
|
|
119
|
+
|
|
120
|
+
[tool.coverage.report]
|
|
121
|
+
skip_empty = true
|
|
122
|
+
|
|
123
|
+
[tool.coverage.run]
|
|
124
|
+
branch = true
|
|
125
|
+
source = ["skippy_cov"]
|
|
126
|
+
|
|
127
|
+
[project.entry-points.pytest11]
|
|
128
|
+
skippy-cov = "skippy_cov.plugin"
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import logging
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from skippy_cov.diff_handler import DiffHandler
|
|
8
|
+
from skippy_cov.tests_finder import ASTTestsFinder
|
|
9
|
+
from skippy_cov.utils import CoverageMap, FileTestCandidate, is_test_file
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
__version__ = "0.2.0"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def discover_tests_in_file(file_path: Path) -> FileTestCandidate | None:
|
|
17
|
+
"""
|
|
18
|
+
Discovers tests within a given Python file using AST parsing.
|
|
19
|
+
Finds top-level functions (sync/async) starting with 'test_' and
|
|
20
|
+
methods (sync/async) starting with 'test_' within classes
|
|
21
|
+
starting with 'Test'.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
file_path: The path to the Python file.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
A list of test identifiers in pytest format 'file::[Class::]test_name'.
|
|
28
|
+
Returns an empty list if the file is not a test file, doesn't exist,
|
|
29
|
+
cannot be read, or contains syntax errors preventing AST parsing.
|
|
30
|
+
"""
|
|
31
|
+
logger.debug(f"Discovering tests in {file_path} using AST...")
|
|
32
|
+
if not is_test_file(file_path):
|
|
33
|
+
logger.debug(
|
|
34
|
+
f"Skipping AST discovery: File path '{file_path}' "
|
|
35
|
+
"doesn't match test file pattern."
|
|
36
|
+
)
|
|
37
|
+
return None
|
|
38
|
+
if not file_path.exists() or not file_path.is_file():
|
|
39
|
+
logger.debug(
|
|
40
|
+
f"Skipping AST discovery: File path '{file_path}' "
|
|
41
|
+
"does not exist or is not a file."
|
|
42
|
+
)
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
tree = ast.parse(file_path.read_text(), filename=file_path.name)
|
|
46
|
+
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.warning(
|
|
49
|
+
f"Could not discover tests via AST in '{file_path}' "
|
|
50
|
+
f"due to unexpected error: {e}"
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
finder = ASTTestsFinder(file_path)
|
|
56
|
+
finder.visit(tree)
|
|
57
|
+
found_tests = finder.tests
|
|
58
|
+
|
|
59
|
+
if found_tests:
|
|
60
|
+
logger.debug(
|
|
61
|
+
f"Found {len(found_tests)} test(s) in '{file_path}' "
|
|
62
|
+
f"via AST: {found_tests}"
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
logger.debug(f"No tests found matching in '{file_path}' via AST.")
|
|
66
|
+
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.warning(f"Error during AST traversal of '{file_path}': {e}")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
return FileTestCandidate(path=file_path, tests=finder.tests)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def select_tests_to_run(
|
|
75
|
+
diff_handler: DiffHandler,
|
|
76
|
+
coverage_map: CoverageMap,
|
|
77
|
+
) -> list[FileTestCandidate]:
|
|
78
|
+
"""
|
|
79
|
+
Determines the set of tests to run based on changed files and coverage.
|
|
80
|
+
"""
|
|
81
|
+
tests_to_run: list[FileTestCandidate] = []
|
|
82
|
+
|
|
83
|
+
logger.debug(f"Processing {len(diff_handler.changed_files)} changed file(s)...")
|
|
84
|
+
logger.debug(f"Changed files: {diff_handler.changed_files}")
|
|
85
|
+
|
|
86
|
+
for file_path in diff_handler.changed_files:
|
|
87
|
+
# 1. If the changed file is a source file with known coverage
|
|
88
|
+
if candidate := coverage_map.get_tests(file_path):
|
|
89
|
+
logger.debug(
|
|
90
|
+
f"Source file '{candidate.path}' changed. Adding {len(candidate.tests)}"
|
|
91
|
+
"related test(s) from coverage map.",
|
|
92
|
+
)
|
|
93
|
+
tests_to_run.append(candidate)
|
|
94
|
+
|
|
95
|
+
# 2. If the changed file is a test file itself
|
|
96
|
+
# Use the discovery function, which internally checks if it's a test file
|
|
97
|
+
# This handles added/modified test files.
|
|
98
|
+
# If a test file is changed, all tests in it will be run
|
|
99
|
+
tests_in_file = discover_tests_in_file(file_path)
|
|
100
|
+
if tests_in_file:
|
|
101
|
+
logger.debug(
|
|
102
|
+
f"Test file '{file_path}' changed or contains tests."
|
|
103
|
+
f" Adding all {len(tests_in_file.tests)} tests from this file.",
|
|
104
|
+
)
|
|
105
|
+
tests_to_run.append(tests_in_file)
|
|
106
|
+
|
|
107
|
+
# 3. Handle files not in coverage map and not identified as test files
|
|
108
|
+
# These might be new source files, documentation, config files etc.
|
|
109
|
+
# Current logic doesn't explicitly add tests for *new* source files
|
|
110
|
+
# unless they come with *new* test files (handled by point 2).
|
|
111
|
+
# If a new source file is added and covered by *existing* tests,
|
|
112
|
+
# the *old* coverage map won't know about it. This is a limitation.
|
|
113
|
+
if not tests_in_file and not is_test_file(file_path):
|
|
114
|
+
logger.debug(
|
|
115
|
+
f"Changed file '{file_path}' is not in the "
|
|
116
|
+
"coverage map and not identified as a test file."
|
|
117
|
+
" No direct tests added for it.",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return tests_to_run
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from skippy_cov import __version__, select_tests_to_run
|
|
10
|
+
from skippy_cov.diff_handler import DiffHandler
|
|
11
|
+
from skippy_cov.utils import CoverageMap, filter_by_path
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run(
|
|
17
|
+
diff: str,
|
|
18
|
+
coverage_file: Path,
|
|
19
|
+
relative_to: list[Path] | None,
|
|
20
|
+
keep_prefix: bool,
|
|
21
|
+
display: bool = False,
|
|
22
|
+
) -> set[str]:
|
|
23
|
+
"""
|
|
24
|
+
Run the test filter. If `display` = True will also print the output to stdout
|
|
25
|
+
"""
|
|
26
|
+
diff_handler = DiffHandler(diff)
|
|
27
|
+
coverage_map = CoverageMap(coverage_file)
|
|
28
|
+
selected_tests = select_tests_to_run(diff_handler, coverage_map)
|
|
29
|
+
tests = sorted(selected_tests)
|
|
30
|
+
if not tests:
|
|
31
|
+
logger.info("No specific tests selected to run based on changes and coverage.")
|
|
32
|
+
if keep_prefix and relative_to and len(relative_to) > 1:
|
|
33
|
+
logger.warning(
|
|
34
|
+
"Trying to remove prefix with more than one path as filter is not allowed. "
|
|
35
|
+
"The keep_prefix flag will be set to True"
|
|
36
|
+
)
|
|
37
|
+
keep_prefix = True
|
|
38
|
+
|
|
39
|
+
if relative_to:
|
|
40
|
+
selected_tests = filter_by_path(selected_tests, relative_to, keep_prefix)
|
|
41
|
+
|
|
42
|
+
output = set()
|
|
43
|
+
for test in selected_tests:
|
|
44
|
+
output |= test.as_set()
|
|
45
|
+
|
|
46
|
+
output_content = " ".join(output)
|
|
47
|
+
if display:
|
|
48
|
+
print(output_content)
|
|
49
|
+
return output
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_default_branch() -> str:
|
|
53
|
+
"""
|
|
54
|
+
Determine the default branch to diff against.
|
|
55
|
+
|
|
56
|
+
This function attempts to determine the default branch by inspecting the
|
|
57
|
+
output of `git remote show origin`. If it fails to determine the branch
|
|
58
|
+
(e.g., not in a git repository, or `origin` is not configured), it falls
|
|
59
|
+
back to "main".
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: The name of the default branch (e.g., "main", "develop").
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
output = subprocess.check_output(
|
|
66
|
+
["git", "remote", "show", "origin"], stderr=subprocess.DEVNULL, text=True
|
|
67
|
+
)
|
|
68
|
+
for line in output.splitlines():
|
|
69
|
+
if "HEAD branch" in line:
|
|
70
|
+
return line.split(":")[-1].strip()
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.info("Could not determine default branch, falling back to 'main'.")
|
|
73
|
+
return "main"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_diff_content(diff_arg):
|
|
77
|
+
"""
|
|
78
|
+
Get the diff content based on the provided argument.
|
|
79
|
+
It can either be a file path or a git ref/branch to diff against.
|
|
80
|
+
"""
|
|
81
|
+
# If it's a file path and exists, read it as a file
|
|
82
|
+
if diff_arg: # Only try Path if diff_arg is not None/empty
|
|
83
|
+
path = Path(diff_arg)
|
|
84
|
+
if path.exists():
|
|
85
|
+
return path.read_text()
|
|
86
|
+
# Otherwise, treat as git diff argument (branch/refspec)
|
|
87
|
+
branch = diff_arg if diff_arg else get_default_branch()
|
|
88
|
+
try:
|
|
89
|
+
diff = subprocess.check_output(
|
|
90
|
+
["git", "diff", branch], stderr=subprocess.DEVNULL, text=True
|
|
91
|
+
)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"skippy-cov: failed to get git diff for '{branch}': {e}", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
else:
|
|
96
|
+
return diff
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main(argv=None):
|
|
100
|
+
parser = argparse.ArgumentParser(
|
|
101
|
+
description="Select pytest tests based on diff and coverage."
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--diff",
|
|
105
|
+
required=False,
|
|
106
|
+
help="Path to a diff file or a git ref/branch to diff against (default: main branch).",
|
|
107
|
+
default=None,
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--coverage-file",
|
|
111
|
+
required=False,
|
|
112
|
+
help="Path to the coverage file (.coverage sqlite database).",
|
|
113
|
+
type=Path,
|
|
114
|
+
default=Path(".coverage"),
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--relative-to",
|
|
118
|
+
required=False,
|
|
119
|
+
help="Display only tests contained in a folder",
|
|
120
|
+
type=Path,
|
|
121
|
+
nargs="+",
|
|
122
|
+
default=None,
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--keep-prefix",
|
|
126
|
+
required=False,
|
|
127
|
+
default=True,
|
|
128
|
+
action="store_true",
|
|
129
|
+
help="When using --relative-to, determine if the original path should be kept or removed",
|
|
130
|
+
)
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
"--strip-prefix",
|
|
133
|
+
dest="keep_prefix",
|
|
134
|
+
action="store_false",
|
|
135
|
+
help="When using --relative-to, determine if the original path should be kept or removed",
|
|
136
|
+
)
|
|
137
|
+
parser.add_argument(
|
|
138
|
+
"--debug", action="store_true", help="Enable debug logging.", default=False
|
|
139
|
+
)
|
|
140
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
141
|
+
args = parser.parse_args(argv)
|
|
142
|
+
|
|
143
|
+
if args.debug:
|
|
144
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
145
|
+
|
|
146
|
+
diff_content = (
|
|
147
|
+
get_diff_content(args.diff) if args.diff is not None else get_diff_content(None)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
run(
|
|
151
|
+
diff_content,
|
|
152
|
+
args.coverage_file,
|
|
153
|
+
args.relative_to,
|
|
154
|
+
args.keep_prefix,
|
|
155
|
+
display=True,
|
|
156
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import sys
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
"""
|
|
10
|
+
mypy doesn't like this
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ImportError:
|
|
14
|
+
import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
see https://github.com/python/mypy/issues/13914
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
if sys.version_info >= (3, 11):
|
|
20
|
+
import tomllib
|
|
21
|
+
else:
|
|
22
|
+
import tomli as tomllib
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_config: ConfigHandler | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_config() -> ConfigHandler:
|
|
29
|
+
global _config
|
|
30
|
+
if not _config:
|
|
31
|
+
_config = ConfigHandler(Path.cwd())
|
|
32
|
+
return _config
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigFormat(Enum):
|
|
36
|
+
INI = "ini"
|
|
37
|
+
TOML = "toml"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ConfigFileHandler:
|
|
41
|
+
values: dict[str, str] | None = None
|
|
42
|
+
section_name: str
|
|
43
|
+
parser: ConfigFormat
|
|
44
|
+
|
|
45
|
+
def __init__(self, config_path: Path):
|
|
46
|
+
self.config_path = Path(config_path)
|
|
47
|
+
self._parse()
|
|
48
|
+
|
|
49
|
+
def _parse(self) -> None:
|
|
50
|
+
if self.parser == ConfigFormat.INI:
|
|
51
|
+
self._parse_ini()
|
|
52
|
+
elif self.parser == ConfigFormat.TOML:
|
|
53
|
+
self._parse_toml()
|
|
54
|
+
else:
|
|
55
|
+
raise NotImplementedError(f"Unsupported {self.parser}")
|
|
56
|
+
|
|
57
|
+
def _parse_ini(self) -> None:
|
|
58
|
+
config = configparser.ConfigParser()
|
|
59
|
+
config.read_string(self.config_path.read_text())
|
|
60
|
+
|
|
61
|
+
cfgdict = {s: dict(config.items(s)) for s in config.sections()}
|
|
62
|
+
self.values = cfgdict.get(self.section_name, None)
|
|
63
|
+
|
|
64
|
+
def _parse_toml(self) -> None:
|
|
65
|
+
config = tomllib.loads(self.config_path.read_text())
|
|
66
|
+
# TOML nests the config, so we dig deep to find it
|
|
67
|
+
for section in self.section_name.split("."):
|
|
68
|
+
if config:
|
|
69
|
+
config = config.get(section, None)
|
|
70
|
+
self.values = config
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class IniHandler(ConfigFileHandler):
|
|
74
|
+
section_name = "pytest"
|
|
75
|
+
parser = ConfigFormat.INI
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SetupCfgHandler(IniHandler):
|
|
79
|
+
section_name = "tool:pytest"
|
|
80
|
+
parser = ConfigFormat.INI
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PyprojectTomlHandler(ConfigFileHandler):
|
|
84
|
+
section_name = "tool.pytest.ini_options"
|
|
85
|
+
parser = ConfigFormat.TOML
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ConfigHandler:
|
|
89
|
+
configs: ClassVar[dict[str, type[ConfigFileHandler]]] = {
|
|
90
|
+
"pytest.ini": IniHandler,
|
|
91
|
+
".pytest.ini": IniHandler,
|
|
92
|
+
"pyproject.toml": PyprojectTomlHandler,
|
|
93
|
+
"tox.ini": IniHandler,
|
|
94
|
+
"setup.cfg": SetupCfgHandler,
|
|
95
|
+
}
|
|
96
|
+
config_priority: ClassVar[list[str]] = [
|
|
97
|
+
"pytest.ini",
|
|
98
|
+
".pytest.ini",
|
|
99
|
+
"pyproject.toml",
|
|
100
|
+
"tox.ini",
|
|
101
|
+
"setup.cfg",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
handler: ConfigFileHandler | None
|
|
105
|
+
|
|
106
|
+
def __init__(self, base_location: Path):
|
|
107
|
+
self.handler = self._discover_config(base_location)
|
|
108
|
+
|
|
109
|
+
def __bool__(self) -> bool:
|
|
110
|
+
return bool(self.handler)
|
|
111
|
+
|
|
112
|
+
def get_value(self, key: str) -> str | int | list[str] | None:
|
|
113
|
+
if not self.handler or not self.handler.values:
|
|
114
|
+
return None
|
|
115
|
+
return self.handler.values.get(key)
|
|
116
|
+
|
|
117
|
+
def _discover_config(self, base: Path) -> ConfigFileHandler | None:
|
|
118
|
+
"""
|
|
119
|
+
Find config files in current or any parent directory
|
|
120
|
+
and return a ConfigFileHandler for the corresponding format
|
|
121
|
+
looks for the following (in order of preference):
|
|
122
|
+
- pytest.ini
|
|
123
|
+
- pyproject.toml
|
|
124
|
+
- tox.ini
|
|
125
|
+
- setup.cfg
|
|
126
|
+
|
|
127
|
+
see: https://docs.pytest.org/en/stable/reference/customize.html#configuration-file-formats
|
|
128
|
+
"""
|
|
129
|
+
for current in [base, *base.absolute().parents]:
|
|
130
|
+
config = {
|
|
131
|
+
path.name for path in current.iterdir() if path.name in self.configs
|
|
132
|
+
}
|
|
133
|
+
if config:
|
|
134
|
+
top_config = sorted(config, key=self.config_priority.index)[0]
|
|
135
|
+
cls = self.configs[top_config]
|
|
136
|
+
cfg_path = current / top_config
|
|
137
|
+
return cls(cfg_path)
|
|
138
|
+
return None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import logging
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unidiff import PatchSet
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DiffHandlerError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DiffHandler:
|
|
18
|
+
def __init__(self, contents: str):
|
|
19
|
+
"""
|
|
20
|
+
Args:
|
|
21
|
+
diff_file: The contents of the file containing the full output of a 'git diff' command.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
FileNotFoundError: If the specified diff file does not exist.
|
|
25
|
+
"""
|
|
26
|
+
self.changes = self.parse_diff(contents)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def changed_files(self) -> set[Path]:
|
|
30
|
+
return set(self.changes.keys())
|
|
31
|
+
|
|
32
|
+
def __getitem__(self, key: Path) -> str:
|
|
33
|
+
return self.changes[key]
|
|
34
|
+
|
|
35
|
+
def parse_diff(self, diff_text: str) -> defaultdict[Path, str]:
|
|
36
|
+
"""
|
|
37
|
+
Parses the full text output of 'git diff' using the 'unidiff' library.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
diff_text: A string containing the full output of a 'git diff' command.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A set of unique file paths affected by the diff and the affected diff
|
|
44
|
+
"""
|
|
45
|
+
mapped_changes: defaultdict[Path, str] = defaultdict(str)
|
|
46
|
+
try:
|
|
47
|
+
patch_set = PatchSet(io.StringIO(diff_text))
|
|
48
|
+
|
|
49
|
+
for patched_file in patch_set:
|
|
50
|
+
if patched_file.is_removed_file:
|
|
51
|
+
path = patched_file.source_file
|
|
52
|
+
if path.startswith("a/"):
|
|
53
|
+
path = path[2:]
|
|
54
|
+
else:
|
|
55
|
+
path = patched_file.target_file
|
|
56
|
+
if path.startswith("b/"):
|
|
57
|
+
path = path[2:]
|
|
58
|
+
mapped_changes[Path(path)] = str(patched_file).strip()
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.exception("Failed to parse diff using unidiff")
|
|
61
|
+
raise DiffHandlerError() from e
|
|
62
|
+
|
|
63
|
+
return mapped_changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from skippy_cov.__main__ import run
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def pytest_addoption(parser):
|
|
12
|
+
group = parser.getgroup("skippy-cov", "Options for skippy coverage-based collection")
|
|
13
|
+
|
|
14
|
+
group.addoption(
|
|
15
|
+
"--skippy-cov",
|
|
16
|
+
required=False,
|
|
17
|
+
dest="skippy_cov",
|
|
18
|
+
action="store_true",
|
|
19
|
+
help="Enable skippy-cov",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
group.addoption(
|
|
23
|
+
"--skippy-cov-diff",
|
|
24
|
+
required=False,
|
|
25
|
+
help="Path to a diff file or a git ref/branch to diff against (default: main branch).",
|
|
26
|
+
default=None,
|
|
27
|
+
)
|
|
28
|
+
group.addoption(
|
|
29
|
+
"--skippy-cov-coverage-file",
|
|
30
|
+
required=False,
|
|
31
|
+
help="Path to the coverage file (.coverage sqlite database).",
|
|
32
|
+
type=Path,
|
|
33
|
+
default=Path(".coverage"),
|
|
34
|
+
)
|
|
35
|
+
group.addoption(
|
|
36
|
+
"--skippy-cov-keep-prefix",
|
|
37
|
+
required=False,
|
|
38
|
+
default=True,
|
|
39
|
+
dest="skippy_cov_keep_prefix",
|
|
40
|
+
action="store_true",
|
|
41
|
+
help="When using --skippy-cov-relative-to, determine if the original path should be kept or removed",
|
|
42
|
+
)
|
|
43
|
+
group.addoption(
|
|
44
|
+
"--skippy-cov-strip-prefix",
|
|
45
|
+
dest="skippy_cov_keep_prefix",
|
|
46
|
+
action="store_false",
|
|
47
|
+
help="When using --skippy-cov-relative-to, determine if the original path should be kept or removed",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.hookimpl(tryfirst=True)
|
|
52
|
+
def pytest_configure(config):
|
|
53
|
+
"""
|
|
54
|
+
Attempts to filter tests using skippy-cov
|
|
55
|
+
"""
|
|
56
|
+
skippy_cov = config.getoption("skippy_cov")
|
|
57
|
+
diff_arg = config.getoption("skippy_cov_diff")
|
|
58
|
+
cov_file = config.getoption("skippy_cov_coverage_file")
|
|
59
|
+
keep_prefix = config.getoption("skippy_cov_keep_prefix")
|
|
60
|
+
if not skippy_cov:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Import get_diff_content from __main__ to match CLI logic
|
|
64
|
+
from skippy_cov.__main__ import get_diff_content
|
|
65
|
+
|
|
66
|
+
diff_content = (
|
|
67
|
+
get_diff_content(diff_arg) if diff_arg is not None else get_diff_content(None)
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
relative_to = [Path(x) for x in config.args if x]
|
|
71
|
+
selected_tests = run(
|
|
72
|
+
diff_content,
|
|
73
|
+
cov_file,
|
|
74
|
+
relative_to,
|
|
75
|
+
keep_prefix,
|
|
76
|
+
)
|
|
77
|
+
if selected_tests:
|
|
78
|
+
config.args = selected_tests
|
|
79
|
+
else:
|
|
80
|
+
pytest.exit("skippy-cov: couldn't find any tests to filter.", returncode=5)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ASTTestsFinder(ast.NodeVisitor):
|
|
8
|
+
"""
|
|
9
|
+
Visits an AST tree and collects test functions and methods
|
|
10
|
+
following basic pytest discovery conventions:
|
|
11
|
+
- Top-level functions `test_*`
|
|
12
|
+
- Classes `Test*` containing methods `test_*`
|
|
13
|
+
- Classes with an `__init__` method are ignored
|
|
14
|
+
See: https://docs.pytest.org/en/latest/explanation/goodpractices.html#conventions-for-python-test-discovery
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, file_path: Path):
|
|
18
|
+
self.file_path = file_path
|
|
19
|
+
self.tests: set[str] = set()
|
|
20
|
+
self.current_class_name: str | None = None
|
|
21
|
+
|
|
22
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
23
|
+
"""Visit Class Definitions."""
|
|
24
|
+
|
|
25
|
+
# TODO: consider python_classes and python_functions parameters on config
|
|
26
|
+
# SEE: https://docs.pytest.org/en/stable/example/pythoncollection.html#changing-naming-conventions
|
|
27
|
+
class_has_init_method = any(
|
|
28
|
+
hasattr(method, "name") and method.name == "__init__" for method in node.body
|
|
29
|
+
)
|
|
30
|
+
if node.name.startswith("Test") and not class_has_init_method:
|
|
31
|
+
original_class_name = self.current_class_name
|
|
32
|
+
self.current_class_name = node.name
|
|
33
|
+
self.generic_visit(node)
|
|
34
|
+
self.current_class_name = original_class_name
|
|
35
|
+
else:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
|
|
39
|
+
"""Visit Function (and Method) Definitions."""
|
|
40
|
+
|
|
41
|
+
# TODO: consider python_classes and python_functions parameters on config
|
|
42
|
+
# SEE: https://docs.pytest.org/en/stable/example/pythoncollection.html#changing-naming-conventions
|
|
43
|
+
if node.name.startswith("test_"):
|
|
44
|
+
if self.current_class_name:
|
|
45
|
+
test_id = f"{self.current_class_name}::{node.name}"
|
|
46
|
+
self.tests.add(test_id)
|
|
47
|
+
else:
|
|
48
|
+
test_id = node.name
|
|
49
|
+
self.tests.add(test_id)
|
|
50
|
+
|
|
51
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
52
|
+
"""Visit Async Function (and Method) Definitions."""
|
|
53
|
+
return self.visit_FunctionDef(node)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from fnmatch import fnmatch
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import coverage
|
|
8
|
+
|
|
9
|
+
from skippy_cov.config_handler import get_config
|
|
10
|
+
|
|
11
|
+
DEFAULT_GLOB_PATTERN = "test_*.py"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FilterCandidatesError(ValueError):
|
|
15
|
+
def __init__(self, *args, paths: list[Path], **kwargs):
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
self.message = f"Attempting to relativize based in multiple paths: {paths}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class FileTestCandidate:
|
|
22
|
+
path: Path
|
|
23
|
+
tests: set[str]
|
|
24
|
+
|
|
25
|
+
def __lt__(self, other: object) -> bool:
|
|
26
|
+
if not isinstance(other, FileTestCandidate):
|
|
27
|
+
raise NotImplementedError
|
|
28
|
+
return self.path < other.path
|
|
29
|
+
|
|
30
|
+
def __eq__(self, other: object) -> bool:
|
|
31
|
+
if not isinstance(other, FileTestCandidate):
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
return self.path == other.path and self.tests == other.tests
|
|
34
|
+
|
|
35
|
+
def as_set(self) -> set[str]:
|
|
36
|
+
tests = set()
|
|
37
|
+
for test in self.tests:
|
|
38
|
+
tests.add(f"{self.path.as_posix()}::{test}")
|
|
39
|
+
return tests
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_test_file(file_path: Path) -> bool:
|
|
43
|
+
"""
|
|
44
|
+
Checks if a file is a test file based on its name.
|
|
45
|
+
|
|
46
|
+
Checks if a config file is present. If so, tries to use the `python_files` key
|
|
47
|
+
from the config file.
|
|
48
|
+
Otherwise uses the default glob pattern. (test_*.py)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# TODO: consider pytest flags such as `ignore`, `addopts`, etc...
|
|
52
|
+
# config_handler.ConfigHandler should already handle it,
|
|
53
|
+
# but I want to have something working before sinking more hours into this
|
|
54
|
+
# SEE: https://docs.pytest.org/en/stable/example/pythoncollection.html#changing-naming-conventions
|
|
55
|
+
cfg = get_config()
|
|
56
|
+
if (
|
|
57
|
+
bool(cfg)
|
|
58
|
+
and (patterns := cfg.get_value("python_files"))
|
|
59
|
+
and isinstance(patterns, str)
|
|
60
|
+
):
|
|
61
|
+
return any(fnmatch(file_path.name, pattern) for pattern in patterns.split(" "))
|
|
62
|
+
|
|
63
|
+
return fnmatch(file_path.name, DEFAULT_GLOB_PATTERN)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _fix_test_name(test_name: str) -> str:
|
|
67
|
+
"""
|
|
68
|
+
Removes everything after the last `|` from the test name.
|
|
69
|
+
Most likely, this is the phase name and not part of the test name or parameters.
|
|
70
|
+
"""
|
|
71
|
+
return test_name.rsplit("|", 1)[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def filter_by_path(
|
|
75
|
+
candidates: list[FileTestCandidate],
|
|
76
|
+
from_folders: list[Path],
|
|
77
|
+
keep_prefix: bool = True,
|
|
78
|
+
) -> list[FileTestCandidate]:
|
|
79
|
+
"""
|
|
80
|
+
Filters a list of FileTestCandidate objects based on a starting folder and depth.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
candidates: A list of FileTestCandidate objects to filter.
|
|
84
|
+
from_folders: A list of Path objects representing the starting folder.
|
|
85
|
+
keep_prefix: A boolean, if true, the origial path is kept, if false it's removed
|
|
86
|
+
Returns:
|
|
87
|
+
A new list of FileTestCandidate objects that satisfy the filtering criteria.
|
|
88
|
+
Raises:
|
|
89
|
+
FilterCandidatesError: If `keep_prefix` is false and there's more than 1 path to filter,
|
|
90
|
+
Which doesn't make sense.
|
|
91
|
+
"""
|
|
92
|
+
filtered_candidates: list[FileTestCandidate] = []
|
|
93
|
+
|
|
94
|
+
if not keep_prefix and len(from_folders) > 1:
|
|
95
|
+
raise FilterCandidatesError(paths=from_folders)
|
|
96
|
+
|
|
97
|
+
for candidate in candidates:
|
|
98
|
+
for filepath in from_folders:
|
|
99
|
+
try:
|
|
100
|
+
relative_path = candidate.path.relative_to(filepath)
|
|
101
|
+
except ValueError:
|
|
102
|
+
# The candidate is not within the from_folder, so skip it
|
|
103
|
+
continue
|
|
104
|
+
if not keep_prefix:
|
|
105
|
+
candidate.path = relative_path
|
|
106
|
+
filtered_candidates.append(candidate)
|
|
107
|
+
|
|
108
|
+
return filtered_candidates
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class CoverageMap:
|
|
112
|
+
db: coverage.CoverageData
|
|
113
|
+
|
|
114
|
+
def __init__(self, filepath: Path):
|
|
115
|
+
self.db = coverage.CoverageData(filepath.name)
|
|
116
|
+
self.db.read()
|
|
117
|
+
|
|
118
|
+
def get_tests(self, filepath: Path) -> FileTestCandidate | None:
|
|
119
|
+
tests = set()
|
|
120
|
+
for line_tests in self.db.contexts_by_lineno(filepath.as_posix()).values():
|
|
121
|
+
tests |= {_fix_test_name(test) for test in line_tests}
|
|
122
|
+
if tests:
|
|
123
|
+
return FileTestCandidate(path=filepath, tests=tests)
|
|
124
|
+
return None
|