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,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
+ )
@@ -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
+ ]
@@ -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}")