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/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