gwc-pybundle 0.4.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.
pybundle/runner.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import asdict
6
+
7
+ from .context import BundleContext
8
+ from .packaging import archive_output_path, make_archive, resolve_archive_format
9
+ from .manifest import write_manifest
10
+ from .steps.base import StepResult
11
+
12
+
13
+ def run_profile(ctx: BundleContext, profile) -> int:
14
+ ctx.write_runlog(f"=== pybundle run {profile.name} ===")
15
+ ctx.write_runlog(f"ROOT: {ctx.root}")
16
+ ctx.write_runlog(f"WORK: {ctx.workdir}")
17
+
18
+ ctx.results.clear()
19
+ results: list[StepResult] = ctx.results
20
+ any_fail = False
21
+
22
+ for step in profile.steps:
23
+ ctx.write_runlog(f"-- START: {step.name}")
24
+ r = step.run(ctx)
25
+ results.append(r)
26
+ ctx.results = results
27
+ ctx.write_runlog(
28
+ f"-- DONE: {step.name} [{r.status}] ({r.seconds}s) {r.note}".rstrip()
29
+ )
30
+ if r.status == "FAIL":
31
+ any_fail = True
32
+ if ctx.strict:
33
+ break
34
+
35
+ ctx.summary_json.write_text(
36
+ json.dumps(
37
+ {
38
+ "profile": profile.name,
39
+ "root": str(ctx.root),
40
+ "workdir": str(ctx.workdir),
41
+ "results": [asdict(r) for r in results],
42
+ },
43
+ indent=2,
44
+ ),
45
+ encoding="utf-8",
46
+ )
47
+
48
+ ctx.results = results
49
+
50
+ # Write the manifest BEFORE archiving so it's included inside the bundle.
51
+ archive_fmt_used = resolve_archive_format(ctx)
52
+ archive_path = archive_output_path(ctx, archive_fmt_used)
53
+ write_manifest(
54
+ ctx=ctx,
55
+ profile_name=profile.name,
56
+ archive_path=archive_path,
57
+ archive_format_used=archive_fmt_used,
58
+ )
59
+
60
+ archive_path, archive_fmt_used = make_archive(ctx)
61
+ ctx.write_runlog(f"ARCHIVE: {archive_path}")
62
+
63
+ print(f"✅ Archive created: {archive_path}")
64
+ if ctx.keep_workdir:
65
+ print(f"📁 Workdir kept: {ctx.workdir}")
66
+
67
+ if not ctx.keep_workdir:
68
+ shutil.rmtree(ctx.workdir, ignore_errors=True)
69
+
70
+ if any_fail and ctx.strict:
71
+ return 10
72
+ return 0
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
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(
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}")
@@ -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)