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