gwc-pybundle 1.4.5__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.

Potentially problematic release.


This version of gwc-pybundle might be problematic. Click here for more details.

Files changed (55) hide show
  1. gwc_pybundle-1.4.5.dist-info/METADATA +876 -0
  2. gwc_pybundle-1.4.5.dist-info/RECORD +55 -0
  3. gwc_pybundle-1.4.5.dist-info/WHEEL +5 -0
  4. gwc_pybundle-1.4.5.dist-info/entry_points.txt +2 -0
  5. gwc_pybundle-1.4.5.dist-info/licenses/LICENSE.md +25 -0
  6. gwc_pybundle-1.4.5.dist-info/top_level.txt +1 -0
  7. pybundle/__init__.py +0 -0
  8. pybundle/__main__.py +4 -0
  9. pybundle/cli.py +365 -0
  10. pybundle/context.py +362 -0
  11. pybundle/doctor.py +148 -0
  12. pybundle/filters.py +178 -0
  13. pybundle/manifest.py +77 -0
  14. pybundle/packaging.py +45 -0
  15. pybundle/policy.py +132 -0
  16. pybundle/profiles.py +340 -0
  17. pybundle/roadmap_model.py +42 -0
  18. pybundle/roadmap_scan.py +295 -0
  19. pybundle/root_detect.py +14 -0
  20. pybundle/runner.py +163 -0
  21. pybundle/steps/__init__.py +26 -0
  22. pybundle/steps/bandit.py +72 -0
  23. pybundle/steps/base.py +20 -0
  24. pybundle/steps/compileall.py +76 -0
  25. pybundle/steps/context_expand.py +272 -0
  26. pybundle/steps/copy_pack.py +293 -0
  27. pybundle/steps/coverage.py +101 -0
  28. pybundle/steps/cprofile_step.py +155 -0
  29. pybundle/steps/dependency_sizes.py +120 -0
  30. pybundle/steps/duplication.py +94 -0
  31. pybundle/steps/error_refs.py +204 -0
  32. pybundle/steps/handoff_md.py +167 -0
  33. pybundle/steps/import_time.py +165 -0
  34. pybundle/steps/interrogate.py +84 -0
  35. pybundle/steps/license_scan.py +96 -0
  36. pybundle/steps/line_profiler.py +108 -0
  37. pybundle/steps/memory_profile.py +173 -0
  38. pybundle/steps/mutation_testing.py +136 -0
  39. pybundle/steps/mypy.py +60 -0
  40. pybundle/steps/pip_audit.py +45 -0
  41. pybundle/steps/pipdeptree.py +61 -0
  42. pybundle/steps/pylance.py +562 -0
  43. pybundle/steps/pytest.py +66 -0
  44. pybundle/steps/radon.py +121 -0
  45. pybundle/steps/repro_md.py +161 -0
  46. pybundle/steps/rg_scans.py +78 -0
  47. pybundle/steps/roadmap.py +153 -0
  48. pybundle/steps/ruff.py +111 -0
  49. pybundle/steps/shell.py +74 -0
  50. pybundle/steps/slow_tests.py +170 -0
  51. pybundle/steps/test_flakiness.py +172 -0
  52. pybundle/steps/tree.py +116 -0
  53. pybundle/steps/unused_deps.py +112 -0
  54. pybundle/steps/vulture.py +83 -0
  55. pybundle/tools.py +63 -0
