testpick 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ """Terminal reporter that renders beautiful terminal output using rich."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+ from rich.panel import Panel
10
+
11
+
12
+ class TerminalReporter:
13
+ def __init__(self) -> None:
14
+ self.console = Console()
15
+
16
+ def render(
17
+ self,
18
+ changed_files: list[tuple[str, str]],
19
+ test_files: list[Path],
20
+ dependency_chains: dict[Path, list[Path]],
21
+ stats: dict,
22
+ ) -> None:
23
+ """Render the test selection results to the terminal.
24
+
25
+ Args:
26
+ changed_files: List of (status, filepath) tuples from git diff.
27
+ test_files: List of selected test filepaths.
28
+ dependency_chains: Mapping from test file to its dependency chain.
29
+ stats: Statistics about the analysis.
30
+ """
31
+ # Header
32
+ self.console.print()
33
+ self.console.print(
34
+ Panel(
35
+ "[bold]testpick - Test Selection Results[/bold]",
36
+ style="blue",
37
+ )
38
+ )
39
+ self.console.print()
40
+
41
+ # Changed files table
42
+ changed_table = Table(title="Changed Files", show_header=True)
43
+ changed_table.add_column("Status", style="bold", width=10)
44
+ changed_table.add_column("File", style="cyan")
45
+
46
+ status_colors = {
47
+ "A": "green",
48
+ "M": "yellow",
49
+ "D": "red",
50
+ "R": "blue",
51
+ }
52
+ status_names = {
53
+ "A": "Added",
54
+ "M": "Modified",
55
+ "D": "Deleted",
56
+ "R": "Renamed",
57
+ }
58
+
59
+ for status, filepath in changed_files:
60
+ color = status_colors.get(status, "white")
61
+ status_name = status_names.get(status, status)
62
+ changed_table.add_row(
63
+ f"[{color}]{status_name}[/{color}]",
64
+ filepath,
65
+ )
66
+
67
+ self.console.print(changed_table)
68
+ self.console.print()
69
+
70
+ # Test files table
71
+ if test_files:
72
+ test_table = Table(title="Selected Tests", show_header=True)
73
+ test_table.add_column("#", style="dim", width=5)
74
+ test_table.add_column("Test File", style="cyan")
75
+ test_table.add_column("Dependency Path", style="dim")
76
+
77
+ for i, test_file in enumerate(test_files, 1):
78
+ chain = dependency_chains.get(test_file, [])
79
+ chain_str = " -> ".join(str(p) for p in chain) if chain else "direct"
80
+ test_table.add_row(
81
+ str(i),
82
+ str(test_file),
83
+ chain_str,
84
+ )
85
+
86
+ self.console.print(test_table)
87
+ else:
88
+ self.console.print(
89
+ Panel(
90
+ "[yellow]No tests selected[/yellow]",
91
+ style="yellow",
92
+ )
93
+ )
94
+
95
+ self.console.print()
96
+
97
+ # Stats
98
+ stats_table = Table(title="Statistics", show_header=False)
99
+ stats_table.add_column("Metric", style="bold")
100
+ stats_table.add_column("Value", style="green")
101
+
102
+ stats_table.add_row("Files Changed", str(stats.get("files_changed", 0)))
103
+ stats_table.add_row("Tests Selected", str(len(test_files)))
104
+ stats_table.add_row("Insertions", str(stats.get("insertions", 0)))
105
+ stats_table.add_row("Deletions", str(stats.get("deletions", 0)))
106
+
107
+ self.console.print(stats_table)
108
+ self.console.print()
@@ -0,0 +1,6 @@
1
+ """Test selector modules for testpick."""
2
+
3
+ from .matcher import TestMatcher
4
+ from .walker import DependencyWalker
5
+
6
+ __all__ = ["DependencyWalker", "TestMatcher"]
@@ -0,0 +1,58 @@
1
+ """Test matcher that filters and sorts test files from affected files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..graph import DependencyGraph
8
+
9
+
10
+ class TestMatcher:
11
+ def match(
12
+ self, affected_files: set[Path], graph: DependencyGraph
13
+ ) -> list[Path]:
14
+ """Filter affected files to only test files and sort by directness.
15
+
16
+ Args:
17
+ affected_files: Set of all affected filepaths.
18
+ graph: The dependency graph.
19
+
20
+ Returns:
21
+ List of test filepaths, sorted with direct imports first.
22
+ """
23
+ test_files: list[Path] = []
24
+ direct_tests: list[Path] = []
25
+ transitive_tests: list[Path] = []
26
+
27
+ for filepath in affected_files:
28
+ if graph.is_test_file(filepath):
29
+ # Check if this test directly imports from a changed file
30
+ if filepath in graph.nodes:
31
+ node = graph.nodes[filepath]
32
+ # Check if any of its imports are in the affected files
33
+ is_direct = False
34
+ for imp in node.imports:
35
+ targets = graph.resolve_import(imp, filepath)
36
+ for target in targets:
37
+ if target in affected_files and not graph.is_test_file(target):
38
+ is_direct = True
39
+ break
40
+ if is_direct:
41
+ break
42
+
43
+ if is_direct:
44
+ direct_tests.append(filepath)
45
+ else:
46
+ transitive_tests.append(filepath)
47
+ else:
48
+ # File not in graph, treat as transitive
49
+ transitive_tests.append(filepath)
50
+
51
+ # Sort each group alphabetically for consistency
52
+ direct_tests.sort()
53
+ transitive_tests.sort()
54
+
55
+ # Direct imports first, then transitive
56
+ test_files = direct_tests + transitive_tests
57
+
58
+ return test_files
@@ -0,0 +1,42 @@
1
+ """Dependency walker that finds all files affected by changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from ..graph import DependencyGraph
8
+
9
+
10
+ class DependencyWalker:
11
+ def walk_from_files(
12
+ self, changed_files: list[tuple[str, str]], graph: DependencyGraph
13
+ ) -> tuple[set[Path], dict[Path, Path]]:
14
+ """Walk from changed files to every affected file, tracking how each was reached.
15
+
16
+ Returns the affected files and a parent map used to explain why a file
17
+ was selected. Deleted files are walked but never selected themselves.
18
+ """
19
+ affected: set[Path] = set()
20
+ parents: dict[Path, Path] = {}
21
+
22
+ for status, filepath in changed_files:
23
+ path = Path(filepath).resolve()
24
+ if not status.startswith("D"):
25
+ affected.add(path)
26
+
27
+ if path not in graph.nodes:
28
+ continue
29
+
30
+ queue = [path]
31
+ visited = {path}
32
+ while queue:
33
+ current = queue.pop(0)
34
+ for dependent in graph.get_dependents(current):
35
+ if dependent in visited:
36
+ continue
37
+ visited.add(dependent)
38
+ affected.add(dependent)
39
+ parents.setdefault(dependent, current)
40
+ queue.append(dependent)
41
+
42
+ return affected, parents
@@ -0,0 +1,211 @@
1
+ Metadata-Version: 2.5
2
+ Name: testpick
3
+ Version: 0.2.0
4
+ Summary: Pick the tests affected by your git diff before you run them
5
+ Project-URL: Homepage, https://github.com/krauzX/testpick
6
+ Project-URL: Repository, https://github.com/krauzX/testpick
7
+ Project-URL: Issues, https://github.com/krauzX/testpick/issues
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ci,git,pytest,static-analysis,testing
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: click>=8.0
24
+ Requires-Dist: gitpython>=3.1
25
+ Requires-Dist: readchar>=4.0
26
+ Requires-Dist: rich>=13.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.5; extra == 'dev'
32
+ Provides-Extra: tree-sitter
33
+ Requires-Dist: tree-sitter-javascript>=0.23; extra == 'tree-sitter'
34
+ Requires-Dist: tree-sitter-python>=0.23; extra == 'tree-sitter'
35
+ Requires-Dist: tree-sitter-typescript>=0.23; extra == 'tree-sitter'
36
+ Requires-Dist: tree-sitter>=0.25; extra == 'tree-sitter'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # testpick
40
+
41
+ [![CI](https://github.com/krauzX/testpick/actions/workflows/ci.yml/badge.svg)](https://github.com/krauzX/testpick/actions/workflows/ci.yml)
42
+
43
+ A Python CLI that looks at your git diff and tells you which tests are going to break. Before you run them.
44
+
45
+ ## Why this exists
46
+
47
+ You change a function in `utils.py`. Now what? You could run the full test suite and wait, or you could guess
48
+ which tests matter and hope you're right. `pytest --lf` reruns failures only after they've already failed.
49
+ Jest's `--changedSince` follows direct file changes.
50
+
51
+ testpick parses your git diff, builds an import graph of your source files, and walks it backwards from what
52
+ you changed to find every test that depends on it — transitively. Each selected test comes with the chain that
53
+ explains why, so you can sanity-check the answer instead of trusting it.
54
+
55
+ ## Getting started
56
+
57
+ ```bash
58
+ git clone https://github.com/krauzX/testpick.git
59
+ cd testpick
60
+ pip install -e .
61
+
62
+ # optional but recommended: real AST parsing instead of the regex fallback
63
+ pip install -e ".[tree-sitter]"
64
+
65
+ # for development (tests, linter, type checker)
66
+ pip install -e ".[dev]"
67
+ ```
68
+
69
+ Python 3.10+, runs on Linux, macOS and Windows.
70
+
71
+ ## Usage
72
+
73
+ ```bash
74
+ # what changed since your last commit, including uncommitted work?
75
+ testpick
76
+
77
+ # committed changes on your branch since it diverged from main
78
+ testpick --base main
79
+
80
+ # a single commit
81
+ testpick --commit abc123
82
+
83
+ # output for scripts and CI
84
+ testpick --format json
85
+ testpick --format github # GitHub Actions annotations
86
+
87
+ # only analyze Python and TypeScript files
88
+ testpick --languages python,typescript
89
+ ```
90
+
91
+ Everything resolves from the repository root, so it works from any directory inside the repo.
92
+
93
+ ### Example output
94
+
95
+ ```
96
+ ┌─────────────────────────────────────────────────────────────────────────────┐
97
+ │ testpick - Test Selection Results │
98
+ └─────────────────────────────────────────────────────────────────────────────┘
99
+
100
+ Changed Files
101
+ ┌────────────┬────────────────────────────────────────┐
102
+ │ Status │ File │
103
+ ├────────────┼────────────────────────────────────────┤
104
+ │ Modified │ src/testpick/graph/builder.py │
105
+ │ Modified │ src/testpick/reporter/github.py │
106
+ │ Modified │ src/testpick/reporter/json_reporter.py │
107
+ │ Added │ tests/test_selection_pipeline.py │
108
+ └────────────┴────────────────────────────────────────┘
109
+
110
+ Selected Tests
111
+ ┌───────┬──────────────────────────────────┬──────────────────────────────────┐
112
+ │ # │ Test File │ Dependency Path │
113
+ ├───────┼──────────────────────────────────┼──────────────────────────────────┤
114
+ │ 1 │ tests/test_cli.py │ tests/test_cli.py -> │
115
+ │ │ │ src/testpick/cli.py -> │
116
+ │ │ │ src/testpick/graph/__init__.py │
117
+ │ │ │ -> src/testpick/graph/builder.py │
118
+ │ 2 │ tests/test_graph/test_builder.py │ tests/test_graph/test_builder.py │
119
+ │ │ │ -> │
120
+ │ │ │ src/testpick/graph/__init__.py │
121
+ │ │ │ -> src/testpick/graph/builder.py │
122
+ │ 3 │ tests/test_selection_pipeline.py │ direct │
123
+ │ 4 │ tests/test_selector/test_matche… │ tests/test_selector/test_matche… │
124
+ │ │ │ -> │
125
+ │ │ │ src/testpick/graph/__init__.py │
126
+ │ │ │ -> src/testpick/graph/builder.py │
127
+ └───────┴──────────────────────────────────┴──────────────────────────────────┘
128
+
129
+ Statistics
130
+ ┌────────────────┬─────┐
131
+ │ Files Changed │ 4 │
132
+ │ Tests Selected │ 4 │
133
+ │ Insertions │ 258 │
134
+ │ Deletions │ 25 │
135
+ └────────────────┴─────┘
136
+ ```
137
+
138
+ `direct` means the test file itself changed. The JSON format carries the same
139
+ information, with chains as arrays:
140
+
141
+ ```json
142
+ {
143
+ "changed_files": [
144
+ { "status": "M", "path": "src/utils.ts" }
145
+ ],
146
+ "test_files": [
147
+ {
148
+ "path": "tests/test_page.ts",
149
+ "dependency_chain": ["tests/test_page.ts", "src/page.ts", "src/utils.ts"]
150
+ }
151
+ ],
152
+ "stats": {
153
+ "files_changed": 1,
154
+ "insertions": 6,
155
+ "deletions": 2,
156
+ "tests_selected": 1
157
+ }
158
+ }
159
+ ```
160
+
161
+ ## How it works
162
+
163
+ 1. Read the diff with `git diff --name-status` and resolve every path against the repository root.
164
+ 2. Parse each source file for imports. tree-sitter grammars are used when installed, otherwise a regex pass
165
+ handles Python, TypeScript and JavaScript.
166
+ 3. Resolve each import to a file: relative specifiers against the importing file's directory, absolute ones
167
+ against `src/`, the project root, then the file's own directory — preferring the importing file's language
168
+ when both a `.py` and a `.ts` file would match.
169
+ 4. Walk reverse edges from the changed files with a breadth-first search, recording the parent of every hit so
170
+ the chain can be explained afterwards.
171
+ 5. Report the files that are test files, direct changes first.
172
+
173
+ ## How it compares
174
+
175
+ Other tools pick tests from a diff too; they differ in how far they can see:
176
+
177
+ - **pytest-testmon** records which code each test actually executed, so it catches runtime coupling a static
178
+ graph misses — but it needs a baseline run and is pytest-only.
179
+ - **Jest `--changedSince`** maps changed files to tests, but inside a Jest project and mostly direct
180
+ relationships.
181
+ - **Nx and Turborepo affected tasks** pick packages to rebuild, which is coarser than picking individual tests.
182
+ - **snob, pytest-impacted, pytest-git-selector** do static graph selection like this one, but for Python only.
183
+
184
+ testpick's corner: one CLI covering Python and TypeScript/JavaScript, no baseline run required, and every
185
+ selection arrives with the chain that produced it.
186
+
187
+ ## Limitations
188
+
189
+ Static analysis only sees imports it can read:
190
+
191
+ - Dynamic imports, dependency injection, plugin registries and other runtime wiring are invisible to it.
192
+ - A file deleted from the working tree is gone from the parse tree, so testpick reports the deletion but
193
+ cannot trace what used to import it.
194
+ - `--languages` scopes which files are analyzed for dependencies; changed files are always reported.
195
+
196
+ Treat the output as a starting point. A full suite run still belongs in CI before a release.
197
+
198
+ ## Running tests
199
+
200
+ ```bash
201
+ pytest tests/ -v
202
+ pytest tests/ --cov=testpick
203
+ ```
204
+
205
+ 77 tests covering diff parsing, import resolution, graph building, the selection pipeline and all three
206
+ reporters, with 82% branch coverage. CI runs ruff, mypy and the suite on Python 3.10-3.13 across Linux, macOS
207
+ and Windows.
208
+
209
+ ## License
210
+
211
+ MIT
@@ -0,0 +1,28 @@
1
+ testpick/__init__.py,sha256=CDFgRH2D_Tuw3V1GXu-CZ_1hcPHn06MbMwks2nMKIg8,254
2
+ testpick/__main__.py,sha256=FY7OCYd3IE4hQpDvI-p_lrfkDl7ZYAhLajJVF1YegPA,38
3
+ testpick/cli.py,sha256=VcWsc9Cyq5xK6IkZj_S8aroOdthOuYRMGaluJI8EXlY,6831
4
+ testpick/git/__init__.py,sha256=atIQtxlOih29JM_vnZcWTPXhC9VOs0Sraq8vNN_cn14,514
5
+ testpick/git/commit.py,sha256=6Vphw1j0-7gaCV3V1V25fthM9vaHh0xVeZvZ6tPb-Jc,1045
6
+ testpick/git/diff.py,sha256=WrA2XPRh3DxDKTjvG-BQNtDIhhG2mz89KBmS2tOnpUw,1702
7
+ testpick/git/interactive.py,sha256=PmIaWxOJwNbbIET9Qv7lkedGCUKepfdIO_MRoTjlCR0,3711
8
+ testpick/graph/__init__.py,sha256=xkV_gekG83ghOlPqSIaCfDbODDgenQQy7Vc0x5vntGU,199
9
+ testpick/graph/builder.py,sha256=HQ10NcxbMRfRn_cu7cVe3jhJfNjsQvQBUkkW3t5Vxtk,7878
10
+ testpick/graph/edge.py,sha256=UrEQEhRha25fAg52L4qsgTNixQ3IitAvj-Q8vfmcwss,301
11
+ testpick/graph/node.py,sha256=geha69c4i0uHv_iZYN7JdXNZNZ1-aV0s9vApNYe7DRY,375
12
+ testpick/parser/__init__.py,sha256=PdxY0L32mY_Rtp86Opbg82Mc5-fIcLZZOUiThVK67Nw,434
13
+ testpick/parser/base.py,sha256=iN03ee9_n_eAxZm73N2aghi0NYpnOAxxTSMNhmHHm2c,1010
14
+ testpick/parser/python.py,sha256=7xeIkcX9gkCRYOufVWmNT9N9FhV4U8MGcwcS6wRfpEA,8840
15
+ testpick/parser/registry.py,sha256=y2vJUQ9znhuu6IdQdGWx3dVqUuVPJ83Eksb8OMa-RVg,621
16
+ testpick/parser/typescript.py,sha256=qR5Tl80t-5LpP4QZSlj3_ZzyCe-tifW-phWrAgP-Cw0,8133
17
+ testpick/reporter/__init__.py,sha256=Lew5Dg3pzFw8RfXRiwav28Vf3Qg1EoK3sKeIHxTQo3M,225
18
+ testpick/reporter/github.py,sha256=KVMoyoZejfABOa_Wpw7oLspfjySnP6oDa9zCReAsk70,2052
19
+ testpick/reporter/json_reporter.py,sha256=fj3OG6xtr6LZfUp2nIfo5JZenm4M1ThIKgax66ShxMg,1565
20
+ testpick/reporter/terminal.py,sha256=9vZXY1yZzW0vRSyHFXmX88MHSByTFgDzthOyy_ZcEh0,3478
21
+ testpick/selector/__init__.py,sha256=H-q1Htokm30NsgcdcBqUdP8bysy65_v4CqSmWK1k8cI,160
22
+ testpick/selector/matcher.py,sha256=Kb923i9XcVZwUq4UymswRiCX5whB2HKLIs3d8Xwit6s,2038
23
+ testpick/selector/walker.py,sha256=9rals56AcZEIBWOHNSeKAjrJZMqHsmU8F0XuvYtmNAs,1394
24
+ testpick-0.2.0.dist-info/METADATA,sha256=E6I6Xq_l9FBdKKGSgw25J6MutIpsArcHW6NBUe5WeRY,9815
25
+ testpick-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
26
+ testpick-0.2.0.dist-info/entry_points.txt,sha256=2IbOPSnYjU9aUd5vlaZgvzeIUelZQcZ3TlgyFmWK3YU,47
27
+ testpick-0.2.0.dist-info/licenses/LICENSE,sha256=bUxTf7MNRUy_sQGHcUt0CLpDV301088prTjhVffHC1U,1063
28
+ testpick-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ testpick = testpick.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 krauzX
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.