gwc-pybundle 2.1.2__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 (82) hide show
  1. gwc_pybundle-2.1.2.dist-info/METADATA +903 -0
  2. gwc_pybundle-2.1.2.dist-info/RECORD +82 -0
  3. gwc_pybundle-2.1.2.dist-info/WHEEL +5 -0
  4. gwc_pybundle-2.1.2.dist-info/entry_points.txt +2 -0
  5. gwc_pybundle-2.1.2.dist-info/licenses/LICENSE.md +25 -0
  6. gwc_pybundle-2.1.2.dist-info/top_level.txt +1 -0
  7. pybundle/__init__.py +0 -0
  8. pybundle/__main__.py +4 -0
  9. pybundle/cli.py +546 -0
  10. pybundle/context.py +404 -0
  11. pybundle/doctor.py +148 -0
  12. pybundle/filters.py +228 -0
  13. pybundle/manifest.py +77 -0
  14. pybundle/packaging.py +45 -0
  15. pybundle/policy.py +132 -0
  16. pybundle/profiles.py +454 -0
  17. pybundle/roadmap_model.py +42 -0
  18. pybundle/roadmap_scan.py +328 -0
  19. pybundle/root_detect.py +14 -0
  20. pybundle/runner.py +180 -0
  21. pybundle/steps/__init__.py +26 -0
  22. pybundle/steps/ai_context.py +791 -0
  23. pybundle/steps/api_docs.py +219 -0
  24. pybundle/steps/asyncio_analysis.py +358 -0
  25. pybundle/steps/bandit.py +72 -0
  26. pybundle/steps/base.py +20 -0
  27. pybundle/steps/blocking_call_detection.py +291 -0
  28. pybundle/steps/call_graph.py +219 -0
  29. pybundle/steps/compileall.py +76 -0
  30. pybundle/steps/config_docs.py +319 -0
  31. pybundle/steps/config_validation.py +302 -0
  32. pybundle/steps/container_image.py +294 -0
  33. pybundle/steps/context_expand.py +272 -0
  34. pybundle/steps/copy_pack.py +293 -0
  35. pybundle/steps/coverage.py +101 -0
  36. pybundle/steps/cprofile_step.py +166 -0
  37. pybundle/steps/dependency_sizes.py +136 -0
  38. pybundle/steps/django_checks.py +214 -0
  39. pybundle/steps/dockerfile_lint.py +282 -0
  40. pybundle/steps/dockerignore.py +311 -0
  41. pybundle/steps/duplication.py +103 -0
  42. pybundle/steps/env_completeness.py +269 -0
  43. pybundle/steps/env_var_usage.py +253 -0
  44. pybundle/steps/error_refs.py +204 -0
  45. pybundle/steps/event_loop_patterns.py +280 -0
  46. pybundle/steps/exception_patterns.py +190 -0
  47. pybundle/steps/fastapi_integration.py +250 -0
  48. pybundle/steps/flask_debugging.py +312 -0
  49. pybundle/steps/git_analytics.py +315 -0
  50. pybundle/steps/handoff_md.py +176 -0
  51. pybundle/steps/import_time.py +175 -0
  52. pybundle/steps/interrogate.py +106 -0
  53. pybundle/steps/license_scan.py +96 -0
  54. pybundle/steps/line_profiler.py +117 -0
  55. pybundle/steps/link_validation.py +287 -0
  56. pybundle/steps/logging_analysis.py +233 -0
  57. pybundle/steps/memory_profile.py +176 -0
  58. pybundle/steps/migration_history.py +336 -0
  59. pybundle/steps/mutation_testing.py +141 -0
  60. pybundle/steps/mypy.py +103 -0
  61. pybundle/steps/orm_optimization.py +316 -0
  62. pybundle/steps/pip_audit.py +45 -0
  63. pybundle/steps/pipdeptree.py +62 -0
  64. pybundle/steps/pylance.py +562 -0
  65. pybundle/steps/pytest.py +66 -0
  66. pybundle/steps/query_pattern_analysis.py +334 -0
  67. pybundle/steps/radon.py +161 -0
  68. pybundle/steps/repro_md.py +161 -0
  69. pybundle/steps/rg_scans.py +78 -0
  70. pybundle/steps/roadmap.py +153 -0
  71. pybundle/steps/ruff.py +117 -0
  72. pybundle/steps/secrets_detection.py +235 -0
  73. pybundle/steps/security_headers.py +309 -0
  74. pybundle/steps/shell.py +74 -0
  75. pybundle/steps/slow_tests.py +178 -0
  76. pybundle/steps/sqlalchemy_validation.py +269 -0
  77. pybundle/steps/test_flakiness.py +184 -0
  78. pybundle/steps/tree.py +116 -0
  79. pybundle/steps/type_coverage.py +277 -0
  80. pybundle/steps/unused_deps.py +211 -0
  81. pybundle/steps/vulture.py +167 -0
  82. pybundle/tools.py +63 -0
