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