@@ -0,0 +1,272 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .base import StepResult
9
+ from ..context import BundleContext
10
+
11
+
12
+ def _read_lines(p: Path) -> list[str]:
13
+ if not p.is_file():
14
+ return []
15
+ return [
16
+ ln.strip()
17
+ for ln in p.read_text(encoding="utf-8", errors="replace").splitlines()
18
+ if ln.strip()
19
+ ]
20
+
21
+
22
+ def _is_under(root: Path, p: Path) -> bool:
23
+ try:
24
+ p.resolve().relative_to(root.resolve())
25
+ return True
26
+ except Exception:
27
+ return False
28
+
29
+
30
+ def _copy_file(src: Path, dst: Path) -> bool:
31
+ try:
32
+ dst.parent.mkdir(parents=True, exist_ok=True)
33
+ dst.write_bytes(src.read_bytes())
34
+ return True
35
+ except Exception:
36
+ return False
37
+
38
+
39
+ def _find_repo_candidates(root: Path) -> list[Path]:
40
+ cands = [root]
41
+ if (root / "src").is_dir():
42
+ cands.append(root / "src")
43
+ return cands
44
+
45
+
46
+ def _module_to_path(roots: list[Path], module: str) -> Path | None:
47
+ """
48
+ Resolve an absolute module like 'pkg.sub' to a file inside repo roots:
49
+ - <root>/pkg/sub.py
50
+ - <root>/pkg/sub/__init__.py
51
+ """
52
+ parts = module.split(".")
53
+ for r in roots:
54
+ f1 = r.joinpath(*parts).with_suffix(".py")
55
+ if f1.is_file():
56
+ return f1
57
+ f2 = r.joinpath(*parts) / "__init__.py"
58
+ if f2.is_file():
59
+ return f2
60
+ return None
61
+
62
+
63
+ def _relative_module_to_path(
64
+ roots: list[Path], base_file: Path, module: str | None, level: int
65
+ ) -> Path | None:
66
+ """
67
+ Resolve relative imports like:
68
+ from . import x (level=1, module=None)
69
+ from ..foo import y (level=2, module="foo")
70
+ """
71
+ # Find the package directory for base_file
72
+ base_dir = base_file.parent
73
+
74
+ # Move up `level` package levels
75
+ rel_dir = base_dir
76
+ for _ in range(level):
77
+ rel_dir = rel_dir.parent
78
+
79
+ if module:
80
+ target = rel_dir.joinpath(*module.split("."))
81
+ else:
82
+ target = rel_dir
83
+
84
+ # Try module as file or package
85
+ f1 = target.with_suffix(".py")
86
+ if f1.is_file():
87
+ return f1
88
+ f2 = target / "__init__.py"
89
+ if f2.is_file():
90
+ return f2
91
+
92
+ # If relative resolution fails, try absolute resolution as fallback
93
+ if module:
94
+ return _module_to_path(roots, module)
95
+ return None
96
+
97
+
98
+ def _extract_import_modules(py_file: Path) -> set[tuple[str | None, int]]:
99
+ """
100
+ Returns a set of (module, level) pairs:
101
+ - absolute imports: (module, 0)
102
+ - relative imports: (module, level>=1)
103
+ module can be None for `from . import x` style.
104
+ """
105
+ out: set[tuple[str | None, int]] = set()
106
+ try:
107
+ src = py_file.read_text(encoding="utf-8", errors="replace")
108
+ tree = ast.parse(src)
109
+ except Exception:
110
+ return out
111
+
112
+ for node in ast.walk(tree):
113
+ if isinstance(node, ast.Import):
114
+ for alias in node.names:
115
+ if alias.name:
116
+ out.add((alias.name, 0))
117
+ elif isinstance(node, ast.ImportFrom):
118
+ # node.module can be None (from . import x)
119
+ lvl = int(getattr(node, "level", 0) or 0)
120
+ mod = node.module
121
+ if mod:
122
+ out.add((mod, lvl))
123
+ else:
124
+ # still useful; we can resolve to the package itself for context
125
+ out.add((None, lvl))
126
+ return out
127
+
128
+
129
+ def _add_package_chain(files: set[Path], root: Path, py: Path) -> None:
130
+ """
131
+ Add __init__.py from file's dir up to repo root.
132
+ """
133
+ cur = py.parent
134
+ root_res = root.resolve()
135
+ while True:
136
+ initp = cur / "__init__.py"
137
+ if initp.is_file():
138
+ files.add(initp)
139
+ if cur.resolve() == root_res:
140
+ break
141
+ if not _is_under(root, cur):
142
+ break
143
+ cur = cur.parent
144
+
145
+
146
+ def _add_conftest_chain(files: set[Path], root: Path, py: Path) -> None:
147
+ """
148
+ Add conftest.py from file's dir up to repo root (pytest glue).
149
+ """
150
+ cur = py.parent
151
+ root_res = root.resolve()
152
+ while True:
153
+ cp = cur / "conftest.py"
154
+ if cp.is_file():
155
+ files.add(cp)
156
+ if cur.resolve() == root_res:
157
+ break
158
+ if not _is_under(root, cur):
159
+ break
160
+ cur = cur.parent
161
+
162
+
163
+ @dataclass
164
+ class ErrorContextExpandStep:
165
+ name: str = "expand error context"
166
+ depth: int = 2
167
+ max_files: int = 600
168
+ # reads this list produced by step 8
169
+ source_list_file: str = "error_files_from_logs.txt"
170
+
171
+ def run(self, ctx: BundleContext) -> StepResult:
172
+ start = time.time()
173
+ roots = _find_repo_candidates(ctx.root)
174
+
175
+ list_path = ctx.workdir / self.source_list_file
176
+ rels = _read_lines(list_path)
177
+
178
+ dest_root = ctx.srcdir / "_error_context"
179
+ dest_root.mkdir(parents=True, exist_ok=True)
180
+
181
+ to_copy: set[Path] = set()
182
+ queue: list[tuple[Path, int]] = []
183
+
184
+ # Seed with referenced python files only
185
+ for rel_str in rels:
186
+ p = (ctx.root / rel_str).resolve()
187
+ if p.is_file() and p.suffix == ".py" and _is_under(ctx.root, p):
188
+ queue.append((p, 0))
189
+ to_copy.add(p)
190
+
191
+ visited: set[Path] = set()
192
+
193
+ while queue and len(to_copy) < self.max_files:
194
+ py_file, d = queue.pop(0)
195
+ if py_file in visited:
196
+ continue
197
+ visited.add(py_file)
198
+
199
+ # add pytest + package glue around this file
200
+ _add_package_chain(to_copy, ctx.root, py_file)
201
+ _add_conftest_chain(to_copy, ctx.root, py_file)
202
+
203
+ if d >= self.depth:
204
+ continue
205
+
206
+ # parse imports and resolve to repo files
207
+ for mod, level in _extract_import_modules(py_file):
208
+ target: Path | None
209
+ if level and level > 0:
210
+ target = _relative_module_to_path(roots, py_file, mod, level)
211
+ else:
212
+ if not mod:
213
+ continue
214
+ target = _module_to_path(roots, mod)
215
+
216
+ if target and target.is_file() and _is_under(ctx.root, target):
217
+ if target.suffix == ".py":
218
+ if target not in to_copy:
219
+ to_copy.add(target)
220
+ queue.append((target, d + 1))
221
+
222
+ if len(to_copy) >= self.max_files:
223
+ break
224
+
225
+ # Always include top-level config files if present (small but high value)
226
+ for cfg in [
227
+ "pyproject.toml",
228
+ "mypy.ini",
229
+ "ruff.toml",
230
+ ".ruff.toml",
231
+ "pytest.ini",
232
+ "setup.cfg",
233
+ "requirements.txt",
234
+ ]:
235
+ p = ctx.root / cfg
236
+ if p.is_file():
237
+ to_copy.add(p)
238
+
239
+ copied = 0
240
+ for p in sorted(to_copy):
241
+ if copied >= self.max_files:
242
+ break
243
+ # copy under src/_error_context/<repo-relative-path>
244
+ try:
245
+ rel_path = p.resolve().relative_to(ctx.root.resolve())
246
+ except Exception:
247
+ continue
248
+ dst = dest_root / rel_path
249
+ if _copy_file(p, dst):
250
+ copied += 1
251
+
252
+ report = ctx.metadir / "61_error_context_report.txt"
253
+ report.write_text(
254
+ "\n".join(
255
+ [
256
+ f"seed_files={len([r for r in rels if r.endswith('.py')])}",
257
+ f"depth={self.depth}",
258
+ f"max_files={self.max_files}",
259
+ f"resolved_total={len(to_copy)}",
260
+ f"copied={copied}",
261
+ "dest=src/_error_context",
262
+ ]
263
+ )
264
+ + "\n",
265
+ encoding="utf-8",
266
+ )
267
+
268
+ dur = int(time.time() - start)
269
+ note = f"resolved={len(to_copy)} copied={copied}"
270
+ if copied >= self.max_files:
271
+ note += " (HIT MAX)"
272
+ return StepResult(self.name, "PASS", dur, note)
@@ -0,0 +1,293 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import time
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .base import StepResult
10
+ from pybundle.context import BundleContext
11
+ from pybundle.policy import AIContextPolicy, PathFilter
12
+
13
+
14
+ def _is_venv_root(p: Path) -> bool:
15
+ if not p.is_dir():
16
+ return False
17
+
18
+ # Strong marker: standard venv metadata
19
+ if (p / "pyvenv.cfg").is_file():
20
+ return True
21
+
22
+ # Typical venv executables (Linux/macOS)
23
+ if (p / "bin").is_dir():
24
+ # venv/virtualenv always has python here
25
+ if (p / "bin" / "python").exists() or (p / "bin" / "python3").exists():
26
+ # activation script is common but not guaranteed; still strong signal
27
+ if (p / "bin" / "activate").is_file():
28
+ return True
29
+ # also accept presence of site-packages under lib
30
+ if any((p / "lib").glob("python*/site-packages")):
31
+ return True
32
+
33
+ # Windows venv layout
34
+ if (p / "Scripts").is_dir():
35
+ if (p / "Scripts" / "python.exe").is_file() or (
36
+ p / "Scripts" / "python"
37
+ ).exists():
38
+ if (p / "Scripts" / "activate").is_file():
39
+ return True
40
+ if (p / "Lib" / "site-packages").is_dir():
41
+ return True
42
+
43
+ # Some virtualenvs keep a .Python marker (macOS, older tooling)
44
+ if (p / ".Python").exists():
45
+ return True
46
+
47
+ return False
48
+
49
+
50
+ def _is_under_venv(root: Path, rel_path: Path) -> bool:
51
+ # walk ancestors: a/b/c.py -> check a, a/b, a/b/c
52
+ cur = root
53
+ for part in rel_path.parts:
54
+ cur = cur / part
55
+ if _is_venv_root(cur):
56
+ return True
57
+ return False
58
+
59
+
60
+ def _safe_copy_file(src: Path, dst: Path) -> None:
61
+ dst.parent.mkdir(parents=True, exist_ok=True)
62
+ # preserve mode + timestamps where possible
63
+ shutil.copy2(src, dst)
64
+
65
+
66
+ def _copy_tree_filtered(
67
+ root: Path,
68
+ src_dir: Path,
69
+ dst_dir: Path,
70
+ filt: "PathFilter",
71
+ ) -> tuple[int, int, int]:
72
+ """
73
+ Copy directory tree while pruning excluded directories and skipping excluded files.
74
+
75
+ Returns: (files_copied, dirs_pruned, files_excluded)
76
+ """
77
+ seen_files = 0
78
+ files_copied = 0
79
+ pruned_dirs = 0
80
+
81
+ for dirpath, dirnames, filenames in os.walk(src_dir):
82
+ dp = Path(dirpath)
83
+ rel_dir = dp.relative_to(src_dir)
84
+
85
+ # prune dirs in-place so os.walk doesn't descend into them
86
+ kept: list[str] = []
87
+ for d in dirnames:
88
+ if filt.should_prune_dir(dp, d):
89
+ pruned_dirs += 1
90
+ continue
91
+ kept.append(d)
92
+ dirnames[:] = kept
93
+
94
+ for fn in filenames:
95
+ seen_files += 1
96
+ sp = dp / fn
97
+ rel_file = rel_dir / fn
98
+
99
+ # single source of truth: PathFilter handles excluded dirs, patterns, and extensions
100
+ if not filt.should_include_file(root, sp):
101
+ continue
102
+
103
+ tp = dst_dir / rel_file
104
+ try:
105
+ _safe_copy_file(sp, tp)
106
+ except OSError:
107
+ continue
108
+
109
+ files_copied += 1
110
+
111
+ files_excluded = max(0, seen_files - files_copied)
112
+ return files_copied, pruned_dirs, files_excluded
113
+
114
+
115
+ def _guess_package_dirs(root: Path, filt: "PathFilter") -> list[Path]:
116
+ out: list[Path] = []
117
+ for p in sorted(root.iterdir()):
118
+ if not p.is_dir():
119
+ continue
120
+ if p.name.startswith("."):
121
+ continue
122
+ if filt.should_prune_dir(root, p.name):
123
+ continue
124
+ if (p / "__init__.py").is_file():
125
+ out.append(p)
126
+ return out
127
+
128
+
129
+ @dataclass
130
+ class CuratedCopyStep:
131
+ name: str = "copy curated source pack"
132
+ include_files: list[str] | None = None
133
+ include_dirs: list[str] | None = None
134
+ include_globs: list[str] | None = None
135
+ exclude_dirs: set[str] | None = None
136
+ max_files: int = 20000
137
+ policy: AIContextPolicy | None = None
138
+
139
+ def run(self, ctx: BundleContext) -> StepResult:
140
+ start = time.time()
141
+ dst_root = ctx.srcdir # bundle/src
142
+ dst_root.mkdir(parents=True, exist_ok=True)
143
+
144
+ policy = self.policy or AIContextPolicy()
145
+
146
+ exclude = (
147
+ set(self.exclude_dirs) if self.exclude_dirs else set(policy.exclude_dirs)
148
+ )
149
+ exclude_patterns = set(policy.exclude_patterns)
150
+ filt = PathFilter(
151
+ exclude_dirs=exclude,
152
+ exclude_patterns=exclude_patterns,
153
+ exclude_file_exts=set(policy.exclude_file_exts),
154
+ )
155
+ include_files = self.include_files or list(policy.include_files)
156
+ include_dirs = self.include_dirs or list(policy.include_dirs)
157
+ include_globs = self.include_globs or list(policy.include_globs)
158
+
159
+ copied = 0
160
+ pruned = 0
161
+ excluded_total = 0
162
+
163
+ # 1) Include well-known top-level files if present
164
+ for rel_file in include_files:
165
+ if copied >= self.max_files:
166
+ break
167
+
168
+ sp = ctx.root / rel_file
169
+ if not sp.is_file():
170
+ continue
171
+ if not filt.should_include_file(ctx.root, sp):
172
+ continue
173
+
174
+ try:
175
+ _safe_copy_file(sp, dst_root / rel_file)
176
+ copied += 1
177
+ if copied >= self.max_files:
178
+ break
179
+ except OSError:
180
+ pass
181
+
182
+ # 2) Include common top-level dirs (src/tests/tools)
183
+ for rel_dir in include_dirs:
184
+ sp = ctx.root / rel_dir
185
+ if not sp.is_dir():
186
+ continue
187
+
188
+ # policy prune (exact + patterns + venv detection inside PathFilter)
189
+ if filt.should_prune_dir(ctx.root, rel_dir):
190
+ pruned += 1
191
+ continue
192
+
193
+ # extra-strong venv detection for oddly-named envs
194
+ if _is_venv_root(sp):
195
+ pruned += 1
196
+ continue
197
+
198
+ files_copied, dirs_pruned, files_excluded = _copy_tree_filtered(
199
+ ctx.root, sp, dst_root / rel_dir, filt
200
+ )
201
+ copied += files_copied
202
+ pruned += dirs_pruned
203
+ excluded_total += files_excluded
204
+
205
+ if copied >= self.max_files:
206
+ break
207
+
208
+ # 3) Include detected package dirs at root (if not already copied)
209
+ if copied < self.max_files:
210
+ for pkg_dir in _guess_package_dirs(ctx.root, filt):
211
+ rel_pkg_name = pkg_dir.name
212
+ if (dst_root / rel_pkg_name).exists():
213
+ continue
214
+ files_copied, dirs_pruned, files_excluded = _copy_tree_filtered(
215
+ ctx.root, pkg_dir, dst_root / rel_pkg_name, filt
216
+ )
217
+ copied += files_copied
218
+ pruned += dirs_pruned
219
+ excluded_total += files_excluded
220
+ if copied >= self.max_files:
221
+ break
222
+
223
+ # 4) Optional globs (best-effort; avoid deep explosion by pruning excluded dirs)
224
+ # We’ll apply globs but skip anything under excluded dirs.
225
+ if copied < self.max_files:
226
+ for g in include_globs:
227
+ for sp in ctx.root.glob(g):
228
+ try:
229
+ if not sp.exists():
230
+ continue
231
+
232
+ rel_path = sp.relative_to(ctx.root)
233
+
234
+ if _is_under_venv(ctx.root, rel_path):
235
+ pruned += 1
236
+ continue
237
+
238
+ dst = dst_root / rel_path
239
+ if dst.exists():
240
+ continue
241
+
242
+ if sp.is_file():
243
+ if not filt.should_include_file(ctx.root, sp):
244
+ continue
245
+ _safe_copy_file(sp, dst)
246
+ copied += 1
247
+
248
+ elif sp.is_dir():
249
+ # prune dir itself before copying
250
+ parent = (
251
+ ctx.root
252
+ if rel_path.parent == Path(".")
253
+ else (ctx.root / rel_path.parent)
254
+ )
255
+ if filt.should_prune_dir(parent, rel_path.name):
256
+ pruned += 1
257
+ continue
258
+ if _is_venv_root(sp):
259
+ pruned += 1
260
+ continue
261
+
262
+ files_copied, dirs_pruned, files_excluded = (
263
+ _copy_tree_filtered(
264
+ ctx.root, sp, dst_root / rel_path, filt
265
+ )
266
+ )
267
+ copied += files_copied
268
+ pruned += dirs_pruned
269
+ excluded_total += files_excluded
270
+
271
+ if copied >= self.max_files:
272
+ break
273
+ except Exception:
274
+ continue
275
+ if copied >= self.max_files:
276
+ break
277
+
278
+ # write a short manifest for sanity
279
+ manifest = ctx.workdir / "meta" / "50_copy_manifest.txt"
280
+ manifest.parent.mkdir(parents=True, exist_ok=True)
281
+ manifest.write_text(
282
+ f"copied_files={copied}\n"
283
+ f"excluded_files={excluded_total}\n"
284
+ f"pruned_dirs={pruned}\n"
285
+ f"max_files={self.max_files}\n",
286
+ encoding="utf-8",
287
+ )
288
+
289
+ dur = int(time.time() - start)
290
+ note = f"copied={copied} pruned={pruned}"
291
+ if copied >= self.max_files:
292
+ note += " (HIT MAX)"
293
+ return StepResult(self.name, "PASS", dur, note)
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess # nosec B404 - Required for tool execution, paths validated
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .base import StepResult
9
+ from ..context import BundleContext
10
+ from ..tools import which
11
+
12
+
13
+ def _has_pytest(root: Path) -> bool:
14
+ """Check if pytest is likely used (tests exist or pytest in deps)."""
15
+ # Look for common test directories (exclude venv)
16
+ for test_dir in ["tests", "test"]:
17
+ test_path = root / test_dir
18
+ if test_path.is_dir():
19
+ # Make sure it's not inside a venv
20
+ if not any(
21
+ p.name.endswith("venv") or p.name.startswith(".")
22
+ for p in test_path.parents
23
+ ):
24
+ return True
25
+
26
+ # Look for test files in the project root and immediate subdirectories
27
+ # (not deep recursion to avoid finding venv tests)
28
+ for pattern in ["test_*.py", "*_test.py"]:
29
+ for p in root.glob(pattern):
30
+ return True
31
+ # Check one level deep
32
+ for subdir in root.iterdir():
33
+ if (
34
+ subdir.is_dir()
35
+ and not subdir.name.startswith(".")
36
+ and not subdir.name.endswith("venv")
37
+ ):
38
+ for p in subdir.glob(pattern):
39
+ return True
40
+
41
+ return False
42
+
43
+
44
+ @dataclass
45
+ class CoverageStep:
46
+ name: str = "coverage"
47
+ outfile: str = "logs/35_coverage.txt"
48
+
49
+ def run(self, ctx: BundleContext) -> StepResult:
50
+ start = time.time()
51
+ out = ctx.workdir / self.outfile
52
+ out.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Check for pytest first (since we use pytest-cov)
55
+ pytest_bin = which("pytest")
56
+ if not pytest_bin:
57
+ out.write_text(
58
+ "pytest not found; skipping coverage (pip install pytest pytest-cov)\n",
59
+ encoding="utf-8",
60
+ )
61
+ return StepResult(self.name, "SKIP", 0, "missing pytest")
62
+
63
+ # Check if there are tests to run
64
+ if not _has_pytest(ctx.root):
65
+ out.write_text("no tests detected; skipping coverage\n", encoding="utf-8")
66
+ return StepResult(self.name, "SKIP", 0, "no tests")
67
+
68
+ # Run pytest with coverage (including branch coverage for v1.4.1+)
69
+ cmd = [
70
+ pytest_bin,
71
+ "--cov",
72
+ "--cov-branch", # Enable branch coverage (v1.4.1+)
73
+ "--cov-report=term-missing:skip-covered",
74
+ "--no-cov-on-fail",
75
+ "-q",
76
+ ]
77
+ header = f"## PWD: {ctx.root}\n## CMD: {' '.join(cmd)}\n\n"
78
+
79
+ cp = subprocess.run( # nosec B603
80
+ cmd, cwd=str(ctx.root), text=True, capture_output=True, check=False
81
+ )
82
+
83
+ # Combine stdout and stderr
84
+ text = header + (cp.stdout or "") + ("\n" + cp.stderr if cp.stderr else "")
85
+
86
+ # If pytest-cov is not installed, provide helpful message
87
+ if "pytest: error: unrecognized arguments: --cov" in text:
88
+ text = (
89
+ header
90
+ + "pytest-cov not found; install with: pip install pytest-cov\n\n"
91
+ + text
92
+ )
93
+ out.write_text(ctx.redact_text(text), encoding="utf-8")
94
+ return StepResult(self.name, "SKIP", 0, "missing pytest-cov")
95
+
96
+ out.write_text(ctx.redact_text(text), encoding="utf-8")
97
+
98
+ dur = int(time.time() - start)
99
+ # Non-zero exit means test failures or coverage threshold not met
100
+ note = "" if cp.returncode == 0 else f"exit={cp.returncode}"
101
+ return StepResult(self.name, "PASS", dur, note)