testgap 0.1.0a0__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.
- testgap/__init__.py +8 -0
- testgap/__main__.py +4 -0
- testgap/cli.py +274 -0
- testgap/config/__init__.py +15 -0
- testgap/config/init_wizard.py +113 -0
- testgap/config/loader.py +54 -0
- testgap/config/schema.py +48 -0
- testgap/cost/__init__.py +3 -0
- testgap/cost/tracker.py +47 -0
- testgap/coverage/__init__.py +22 -0
- testgap/coverage/ast_grouping.py +135 -0
- testgap/coverage/diff_coverage.py +68 -0
- testgap/coverage/git_diff.py +114 -0
- testgap/coverage/runner.py +88 -0
- testgap/detect/__init__.py +11 -0
- testgap/detect/_toml.py +18 -0
- testgap/detect/layout_detect.py +130 -0
- testgap/detect/pytest_detect.py +107 -0
- testgap/detect/test_dir_detect.py +37 -0
- testgap/generator/__init__.py +17 -0
- testgap/generator/few_shot.py +68 -0
- testgap/generator/llm_client.py +102 -0
- testgap/generator/parser.py +75 -0
- testgap/generator/prompt.py +171 -0
- testgap/pipeline.py +510 -0
- testgap/validator/__init__.py +10 -0
- testgap/validator/result.py +43 -0
- testgap/validator/runner.py +141 -0
- testgap-0.1.0a0.dist-info/METADATA +143 -0
- testgap-0.1.0a0.dist-info/RECORD +33 -0
- testgap-0.1.0a0.dist-info/WHEEL +4 -0
- testgap-0.1.0a0.dist-info/entry_points.txt +2 -0
- testgap-0.1.0a0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from testgap.coverage.diff_coverage import UncoveredLine
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class UncoveredFunction:
|
|
10
|
+
file: Path
|
|
11
|
+
qualname: str # e.g. "ClassName.method" or "function_name"
|
|
12
|
+
start_line: int
|
|
13
|
+
end_line: int
|
|
14
|
+
source: str
|
|
15
|
+
uncovered_lines: list[int] = field(default_factory=list)
|
|
16
|
+
has_branch: bool = False
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def priority(self) -> tuple[int, int]:
|
|
20
|
+
"""Sort key: branches first, then larger functions first."""
|
|
21
|
+
return (0 if self.has_branch else 1, -(self.end_line - self.start_line))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def group_by_function(uncovered: list[UncoveredLine]) -> list[UncoveredFunction]:
|
|
25
|
+
"""Group uncovered lines into the enclosing function/method."""
|
|
26
|
+
by_file: dict[Path, list[int]] = {}
|
|
27
|
+
for u in uncovered:
|
|
28
|
+
by_file.setdefault(u.file, []).append(u.line)
|
|
29
|
+
|
|
30
|
+
results: list[UncoveredFunction] = []
|
|
31
|
+
for file, lines in by_file.items():
|
|
32
|
+
results.extend(_group_in_file(file, sorted(set(lines))))
|
|
33
|
+
|
|
34
|
+
results.sort(key=lambda f: f.priority)
|
|
35
|
+
return results
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _group_in_file(file: Path, lines: list[int]) -> list[UncoveredFunction]:
|
|
39
|
+
try:
|
|
40
|
+
source = file.read_text(encoding="utf-8", errors="replace")
|
|
41
|
+
except OSError:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
tree = ast.parse(source, filename=str(file))
|
|
46
|
+
except SyntaxError:
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
source_lines = source.splitlines()
|
|
50
|
+
functions = _collect_functions(tree)
|
|
51
|
+
if not functions:
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
by_function: dict[tuple[str, int, int], list[int]] = {}
|
|
55
|
+
for line in lines:
|
|
56
|
+
owner = _find_owning_function(functions, line)
|
|
57
|
+
if owner is None:
|
|
58
|
+
continue
|
|
59
|
+
key = (owner.qualname, owner.start, owner.end)
|
|
60
|
+
by_function.setdefault(key, []).append(line)
|
|
61
|
+
|
|
62
|
+
out: list[UncoveredFunction] = []
|
|
63
|
+
for (qualname, start, end), uncov in by_function.items():
|
|
64
|
+
body_text = "\n".join(source_lines[start - 1 : end])
|
|
65
|
+
has_branch = _function_has_branch(functions_index=functions, start=start)
|
|
66
|
+
out.append(
|
|
67
|
+
UncoveredFunction(
|
|
68
|
+
file=file,
|
|
69
|
+
qualname=qualname,
|
|
70
|
+
start_line=start,
|
|
71
|
+
end_line=end,
|
|
72
|
+
source=body_text,
|
|
73
|
+
uncovered_lines=sorted(uncov),
|
|
74
|
+
has_branch=has_branch,
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class _FunctionRange:
|
|
82
|
+
qualname: str
|
|
83
|
+
start: int
|
|
84
|
+
end: int
|
|
85
|
+
node: ast.AST
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _collect_functions(tree: ast.AST) -> list[_FunctionRange]:
|
|
89
|
+
ranges: list[_FunctionRange] = []
|
|
90
|
+
|
|
91
|
+
def visit(node: ast.AST, prefix: list[str]) -> None:
|
|
92
|
+
for child in ast.iter_child_nodes(node):
|
|
93
|
+
if isinstance(child, ast.ClassDef):
|
|
94
|
+
visit(child, [*prefix, child.name])
|
|
95
|
+
elif isinstance(child, ast.FunctionDef | ast.AsyncFunctionDef):
|
|
96
|
+
name = ".".join([*prefix, child.name]) if prefix else child.name
|
|
97
|
+
start = child.lineno
|
|
98
|
+
end = _last_line(child)
|
|
99
|
+
ranges.append(_FunctionRange(qualname=name, start=start, end=end, node=child))
|
|
100
|
+
visit(child, [*prefix, child.name])
|
|
101
|
+
else:
|
|
102
|
+
visit(child, prefix)
|
|
103
|
+
|
|
104
|
+
visit(tree, [])
|
|
105
|
+
return ranges
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _find_owning_function(functions: list[_FunctionRange], line: int) -> _FunctionRange | None:
|
|
109
|
+
candidates = [f for f in functions if f.start <= line <= f.end]
|
|
110
|
+
if not candidates:
|
|
111
|
+
return None
|
|
112
|
+
return min(candidates, key=lambda f: f.end - f.start)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _last_line(node: ast.AST) -> int:
|
|
116
|
+
end = getattr(node, "end_lineno", None)
|
|
117
|
+
if end is not None:
|
|
118
|
+
return end
|
|
119
|
+
max_line = getattr(node, "lineno", 0)
|
|
120
|
+
for child in ast.walk(node):
|
|
121
|
+
line = getattr(child, "end_lineno", None) or getattr(child, "lineno", 0)
|
|
122
|
+
if line > max_line:
|
|
123
|
+
max_line = line
|
|
124
|
+
return max_line
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _function_has_branch(*, functions_index: list[_FunctionRange], start: int) -> bool:
|
|
128
|
+
for func in functions_index:
|
|
129
|
+
if func.start != start:
|
|
130
|
+
continue
|
|
131
|
+
for node in ast.walk(func.node):
|
|
132
|
+
if isinstance(node, ast.If | ast.For | ast.While | ast.Try | ast.Match):
|
|
133
|
+
return True
|
|
134
|
+
return False
|
|
135
|
+
return False
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fnmatch
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from testgap.coverage.git_diff import FileLines
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class UncoveredLine:
|
|
10
|
+
file: Path
|
|
11
|
+
line: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DiffCoverageReport:
|
|
16
|
+
base_ref: str
|
|
17
|
+
head_ref: str
|
|
18
|
+
uncovered: list[UncoveredLine] = field(default_factory=list)
|
|
19
|
+
changed_total: int = 0
|
|
20
|
+
covered_total: int = 0
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def diff_coverage_pct(self) -> float:
|
|
24
|
+
if self.changed_total == 0:
|
|
25
|
+
return 100.0
|
|
26
|
+
return round((self.covered_total / self.changed_total) * 100, 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_diff_coverage(
|
|
30
|
+
*,
|
|
31
|
+
diff: list[FileLines],
|
|
32
|
+
executed: dict[Path, frozenset[int]],
|
|
33
|
+
base_ref: str,
|
|
34
|
+
head_ref: str = "HEAD",
|
|
35
|
+
exclude_patterns: list[str] | None = None,
|
|
36
|
+
project_root: Path,
|
|
37
|
+
) -> DiffCoverageReport:
|
|
38
|
+
"""Intersect git diff with coverage executed lines to find uncovered diff lines."""
|
|
39
|
+
exclude_patterns = exclude_patterns or []
|
|
40
|
+
report = DiffCoverageReport(base_ref=base_ref, head_ref=head_ref)
|
|
41
|
+
|
|
42
|
+
for file_lines in diff:
|
|
43
|
+
rel_path = _safe_relative(file_lines.path, project_root)
|
|
44
|
+
if _is_excluded(rel_path, exclude_patterns):
|
|
45
|
+
continue
|
|
46
|
+
if not file_lines.path.is_file() or file_lines.path.suffix != ".py":
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
executed_for_file = executed.get(file_lines.path.resolve(), frozenset())
|
|
50
|
+
for line in sorted(file_lines.lines):
|
|
51
|
+
report.changed_total += 1
|
|
52
|
+
if line in executed_for_file:
|
|
53
|
+
report.covered_total += 1
|
|
54
|
+
else:
|
|
55
|
+
report.uncovered.append(UncoveredLine(file=file_lines.path, line=line))
|
|
56
|
+
|
|
57
|
+
return report
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_relative(path: Path, root: Path) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
63
|
+
except ValueError:
|
|
64
|
+
return path.as_posix()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_excluded(rel_path: str, patterns: list[str]) -> bool:
|
|
68
|
+
return any(fnmatch.fnmatch(rel_path, pat) for pat in patterns)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitDiffError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class FileLines:
|
|
13
|
+
path: Path
|
|
14
|
+
lines: frozenset[int]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_HUNK_HEADER = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_base_ref(repo_root: Path, explicit: str | None = None) -> str:
|
|
21
|
+
"""Pick the base ref for the diff. Order: explicit > origin/HEAD > main > master."""
|
|
22
|
+
if explicit:
|
|
23
|
+
return explicit
|
|
24
|
+
|
|
25
|
+
candidates = ("origin/HEAD", "origin/main", "main", "origin/master", "master")
|
|
26
|
+
for ref in candidates:
|
|
27
|
+
if _ref_exists(repo_root, ref):
|
|
28
|
+
return ref
|
|
29
|
+
raise GitDiffError(
|
|
30
|
+
"Could not determine base ref. Specify one with --base or ensure "
|
|
31
|
+
"origin/HEAD / main / master exists."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def changed_lines(
|
|
36
|
+
repo_root: Path,
|
|
37
|
+
base: str,
|
|
38
|
+
head: str = "HEAD",
|
|
39
|
+
only_paths: list[Path] | None = None,
|
|
40
|
+
) -> list[FileLines]:
|
|
41
|
+
"""Return per-file added/modified line numbers between base and head."""
|
|
42
|
+
cmd = ["git", "diff", "--unified=0", "--no-color", f"{base}...{head}"]
|
|
43
|
+
if only_paths:
|
|
44
|
+
cmd.append("--")
|
|
45
|
+
cmd.extend(str(p) for p in only_paths)
|
|
46
|
+
|
|
47
|
+
result = _run_git(repo_root, cmd)
|
|
48
|
+
return _parse_diff(result.stdout, repo_root)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_diff(diff_text: str, repo_root: Path) -> list[FileLines]:
|
|
52
|
+
results: list[FileLines] = []
|
|
53
|
+
current_file: Path | None = None
|
|
54
|
+
current_lines: set[int] = set()
|
|
55
|
+
|
|
56
|
+
for line in diff_text.splitlines():
|
|
57
|
+
if line.startswith("diff --git "):
|
|
58
|
+
if current_file is not None:
|
|
59
|
+
results.append(FileLines(current_file, frozenset(current_lines)))
|
|
60
|
+
current_file = None
|
|
61
|
+
current_lines = set()
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if line.startswith("+++ "):
|
|
65
|
+
target = line[4:].strip()
|
|
66
|
+
if target == "/dev/null":
|
|
67
|
+
current_file = None
|
|
68
|
+
elif target.startswith("b/"):
|
|
69
|
+
current_file = repo_root / target[2:]
|
|
70
|
+
else:
|
|
71
|
+
current_file = repo_root / target
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
if current_file is None:
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
match = _HUNK_HEADER.match(line)
|
|
78
|
+
if match:
|
|
79
|
+
start = int(match.group(1))
|
|
80
|
+
count_str = match.group(2)
|
|
81
|
+
count = int(count_str) if count_str else 1
|
|
82
|
+
if count == 0:
|
|
83
|
+
continue
|
|
84
|
+
for n in range(start, start + count):
|
|
85
|
+
current_lines.add(n)
|
|
86
|
+
|
|
87
|
+
if current_file is not None:
|
|
88
|
+
results.append(FileLines(current_file, frozenset(current_lines)))
|
|
89
|
+
|
|
90
|
+
return [fl for fl in results if fl.lines]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ref_exists(repo_root: Path, ref: str) -> bool:
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["git", "rev-parse", "--verify", "--quiet", ref],
|
|
96
|
+
cwd=repo_root,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
)
|
|
100
|
+
return result.returncode == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _run_git(repo_root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
104
|
+
try:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
args, cwd=repo_root, capture_output=True, text=True, check=False
|
|
107
|
+
)
|
|
108
|
+
except FileNotFoundError as e:
|
|
109
|
+
raise GitDiffError("git executable not found on PATH") from e
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
raise GitDiffError(
|
|
112
|
+
f"git {' '.join(args[1:])} failed (exit {result.returncode}):\n{result.stderr.strip()}"
|
|
113
|
+
)
|
|
114
|
+
return result
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoverageError(Exception):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CoverageRunResult:
|
|
14
|
+
coverage_json_path: Path
|
|
15
|
+
executed_lines: dict[Path, frozenset[int]]
|
|
16
|
+
raw_pytest_exit_code: int
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def run_pytest_with_coverage(
|
|
20
|
+
project_root: Path,
|
|
21
|
+
source_paths: list[str],
|
|
22
|
+
extra_pytest_args: list[str] | None = None,
|
|
23
|
+
timeout_seconds: int = 300,
|
|
24
|
+
) -> CoverageRunResult:
|
|
25
|
+
"""Run pytest under coverage.py, return per-file executed line sets."""
|
|
26
|
+
output_dir = project_root / ".testgap"
|
|
27
|
+
output_dir.mkdir(exist_ok=True)
|
|
28
|
+
json_path = output_dir / "coverage.json"
|
|
29
|
+
if json_path.exists():
|
|
30
|
+
json_path.unlink()
|
|
31
|
+
|
|
32
|
+
source_args = []
|
|
33
|
+
for path in source_paths:
|
|
34
|
+
source_args.extend(["--cov", path.rstrip("/")])
|
|
35
|
+
|
|
36
|
+
cmd = [
|
|
37
|
+
sys.executable,
|
|
38
|
+
"-m",
|
|
39
|
+
"pytest",
|
|
40
|
+
*source_args,
|
|
41
|
+
"--cov-report",
|
|
42
|
+
f"json:{json_path}",
|
|
43
|
+
"-q",
|
|
44
|
+
"--no-header",
|
|
45
|
+
]
|
|
46
|
+
if extra_pytest_args:
|
|
47
|
+
cmd.extend(extra_pytest_args)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
cmd,
|
|
52
|
+
cwd=project_root,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=timeout_seconds,
|
|
56
|
+
check=False,
|
|
57
|
+
)
|
|
58
|
+
except subprocess.TimeoutExpired as e:
|
|
59
|
+
raise CoverageError(f"pytest timed out after {timeout_seconds}s") from e
|
|
60
|
+
except FileNotFoundError as e:
|
|
61
|
+
raise CoverageError("python executable not found on PATH") from e
|
|
62
|
+
|
|
63
|
+
if not json_path.is_file():
|
|
64
|
+
raise CoverageError(
|
|
65
|
+
f"coverage.json was not produced. pytest stderr:\n{result.stderr.strip()}"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
executed = _parse_coverage_json(json_path, project_root)
|
|
69
|
+
return CoverageRunResult(
|
|
70
|
+
coverage_json_path=json_path,
|
|
71
|
+
executed_lines=executed,
|
|
72
|
+
raw_pytest_exit_code=result.returncode,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parse_coverage_json(json_path: Path, project_root: Path) -> dict[Path, frozenset[int]]:
|
|
77
|
+
try:
|
|
78
|
+
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
79
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
80
|
+
raise CoverageError(f"failed to read {json_path}: {e}") from e
|
|
81
|
+
|
|
82
|
+
files = data.get("files", {})
|
|
83
|
+
out: dict[Path, frozenset[int]] = {}
|
|
84
|
+
for raw_path, info in files.items():
|
|
85
|
+
abs_path = (project_root / raw_path).resolve()
|
|
86
|
+
executed = info.get("executed_lines", []) or []
|
|
87
|
+
out[abs_path] = frozenset(int(n) for n in executed)
|
|
88
|
+
return out
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from testgap.detect.layout_detect import LayoutKind, detect_layout, detect_source_paths
|
|
2
|
+
from testgap.detect.pytest_detect import detect_pytest
|
|
3
|
+
from testgap.detect.test_dir_detect import detect_test_dirs
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"detect_pytest",
|
|
7
|
+
"detect_layout",
|
|
8
|
+
"detect_source_paths",
|
|
9
|
+
"detect_test_dirs",
|
|
10
|
+
"LayoutKind",
|
|
11
|
+
]
|
testgap/detect/_toml.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 11):
|
|
6
|
+
import tomllib
|
|
7
|
+
else:
|
|
8
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_toml(path: Path) -> dict[str, Any]:
|
|
12
|
+
if not path.is_file():
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
with path.open("rb") as f:
|
|
16
|
+
return tomllib.load(f)
|
|
17
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
18
|
+
return {}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from configparser import ConfigParser
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from testgap.detect._toml import load_toml
|
|
7
|
+
|
|
8
|
+
_EXCLUDE_DIRS = {
|
|
9
|
+
"tests", "test", "docs", "doc", "examples", "example",
|
|
10
|
+
".venv", "venv", "env", ".env", "build", "dist",
|
|
11
|
+
"node_modules", ".git", ".tox", ".mypy_cache", ".pytest_cache",
|
|
12
|
+
"__pycache__", ".ruff_cache",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LayoutKind(str, Enum):
|
|
17
|
+
SRC = "src"
|
|
18
|
+
FLAT = "flat"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class LayoutDetection:
|
|
24
|
+
kind: LayoutKind
|
|
25
|
+
candidates: list[Path]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def detect_layout(root: Path) -> LayoutDetection:
|
|
29
|
+
src_candidates = _detect_src_layout(root)
|
|
30
|
+
if src_candidates:
|
|
31
|
+
return LayoutDetection(kind=LayoutKind.SRC, candidates=src_candidates)
|
|
32
|
+
|
|
33
|
+
flat_candidates = _detect_flat_layout(root)
|
|
34
|
+
if flat_candidates:
|
|
35
|
+
return LayoutDetection(kind=LayoutKind.FLAT, candidates=flat_candidates)
|
|
36
|
+
|
|
37
|
+
return LayoutDetection(kind=LayoutKind.UNKNOWN, candidates=[])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def detect_source_paths(root: Path) -> list[str]:
|
|
41
|
+
"""Return source paths relative to root, ready for config.project.source_paths."""
|
|
42
|
+
detection = detect_layout(root)
|
|
43
|
+
if detection.kind == LayoutKind.SRC:
|
|
44
|
+
return ["src/"]
|
|
45
|
+
if detection.kind == LayoutKind.FLAT:
|
|
46
|
+
return sorted({f"{p.name}/" for p in detection.candidates})
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _detect_src_layout(root: Path) -> list[Path]:
|
|
51
|
+
candidates: list[Path] = []
|
|
52
|
+
|
|
53
|
+
src_dir = root / "src"
|
|
54
|
+
if src_dir.is_dir():
|
|
55
|
+
for child in src_dir.iterdir():
|
|
56
|
+
if child.is_dir() and (child / "__init__.py").is_file():
|
|
57
|
+
candidates.append(child)
|
|
58
|
+
|
|
59
|
+
pyproject = load_toml(root / "pyproject.toml")
|
|
60
|
+
if _toml_indicates_src(pyproject):
|
|
61
|
+
if src_dir.is_dir() and src_dir not in candidates:
|
|
62
|
+
candidates.append(src_dir)
|
|
63
|
+
|
|
64
|
+
if _setup_cfg_indicates_src(root / "setup.cfg") and src_dir.is_dir():
|
|
65
|
+
candidates.append(src_dir)
|
|
66
|
+
|
|
67
|
+
seen: set[Path] = set()
|
|
68
|
+
unique: list[Path] = []
|
|
69
|
+
for c in candidates:
|
|
70
|
+
if c not in seen:
|
|
71
|
+
seen.add(c)
|
|
72
|
+
unique.append(c)
|
|
73
|
+
return unique
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _detect_flat_layout(root: Path) -> list[Path]:
|
|
77
|
+
candidates: list[Path] = []
|
|
78
|
+
for child in root.iterdir():
|
|
79
|
+
if not child.is_dir():
|
|
80
|
+
continue
|
|
81
|
+
if child.name.startswith("."):
|
|
82
|
+
continue
|
|
83
|
+
if child.name in _EXCLUDE_DIRS:
|
|
84
|
+
continue
|
|
85
|
+
if (child / "__init__.py").is_file():
|
|
86
|
+
candidates.append(child)
|
|
87
|
+
return candidates
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _toml_indicates_src(pyproject: dict) -> bool:
|
|
91
|
+
if not pyproject:
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
tool = pyproject.get("tool", {})
|
|
95
|
+
|
|
96
|
+
setuptools = tool.get("setuptools", {})
|
|
97
|
+
if setuptools.get("package-dir", {}).get("", "") == "src":
|
|
98
|
+
return True
|
|
99
|
+
pkg_find = setuptools.get("packages", {}).get("find", {})
|
|
100
|
+
if pkg_find.get("where") == ["src"] or pkg_find.get("where") == "src":
|
|
101
|
+
return True
|
|
102
|
+
|
|
103
|
+
hatch = tool.get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {})
|
|
104
|
+
packages = hatch.get("packages", [])
|
|
105
|
+
if any(str(p).startswith("src/") for p in packages):
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
poetry_pkgs = tool.get("poetry", {}).get("packages", [])
|
|
109
|
+
if any(p.get("from") == "src" for p in poetry_pkgs if isinstance(p, dict)):
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _setup_cfg_indicates_src(setup_cfg: Path) -> bool:
|
|
116
|
+
if not setup_cfg.is_file():
|
|
117
|
+
return False
|
|
118
|
+
parser = ConfigParser()
|
|
119
|
+
try:
|
|
120
|
+
parser.read(setup_cfg, encoding="utf-8")
|
|
121
|
+
except (OSError, UnicodeDecodeError):
|
|
122
|
+
return False
|
|
123
|
+
if parser.has_option("options", "package_dir"):
|
|
124
|
+
raw = parser.get("options", "package_dir")
|
|
125
|
+
if "=src" in raw.replace(" ", ""):
|
|
126
|
+
return True
|
|
127
|
+
if parser.has_option("options.packages.find", "where"):
|
|
128
|
+
if "src" in parser.get("options.packages.find", "where"):
|
|
129
|
+
return True
|
|
130
|
+
return False
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from configparser import ConfigParser
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from testgap.detect._toml import load_toml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class PytestDetection:
|
|
10
|
+
detected: bool
|
|
11
|
+
signals: list[str] = field(default_factory=list)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_DEP_FILES = (
|
|
15
|
+
"requirements.txt",
|
|
16
|
+
"requirements-dev.txt",
|
|
17
|
+
"requirements-test.txt",
|
|
18
|
+
"Pipfile",
|
|
19
|
+
"poetry.lock",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_pytest(root: Path) -> PytestDetection:
|
|
24
|
+
signals: list[str] = []
|
|
25
|
+
|
|
26
|
+
pyproject = load_toml(root / "pyproject.toml")
|
|
27
|
+
if "tool" in pyproject and "pytest" in pyproject.get("tool", {}):
|
|
28
|
+
signals.append("pyproject.toml:[tool.pytest.ini_options]")
|
|
29
|
+
|
|
30
|
+
if (root / "pytest.ini").is_file():
|
|
31
|
+
signals.append("pytest.ini")
|
|
32
|
+
|
|
33
|
+
setup_cfg = root / "setup.cfg"
|
|
34
|
+
if setup_cfg.is_file():
|
|
35
|
+
parser = ConfigParser()
|
|
36
|
+
try:
|
|
37
|
+
parser.read(setup_cfg, encoding="utf-8")
|
|
38
|
+
if parser.has_section("tool:pytest"):
|
|
39
|
+
signals.append("setup.cfg:[tool:pytest]")
|
|
40
|
+
except (OSError, UnicodeDecodeError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
tox_ini = root / "tox.ini"
|
|
44
|
+
if tox_ini.is_file():
|
|
45
|
+
parser = ConfigParser()
|
|
46
|
+
try:
|
|
47
|
+
parser.read(tox_ini, encoding="utf-8")
|
|
48
|
+
if parser.has_section("pytest"):
|
|
49
|
+
signals.append("tox.ini:[pytest]")
|
|
50
|
+
except (OSError, UnicodeDecodeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
for conftest in (root / "conftest.py", root / "tests" / "conftest.py"):
|
|
54
|
+
if conftest.is_file():
|
|
55
|
+
signals.append(f"conftest.py at {conftest.relative_to(root)}")
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
if _has_pytest_in_dependencies(root, pyproject):
|
|
59
|
+
signals.append("pytest in dependencies")
|
|
60
|
+
|
|
61
|
+
if _has_test_files(root):
|
|
62
|
+
signals.append("test_*.py files present")
|
|
63
|
+
|
|
64
|
+
return PytestDetection(detected=bool(signals), signals=signals)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_pytest_in_dependencies(root: Path, pyproject: dict) -> bool:
|
|
68
|
+
project_deps = pyproject.get("project", {}).get("dependencies", []) or []
|
|
69
|
+
optional_deps = pyproject.get("project", {}).get("optional-dependencies", {}) or {}
|
|
70
|
+
poetry_deps = (
|
|
71
|
+
pyproject.get("tool", {}).get("poetry", {}).get("dependencies", {}) or {}
|
|
72
|
+
)
|
|
73
|
+
poetry_groups = pyproject.get("tool", {}).get("poetry", {}).get("group", {})
|
|
74
|
+
poetry_dev = poetry_groups.get("dev", {}).get("dependencies", {}) or {}
|
|
75
|
+
|
|
76
|
+
haystacks: list[str] = []
|
|
77
|
+
haystacks.extend(str(d) for d in project_deps)
|
|
78
|
+
for group_deps in optional_deps.values():
|
|
79
|
+
haystacks.extend(str(d) for d in group_deps)
|
|
80
|
+
haystacks.extend(poetry_deps.keys())
|
|
81
|
+
haystacks.extend(poetry_dev.keys())
|
|
82
|
+
|
|
83
|
+
if any("pytest" in h.lower() for h in haystacks):
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
for filename in _DEP_FILES:
|
|
87
|
+
path = root / filename
|
|
88
|
+
if path.is_file():
|
|
89
|
+
try:
|
|
90
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
91
|
+
except OSError:
|
|
92
|
+
continue
|
|
93
|
+
if "pytest" in content.lower():
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _has_test_files(root: Path) -> bool:
|
|
100
|
+
for tests_dir_name in ("tests", "test"):
|
|
101
|
+
tests_dir = root / tests_dir_name
|
|
102
|
+
if not tests_dir.is_dir():
|
|
103
|
+
continue
|
|
104
|
+
for pattern in ("test_*.py", "*_test.py"):
|
|
105
|
+
if any(tests_dir.rglob(pattern)):
|
|
106
|
+
return True
|
|
107
|
+
return False
|