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.
- gwc_pybundle-1.4.5.dist-info/METADATA +876 -0
- gwc_pybundle-1.4.5.dist-info/RECORD +55 -0
- gwc_pybundle-1.4.5.dist-info/WHEEL +5 -0
- gwc_pybundle-1.4.5.dist-info/entry_points.txt +2 -0
- gwc_pybundle-1.4.5.dist-info/licenses/LICENSE.md +25 -0
- gwc_pybundle-1.4.5.dist-info/top_level.txt +1 -0
- pybundle/__init__.py +0 -0
- pybundle/__main__.py +4 -0
- pybundle/cli.py +365 -0
- pybundle/context.py +362 -0
- pybundle/doctor.py +148 -0
- pybundle/filters.py +178 -0
- pybundle/manifest.py +77 -0
- pybundle/packaging.py +45 -0
- pybundle/policy.py +132 -0
- pybundle/profiles.py +340 -0
- pybundle/roadmap_model.py +42 -0
- pybundle/roadmap_scan.py +295 -0
- pybundle/root_detect.py +14 -0
- pybundle/runner.py +163 -0
- pybundle/steps/__init__.py +26 -0
- pybundle/steps/bandit.py +72 -0
- pybundle/steps/base.py +20 -0
- pybundle/steps/compileall.py +76 -0
- pybundle/steps/context_expand.py +272 -0
- pybundle/steps/copy_pack.py +293 -0
- pybundle/steps/coverage.py +101 -0
- pybundle/steps/cprofile_step.py +155 -0
- pybundle/steps/dependency_sizes.py +120 -0
- pybundle/steps/duplication.py +94 -0
- pybundle/steps/error_refs.py +204 -0
- pybundle/steps/handoff_md.py +167 -0
- pybundle/steps/import_time.py +165 -0
- pybundle/steps/interrogate.py +84 -0
- pybundle/steps/license_scan.py +96 -0
- pybundle/steps/line_profiler.py +108 -0
- pybundle/steps/memory_profile.py +173 -0
- pybundle/steps/mutation_testing.py +136 -0
- pybundle/steps/mypy.py +60 -0
- pybundle/steps/pip_audit.py +45 -0
- pybundle/steps/pipdeptree.py +61 -0
- pybundle/steps/pylance.py +562 -0
- pybundle/steps/pytest.py +66 -0
- pybundle/steps/radon.py +121 -0
- pybundle/steps/repro_md.py +161 -0
- pybundle/steps/rg_scans.py +78 -0
- pybundle/steps/roadmap.py +153 -0
- pybundle/steps/ruff.py +111 -0
- pybundle/steps/shell.py +74 -0
- pybundle/steps/slow_tests.py +170 -0
- pybundle/steps/test_flakiness.py +172 -0
- pybundle/steps/tree.py +116 -0
- pybundle/steps/unused_deps.py +112 -0
- pybundle/steps/vulture.py +83 -0
- 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)
|