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.
- testpick/__init__.py +10 -0
- testpick/__main__.py +3 -0
- testpick/cli.py +228 -0
- testpick/git/__init__.py +21 -0
- testpick/git/commit.py +28 -0
- testpick/git/diff.py +59 -0
- testpick/git/interactive.py +106 -0
- testpick/graph/__init__.py +7 -0
- testpick/graph/builder.py +201 -0
- testpick/graph/edge.py +13 -0
- testpick/graph/node.py +16 -0
- testpick/parser/__init__.py +18 -0
- testpick/parser/base.py +46 -0
- testpick/parser/python.py +225 -0
- testpick/parser/registry.py +24 -0
- testpick/parser/typescript.py +207 -0
- testpick/reporter/__init__.py +7 -0
- testpick/reporter/github.py +58 -0
- testpick/reporter/json_reporter.py +49 -0
- testpick/reporter/terminal.py +108 -0
- testpick/selector/__init__.py +6 -0
- testpick/selector/matcher.py +58 -0
- testpick/selector/walker.py +42 -0
- testpick-0.2.0.dist-info/METADATA +211 -0
- testpick-0.2.0.dist-info/RECORD +28 -0
- testpick-0.2.0.dist-info/WHEEL +4 -0
- testpick-0.2.0.dist-info/entry_points.txt +2 -0
- testpick-0.2.0.dist-info/licenses/LICENSE +21 -0
testpick/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""testpick - Select which tests to run based on code changes."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("testpick")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
testpick/__main__.py
ADDED
testpick/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""CLI interface for testpick."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from git.exc import GitError
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .git.commit import (
|
|
14
|
+
get_branch_diff,
|
|
15
|
+
get_commit_files,
|
|
16
|
+
get_commit_message,
|
|
17
|
+
get_commit_stats,
|
|
18
|
+
)
|
|
19
|
+
from .git.diff import get_changed_files, get_diff_stats, get_repo_root
|
|
20
|
+
from .git.interactive import InteractiveCommitPicker, is_interactive
|
|
21
|
+
from .graph import DependencyGraph
|
|
22
|
+
from .parser import LANGUAGES
|
|
23
|
+
from .reporter import GitHubReporter, JsonReporter, TerminalReporter
|
|
24
|
+
from .selector import DependencyWalker, TestMatcher
|
|
25
|
+
|
|
26
|
+
LANGUAGE_FILTERS = {
|
|
27
|
+
"python": {".py", ".pyi"},
|
|
28
|
+
"typescript": {".ts", ".tsx"},
|
|
29
|
+
"javascript": {".js", ".jsx", ".mjs", ".cjs"},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
LANGUAGE_ALIASES = {"py": "python", "ts": "typescript", "js": "javascript"}
|
|
33
|
+
|
|
34
|
+
EXCLUDE_DIRS = {
|
|
35
|
+
"__pycache__",
|
|
36
|
+
"node_modules",
|
|
37
|
+
".git",
|
|
38
|
+
"venv",
|
|
39
|
+
".venv",
|
|
40
|
+
"dist",
|
|
41
|
+
"build",
|
|
42
|
+
".mypy_cache",
|
|
43
|
+
".pytest_cache",
|
|
44
|
+
".ruff_cache",
|
|
45
|
+
".tox",
|
|
46
|
+
".nox",
|
|
47
|
+
"site-packages",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _language_extensions(languages: str | None) -> set[str]:
|
|
52
|
+
if not languages:
|
|
53
|
+
return set(LANGUAGES)
|
|
54
|
+
extensions: set[str] = set()
|
|
55
|
+
for name in languages.split(","):
|
|
56
|
+
key = name.strip().lower()
|
|
57
|
+
extensions |= LANGUAGE_FILTERS.get(LANGUAGE_ALIASES.get(key, key), set())
|
|
58
|
+
return extensions or set(LANGUAGES)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _discover_source_files(root: Path, languages: str | None) -> list[Path]:
|
|
62
|
+
extensions = _language_extensions(languages)
|
|
63
|
+
files: list[Path] = []
|
|
64
|
+
for path in root.rglob("*"):
|
|
65
|
+
if not path.is_file() or path.suffix.lower() not in extensions:
|
|
66
|
+
continue
|
|
67
|
+
if any(part in EXCLUDE_DIRS for part in path.parts):
|
|
68
|
+
continue
|
|
69
|
+
files.append(path)
|
|
70
|
+
return files
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _relative(path: Path, root: Path) -> Path:
|
|
74
|
+
try:
|
|
75
|
+
return path.relative_to(root)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _enable_utf8_output() -> None:
|
|
81
|
+
for stream in (sys.stdout, sys.stderr):
|
|
82
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
83
|
+
if reconfigure is None:
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
reconfigure(encoding="utf-8", errors="replace")
|
|
87
|
+
except (ValueError, OSError):
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _build_chains(
|
|
92
|
+
test_files: list[Path], parents: dict[Path, Path], changed_paths: set[Path]
|
|
93
|
+
) -> dict[Path, list[Path]]:
|
|
94
|
+
chains: dict[Path, list[Path]] = {}
|
|
95
|
+
for test_file in test_files:
|
|
96
|
+
if test_file in changed_paths:
|
|
97
|
+
chains[test_file] = []
|
|
98
|
+
continue
|
|
99
|
+
chain = [test_file]
|
|
100
|
+
seen = {test_file}
|
|
101
|
+
current = test_file
|
|
102
|
+
while current in parents:
|
|
103
|
+
current = parents[current]
|
|
104
|
+
if current in seen:
|
|
105
|
+
break
|
|
106
|
+
seen.add(current)
|
|
107
|
+
chain.append(current)
|
|
108
|
+
chains[test_file] = chain
|
|
109
|
+
return chains
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_commit(commit_arg: str | None) -> str | None:
|
|
113
|
+
if commit_arg is None:
|
|
114
|
+
return None
|
|
115
|
+
if commit_arg == "":
|
|
116
|
+
if not is_interactive():
|
|
117
|
+
click.echo(
|
|
118
|
+
"Error: '--commit' without a value requires an interactive terminal.",
|
|
119
|
+
err=True,
|
|
120
|
+
)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
selected = InteractiveCommitPicker().pick()
|
|
123
|
+
if not selected:
|
|
124
|
+
click.echo("No commit selected. Aborting.")
|
|
125
|
+
sys.exit(0)
|
|
126
|
+
return selected
|
|
127
|
+
return commit_arg
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _fetch_changes(
|
|
131
|
+
commit: str | None, base: str
|
|
132
|
+
) -> tuple[list[tuple[str, str]], dict]:
|
|
133
|
+
if commit is not None:
|
|
134
|
+
return get_commit_files(commit), get_commit_stats(commit)
|
|
135
|
+
if base == "HEAD":
|
|
136
|
+
return get_changed_files(base="HEAD"), get_diff_stats(base="HEAD")
|
|
137
|
+
return get_branch_diff(base=base), get_diff_stats(base=f"{base}...HEAD")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@click.command()
|
|
141
|
+
@click.option(
|
|
142
|
+
"--commit",
|
|
143
|
+
is_flag=False,
|
|
144
|
+
flag_value="",
|
|
145
|
+
default=None,
|
|
146
|
+
help="Analyze a specific commit. Run without a value to launch an interactive picker.",
|
|
147
|
+
)
|
|
148
|
+
@click.option(
|
|
149
|
+
"--base",
|
|
150
|
+
default="HEAD",
|
|
151
|
+
show_default=True,
|
|
152
|
+
help="Branch to compare against for committed changes",
|
|
153
|
+
)
|
|
154
|
+
@click.option(
|
|
155
|
+
"--format",
|
|
156
|
+
"output_format",
|
|
157
|
+
type=click.Choice(["terminal", "json", "github"]),
|
|
158
|
+
default="terminal",
|
|
159
|
+
show_default=True,
|
|
160
|
+
)
|
|
161
|
+
@click.option("--verbose", is_flag=True, help="Enable verbose output")
|
|
162
|
+
@click.option("--languages", help="Comma-separated language filter, e.g. python,typescript")
|
|
163
|
+
@click.version_option(version=__version__)
|
|
164
|
+
def main(commit, base, output_format, verbose, languages):
|
|
165
|
+
"""testpick - Select which tests to run based on code changes."""
|
|
166
|
+
start_time = time.time()
|
|
167
|
+
_enable_utf8_output()
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
root = get_repo_root()
|
|
171
|
+
|
|
172
|
+
commit = _resolve_commit(commit)
|
|
173
|
+
changed_files, stats = _fetch_changes(commit, base)
|
|
174
|
+
|
|
175
|
+
if verbose and commit:
|
|
176
|
+
message = get_commit_message(commit)
|
|
177
|
+
first_line = message.splitlines()[0] if message else "(no message)"
|
|
178
|
+
click.echo(f"Commit {commit[:10]}: {first_line}")
|
|
179
|
+
|
|
180
|
+
if not changed_files:
|
|
181
|
+
click.echo("No changed files found.")
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
if verbose:
|
|
185
|
+
click.echo(f"Found {len(changed_files)} changed files")
|
|
186
|
+
|
|
187
|
+
graph = DependencyGraph()
|
|
188
|
+
graph.build_from_files(_discover_source_files(root, languages))
|
|
189
|
+
|
|
190
|
+
if verbose:
|
|
191
|
+
click.echo(f"Graph built with {len(graph.nodes)} nodes and {len(graph.edges)} edges")
|
|
192
|
+
|
|
193
|
+
absolute_changes = [(status, str(root / path)) for status, path in changed_files]
|
|
194
|
+
affected, parents = DependencyWalker().walk_from_files(absolute_changes, graph)
|
|
195
|
+
|
|
196
|
+
matcher = TestMatcher()
|
|
197
|
+
test_files = matcher.match(affected, graph)
|
|
198
|
+
|
|
199
|
+
if verbose:
|
|
200
|
+
click.echo(f"Found {len(affected)} affected files")
|
|
201
|
+
click.echo(f"Selected {len(test_files)} test files")
|
|
202
|
+
|
|
203
|
+
changed_paths = {Path(path).resolve() for _, path in absolute_changes}
|
|
204
|
+
chains = _build_chains(test_files, parents, changed_paths)
|
|
205
|
+
|
|
206
|
+
test_files = [_relative(path, root) for path in test_files]
|
|
207
|
+
chains = {
|
|
208
|
+
_relative(name, root): [_relative(p, root) for p in chain]
|
|
209
|
+
for name, chain in chains.items()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if output_format == "json":
|
|
213
|
+
click.echo(JsonReporter().render(changed_files, test_files, chains, stats))
|
|
214
|
+
elif output_format == "github":
|
|
215
|
+
click.echo(GitHubReporter().render(changed_files, test_files, chains, stats))
|
|
216
|
+
else:
|
|
217
|
+
TerminalReporter().render(changed_files, test_files, chains, stats)
|
|
218
|
+
|
|
219
|
+
if verbose:
|
|
220
|
+
click.echo(f"\nCompleted in {time.time() - start_time:.2f}s")
|
|
221
|
+
|
|
222
|
+
except GitError as e:
|
|
223
|
+
click.echo(f"Error: {e}", err=True)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
main()
|
testpick/git/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .diff import get_changed_files, get_file_diff, get_diff_stats, get_repo_root
|
|
2
|
+
from .commit import (
|
|
3
|
+
get_commit_files,
|
|
4
|
+
get_commit_message,
|
|
5
|
+
get_commit_stats,
|
|
6
|
+
get_branch_diff,
|
|
7
|
+
)
|
|
8
|
+
from .interactive import InteractiveCommitPicker, is_interactive
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"get_changed_files",
|
|
12
|
+
"get_file_diff",
|
|
13
|
+
"get_diff_stats",
|
|
14
|
+
"get_repo_root",
|
|
15
|
+
"get_commit_files",
|
|
16
|
+
"get_commit_message",
|
|
17
|
+
"get_commit_stats",
|
|
18
|
+
"get_branch_diff",
|
|
19
|
+
"InteractiveCommitPicker",
|
|
20
|
+
"is_interactive",
|
|
21
|
+
]
|
testpick/git/commit.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Git commit analysis utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .diff import _get_repo, _parse_name_status, _parse_numstat
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_commit_files(commit_hash: str, cwd: Path | None = None) -> list[tuple[str, str]]:
|
|
9
|
+
repo = _get_repo(cwd)
|
|
10
|
+
output = repo.git.diff_tree("--no-commit-id", "-r", "--name-status", commit_hash)
|
|
11
|
+
if not output.strip():
|
|
12
|
+
output = repo.git.diff_tree("--root", "--no-commit-id", "-r", "--name-status", commit_hash)
|
|
13
|
+
return _parse_name_status(output)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_commit_message(commit_hash: str, cwd: Path | None = None) -> str:
|
|
17
|
+
repo = _get_repo(cwd)
|
|
18
|
+
return repo.git.log("-1", "--format=%B", commit_hash).strip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_commit_stats(commit_hash: str, cwd: Path | None = None) -> dict:
|
|
22
|
+
repo = _get_repo(cwd)
|
|
23
|
+
return _parse_numstat(repo.git.show("--numstat", "--format=", commit_hash))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_branch_diff(base: str = "main", cwd: Path | None = None) -> list[tuple[str, str]]:
|
|
27
|
+
repo = _get_repo(cwd)
|
|
28
|
+
return _parse_name_status(repo.git.diff("--name-status", f"{base}...HEAD"))
|
testpick/git/diff.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Git diff analysis utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from git import Repo
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_repo(cwd: Path | None = None) -> Repo:
|
|
8
|
+
return Repo(path=cwd, search_parent_directories=True)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_repo_root(cwd: Path | None = None) -> Path:
|
|
12
|
+
return Path(_get_repo(cwd).working_dir).resolve()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _parse_name_status(output: str) -> list[tuple[str, str]]:
|
|
16
|
+
files: list[tuple[str, str]] = []
|
|
17
|
+
for line in output.strip().splitlines():
|
|
18
|
+
if not line:
|
|
19
|
+
continue
|
|
20
|
+
parts = line.split("\t")
|
|
21
|
+
if len(parts) >= 2:
|
|
22
|
+
files.append((parts[0].rstrip("0123456789"), parts[-1]))
|
|
23
|
+
return files
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_numstat(output: str) -> dict:
|
|
27
|
+
insertions = 0
|
|
28
|
+
deletions = 0
|
|
29
|
+
files_changed = 0
|
|
30
|
+
for line in output.strip().splitlines():
|
|
31
|
+
if not line:
|
|
32
|
+
continue
|
|
33
|
+
parts = line.split("\t")
|
|
34
|
+
if len(parts) >= 3:
|
|
35
|
+
files_changed += 1
|
|
36
|
+
if parts[0] != "-":
|
|
37
|
+
insertions += int(parts[0])
|
|
38
|
+
if parts[1] != "-":
|
|
39
|
+
deletions += int(parts[1])
|
|
40
|
+
return {
|
|
41
|
+
"files_changed": files_changed,
|
|
42
|
+
"insertions": insertions,
|
|
43
|
+
"deletions": deletions,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_changed_files(base: str = "HEAD", cwd: Path | None = None) -> list[tuple[str, str]]:
|
|
48
|
+
repo = _get_repo(cwd)
|
|
49
|
+
return _parse_name_status(repo.git.diff("--name-status", base))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_file_diff(filepath: str, base: str = "HEAD", cwd: Path | None = None) -> str:
|
|
53
|
+
repo = _get_repo(cwd)
|
|
54
|
+
return repo.git.diff(base, "--", filepath)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_diff_stats(base: str = "HEAD", cwd: Path | None = None) -> dict:
|
|
58
|
+
repo = _get_repo(cwd)
|
|
59
|
+
return _parse_numstat(repo.git.diff("--numstat", base))
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import readchar
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .diff import _get_repo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_interactive() -> bool:
|
|
15
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InteractiveCommitPicker:
|
|
19
|
+
def __init__(self, limit: int = 50, cwd: Path | None = None) -> None:
|
|
20
|
+
self._repo = _get_repo(cwd)
|
|
21
|
+
self._limit = limit
|
|
22
|
+
self._console = Console()
|
|
23
|
+
self._selected = 0
|
|
24
|
+
self._commits: list[tuple[str, str, str, str]] = []
|
|
25
|
+
self._filter: str = ""
|
|
26
|
+
|
|
27
|
+
def _fetch_commits(self) -> None:
|
|
28
|
+
output = self._repo.git.log(f"-{self._limit}", "--format=%H%x00%ai%x00%an%x00%s")
|
|
29
|
+
for line in output.splitlines():
|
|
30
|
+
parts = line.split("\x00")
|
|
31
|
+
if len(parts) == 4:
|
|
32
|
+
self._commits.append(tuple(parts))
|
|
33
|
+
|
|
34
|
+
def _visible(self) -> list[tuple[str, str, str, str]]:
|
|
35
|
+
if not self._filter:
|
|
36
|
+
return self._commits
|
|
37
|
+
f = self._filter.lower()
|
|
38
|
+
return [c for c in self._commits if f in c[0].lower() or f in c[3].lower()]
|
|
39
|
+
|
|
40
|
+
def _render(self) -> None:
|
|
41
|
+
commits = self._visible()
|
|
42
|
+
self._console.print(Panel("[bold]testpick — Select a commit[/bold]", style="blue"))
|
|
43
|
+
|
|
44
|
+
tb = Table(show_header=True, header_style="bold cyan")
|
|
45
|
+
tb.add_column("#", width=5)
|
|
46
|
+
tb.add_column("Hash", width=10)
|
|
47
|
+
tb.add_column("Date", width=20)
|
|
48
|
+
tb.add_column("Author", width=18)
|
|
49
|
+
tb.add_column("Subject", overflow="fold")
|
|
50
|
+
|
|
51
|
+
for i, (h, d, a, s) in enumerate(commits):
|
|
52
|
+
marker = ">" if i == self._selected else " "
|
|
53
|
+
tb.add_row(
|
|
54
|
+
f"{marker} {i + 1}", h[:8], d[:19], a, s,
|
|
55
|
+
style="reverse" if i == self._selected else None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
self._console.print(tb)
|
|
59
|
+
|
|
60
|
+
total = len(commits)
|
|
61
|
+
pos = f"{self._selected + 1}/{total}" if total else "0/0"
|
|
62
|
+
filter_line = f" [filter: {self._filter}]" if self._filter else ""
|
|
63
|
+
self._console.print(f"\n{pos}{filter_line}")
|
|
64
|
+
self._console.print("[↑/↓] navigate [type] search [Enter] select [Esc] cancel")
|
|
65
|
+
|
|
66
|
+
def pick(self) -> str | None:
|
|
67
|
+
try:
|
|
68
|
+
self._fetch_commits()
|
|
69
|
+
except Exception:
|
|
70
|
+
self._console.print("[red]Could not read commit history.[/red]")
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
if not self._commits:
|
|
74
|
+
self._console.print("[yellow]No commits found.[/yellow]")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
while True:
|
|
78
|
+
self._console.clear()
|
|
79
|
+
self._render()
|
|
80
|
+
key = readchar.readkey()
|
|
81
|
+
|
|
82
|
+
if key == readchar.key.UP:
|
|
83
|
+
commits = self._visible()
|
|
84
|
+
if commits:
|
|
85
|
+
self._selected = (self._selected - 1) % len(commits)
|
|
86
|
+
elif key == readchar.key.DOWN:
|
|
87
|
+
commits = self._visible()
|
|
88
|
+
if commits:
|
|
89
|
+
self._selected = (self._selected + 1) % len(commits)
|
|
90
|
+
elif key == readchar.key.ENTER:
|
|
91
|
+
commits = self._visible()
|
|
92
|
+
return commits[self._selected][0] if commits else None
|
|
93
|
+
elif key == readchar.key.ESC:
|
|
94
|
+
if self._filter:
|
|
95
|
+
self._filter = ""
|
|
96
|
+
self._selected = 0
|
|
97
|
+
else:
|
|
98
|
+
return None
|
|
99
|
+
elif key in ("\x7f", "\x08"):
|
|
100
|
+
self._filter = self._filter[:-1]
|
|
101
|
+
self._selected = 0
|
|
102
|
+
elif key in ("\x03", "\x04"):
|
|
103
|
+
return None
|
|
104
|
+
elif len(key) == 1 and key.isprintable():
|
|
105
|
+
self._filter += key
|
|
106
|
+
self._selected = 0
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Dependency graph builder that parses files and resolves imports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..parser.base import ImportInfo
|
|
8
|
+
from ..parser.registry import LANGUAGES, get_parser
|
|
9
|
+
from .edge import ImportEdge
|
|
10
|
+
from .node import ModuleNode
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _preferred_extensions(filepath: Path) -> tuple[str, ...]:
|
|
14
|
+
if filepath.suffix.lower() in (".py", ".pyi"):
|
|
15
|
+
return (".py", ".pyi")
|
|
16
|
+
return tuple(ext for ext in LANGUAGES if ext not in (".py", ".pyi"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DependencyGraph:
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.nodes: dict[Path, ModuleNode] = {}
|
|
22
|
+
self.edges: dict[tuple[Path, Path], ImportEdge] = {}
|
|
23
|
+
|
|
24
|
+
def add_node(self, filepath: Path, node: ModuleNode) -> None:
|
|
25
|
+
self.nodes[filepath] = node
|
|
26
|
+
|
|
27
|
+
def add_edge(self, edge: ImportEdge) -> None:
|
|
28
|
+
key = (edge.source_file, edge.target_file)
|
|
29
|
+
self.edges[key] = edge
|
|
30
|
+
|
|
31
|
+
def build_from_files(self, filepaths: list[Path]) -> None:
|
|
32
|
+
for fp in filepaths:
|
|
33
|
+
self._parse_and_add(fp)
|
|
34
|
+
for fp, node in self.nodes.items():
|
|
35
|
+
for imp in node.imports:
|
|
36
|
+
targets = self.resolve_import(imp, fp)
|
|
37
|
+
for target in targets:
|
|
38
|
+
# Ensure target is resolved for consistent comparison
|
|
39
|
+
resolved_target = target.resolve()
|
|
40
|
+
if resolved_target in self.nodes:
|
|
41
|
+
edge = ImportEdge(
|
|
42
|
+
source_file=fp,
|
|
43
|
+
target_file=resolved_target,
|
|
44
|
+
imported_names=imp.names,
|
|
45
|
+
)
|
|
46
|
+
self.add_edge(edge)
|
|
47
|
+
|
|
48
|
+
def _parse_and_add(self, filepath: Path) -> None:
|
|
49
|
+
parser = get_parser(filepath)
|
|
50
|
+
if parser is None:
|
|
51
|
+
return
|
|
52
|
+
result = parser.parse(filepath)
|
|
53
|
+
exports = [s.name for s in result.symbols if s.is_exported]
|
|
54
|
+
# Normalize to resolved path for consistent comparisons
|
|
55
|
+
resolved = filepath.resolve()
|
|
56
|
+
node = ModuleNode(
|
|
57
|
+
filepath=resolved,
|
|
58
|
+
language=result.language,
|
|
59
|
+
exports=exports,
|
|
60
|
+
imports=result.imports,
|
|
61
|
+
)
|
|
62
|
+
self.add_node(resolved, node)
|
|
63
|
+
|
|
64
|
+
def resolve_import(self, import_info: ImportInfo, current_file: Path) -> list[Path]:
|
|
65
|
+
module = import_info.module
|
|
66
|
+
preferred = _preferred_extensions(current_file)
|
|
67
|
+
if module.startswith("."):
|
|
68
|
+
return self._resolve_relative(module, current_file, preferred)
|
|
69
|
+
return self._resolve_absolute(module, current_file, preferred)
|
|
70
|
+
|
|
71
|
+
def _resolve_relative(
|
|
72
|
+
self, module: str, current_file: Path, preferred: tuple[str, ...]
|
|
73
|
+
) -> list[Path]:
|
|
74
|
+
parent = current_file.parent
|
|
75
|
+
dots = 0
|
|
76
|
+
for ch in module:
|
|
77
|
+
if ch == ".":
|
|
78
|
+
dots += 1
|
|
79
|
+
else:
|
|
80
|
+
break
|
|
81
|
+
rest = module[dots:].lstrip("/")
|
|
82
|
+
for _ in range(dots - 1):
|
|
83
|
+
parent = parent.parent
|
|
84
|
+
if not rest:
|
|
85
|
+
init = (parent / "__init__.py").resolve()
|
|
86
|
+
return [init] if init.is_file() else []
|
|
87
|
+
return self._find_module_candidates(rest, parent, preferred)
|
|
88
|
+
|
|
89
|
+
def _resolve_absolute(
|
|
90
|
+
self, module: str, current_file: Path, preferred: tuple[str, ...]
|
|
91
|
+
) -> list[Path]:
|
|
92
|
+
# Strategy 1: Find src/ directory and resolve from there
|
|
93
|
+
src_root = self._find_src_root(current_file)
|
|
94
|
+
if src_root:
|
|
95
|
+
candidates = self._find_module_candidates(module, src_root, preferred)
|
|
96
|
+
if candidates:
|
|
97
|
+
return candidates
|
|
98
|
+
|
|
99
|
+
# Strategy 2: Find project root (where pyproject.toml or setup.py lives)
|
|
100
|
+
project_root = self._find_project_root(current_file)
|
|
101
|
+
if project_root:
|
|
102
|
+
# Try src/ layout: project_root/src/package/...
|
|
103
|
+
src_dir = project_root / "src"
|
|
104
|
+
if src_dir.is_dir():
|
|
105
|
+
candidates = self._find_module_candidates(module, src_dir, preferred)
|
|
106
|
+
if candidates:
|
|
107
|
+
return candidates
|
|
108
|
+
# Try flat layout: project_root/package/...
|
|
109
|
+
candidates = self._find_module_candidates(module, project_root, preferred)
|
|
110
|
+
if candidates:
|
|
111
|
+
return candidates
|
|
112
|
+
|
|
113
|
+
# Strategy 3: Relative to current file's directory
|
|
114
|
+
return self._find_module_candidates(module, current_file.parent, preferred)
|
|
115
|
+
|
|
116
|
+
def _find_src_root(self, start: Path) -> Path | None:
|
|
117
|
+
"""Walk up from start to find a src/ directory."""
|
|
118
|
+
current = start
|
|
119
|
+
while current != current.parent:
|
|
120
|
+
if current.name == "src" and current.is_dir():
|
|
121
|
+
return current
|
|
122
|
+
# Also check if current IS the src root's child
|
|
123
|
+
if (current.parent / "src").is_dir() and current.parent.name != "src":
|
|
124
|
+
return current.parent / "src"
|
|
125
|
+
current = current.parent
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def _find_project_root(self, start: Path) -> Path | None:
|
|
129
|
+
"""Walk up from start to find project root (has pyproject.toml or setup.py)."""
|
|
130
|
+
markers = ("pyproject.toml", "setup.py", "package.json", "tsconfig.json")
|
|
131
|
+
current = start
|
|
132
|
+
while current != current.parent:
|
|
133
|
+
if (current / ".git").exists() or any(
|
|
134
|
+
(current / marker).is_file() for marker in markers
|
|
135
|
+
):
|
|
136
|
+
return current
|
|
137
|
+
current = current.parent
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def _find_module_candidates(
|
|
141
|
+
self, module_path: str, base_dir: Path, preferred: tuple[str, ...] = ()
|
|
142
|
+
) -> list[Path]:
|
|
143
|
+
module_path = self._strip_source_extension(module_path)
|
|
144
|
+
if "/" in module_path:
|
|
145
|
+
bases = [base_dir.joinpath(*module_path.split("/"))]
|
|
146
|
+
else:
|
|
147
|
+
bases = [base_dir.joinpath(*module_path.split(".")), base_dir / module_path]
|
|
148
|
+
|
|
149
|
+
candidates = self._probe_bases(bases, preferred or tuple(LANGUAGES))
|
|
150
|
+
if not candidates and preferred:
|
|
151
|
+
candidates = self._probe_bases(bases, tuple(LANGUAGES))
|
|
152
|
+
return candidates
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def _probe_bases(bases: list[Path], extensions: tuple[str, ...]) -> list[Path]:
|
|
156
|
+
candidates: list[Path] = []
|
|
157
|
+
for base in bases:
|
|
158
|
+
for ext in extensions:
|
|
159
|
+
candidate = Path(str(base) + ext)
|
|
160
|
+
if candidate.is_file():
|
|
161
|
+
candidates.append(candidate.resolve())
|
|
162
|
+
for stem in ("__init__", "index"):
|
|
163
|
+
for ext in extensions:
|
|
164
|
+
candidate = base / f"{stem}{ext}"
|
|
165
|
+
if candidate.is_file():
|
|
166
|
+
candidates.append(candidate.resolve())
|
|
167
|
+
return list(dict.fromkeys(candidates))
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _strip_source_extension(module_path: str) -> str:
|
|
171
|
+
for ext in LANGUAGES:
|
|
172
|
+
if module_path.endswith(ext) and len(module_path) > len(ext):
|
|
173
|
+
return module_path[: -len(ext)]
|
|
174
|
+
return module_path
|
|
175
|
+
|
|
176
|
+
def get_dependents(self, filepath: Path) -> set[Path]:
|
|
177
|
+
dependents = set()
|
|
178
|
+
for (source, _target), edge in self.edges.items():
|
|
179
|
+
if edge.target_file == filepath:
|
|
180
|
+
dependents.add(source)
|
|
181
|
+
return dependents
|
|
182
|
+
|
|
183
|
+
def is_test_file(self, filepath: Path) -> bool:
|
|
184
|
+
if filepath.suffix.lower() not in LANGUAGES:
|
|
185
|
+
return False
|
|
186
|
+
name = filepath.name
|
|
187
|
+
parts = filepath.parts
|
|
188
|
+
if "tests" in parts:
|
|
189
|
+
return True
|
|
190
|
+
stem = filepath.stem
|
|
191
|
+
if stem.startswith("test_") or stem.endswith("_test"):
|
|
192
|
+
return True
|
|
193
|
+
if name.endswith(
|
|
194
|
+
(".test.ts", ".test.tsx", ".test.js", ".test.jsx", ".test.mjs", ".test.cjs")
|
|
195
|
+
):
|
|
196
|
+
return True
|
|
197
|
+
if name.endswith(
|
|
198
|
+
(".spec.ts", ".spec.tsx", ".spec.js", ".spec.jsx", ".spec.mjs", ".spec.cjs")
|
|
199
|
+
):
|
|
200
|
+
return True
|
|
201
|
+
return False
|
testpick/graph/edge.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Import edge representing a dependency between two files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ImportEdge:
|
|
11
|
+
source_file: Path
|
|
12
|
+
target_file: Path
|
|
13
|
+
imported_names: list[str] = field(default_factory=list)
|