@@ -0,0 +1,328 @@
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 (search root and common dirs)
81
+ for search_path in [root, root / "src", root / "app"]:
82
+ if search_path.exists():
83
+ for main in search_path.rglob("__main__.py"):
84
+ if not _is_under_venv(main) and "site-packages" not in str(main):
85
+ eps.append(
86
+ EntryPoint(
87
+ node=_rel(root, main), reason="python __main__.py", confidence=3
88
+ )
89
+ )
90
+
91
+ # Python: Check pyproject.toml for console_scripts entrypoints
92
+ pyproject = root / "pyproject.toml"
93
+ if pyproject.is_file():
94
+ try:
95
+ import tomllib
96
+ with open(pyproject, "rb") as f:
97
+ data = tomllib.load(f)
98
+ # PEP 621 project.scripts
99
+ scripts = data.get("project", {}).get("scripts", {})
100
+ if scripts:
101
+ for script_name, target in scripts.items():
102
+ eps.append(
103
+ EntryPoint(
104
+ node=f"pyproject.toml:scripts.{script_name}",
105
+ reason=f"console script -> {target}",
106
+ confidence=3
107
+ )
108
+ )
109
+ # Poetry scripts
110
+ poetry_scripts = data.get("tool", {}).get("poetry", {}).get("scripts", {})
111
+ if poetry_scripts:
112
+ for script_name in poetry_scripts:
113
+ eps.append(
114
+ EntryPoint(
115
+ node=f"pyproject.toml:poetry.scripts.{script_name}",
116
+ reason="poetry script",
117
+ confidence=3
118
+ )
119
+ )
120
+ except Exception:
121
+ pass # TOML parsing failed, skip
122
+
123
+ # Rust main.rs (including tauri src-tauri)
124
+ for mr in root.rglob("main.rs"):
125
+ if "target/" in str(mr): # safety
126
+ continue
127
+ eps.append(EntryPoint(node=_rel(root, mr), reason="rust main.rs", confidence=3))
128
+
129
+ # package.json scripts as entrypoints (synthetic)
130
+ pkg = root / "package.json"
131
+ if pkg.is_file():
132
+ eps.append(EntryPoint(node="package.json", reason="node scripts", confidence=2))
133
+
134
+ return eps
135
+
136
+
137
+ def detect_entrypoints_from_nodes(nodes: dict[str, Node]) -> list[EntryPoint]:
138
+ """Derive entrypoints from the scanned node list (deterministic, no FS scope issues)."""
139
+ eps: list[EntryPoint] = []
140
+
141
+ for nid, n in nodes.items():
142
+ path = n.path
143
+ if path.endswith("__main__.py"):
144
+ eps.append(EntryPoint(node=nid, reason="python __main__.py", confidence=3))
145
+ elif path.endswith("main.rs"):
146
+ eps.append(EntryPoint(node=nid, reason="rust main.rs", confidence=3))
147
+ elif path == "package.json":
148
+ eps.append(
149
+ EntryPoint(node=nid, reason="node package.json scripts", confidence=2)
150
+ )
151
+ elif path == "pyproject.toml":
152
+ eps.append(
153
+ EntryPoint(
154
+ node=nid,
155
+ reason="python pyproject.toml (scripts/entrypoints likely)",
156
+ confidence=1,
157
+ )
158
+ )
159
+
160
+ # Optional hints (useful for library-ish layouts)
161
+ for hint in ("src/pybundle/cli.py", "src/pybundle/__init__.py"):
162
+ if hint in nodes:
163
+ eps.append(
164
+ EntryPoint(node=hint, reason="likely CLI/module entry", confidence=1)
165
+ )
166
+
167
+ # Deduplicate deterministically
168
+ uniq = {(e.node, e.reason, e.confidence) for e in eps}
169
+ eps = [EntryPoint(node=a, reason=b, confidence=c) for (a, b, c) in uniq]
170
+ return sorted(eps, key=lambda e: (e.node, -e.confidence, e.reason))
171
+
172
+
173
+ def _resolve_py_to_node(root: Path, src_rel: str, mod: str) -> Optional[str]:
174
+ """
175
+ Resolve a Python import module string to a local file node (relative path),
176
+ if it exists in the scanned repo. Deterministic, no sys.path tricks.
177
+ """
178
+ # Normalize relative imports like ".cli" or "..utils"
179
+ # We only support relative imports within the src file's package directory.
180
+ if mod.startswith("."):
181
+ # count leading dots
182
+ dots = 0
183
+ for ch in mod:
184
+ if ch == ".":
185
+ dots += 1
186
+ else:
187
+ break
188
+ tail = mod[dots:] # remaining name after dots
189
+ src_dir = Path(src_rel).parent # e.g. pybundle/
190
+ # go up (dots-1) levels: from . = same package, .. = parent, etc
191
+ base = src_dir
192
+ for _ in range(max(dots - 1, 0)):
193
+ base = base.parent
194
+ if tail:
195
+ parts = tail.split(".")
196
+ cand = base.joinpath(*parts)
197
+ else:
198
+ cand = base
199
+ else:
200
+ cand = Path(*mod.split("."))
201
+
202
+ # candidate file paths relative to root
203
+ py_file = (root / cand).with_suffix(".py")
204
+ init_file = root / cand / "__init__.py"
205
+
206
+ if py_file.is_file():
207
+ return _rel(root, py_file)
208
+ if init_file.is_file():
209
+ return _rel(root, init_file)
210
+ return None
211
+
212
+
213
+ def build_roadmap(
214
+ root: Path, include_dirs: list[Path], exclude_dirs: set[str], max_files: int = 20000
215
+ ) -> RoadmapGraph:
216
+ nodes: dict[str, Node] = {}
217
+ edges: list[Edge] = []
218
+
219
+ # Walk selected dirs
220
+ files: list[Path] = []
221
+ skipped_big = 0
222
+
223
+ for d in include_dirs:
224
+ if not d.exists():
225
+ continue
226
+
227
+ if _is_venv_root(d):
228
+ continue
229
+
230
+ for dirpath, dirnames, filenames in os.walk(d):
231
+ dirpath_p = Path(dirpath)
232
+
233
+ # 1) prune excluded dirs by name
234
+ dirnames[:] = [dn for dn in dirnames if dn not in exclude_dirs]
235
+
236
+ # 2) prune venv dirs by structure (ANY name)
237
+ dirnames[:] = [
238
+ dn
239
+ for dn in dirnames
240
+ if dn not in exclude_dirs and dn != ".pybundle-venv"
241
+ ]
242
+ dirnames[:] = [dn for dn in dirnames if not _is_venv_root(dirpath_p / dn)]
243
+
244
+ for fn in filenames:
245
+ p = dirpath_p / fn
246
+
247
+ # 3️⃣ skip anything under a venv (belt + suspenders)
248
+ rel_p = Path(_rel(root, p))
249
+ if _is_under_venv(root, rel_p):
250
+ continue
251
+
252
+ rel_s = _rel(root, p)
253
+ if rel_s.startswith(".pybundle-venv/") or "/site-packages/" in rel_s:
254
+ continue
255
+ if _is_under_venv(root, Path(rel_s)):
256
+ continue
257
+
258
+ try:
259
+ if p.stat().st_size > 2_000_000:
260
+ skipped_big += 1
261
+ continue
262
+ except OSError:
263
+ continue
264
+
265
+ files.append(p)
266
+ if len(files) >= max_files:
267
+ break
268
+
269
+ if len(files) >= max_files:
270
+ break
271
+
272
+ # Create nodes
273
+ for f in files:
274
+ rel = _rel(root, f)
275
+ nodes[rel] = Node(id=rel, path=rel, lang=guess_lang(f))
276
+
277
+ # Scan edges
278
+ for f in files:
279
+ rel = _rel(root, f)
280
+ lang = nodes[rel].lang
281
+ text = None
282
+
283
+ if lang in {"js", "ts", "rust", "html", "config"}:
284
+ text = f.read_text(encoding="utf-8", errors="replace")
285
+
286
+ if lang == "python":
287
+ for mod in scan_python_imports(root, f):
288
+ resolved = _resolve_py_to_node(root, rel, mod)
289
+ if resolved and resolved in nodes:
290
+ edges.append(Edge(src=rel, dst=resolved, type="import"))
291
+ else:
292
+ edges.append(Edge(src=rel, dst=f"py:{mod}", type="import"))
293
+ elif lang in {"js", "ts"} and text is not None:
294
+ for spec in scan_js_imports(text):
295
+ edges.append(Edge(src=rel, dst=f"js:{spec}", type="import"))
296
+ elif lang == "rust" and text is not None:
297
+ uses, mods = scan_rust_uses(text)
298
+ for u in uses:
299
+ edges.append(Edge(src=rel, dst=f"rs:{u}", type="use"))
300
+ for m in mods:
301
+ edges.append(Edge(src=rel, dst=f"rsmod:{m}", type="mod"))
302
+
303
+ # TODO: add template includes, docker compose, pyproject scripts, etc.
304
+
305
+ # Entrypoints
306
+ eps = detect_entrypoints_from_nodes(nodes)
307
+
308
+ # Stats
309
+ stats: dict[str, int] = {}
310
+ for n in nodes.values():
311
+ stats[f"nodes_{n.lang}"] = stats.get(f"nodes_{n.lang}", 0) + 1
312
+ for e in edges:
313
+ stats[f"edges_{e.type}"] = stats.get(f"edges_{e.type}", 0) + 1
314
+ stats["skipped_big_files"] = skipped_big
315
+
316
+ # determinism: sort
317
+ node_list = sorted(nodes.values(), key=lambda x: x.id)
318
+ edge_list = sorted(edges, key=lambda e: (e.src, e.dst, e.type, e.note))
319
+ eps_sorted = sorted(eps, key=lambda e: (e.node, -e.confidence, e.reason))
320
+
321
+ return RoadmapGraph(
322
+ version=1,
323
+ root=str(root),
324
+ nodes=node_list,
325
+ edges=edge_list,
326
+ entrypoints=eps_sorted,
327
+ stats=stats,
328
+ )
@@ -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,180 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import sys
6
+ import time
7
+ from dataclasses import asdict
8
+ from typing import Any
9
+
10
+ try:
11
+ from colorama import Fore as ColorFore, Style as ColorStyle, init as colorama_init # type: ignore[import-untyped]
12
+
13
+ # Force colors in xterm and other terminals
14
+ # strip=False keeps ANSI codes, autoreset=True resets after each print
15
+ colorama_init(autoreset=True, strip=False)
16
+ COLORS_AVAILABLE = True
17
+ ForeColor: Any = ColorFore
18
+ StyleColor: Any = ColorStyle
19
+ Fore = ForeColor
20
+ Style = StyleColor
21
+ except ImportError:
22
+ COLORS_AVAILABLE = False
23
+ # Fallback if colorama not available
24
+ class _Fore:
25
+ RED: str = ""
26
+ YELLOW: str = ""
27
+ GREEN: str = ""
28
+ CYAN: str = ""
29
+ RESET: str = ""
30
+
31
+ class _Style:
32
+ BRIGHT: str = ""
33
+ RESET_ALL: str = ""
34
+
35
+ Fore = _Fore()
36
+ Style = _Style()
37
+
38
+ from .context import BundleContext
39
+ from .packaging import archive_output_path, make_archive, resolve_archive_format
40
+ from .manifest import write_manifest
41
+ from .steps.base import StepResult
42
+ from .profiles import Profile
43
+
44
+
45
+ def _format_duration(milliseconds: int) -> str:
46
+ """Format milliseconds into human-readable duration (hr min sec)."""
47
+ seconds = milliseconds / 1000.0
48
+
49
+ if seconds < 1:
50
+ return f"{milliseconds}ms"
51
+ elif seconds < 60:
52
+ return f"{seconds:.1f}s"
53
+ elif seconds < 3600:
54
+ minutes = int(seconds // 60)
55
+ secs = int(seconds % 60)
56
+ return f"{minutes}m {secs}s"
57
+ else:
58
+ hours = int(seconds // 3600)
59
+ minutes = int((seconds % 3600) // 60)
60
+ secs = int(seconds % 60)
61
+ return f"{hours}h {minutes}m {secs}s"
62
+
63
+
64
+ def _emit_progress(msg: str, color: str = "cyan") -> None:
65
+ """Emit a progress message with optional color."""
66
+ color_code = getattr(Fore, color.upper(), Fore.CYAN)
67
+ print(f"{color_code}{msg}{Style.RESET_ALL}", file=sys.stderr, flush=True)
68
+
69
+
70
+ def _emit_step_result(idx: int, total: int, step_name: str, result: StepResult) -> None:
71
+ """Emit colored step result based on status."""
72
+ # Determine color and symbol based on status
73
+ if result.status == "OK":
74
+ color = Fore.GREEN
75
+ symbol = "✓"
76
+ elif result.status == "SKIP":
77
+ color = Fore.YELLOW
78
+ symbol = "⊘"
79
+ elif result.status == "FAIL":
80
+ color = Fore.RED
81
+ symbol = "✗"
82
+ else:
83
+ color = Fore.CYAN
84
+ symbol = "•"
85
+
86
+ # Format duration in human-readable format
87
+ duration = _format_duration(result.seconds)
88
+
89
+ # Build status line with colorful step counter and status-colored step name
90
+ step_counter = f"{Fore.BLACK}{Style.BRIGHT}[{Fore.MAGENTA}{idx}{Fore.WHITE}/{Fore.CYAN}{total}{Fore.BLACK}{Style.BRIGHT}]{Style.RESET_ALL}"
91
+ status_msg = (
92
+ f"{step_counter} {color}{Style.BRIGHT}{symbol} {step_name}{Style.RESET_ALL}"
93
+ )
94
+
95
+ # Add note and duration in terminal default color
96
+ if result.note:
97
+ status_msg += f" - {result.note}"
98
+ status_msg += f" ({duration})"
99
+
100
+ print(status_msg, file=sys.stderr, flush=True)
101
+
102
+
103
+ def run_profile(ctx: BundleContext, profile: Profile) -> int:
104
+ t0 = time.time()
105
+ ctx.write_runlog(f"=== pybundle run {profile.name} ===")
106
+ ctx.write_runlog(f"ROOT: {ctx.root}")
107
+ ctx.write_runlog(f"WORK: {ctx.workdir}")
108
+
109
+ ctx.results.clear()
110
+ results: list[StepResult] = ctx.results
111
+ any_fail = False
112
+
113
+ total_steps = len(profile.steps)
114
+ for idx, step in enumerate(profile.steps, 1):
115
+ # Progress indicator with colorful step count: [magenta#white/cyan#] Running: step
116
+ step_counter = f"{Fore.BLACK}{Style.BRIGHT}[{Fore.MAGENTA}{idx}{Fore.WHITE}/{Fore.CYAN}{total_steps}{Fore.BLACK}{Style.BRIGHT}]{Style.RESET_ALL}"
117
+ print(
118
+ f"{step_counter} {Fore.WHITE}Running: {step.name}...{Style.RESET_ALL}",
119
+ file=sys.stderr,
120
+ flush=True,
121
+ )
122
+ ctx.write_runlog(f"-- START: {step.name}")
123
+
124
+ r = step.run(ctx)
125
+ results.append(r)
126
+ ctx.results = results
127
+
128
+ # Colored status output
129
+ _emit_step_result(idx, total_steps, step.name, r)
130
+ ctx.write_runlog(
131
+ f"-- DONE: {step.name} [{r.status}] ({r.seconds}s) {r.note}".rstrip()
132
+ )
133
+
134
+ if r.status == "FAIL":
135
+ any_fail = True
136
+ if ctx.strict:
137
+ break
138
+
139
+ ctx.summary_json.write_text(
140
+ json.dumps(
141
+ {
142
+ "profile": profile.name,
143
+ "root": str(ctx.root),
144
+ "workdir": str(ctx.workdir),
145
+ "results": [asdict(r) for r in results],
146
+ },
147
+ indent=2,
148
+ ),
149
+ encoding="utf-8",
150
+ )
151
+
152
+ ctx.results = results
153
+
154
+ # Write the manifest BEFORE archiving so it's included inside the bundle.
155
+ archive_fmt_used = resolve_archive_format(ctx)
156
+ archive_path = archive_output_path(ctx, archive_fmt_used)
157
+
158
+ write_manifest(
159
+ ctx=ctx,
160
+ profile_name=profile.name,
161
+ archive_path=archive_path,
162
+ archive_format_used=archive_fmt_used,
163
+ )
164
+
165
+ archive_path, archive_fmt_used = make_archive(ctx)
166
+ ctx.archive_path = archive_path
167
+ ctx.duration_ms = int((time.time() - t0) * 1000)
168
+
169
+ ctx.write_runlog(f"ARCHIVE: {archive_path}")
170
+
171
+ ctx.emit(f"✅ Archive created: {archive_path}")
172
+ if ctx.keep_workdir:
173
+ ctx.emit(f"📁 Workdir kept: {ctx.workdir}")
174
+
175
+ if not ctx.keep_workdir:
176
+ shutil.rmtree(ctx.workdir, ignore_errors=True)
177
+
178
+ if any_fail and ctx.strict:
179
+ return 10
180
+ 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
+ ]