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
pybundle/roadmap_scan.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
from .steps.copy_pack import _is_venv_root, _is_under_venv
|
|
9
|
+
|
|
10
|
+
from .roadmap_model import Node, Edge, EntryPoint, RoadmapGraph
|
|
11
|
+
|
|
12
|
+
PY_EXT = {".py"}
|
|
13
|
+
JS_EXT = {".js", ".jsx", ".mjs", ".cjs"}
|
|
14
|
+
TS_EXT = {".ts", ".tsx"}
|
|
15
|
+
RUST_EXT = {".rs"}
|
|
16
|
+
Lang = Literal["python", "js", "ts", "rust", "html", "css", "config", "unknown"]
|
|
17
|
+
|
|
18
|
+
IMPORT_RE = re.compile(r'^\s*import\s+.*?\s+from\s+[\'"](.+?)[\'"]\s*;?\s*$', re.M)
|
|
19
|
+
REQUIRE_RE = re.compile(r'require\(\s*[\'"](.+?)[\'"]\s*\)')
|
|
20
|
+
RUST_USE_RE = re.compile(r"^\s*use\s+([a-zA-Z0-9_:]+)", re.M)
|
|
21
|
+
RUST_MOD_RE = re.compile(r"^\s*mod\s+([a-zA-Z0-9_]+)\s*;", re.M)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _rel(root: Path, p: Path) -> str:
|
|
25
|
+
return str(p.resolve().relative_to(root.resolve())).replace("\\", "/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def guess_lang(p: Path) -> Lang:
|
|
29
|
+
suf = p.suffix.lower()
|
|
30
|
+
if suf in PY_EXT:
|
|
31
|
+
return "python"
|
|
32
|
+
if suf in TS_EXT:
|
|
33
|
+
return "ts"
|
|
34
|
+
if suf in JS_EXT:
|
|
35
|
+
return "js"
|
|
36
|
+
if suf in RUST_EXT:
|
|
37
|
+
return "rust"
|
|
38
|
+
if suf in {".html", ".jinja", ".j2"}:
|
|
39
|
+
return "html"
|
|
40
|
+
if suf in {".css", ".scss", ".sass"}:
|
|
41
|
+
return "css"
|
|
42
|
+
if suf in {".toml", ".yaml", ".yml", ".json", ".ini", ".cfg"}:
|
|
43
|
+
return "config"
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_python_imports(root: Path, file_path: Path) -> list[str]:
|
|
48
|
+
# returns import module strings (not resolved paths)
|
|
49
|
+
try:
|
|
50
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
51
|
+
except Exception:
|
|
52
|
+
return []
|
|
53
|
+
mods: list[str] = []
|
|
54
|
+
for n in ast.walk(tree):
|
|
55
|
+
if isinstance(n, ast.Import):
|
|
56
|
+
for a in n.names:
|
|
57
|
+
mods.append(a.name)
|
|
58
|
+
elif isinstance(n, ast.ImportFrom):
|
|
59
|
+
if n.module:
|
|
60
|
+
mods.append(n.module)
|
|
61
|
+
return mods
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def scan_js_imports(text: str) -> list[str]:
|
|
65
|
+
out = []
|
|
66
|
+
out += IMPORT_RE.findall(text)
|
|
67
|
+
out += REQUIRE_RE.findall(text)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def scan_rust_uses(text: str) -> tuple[list[str], list[str]]:
|
|
72
|
+
uses = RUST_USE_RE.findall(text)
|
|
73
|
+
mods = RUST_MOD_RE.findall(text)
|
|
74
|
+
return uses, mods
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def detect_entrypoints(root: Path) -> list[EntryPoint]:
|
|
78
|
+
eps: list[EntryPoint] = []
|
|
79
|
+
|
|
80
|
+
# Python CLI entry: __main__.py
|
|
81
|
+
p = root / "src"
|
|
82
|
+
if p.exists():
|
|
83
|
+
for main in p.rglob("__main__.py"):
|
|
84
|
+
eps.append(
|
|
85
|
+
EntryPoint(
|
|
86
|
+
node=_rel(root, main), reason="python __main__.py", confidence=3
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Rust main.rs (including tauri src-tauri)
|
|
91
|
+
for mr in root.rglob("main.rs"):
|
|
92
|
+
if "target/" in str(mr): # safety
|
|
93
|
+
continue
|
|
94
|
+
eps.append(EntryPoint(node=_rel(root, mr), reason="rust main.rs", confidence=3))
|
|
95
|
+
|
|
96
|
+
# package.json scripts as entrypoints (synthetic)
|
|
97
|
+
pkg = root / "package.json"
|
|
98
|
+
if pkg.is_file():
|
|
99
|
+
eps.append(EntryPoint(node="package.json", reason="node scripts", confidence=2))
|
|
100
|
+
|
|
101
|
+
return eps
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def detect_entrypoints_from_nodes(nodes: dict[str, Node]) -> list[EntryPoint]:
|
|
105
|
+
"""Derive entrypoints from the scanned node list (deterministic, no FS scope issues)."""
|
|
106
|
+
eps: list[EntryPoint] = []
|
|
107
|
+
|
|
108
|
+
for nid, n in nodes.items():
|
|
109
|
+
path = n.path
|
|
110
|
+
if path.endswith("__main__.py"):
|
|
111
|
+
eps.append(EntryPoint(node=nid, reason="python __main__.py", confidence=3))
|
|
112
|
+
elif path.endswith("main.rs"):
|
|
113
|
+
eps.append(EntryPoint(node=nid, reason="rust main.rs", confidence=3))
|
|
114
|
+
elif path == "package.json":
|
|
115
|
+
eps.append(
|
|
116
|
+
EntryPoint(node=nid, reason="node package.json scripts", confidence=2)
|
|
117
|
+
)
|
|
118
|
+
elif path == "pyproject.toml":
|
|
119
|
+
eps.append(
|
|
120
|
+
EntryPoint(
|
|
121
|
+
node=nid,
|
|
122
|
+
reason="python pyproject.toml (scripts/entrypoints likely)",
|
|
123
|
+
confidence=1,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
# Optional hints (useful for library-ish layouts)
|
|
128
|
+
for hint in ("src/pybundle/cli.py", "src/pybundle/__init__.py"):
|
|
129
|
+
if hint in nodes:
|
|
130
|
+
eps.append(
|
|
131
|
+
EntryPoint(node=hint, reason="likely CLI/module entry", confidence=1)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Deduplicate deterministically
|
|
135
|
+
uniq = {(e.node, e.reason, e.confidence) for e in eps}
|
|
136
|
+
eps = [EntryPoint(node=a, reason=b, confidence=c) for (a, b, c) in uniq]
|
|
137
|
+
return sorted(eps, key=lambda e: (e.node, -e.confidence, e.reason))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _resolve_py_to_node(root: Path, src_rel: str, mod: str) -> Optional[str]:
|
|
141
|
+
"""
|
|
142
|
+
Resolve a Python import module string to a local file node (relative path),
|
|
143
|
+
if it exists in the scanned repo. Deterministic, no sys.path tricks.
|
|
144
|
+
"""
|
|
145
|
+
# Normalize relative imports like ".cli" or "..utils"
|
|
146
|
+
# We only support relative imports within the src file's package directory.
|
|
147
|
+
if mod.startswith("."):
|
|
148
|
+
# count leading dots
|
|
149
|
+
dots = 0
|
|
150
|
+
for ch in mod:
|
|
151
|
+
if ch == ".":
|
|
152
|
+
dots += 1
|
|
153
|
+
else:
|
|
154
|
+
break
|
|
155
|
+
tail = mod[dots:] # remaining name after dots
|
|
156
|
+
src_dir = Path(src_rel).parent # e.g. pybundle/
|
|
157
|
+
# go up (dots-1) levels: from . = same package, .. = parent, etc
|
|
158
|
+
base = src_dir
|
|
159
|
+
for _ in range(max(dots - 1, 0)):
|
|
160
|
+
base = base.parent
|
|
161
|
+
if tail:
|
|
162
|
+
parts = tail.split(".")
|
|
163
|
+
cand = base.joinpath(*parts)
|
|
164
|
+
else:
|
|
165
|
+
cand = base
|
|
166
|
+
else:
|
|
167
|
+
cand = Path(*mod.split("."))
|
|
168
|
+
|
|
169
|
+
# candidate file paths relative to root
|
|
170
|
+
py_file = (root / cand).with_suffix(".py")
|
|
171
|
+
init_file = root / cand / "__init__.py"
|
|
172
|
+
|
|
173
|
+
if py_file.is_file():
|
|
174
|
+
return _rel(root, py_file)
|
|
175
|
+
if init_file.is_file():
|
|
176
|
+
return _rel(root, init_file)
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_roadmap(
|
|
181
|
+
root: Path, include_dirs: list[Path], exclude_dirs: set[str], max_files: int = 20000
|
|
182
|
+
) -> RoadmapGraph:
|
|
183
|
+
nodes: dict[str, Node] = {}
|
|
184
|
+
edges: list[Edge] = []
|
|
185
|
+
|
|
186
|
+
# Walk selected dirs
|
|
187
|
+
files: list[Path] = []
|
|
188
|
+
skipped_big = 0
|
|
189
|
+
|
|
190
|
+
for d in include_dirs:
|
|
191
|
+
if not d.exists():
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
if _is_venv_root(d):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
for dirpath, dirnames, filenames in os.walk(d):
|
|
198
|
+
dirpath_p = Path(dirpath)
|
|
199
|
+
|
|
200
|
+
# 1) prune excluded dirs by name
|
|
201
|
+
dirnames[:] = [dn for dn in dirnames if dn not in exclude_dirs]
|
|
202
|
+
|
|
203
|
+
# 2) prune venv dirs by structure (ANY name)
|
|
204
|
+
dirnames[:] = [
|
|
205
|
+
dn
|
|
206
|
+
for dn in dirnames
|
|
207
|
+
if dn not in exclude_dirs and dn != ".pybundle-venv"
|
|
208
|
+
]
|
|
209
|
+
dirnames[:] = [dn for dn in dirnames if not _is_venv_root(dirpath_p / dn)]
|
|
210
|
+
|
|
211
|
+
for fn in filenames:
|
|
212
|
+
p = dirpath_p / fn
|
|
213
|
+
|
|
214
|
+
# 3️⃣ skip anything under a venv (belt + suspenders)
|
|
215
|
+
rel_p = Path(_rel(root, p))
|
|
216
|
+
if _is_under_venv(root, rel_p):
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
rel_s = _rel(root, p)
|
|
220
|
+
if rel_s.startswith(".pybundle-venv/") or "/site-packages/" in rel_s:
|
|
221
|
+
continue
|
|
222
|
+
if _is_under_venv(root, Path(rel_s)):
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
if p.stat().st_size > 2_000_000:
|
|
227
|
+
skipped_big += 1
|
|
228
|
+
continue
|
|
229
|
+
except OSError:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
files.append(p)
|
|
233
|
+
if len(files) >= max_files:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
if len(files) >= max_files:
|
|
237
|
+
break
|
|
238
|
+
|
|
239
|
+
# Create nodes
|
|
240
|
+
for f in files:
|
|
241
|
+
rel = _rel(root, f)
|
|
242
|
+
nodes[rel] = Node(id=rel, path=rel, lang=guess_lang(f))
|
|
243
|
+
|
|
244
|
+
# Scan edges
|
|
245
|
+
for f in files:
|
|
246
|
+
rel = _rel(root, f)
|
|
247
|
+
lang = nodes[rel].lang
|
|
248
|
+
text = None
|
|
249
|
+
|
|
250
|
+
if lang in {"js", "ts", "rust", "html", "config"}:
|
|
251
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
252
|
+
|
|
253
|
+
if lang == "python":
|
|
254
|
+
for mod in scan_python_imports(root, f):
|
|
255
|
+
resolved = _resolve_py_to_node(root, rel, mod)
|
|
256
|
+
if resolved and resolved in nodes:
|
|
257
|
+
edges.append(Edge(src=rel, dst=resolved, type="import"))
|
|
258
|
+
else:
|
|
259
|
+
edges.append(Edge(src=rel, dst=f"py:{mod}", type="import"))
|
|
260
|
+
elif lang in {"js", "ts"} and text is not None:
|
|
261
|
+
for spec in scan_js_imports(text):
|
|
262
|
+
edges.append(Edge(src=rel, dst=f"js:{spec}", type="import"))
|
|
263
|
+
elif lang == "rust" and text is not None:
|
|
264
|
+
uses, mods = scan_rust_uses(text)
|
|
265
|
+
for u in uses:
|
|
266
|
+
edges.append(Edge(src=rel, dst=f"rs:{u}", type="use"))
|
|
267
|
+
for m in mods:
|
|
268
|
+
edges.append(Edge(src=rel, dst=f"rsmod:{m}", type="mod"))
|
|
269
|
+
|
|
270
|
+
# TODO: add template includes, docker compose, pyproject scripts, etc.
|
|
271
|
+
|
|
272
|
+
# Entrypoints
|
|
273
|
+
eps = detect_entrypoints_from_nodes(nodes)
|
|
274
|
+
|
|
275
|
+
# Stats
|
|
276
|
+
stats: dict[str, int] = {}
|
|
277
|
+
for n in nodes.values():
|
|
278
|
+
stats[f"nodes_{n.lang}"] = stats.get(f"nodes_{n.lang}", 0) + 1
|
|
279
|
+
for e in edges:
|
|
280
|
+
stats[f"edges_{e.type}"] = stats.get(f"edges_{e.type}", 0) + 1
|
|
281
|
+
stats["skipped_big_files"] = skipped_big
|
|
282
|
+
|
|
283
|
+
# determinism: sort
|
|
284
|
+
node_list = sorted(nodes.values(), key=lambda x: x.id)
|
|
285
|
+
edge_list = sorted(edges, key=lambda e: (e.src, e.dst, e.type, e.note))
|
|
286
|
+
eps_sorted = sorted(eps, key=lambda e: (e.node, -e.confidence, e.reason))
|
|
287
|
+
|
|
288
|
+
return RoadmapGraph(
|
|
289
|
+
version=1,
|
|
290
|
+
root=str(root),
|
|
291
|
+
nodes=node_list,
|
|
292
|
+
edges=edge_list,
|
|
293
|
+
entrypoints=eps_sorted,
|
|
294
|
+
stats=stats,
|
|
295
|
+
)
|
pybundle/root_detect.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
_MARKERS = [".git", "pyproject.toml", "requirements.txt", "setup.cfg", "setup.py"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def detect_project_root(start: Path) -> Path | None:
|
|
9
|
+
cur = start.resolve()
|
|
10
|
+
for p in [cur, *cur.parents]:
|
|
11
|
+
for m in _MARKERS:
|
|
12
|
+
if (p / m).exists():
|
|
13
|
+
return p
|
|
14
|
+
return None
|
pybundle/runner.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from colorama import Fore, Style, init as colorama_init
|
|
11
|
+
# Force colors in xterm and other terminals
|
|
12
|
+
# strip=False keeps ANSI codes, autoreset=True resets after each print
|
|
13
|
+
colorama_init(autoreset=True, strip=False)
|
|
14
|
+
COLORS_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
COLORS_AVAILABLE = False
|
|
17
|
+
# Fallback if colorama not available
|
|
18
|
+
class Fore:
|
|
19
|
+
RED = ""
|
|
20
|
+
YELLOW = ""
|
|
21
|
+
GREEN = ""
|
|
22
|
+
CYAN = ""
|
|
23
|
+
RESET = ""
|
|
24
|
+
class Style:
|
|
25
|
+
BRIGHT = ""
|
|
26
|
+
RESET_ALL = ""
|
|
27
|
+
|
|
28
|
+
from .context import BundleContext
|
|
29
|
+
from .packaging import archive_output_path, make_archive, resolve_archive_format
|
|
30
|
+
from .manifest import write_manifest
|
|
31
|
+
from .steps.base import StepResult
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_duration(milliseconds: int) -> str:
|
|
35
|
+
"""Format milliseconds into human-readable duration (hr min sec)."""
|
|
36
|
+
seconds = milliseconds / 1000.0
|
|
37
|
+
|
|
38
|
+
if seconds < 1:
|
|
39
|
+
return f"{milliseconds}ms"
|
|
40
|
+
elif seconds < 60:
|
|
41
|
+
return f"{seconds:.1f}s"
|
|
42
|
+
elif seconds < 3600:
|
|
43
|
+
minutes = int(seconds // 60)
|
|
44
|
+
secs = int(seconds % 60)
|
|
45
|
+
return f"{minutes}m {secs}s"
|
|
46
|
+
else:
|
|
47
|
+
hours = int(seconds // 3600)
|
|
48
|
+
minutes = int((seconds % 3600) // 60)
|
|
49
|
+
secs = int(seconds % 60)
|
|
50
|
+
return f"{hours}h {minutes}m {secs}s"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _emit_progress(msg: str, color: str = "cyan") -> None:
|
|
54
|
+
"""Emit a progress message with optional color."""
|
|
55
|
+
color_code = getattr(Fore, color.upper(), Fore.CYAN)
|
|
56
|
+
print(f"{color_code}{msg}{Style.RESET_ALL}", file=sys.stderr, flush=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _emit_step_result(idx: int, total: int, step_name: str, result: StepResult) -> None:
|
|
60
|
+
"""Emit colored step result based on status."""
|
|
61
|
+
# Determine color and symbol based on status
|
|
62
|
+
if result.status == "OK":
|
|
63
|
+
color = Fore.GREEN
|
|
64
|
+
symbol = "✓"
|
|
65
|
+
elif result.status == "SKIP":
|
|
66
|
+
color = Fore.YELLOW
|
|
67
|
+
symbol = "⊘"
|
|
68
|
+
elif result.status == "FAIL":
|
|
69
|
+
color = Fore.RED
|
|
70
|
+
symbol = "✗"
|
|
71
|
+
else:
|
|
72
|
+
color = Fore.CYAN
|
|
73
|
+
symbol = "•"
|
|
74
|
+
|
|
75
|
+
# Format duration in human-readable format
|
|
76
|
+
duration = _format_duration(result.seconds)
|
|
77
|
+
|
|
78
|
+
# Build status line with colorful step counter and status-colored step name
|
|
79
|
+
step_counter = f"{Fore.BLACK}{Style.BRIGHT}[{Fore.MAGENTA}{idx}{Fore.WHITE}/{Fore.CYAN}{total}{Fore.BLACK}{Style.BRIGHT}]{Style.RESET_ALL}"
|
|
80
|
+
status_msg = f"{step_counter} {color}{Style.BRIGHT}{symbol} {step_name}{Style.RESET_ALL}"
|
|
81
|
+
|
|
82
|
+
# Add note and duration in terminal default color
|
|
83
|
+
if result.note:
|
|
84
|
+
status_msg += f" - {result.note}"
|
|
85
|
+
status_msg += f" ({duration})"
|
|
86
|
+
|
|
87
|
+
print(status_msg, file=sys.stderr, flush=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_profile(ctx: BundleContext, profile) -> int:
|
|
91
|
+
t0 = time.time()
|
|
92
|
+
ctx.write_runlog(f"=== pybundle run {profile.name} ===")
|
|
93
|
+
ctx.write_runlog(f"ROOT: {ctx.root}")
|
|
94
|
+
ctx.write_runlog(f"WORK: {ctx.workdir}")
|
|
95
|
+
|
|
96
|
+
ctx.results.clear()
|
|
97
|
+
results: list[StepResult] = ctx.results
|
|
98
|
+
any_fail = False
|
|
99
|
+
|
|
100
|
+
total_steps = len(profile.steps)
|
|
101
|
+
for idx, step in enumerate(profile.steps, 1):
|
|
102
|
+
# Progress indicator with colorful step count: [magenta#white/cyan#] Running: step
|
|
103
|
+
step_counter = f"{Fore.BLACK}{Style.BRIGHT}[{Fore.MAGENTA}{idx}{Fore.WHITE}/{Fore.CYAN}{total_steps}{Fore.BLACK}{Style.BRIGHT}]{Style.RESET_ALL}"
|
|
104
|
+
print(f"{step_counter} {Fore.WHITE}Running: {step.name}...{Style.RESET_ALL}", file=sys.stderr, flush=True)
|
|
105
|
+
ctx.write_runlog(f"-- START: {step.name}")
|
|
106
|
+
|
|
107
|
+
r = step.run(ctx)
|
|
108
|
+
results.append(r)
|
|
109
|
+
ctx.results = results
|
|
110
|
+
|
|
111
|
+
# Colored status output
|
|
112
|
+
_emit_step_result(idx, total_steps, step.name, r)
|
|
113
|
+
ctx.write_runlog(
|
|
114
|
+
f"-- DONE: {step.name} [{r.status}] ({r.seconds}s) {r.note}".rstrip()
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if r.status == "FAIL":
|
|
118
|
+
any_fail = True
|
|
119
|
+
if ctx.strict:
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
ctx.summary_json.write_text(
|
|
123
|
+
json.dumps(
|
|
124
|
+
{
|
|
125
|
+
"profile": profile.name,
|
|
126
|
+
"root": str(ctx.root),
|
|
127
|
+
"workdir": str(ctx.workdir),
|
|
128
|
+
"results": [asdict(r) for r in results],
|
|
129
|
+
},
|
|
130
|
+
indent=2,
|
|
131
|
+
),
|
|
132
|
+
encoding="utf-8",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
ctx.results = results
|
|
136
|
+
|
|
137
|
+
# Write the manifest BEFORE archiving so it's included inside the bundle.
|
|
138
|
+
archive_fmt_used = resolve_archive_format(ctx)
|
|
139
|
+
archive_path = archive_output_path(ctx, archive_fmt_used)
|
|
140
|
+
|
|
141
|
+
write_manifest(
|
|
142
|
+
ctx=ctx,
|
|
143
|
+
profile_name=profile.name,
|
|
144
|
+
archive_path=archive_path,
|
|
145
|
+
archive_format_used=archive_fmt_used,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
archive_path, archive_fmt_used = make_archive(ctx)
|
|
149
|
+
ctx.archive_path = archive_path
|
|
150
|
+
ctx.duration_ms = int((time.time() - t0) * 1000)
|
|
151
|
+
|
|
152
|
+
ctx.write_runlog(f"ARCHIVE: {archive_path}")
|
|
153
|
+
|
|
154
|
+
ctx.emit(f"✅ Archive created: {archive_path}")
|
|
155
|
+
if ctx.keep_workdir:
|
|
156
|
+
ctx.emit(f"📁 Workdir kept: {ctx.workdir}")
|
|
157
|
+
|
|
158
|
+
if not ctx.keep_workdir:
|
|
159
|
+
shutil.rmtree(ctx.workdir, ignore_errors=True)
|
|
160
|
+
|
|
161
|
+
if any_fail and ctx.strict:
|
|
162
|
+
return 10
|
|
163
|
+
return 0
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pybundle steps - individual analysis and packaging steps.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"bandit",
|
|
9
|
+
"base",
|
|
10
|
+
"compileall",
|
|
11
|
+
"context_expand",
|
|
12
|
+
"coverage",
|
|
13
|
+
"copy_pack",
|
|
14
|
+
"error_refs",
|
|
15
|
+
"handoff_md",
|
|
16
|
+
"mypy",
|
|
17
|
+
"pip_audit",
|
|
18
|
+
"pylance",
|
|
19
|
+
"pytest",
|
|
20
|
+
"repro_md",
|
|
21
|
+
"rg_scans",
|
|
22
|
+
"roadmap",
|
|
23
|
+
"ruff",
|
|
24
|
+
"shell",
|
|
25
|
+
"tree",
|
|
26
|
+
]
|
pybundle/steps/bandit.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
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 _repo_has_py_files(root: Path) -> bool:
|
|
14
|
+
"""Fast check if there are Python files to scan."""
|
|
15
|
+
for p in root.rglob("*.py"):
|
|
16
|
+
parts = set(p.parts)
|
|
17
|
+
if (
|
|
18
|
+
".venv" not in parts
|
|
19
|
+
and "__pycache__" not in parts
|
|
20
|
+
and "node_modules" not in parts
|
|
21
|
+
and "dist" not in parts
|
|
22
|
+
and "build" not in parts
|
|
23
|
+
and "artifacts" not in parts
|
|
24
|
+
):
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class BanditStep:
|
|
31
|
+
name: str = "bandit"
|
|
32
|
+
target: str = "."
|
|
33
|
+
outfile: str = "logs/50_bandit.txt"
|
|
34
|
+
|
|
35
|
+
def run(self, ctx: BundleContext) -> StepResult:
|
|
36
|
+
start = time.time()
|
|
37
|
+
out = ctx.workdir / self.outfile
|
|
38
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
|
|
40
|
+
bandit = which("bandit")
|
|
41
|
+
if not bandit:
|
|
42
|
+
out.write_text(
|
|
43
|
+
"bandit not found; skipping (pip install bandit)\n", encoding="utf-8"
|
|
44
|
+
)
|
|
45
|
+
return StepResult(self.name, "SKIP", 0, "missing bandit")
|
|
46
|
+
|
|
47
|
+
if not _repo_has_py_files(ctx.root):
|
|
48
|
+
out.write_text("no .py files detected; skipping bandit\n", encoding="utf-8")
|
|
49
|
+
return StepResult(self.name, "SKIP", 0, "no python files")
|
|
50
|
+
|
|
51
|
+
# Run bandit with recursive mode, excluding common directories
|
|
52
|
+
cmd = [
|
|
53
|
+
bandit,
|
|
54
|
+
"-r",
|
|
55
|
+
self.target,
|
|
56
|
+
"--exclude",
|
|
57
|
+
"**/artifacts/**,.venv,venv,__pycache__,.mypy_cache,.ruff_cache,node_modules,dist,build,.git,.tox,*.egg-info",
|
|
58
|
+
"--format",
|
|
59
|
+
"txt",
|
|
60
|
+
]
|
|
61
|
+
header = f"## PWD: {ctx.root}\n## CMD: {' '.join(cmd)}\n\n"
|
|
62
|
+
|
|
63
|
+
cp = subprocess.run( # nosec B603
|
|
64
|
+
cmd, cwd=str(ctx.root), text=True, capture_output=True, check=False
|
|
65
|
+
)
|
|
66
|
+
text = header + (cp.stdout or "") + ("\n" + cp.stderr if cp.stderr else "")
|
|
67
|
+
out.write_text(ctx.redact_text(text), encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
dur = int(time.time() - start)
|
|
70
|
+
# Bandit exit codes: 0=no issues, 1=issues found
|
|
71
|
+
note = "" if cp.returncode == 0 else f"exit={cp.returncode} (security issues)"
|
|
72
|
+
return StepResult(self.name, "PASS", dur, note)
|
pybundle/steps/base.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from ..context import BundleContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class StepResult:
|
|
11
|
+
name: str
|
|
12
|
+
status: str # "PASS" | "FAIL" | "SKIP"
|
|
13
|
+
seconds: int
|
|
14
|
+
note: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Step(Protocol):
|
|
18
|
+
name: str
|
|
19
|
+
|
|
20
|
+
def run(self, ctx: BundleContext) -> StepResult: ...
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
|
|
11
|
+
|
|
12
|
+
def _guess_targets(root: Path) -> list[str]:
|
|
13
|
+
"""
|
|
14
|
+
Heuristic targets:
|
|
15
|
+
- If there are top-level Python package dirs (contain __init__.py), compile those.
|
|
16
|
+
- Otherwise compile '.' (repo root).
|
|
17
|
+
"""
|
|
18
|
+
targets: list[str] = []
|
|
19
|
+
|
|
20
|
+
for p in sorted(root.iterdir()):
|
|
21
|
+
if not p.is_dir():
|
|
22
|
+
continue
|
|
23
|
+
if p.name.startswith("."):
|
|
24
|
+
continue
|
|
25
|
+
if (p / "__init__.py").is_file():
|
|
26
|
+
targets.append(p.name)
|
|
27
|
+
|
|
28
|
+
return targets or ["."]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CompileAllStep:
|
|
33
|
+
name: str = "compileall"
|
|
34
|
+
quiet: bool = True
|
|
35
|
+
|
|
36
|
+
def run(self, ctx: BundleContext) -> StepResult:
|
|
37
|
+
start = time.time()
|
|
38
|
+
out = ctx.logdir / "30_compileall.txt"
|
|
39
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
py = ctx.tools.python
|
|
42
|
+
if not py:
|
|
43
|
+
out.write_text("python not found; skipping compileall\n", encoding="utf-8")
|
|
44
|
+
return StepResult(self.name, "SKIP", 0, "missing python")
|
|
45
|
+
|
|
46
|
+
targets = _guess_targets(ctx.root)
|
|
47
|
+
cmd = [py, "-m", "compileall"]
|
|
48
|
+
if self.quiet:
|
|
49
|
+
cmd.append("-q")
|
|
50
|
+
cmd.extend(targets)
|
|
51
|
+
|
|
52
|
+
header = (
|
|
53
|
+
f"## PWD: {ctx.root}\n## CMD: {' '.join(cmd)}\n## TARGETS: {targets}\n\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
cp = subprocess.run( # nosec B603
|
|
58
|
+
cmd,
|
|
59
|
+
cwd=str(ctx.root),
|
|
60
|
+
text=True,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
check=False,
|
|
63
|
+
)
|
|
64
|
+
text = header + (cp.stdout or "") + ("\n" + cp.stderr if cp.stderr else "")
|
|
65
|
+
out.write_text(ctx.redact_text(text), encoding="utf-8")
|
|
66
|
+
dur = int(time.time() - start)
|
|
67
|
+
|
|
68
|
+
# compileall uses non-zero for compile failures; we record it but don't fail bundling.
|
|
69
|
+
note = "" if cp.returncode == 0 else f"exit={cp.returncode} (recorded)"
|
|
70
|
+
return StepResult(self.name, "PASS", dur, note)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
out.write_text(
|
|
73
|
+
ctx.redact_text(header + f"\nEXCEPTION: {e}\n"), encoding="utf-8"
|
|
74
|
+
)
|
|
75
|
+
dur = int(time.time() - start)
|
|
76
|
+
return StepResult(self.name, "PASS", dur, f"exception recorded: {e}")
|