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/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from .context import BundleContext, RunOptions
|
|
8
|
+
from .profiles import get_profile
|
|
9
|
+
from .root_detect import detect_project_root
|
|
10
|
+
from importlib.metadata import PackageNotFoundError, version as pkg_version
|
|
11
|
+
from .runner import run_profile
|
|
12
|
+
|
|
13
|
+
def get_version() -> str:
|
|
14
|
+
# 1) Canonical for installed distributions (including editable)
|
|
15
|
+
try:
|
|
16
|
+
return pkg_version("pybundle")
|
|
17
|
+
except PackageNotFoundError:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
# 2) Dev fallback: locate pyproject.toml by walking up from this file
|
|
21
|
+
try:
|
|
22
|
+
import tomllib # py3.11+
|
|
23
|
+
except Exception:
|
|
24
|
+
return "unknown"
|
|
25
|
+
|
|
26
|
+
here = Path(__file__).resolve()
|
|
27
|
+
for parent in [here.parent] + list(here.parents):
|
|
28
|
+
pp = parent / "pyproject.toml"
|
|
29
|
+
if pp.is_file():
|
|
30
|
+
try:
|
|
31
|
+
data = tomllib.loads(pp.read_text(encoding="utf-8"))
|
|
32
|
+
return data.get("project", {}).get("version", "unknown")
|
|
33
|
+
except Exception:
|
|
34
|
+
return "unknown"
|
|
35
|
+
|
|
36
|
+
return "unknown"
|
|
37
|
+
|
|
38
|
+
def add_common_args(sp: argparse.ArgumentParser) -> None:
|
|
39
|
+
sp.add_argument(
|
|
40
|
+
"--project-root",
|
|
41
|
+
type=Path,
|
|
42
|
+
default=None,
|
|
43
|
+
help="Explicit project root (skip auto-detect)",
|
|
44
|
+
)
|
|
45
|
+
sp.add_argument(
|
|
46
|
+
"--outdir",
|
|
47
|
+
type=Path,
|
|
48
|
+
default=None,
|
|
49
|
+
help="Output directory (default: <root>/artifacts)",
|
|
50
|
+
)
|
|
51
|
+
sp.add_argument("--name", default=None, help="Override archive name prefix")
|
|
52
|
+
sp.add_argument(
|
|
53
|
+
"--strict", action="store_true", help="Fail non-zero if any step fails"
|
|
54
|
+
)
|
|
55
|
+
sp.add_argument(
|
|
56
|
+
"--no-spinner", action="store_true", help="Disable spinner output (CI-friendly)"
|
|
57
|
+
)
|
|
58
|
+
sp.add_argument(
|
|
59
|
+
"--redact",
|
|
60
|
+
action=argparse.BooleanOptionalAction,
|
|
61
|
+
default=True,
|
|
62
|
+
help="Redact secrets in logs/text",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _resolve_profile_defaults(profile: str, o: RunOptions) -> RunOptions:
|
|
66
|
+
if profile == "ai":
|
|
67
|
+
# AI defaults: skip slow/flake-prone tools unless explicitly enabled
|
|
68
|
+
return RunOptions(
|
|
69
|
+
**{
|
|
70
|
+
**o.__dict__,
|
|
71
|
+
"no_ruff": o.no_ruff if o.no_ruff is not None else True,
|
|
72
|
+
"no_mypy": o.no_mypy if o.no_mypy is not None else True,
|
|
73
|
+
"no_pytest":o.no_pytest if o.no_pytest is not None else True,
|
|
74
|
+
"no_rg": o.no_rg if o.no_rg is not None else True,
|
|
75
|
+
"no_error_refs": o.no_error_refs if o.no_error_refs is not None else True,
|
|
76
|
+
"no_context": o.no_context if o.no_context is not None else True,
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return o
|
|
80
|
+
|
|
81
|
+
def add_run_only_args(sp: argparse.ArgumentParser) -> None:
|
|
82
|
+
sp.add_argument(
|
|
83
|
+
"--format",
|
|
84
|
+
choices=["auto", "zip", "tar.gz"],
|
|
85
|
+
default="auto",
|
|
86
|
+
help="Archive format",
|
|
87
|
+
)
|
|
88
|
+
sp.add_argument(
|
|
89
|
+
"--clean-workdir",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Delete expanded workdir after packaging",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def add_knobs(sp: argparse.ArgumentParser) -> None:
|
|
95
|
+
# selective skips
|
|
96
|
+
sp.add_argument("--ruff", dest="no_ruff", action="store_false", default=None)
|
|
97
|
+
sp.add_argument("--no-ruff", dest="no_ruff", action="store_true", default=None)
|
|
98
|
+
sp.add_argument("--mypy", dest="no_mypy", action="store_false", default=None)
|
|
99
|
+
sp.add_argument("--no-mypy", dest="no_mypy", action="store_true", default=None)
|
|
100
|
+
sp.add_argument("--pytest", dest="no_pytest", action="store_false", default=None)
|
|
101
|
+
sp.add_argument("--no-pytest", dest="no_pytest", action="store_true", default=None)
|
|
102
|
+
sp.add_argument("--rg", dest="no_rg", action="store_false", default=None)
|
|
103
|
+
sp.add_argument("--no-rg", dest="no_rg", action="store_true", default=None)
|
|
104
|
+
sp.add_argument("--error-refs", dest="no_error_refs", action="store_false", default=None)
|
|
105
|
+
sp.add_argument("--no-error-refs", dest="no_error_refs", action="store_true", default=None)
|
|
106
|
+
sp.add_argument("--context", dest="no_context", action="store_false", default=None)
|
|
107
|
+
sp.add_argument("--no-context", dest="no_context", action="store_true", default=None)
|
|
108
|
+
|
|
109
|
+
# targets / args
|
|
110
|
+
sp.add_argument("--ruff-target", default=".")
|
|
111
|
+
sp.add_argument("--mypy-target", default=".")
|
|
112
|
+
sp.add_argument(
|
|
113
|
+
"--pytest-args",
|
|
114
|
+
default="-q",
|
|
115
|
+
help='Pytest args as a single string, e.g. "--maxfail=1 -q"',
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# caps
|
|
119
|
+
sp.add_argument("--error-max-files", type=int, default=250)
|
|
120
|
+
sp.add_argument("--context-depth", type=int, default=2)
|
|
121
|
+
sp.add_argument("--context-max-files", type=int, default=600)
|
|
122
|
+
|
|
123
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
124
|
+
p = argparse.ArgumentParser(
|
|
125
|
+
prog="pybundle", description="Build portable diagnostic bundles for projects."
|
|
126
|
+
)
|
|
127
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
128
|
+
sub.add_parser("version", help="Show version")
|
|
129
|
+
sub.add_parser("list-profiles", help="List available profiles")
|
|
130
|
+
|
|
131
|
+
runp = sub.add_parser("run", help="Run a profile and build an archive")
|
|
132
|
+
runp.add_argument("profile", choices=["analysis", "debug", "backup", "ai"])
|
|
133
|
+
add_common_args(runp)
|
|
134
|
+
add_run_only_args(runp)
|
|
135
|
+
add_knobs(runp)
|
|
136
|
+
|
|
137
|
+
docp = sub.add_parser("doctor", help="Show tool availability and what would run")
|
|
138
|
+
docp.add_argument(
|
|
139
|
+
"profile",
|
|
140
|
+
choices=["analysis", "debug", "backup", "ai"],
|
|
141
|
+
nargs="?",
|
|
142
|
+
default="analysis"
|
|
143
|
+
)
|
|
144
|
+
add_common_args(docp)
|
|
145
|
+
add_knobs(docp)
|
|
146
|
+
|
|
147
|
+
return p
|
|
148
|
+
|
|
149
|
+
def _build_options(args) -> RunOptions:
|
|
150
|
+
pytest_args = (
|
|
151
|
+
shlex.split(args.pytest_args) if getattr(args, "pytest_args", None) else ["-q"]
|
|
152
|
+
)
|
|
153
|
+
return RunOptions(
|
|
154
|
+
no_ruff=getattr(args, "no_ruff", None),
|
|
155
|
+
no_mypy=getattr(args, "no_mypy", None),
|
|
156
|
+
no_pytest=getattr(args, "no_pytest", None),
|
|
157
|
+
no_rg=getattr(args, "no_rg", None),
|
|
158
|
+
no_error_refs=getattr(args, "no_error_refs", None),
|
|
159
|
+
no_context=getattr(args, "no_context", None),
|
|
160
|
+
ruff_target=getattr(args, "ruff_target", "."),
|
|
161
|
+
mypy_target=getattr(args, "mypy_target", "."),
|
|
162
|
+
pytest_args=pytest_args,
|
|
163
|
+
error_max_files=getattr(args, "error_max_files", 250),
|
|
164
|
+
context_depth=getattr(args, "context_depth", 2),
|
|
165
|
+
context_max_files=getattr(args, "context_max_files", 600),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def main(argv: list[str] | None = None) -> int:
|
|
169
|
+
args = build_parser().parse_args(argv)
|
|
170
|
+
|
|
171
|
+
if args.cmd == "version":
|
|
172
|
+
print(f"pybundle {get_version()}")
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
if args.cmd == "list-profiles":
|
|
176
|
+
print("ai - AI-friendly context bundle (fast, low-flake defaults)")
|
|
177
|
+
print("backup - portable snapshot (scaffold)")
|
|
178
|
+
print("analysis - neutral diagnostic bundle (humans, tools, assistants)")
|
|
179
|
+
print("debug - deeper diagnostics for developers")
|
|
180
|
+
return 0
|
|
181
|
+
|
|
182
|
+
# run + doctor need a root
|
|
183
|
+
root = args.project_root or detect_project_root(Path.cwd())
|
|
184
|
+
if root is None:
|
|
185
|
+
print("❌ Could not detect project root. Use --project-root PATH.")
|
|
186
|
+
return 20
|
|
187
|
+
|
|
188
|
+
outdir = args.outdir or (root / "artifacts")
|
|
189
|
+
|
|
190
|
+
options = _resolve_profile_defaults(args.profile, _build_options(args))
|
|
191
|
+
profile = get_profile(args.profile, options)
|
|
192
|
+
|
|
193
|
+
if args.cmd == "doctor":
|
|
194
|
+
ctx = BundleContext.create(
|
|
195
|
+
root=root,
|
|
196
|
+
outdir=outdir,
|
|
197
|
+
profile_name=args.profile,
|
|
198
|
+
archive_format="auto",
|
|
199
|
+
name_prefix=args.name,
|
|
200
|
+
strict=args.strict,
|
|
201
|
+
redact=args.redact,
|
|
202
|
+
spinner=not args.no_spinner,
|
|
203
|
+
keep_workdir=True,
|
|
204
|
+
options=options,
|
|
205
|
+
)
|
|
206
|
+
ctx.print_doctor(profile)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
# cmd == run
|
|
210
|
+
keep_workdir = not args.clean_workdir
|
|
211
|
+
|
|
212
|
+
ctx = BundleContext.create(
|
|
213
|
+
root=root,
|
|
214
|
+
outdir=outdir,
|
|
215
|
+
profile_name=args.profile,
|
|
216
|
+
archive_format=args.format,
|
|
217
|
+
name_prefix=args.name,
|
|
218
|
+
strict=args.strict,
|
|
219
|
+
redact=args.redact,
|
|
220
|
+
spinner=not args.no_spinner,
|
|
221
|
+
keep_workdir=keep_workdir,
|
|
222
|
+
options=options,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return run_profile(ctx, profile)
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
pybundle/context.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field, asdict
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from .tools import which
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .steps.base import StepResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fmt_tool(path: str | None) -> str:
|
|
16
|
+
if path:
|
|
17
|
+
return path
|
|
18
|
+
return "\x1b[31m<missing>\x1b[0m"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Tooling:
|
|
23
|
+
git: str | None
|
|
24
|
+
python: str | None
|
|
25
|
+
pip: str | None
|
|
26
|
+
zip: str | None
|
|
27
|
+
tar: str | None
|
|
28
|
+
uname: str | None
|
|
29
|
+
|
|
30
|
+
# analysis/debug tools
|
|
31
|
+
ruff: str | None
|
|
32
|
+
mypy: str | None
|
|
33
|
+
pytest: str | None
|
|
34
|
+
rg: str | None
|
|
35
|
+
tree: str | None
|
|
36
|
+
npm: str | None
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def detect() -> "Tooling":
|
|
40
|
+
return Tooling(
|
|
41
|
+
git=which("git"),
|
|
42
|
+
python=which("python") or which("python3"),
|
|
43
|
+
pip=which("pip") or which("pip3"),
|
|
44
|
+
zip=which("zip"),
|
|
45
|
+
tar=which("tar"),
|
|
46
|
+
uname=which("uname"),
|
|
47
|
+
ruff=which("ruff"),
|
|
48
|
+
mypy=which("mypy"),
|
|
49
|
+
pytest=which("pytest"),
|
|
50
|
+
rg=which("rg"),
|
|
51
|
+
tree=which("tree"),
|
|
52
|
+
npm=which("npm"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class RunOptions:
|
|
58
|
+
no_ruff: bool | None = None
|
|
59
|
+
no_mypy: bool | None = None
|
|
60
|
+
no_pytest: bool | None = None
|
|
61
|
+
no_rg: bool | None = None
|
|
62
|
+
no_error_refs: bool | None = None
|
|
63
|
+
no_context: bool | None = None
|
|
64
|
+
no_compileall: bool | None = None
|
|
65
|
+
|
|
66
|
+
ruff_target: str = "."
|
|
67
|
+
mypy_target: str = "."
|
|
68
|
+
pytest_args: list[str] = field(default_factory=lambda: ["-q"])
|
|
69
|
+
|
|
70
|
+
error_max_files: int = 250
|
|
71
|
+
context_depth: int = 2
|
|
72
|
+
context_max_files: int = 600
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class BundleContext:
|
|
77
|
+
root: Path
|
|
78
|
+
options: RunOptions
|
|
79
|
+
outdir: Path
|
|
80
|
+
profile_name: str
|
|
81
|
+
ts: str
|
|
82
|
+
workdir: Path
|
|
83
|
+
srcdir: Path
|
|
84
|
+
logdir: Path
|
|
85
|
+
metadir: Path
|
|
86
|
+
runlog: Path
|
|
87
|
+
summary_json: Path
|
|
88
|
+
manifest_json: Path
|
|
89
|
+
archive_format: str
|
|
90
|
+
name_prefix: str
|
|
91
|
+
strict: bool
|
|
92
|
+
redact: bool
|
|
93
|
+
spinner: bool
|
|
94
|
+
keep_workdir: bool
|
|
95
|
+
tools: Tooling
|
|
96
|
+
results: list["StepResult"] = field(default_factory=list)
|
|
97
|
+
command_used: str = ""
|
|
98
|
+
|
|
99
|
+
def have(self, cmd: str) -> bool:
|
|
100
|
+
return getattr(self.tools, cmd, None) is not None
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def utc_ts() -> str:
|
|
104
|
+
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def create(
|
|
108
|
+
cls,
|
|
109
|
+
*,
|
|
110
|
+
root: Path,
|
|
111
|
+
options: RunOptions | None = None,
|
|
112
|
+
outdir: Path,
|
|
113
|
+
profile_name: str,
|
|
114
|
+
archive_format: str,
|
|
115
|
+
name_prefix: str | None,
|
|
116
|
+
strict: bool,
|
|
117
|
+
redact: bool,
|
|
118
|
+
spinner: bool,
|
|
119
|
+
keep_workdir: bool,
|
|
120
|
+
) -> "BundleContext":
|
|
121
|
+
ts = cls.utc_ts()
|
|
122
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
|
|
124
|
+
workdir = outdir / f"pybundle_{profile_name}_{ts}"
|
|
125
|
+
srcdir = workdir / "src"
|
|
126
|
+
logdir = workdir / "logs"
|
|
127
|
+
metadir = workdir / "meta"
|
|
128
|
+
|
|
129
|
+
srcdir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
131
|
+
metadir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
runlog = workdir / "RUN_LOG.txt"
|
|
134
|
+
summary_json = workdir / "SUMMARY.json"
|
|
135
|
+
manifest_json = workdir / "MANIFEST.json"
|
|
136
|
+
|
|
137
|
+
tools = Tooling.detect()
|
|
138
|
+
prefix = name_prefix or f"pybundle_{profile_name}_{ts}"
|
|
139
|
+
|
|
140
|
+
options = options or RunOptions()
|
|
141
|
+
|
|
142
|
+
return cls(
|
|
143
|
+
root=root,
|
|
144
|
+
options=options,
|
|
145
|
+
outdir=outdir,
|
|
146
|
+
profile_name=profile_name,
|
|
147
|
+
ts=ts,
|
|
148
|
+
workdir=workdir,
|
|
149
|
+
srcdir=srcdir,
|
|
150
|
+
logdir=logdir,
|
|
151
|
+
metadir=metadir,
|
|
152
|
+
runlog=runlog,
|
|
153
|
+
summary_json=summary_json,
|
|
154
|
+
manifest_json=manifest_json,
|
|
155
|
+
archive_format=archive_format,
|
|
156
|
+
name_prefix=prefix,
|
|
157
|
+
strict=strict,
|
|
158
|
+
redact=redact,
|
|
159
|
+
spinner=spinner,
|
|
160
|
+
keep_workdir=keep_workdir,
|
|
161
|
+
tools=tools,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def rel(self, p: Path) -> str:
|
|
165
|
+
try:
|
|
166
|
+
return str(p.relative_to(self.root))
|
|
167
|
+
except Exception:
|
|
168
|
+
return str(p)
|
|
169
|
+
|
|
170
|
+
def redact_text(self, text: str) -> str:
|
|
171
|
+
if not self.redact:
|
|
172
|
+
return text
|
|
173
|
+
# Minimal default redaction rules (you can expand with a rules file later)
|
|
174
|
+
rules: Iterable[tuple[str, str]] = [
|
|
175
|
+
(
|
|
176
|
+
r"(?i)(api[_-]?key)\s*[:=]\s*['\"]?([A-Za-z0-9_\-]{10,})",
|
|
177
|
+
r"\1=<REDACTED>",
|
|
178
|
+
),
|
|
179
|
+
(r"(?i)(token)\s*[:=]\s*['\"]?([A-Za-z0-9_\-\.]{10,})", r"\1=<REDACTED>"),
|
|
180
|
+
(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"]?([^'\"\s]+)", r"\1=<REDACTED>"),
|
|
181
|
+
(r"(?i)(dsn)\s*[:=]\s*['\"]?([^'\"\s]+)", r"\1=<REDACTED>"),
|
|
182
|
+
]
|
|
183
|
+
out = text
|
|
184
|
+
for pat, repl in rules:
|
|
185
|
+
out = re.sub(pat, repl, out)
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
def write_runlog(self, line: str) -> None:
|
|
189
|
+
self.runlog.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
with self.runlog.open("a", encoding="utf-8") as f:
|
|
191
|
+
f.write(line.rstrip() + "\n")
|
|
192
|
+
|
|
193
|
+
def print_doctor(self, profile) -> None:
|
|
194
|
+
print(f"Root: {self.root}")
|
|
195
|
+
print(f"Out: {self.outdir}\n")
|
|
196
|
+
|
|
197
|
+
# Tools (keep your existing output)
|
|
198
|
+
print("Tools:")
|
|
199
|
+
for k, v in asdict(self.tools).items():
|
|
200
|
+
print(f"{k:>8}: {fmt_tool(v)}")
|
|
201
|
+
print()
|
|
202
|
+
|
|
203
|
+
# Options (super useful)
|
|
204
|
+
print("Options:")
|
|
205
|
+
o = self.options
|
|
206
|
+
print(f" ruff_target: {o.ruff_target}")
|
|
207
|
+
print(f" mypy_target: {o.mypy_target}")
|
|
208
|
+
print(f" pytest_args: {' '.join(o.pytest_args)}")
|
|
209
|
+
print(f" no_ruff: {o.no_ruff}")
|
|
210
|
+
print(f" no_mypy: {o.no_mypy}")
|
|
211
|
+
print(f" no_pytest: {o.no_pytest}")
|
|
212
|
+
print(f" no_rg: {o.no_rg}")
|
|
213
|
+
print(f" no_error_refs: {o.no_error_refs}")
|
|
214
|
+
print(f" no_context: {o.no_context}")
|
|
215
|
+
print(f" error_max_files: {o.error_max_files}")
|
|
216
|
+
print(f" context_depth: {o.context_depth}")
|
|
217
|
+
print(f" context_max_files: {o.context_max_files}")
|
|
218
|
+
print()
|
|
219
|
+
|
|
220
|
+
# Plan
|
|
221
|
+
from .doctor import plan_for_profile # local import avoids circulars
|
|
222
|
+
|
|
223
|
+
plan = plan_for_profile(self, profile)
|
|
224
|
+
|
|
225
|
+
print(f"Plan ({profile.name}):")
|
|
226
|
+
for item in plan:
|
|
227
|
+
out = f" -> {item.out_rel}" if item.out_rel else ""
|
|
228
|
+
if item.status == "RUN":
|
|
229
|
+
print(f" RUN {item.name:<28}{out}")
|
|
230
|
+
else:
|
|
231
|
+
why = f" ({item.reason})" if item.reason else ""
|
|
232
|
+
print(f" SKIP {item.name:<28}{out}{why}")
|
pybundle/doctor.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable, TypeVar
|
|
5
|
+
|
|
6
|
+
from .steps.shell import ShellStep
|
|
7
|
+
from .steps.ruff import RuffCheckStep, RuffFormatCheckStep
|
|
8
|
+
from .steps.mypy import MypyStep
|
|
9
|
+
from .steps.pytest import PytestStep
|
|
10
|
+
from .steps.rg_scans import RipgrepScanStep
|
|
11
|
+
|
|
12
|
+
T = TypeVar("T")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PlanItem:
|
|
17
|
+
name: str
|
|
18
|
+
status: str # "RUN" | "SKIP"
|
|
19
|
+
out_rel: str | None
|
|
20
|
+
reason: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
EvalFn = Callable[[Any, T], PlanItem]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _out(step: Any) -> str | None:
|
|
27
|
+
return (
|
|
28
|
+
getattr(step, "out_rel", None)
|
|
29
|
+
or getattr(step, "outfile_rel", None)
|
|
30
|
+
or getattr(step, "outfile", None)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def eval_shell(ctx: Any, step: ShellStep) -> PlanItem:
|
|
35
|
+
if step.require_cmd and not ctx.have(step.require_cmd):
|
|
36
|
+
return PlanItem(
|
|
37
|
+
step.name, "SKIP", _out(step), f"missing tool: {step.require_cmd}"
|
|
38
|
+
)
|
|
39
|
+
return PlanItem(step.name, "RUN", _out(step), "")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def eval_ruff(ctx: Any, step: Any) -> PlanItem:
|
|
43
|
+
if ctx.options.no_ruff:
|
|
44
|
+
return PlanItem(step.name, "SKIP", _out(step), "disabled by --no-ruff")
|
|
45
|
+
if not ctx.have("ruff"):
|
|
46
|
+
return PlanItem(step.name, "SKIP", _out(step), "missing tool: ruff")
|
|
47
|
+
return PlanItem(step.name, "RUN", _out(step), "")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def eval_mypy(ctx: Any, step: MypyStep) -> PlanItem:
|
|
51
|
+
if ctx.options.no_mypy:
|
|
52
|
+
return PlanItem(step.name, "SKIP", _out(step), "disabled by --no-mypy")
|
|
53
|
+
if not ctx.have("mypy"):
|
|
54
|
+
return PlanItem(step.name, "SKIP", _out(step), "missing tool: mypy")
|
|
55
|
+
return PlanItem(step.name, "RUN", _out(step), "")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def eval_pytest(ctx: Any, step: PytestStep) -> PlanItem:
|
|
59
|
+
if ctx.options.no_pytest:
|
|
60
|
+
return PlanItem(step.name, "SKIP", _out(step), "disabled by --no-pytest")
|
|
61
|
+
if not ctx.have("pytest"):
|
|
62
|
+
return PlanItem(step.name, "SKIP", _out(step), "missing tool: pytest")
|
|
63
|
+
if (
|
|
64
|
+
not (ctx.root / "tests").exists()
|
|
65
|
+
and not (ctx.root / "sentra" / "tests").exists()
|
|
66
|
+
):
|
|
67
|
+
return PlanItem(step.name, "SKIP", _out(step), "no tests/ directory found")
|
|
68
|
+
return PlanItem(step.name, "RUN", _out(step), "")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def eval_rg(ctx: Any, step: Any) -> PlanItem:
|
|
72
|
+
if ctx.options.no_rg:
|
|
73
|
+
return PlanItem(step.name, "SKIP", _out(step), "disabled by --no-rg")
|
|
74
|
+
if not ctx.have("rg"):
|
|
75
|
+
return PlanItem(step.name, "SKIP", _out(step), "missing tool: rg")
|
|
76
|
+
return PlanItem(step.name, "RUN", _out(step), "")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
REGISTRY: list[tuple[type[Any], Callable[[Any, Any], PlanItem]]] = [
|
|
80
|
+
(ShellStep, eval_shell),
|
|
81
|
+
(RuffCheckStep, eval_ruff),
|
|
82
|
+
(RuffFormatCheckStep, eval_ruff),
|
|
83
|
+
(MypyStep, eval_mypy),
|
|
84
|
+
(PytestStep, eval_pytest),
|
|
85
|
+
(RipgrepScanStep, eval_rg),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def plan_for_profile(ctx: Any, profile: Any) -> list[PlanItem]:
|
|
90
|
+
items: list[PlanItem] = []
|
|
91
|
+
for step in profile.steps:
|
|
92
|
+
item: PlanItem | None = None
|
|
93
|
+
|
|
94
|
+
for cls, fn in REGISTRY:
|
|
95
|
+
if isinstance(step, cls):
|
|
96
|
+
item = fn(ctx, step)
|
|
97
|
+
break
|
|
98
|
+
if item is None:
|
|
99
|
+
item = PlanItem(step.name, "RUN", _out(step), "")
|
|
100
|
+
items.append(item)
|
|
101
|
+
return items
|
pybundle/manifest.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .context import BundleContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git_commit_hash(root: Path, git_path: str | None) -> str | None:
|
|
13
|
+
"""Return HEAD commit hash if root is inside a git repo, else None."""
|
|
14
|
+
if not git_path:
|
|
15
|
+
return None
|
|
16
|
+
try:
|
|
17
|
+
# If this fails, we're not in a repo or git isn't functional.
|
|
18
|
+
p = subprocess.run(
|
|
19
|
+
[git_path, "rev-parse", "HEAD"],
|
|
20
|
+
cwd=str(root),
|
|
21
|
+
capture_output=True,
|
|
22
|
+
text=True,
|
|
23
|
+
check=True,
|
|
24
|
+
)
|
|
25
|
+
return p.stdout.strip() or None
|
|
26
|
+
except Exception:
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_manifest(
|
|
31
|
+
*,
|
|
32
|
+
ctx: BundleContext,
|
|
33
|
+
profile_name: str,
|
|
34
|
+
archive_path: Path,
|
|
35
|
+
archive_format_used: str,
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Write a stable, machine-readable manifest for automation."""
|
|
38
|
+
git_hash = _git_commit_hash(ctx.root, ctx.tools.git)
|
|
39
|
+
|
|
40
|
+
manifest: dict[str, Any] = {
|
|
41
|
+
"schema_version": 1,
|
|
42
|
+
"tool": {"name": "pybundle"},
|
|
43
|
+
"timestamp_utc": ctx.ts,
|
|
44
|
+
"profile": profile_name,
|
|
45
|
+
"paths": {
|
|
46
|
+
"root": str(ctx.root),
|
|
47
|
+
"workdir": str(ctx.workdir),
|
|
48
|
+
"srcdir": str(ctx.srcdir),
|
|
49
|
+
"logdir": str(ctx.logdir),
|
|
50
|
+
"metadir": str(ctx.metadir),
|
|
51
|
+
},
|
|
52
|
+
"outputs": {
|
|
53
|
+
"archive": {
|
|
54
|
+
"path": str(archive_path),
|
|
55
|
+
"name": archive_path.name,
|
|
56
|
+
"format": archive_format_used,
|
|
57
|
+
},
|
|
58
|
+
"summary_json": str(ctx.summary_json),
|
|
59
|
+
"manifest_json": str(ctx.manifest_json),
|
|
60
|
+
"runlog": str(ctx.runlog),
|
|
61
|
+
},
|
|
62
|
+
"options": asdict(ctx.options),
|
|
63
|
+
"run": {
|
|
64
|
+
"strict": ctx.strict,
|
|
65
|
+
"redact": ctx.redact,
|
|
66
|
+
"spinner": ctx.spinner,
|
|
67
|
+
"keep_workdir": ctx.keep_workdir,
|
|
68
|
+
"archive_format_requested": ctx.archive_format,
|
|
69
|
+
"name_prefix": ctx.name_prefix,
|
|
70
|
+
},
|
|
71
|
+
"tools": asdict(ctx.tools),
|
|
72
|
+
"git": {"commit": git_hash},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ctx.manifest_json.write_text(
|
|
76
|
+
json.dumps(manifest, indent=2, sort_keys=True),
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
pybundle/packaging.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .context import BundleContext
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def resolve_archive_format(ctx: BundleContext) -> str:
|
|
10
|
+
fmt_used = ctx.archive_format
|
|
11
|
+
if fmt_used == "auto":
|
|
12
|
+
fmt_used = "zip" if ctx.tools.zip else "tar.gz"
|
|
13
|
+
return fmt_used
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def archive_output_path(ctx: BundleContext, fmt_used: str) -> Path:
|
|
17
|
+
if fmt_used == "zip":
|
|
18
|
+
return ctx.outdir / f"{ctx.name_prefix}.zip"
|
|
19
|
+
return ctx.outdir / f"{ctx.name_prefix}.tar.gz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_archive(ctx: BundleContext) -> tuple[Path, str]:
|
|
23
|
+
fmt_used = resolve_archive_format(ctx)
|
|
24
|
+
|
|
25
|
+
if fmt_used == "zip":
|
|
26
|
+
out = archive_output_path(ctx, fmt_used)
|
|
27
|
+
# zip wants working dir above target folder
|
|
28
|
+
subprocess.run(
|
|
29
|
+
["zip", "-qr", str(out), ctx.workdir.name],
|
|
30
|
+
cwd=str(ctx.workdir.parent),
|
|
31
|
+
check=False,
|
|
32
|
+
)
|
|
33
|
+
return out, fmt_used
|
|
34
|
+
|
|
35
|
+
out = archive_output_path(ctx, fmt_used)
|
|
36
|
+
subprocess.run(
|
|
37
|
+
["tar", "-czf", str(out), ctx.workdir.name],
|
|
38
|
+
cwd=str(ctx.workdir.parent),
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
return out, fmt_used
|