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.
- gwc_pybundle-0.4.2.dist-info/METADATA +476 -0
- gwc_pybundle-0.4.2.dist-info/RECORD +34 -0
- gwc_pybundle-0.4.2.dist-info/WHEEL +5 -0
- gwc_pybundle-0.4.2.dist-info/entry_points.txt +2 -0
- gwc_pybundle-0.4.2.dist-info/licenses/LICENSE.md +25 -0
- gwc_pybundle-0.4.2.dist-info/top_level.txt +1 -0
- pybundle/__init__.py +0 -0
- pybundle/__main__.py +4 -0
- pybundle/cli.py +228 -0
- pybundle/context.py +232 -0
- pybundle/doctor.py +101 -0
- pybundle/manifest.py +78 -0
- pybundle/packaging.py +41 -0
- pybundle/policy.py +176 -0
- pybundle/profiles.py +146 -0
- pybundle/roadmap_model.py +38 -0
- pybundle/roadmap_scan.py +262 -0
- pybundle/root_detect.py +14 -0
- pybundle/runner.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 +300 -0
- pybundle/steps/error_refs.py +204 -0
- pybundle/steps/handoff_md.py +166 -0
- pybundle/steps/mypy.py +60 -0
- pybundle/steps/pytest.py +66 -0
- pybundle/steps/repro_md.py +161 -0
- pybundle/steps/rg_scans.py +78 -0
- pybundle/steps/roadmap.py +158 -0
- pybundle/steps/ruff.py +111 -0
- pybundle/steps/shell.py +67 -0
- pybundle/steps/tree.py +136 -0
- pybundle/tools.py +7 -0
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)
|