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 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
@@ -0,0 +1,3 @@
1
+ from testpick.cli import main
2
+
3
+ main()
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()
@@ -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,7 @@
1
+ """Import dependency graph for testpick."""
2
+
3
+ from .builder import DependencyGraph
4
+ from .edge import ImportEdge
5
+ from .node import ModuleNode
6
+
7
+ __all__ = ["DependencyGraph", "ImportEdge", "ModuleNode"]
@@ -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)