Python · 9671 bytes Raw Blame History
1 """Spec + artifacts → portable swaypack tarball (Sprint 26 / X3).
2
3 A ``swaypack`` is a single ``.tar.gz`` containing everything needed to
4 reproduce a sway run **without** hitting the user's home cache or the
5 network: the spec YAML, a copy of the source ``.dlm`` document (when
6 the spec resolves one), the null-stats cache entries the spec's probes
7 will look up, and an optional last-known-good golden JSON report.
8
9 Layout inside the tarball::
10
11 swaypack/
12 manifest.json # version + included artifacts + pack-time pinned versions
13 sway.yaml # the spec the user ran ``sway pack`` on (verbatim)
14 source.dlm # copied from spec.dlm_source if present (X3 — optional)
15 null-stats/
16 <key>.json # one per cache key the spec's probes might query
17 golden.json # last-known-good ``sway run`` report (optional)
18
19 Compression: stdlib ``tar.gz``. The sprint planning leaned toward
20 ``zstd`` for compactness but the dep cost (no zstandard in core) isn't
21 worth it for the typical 1–5 MB pack. Future: swap to zstd behind a
22 ``--compression`` flag.
23 """
24
25 from __future__ import annotations
26
27 import io
28 import json
29 import logging
30 import tarfile
31 import time
32 from dataclasses import dataclass
33 from pathlib import Path
34 from typing import TYPE_CHECKING
35
36 from dlm_sway import __version__
37 from dlm_sway.core.errors import SwayError
38 from dlm_sway.suite.loader import load_spec
39
40 if TYPE_CHECKING:
41 from dlm_sway.suite.spec import SwaySpec
42
43
44 logger = logging.getLogger(__name__)
45
46
47 SWAYPACK_VERSION = 1
48 """Bump this when the on-disk pack layout changes incompatibly."""
49
50 DEFAULT_MAX_PACK_SIZE_BYTES = 50 * 1024 * 1024
51 """50 MB cap. ``sway pack`` warns when the result exceeds this; use
52 ``--max-size`` to override."""
53
54
55 class PackError(SwayError):
56 """Raised when ``sway pack`` can't build a usable tarball."""
57
58
59 @dataclass(frozen=True, slots=True)
60 class PackReport:
61 """Result of a successful pack call.
62
63 Attributes
64 ----------
65 out_path:
66 Where the tarball was written.
67 size_bytes:
68 Final tarball size.
69 spec_path:
70 Source spec path (resolved to absolute).
71 section_bytes:
72 Bytes of source ``.dlm`` content packed (0 when no
73 ``dlm_source`` resolved).
74 null_stats_count:
75 Number of null-stats JSON entries packed.
76 golden_included:
77 Whether a golden report was bundled.
78 """
79
80 out_path: Path
81 size_bytes: int
82 spec_path: Path
83 section_bytes: int
84 null_stats_count: int
85 golden_included: bool
86
87
88 def pack_spec(
89 spec_path: Path,
90 *,
91 out_path: Path,
92 include_golden: Path | None = None,
93 include_null_cache: bool = True,
94 max_size_bytes: int = DEFAULT_MAX_PACK_SIZE_BYTES,
95 ) -> PackReport:
96 """Build a swaypack tarball at ``out_path``.
97
98 Parameters
99 ----------
100 spec_path:
101 Path to the ``sway.yaml`` to pack.
102 out_path:
103 Destination tarball (typically ``<name>.swaypack.tar.gz``).
104 Refuses to overwrite an existing file — caller must ``rm``
105 first to avoid silent clobber of a previous pack.
106 include_golden:
107 Optional path to a JSON ``sway run`` report to embed as
108 ``swaypack/golden.json`` for reproducibility comparison.
109 include_null_cache:
110 When True (default), copy any null-stats JSON files the
111 spec's probes might query from
112 ``$XDG_CACHE_HOME/dlm-sway/null-stats`` into the pack.
113 max_size_bytes:
114 Soft cap. The function builds the tarball regardless but
115 raises ``PackError`` *before* writing if it'll exceed this
116 cap. ``--max-size`` overrides on the CLI.
117
118 Returns
119 -------
120 PackReport
121 What was packed + final size.
122
123 Raises
124 ------
125 PackError
126 Spec invalid, output already exists, or final size exceeds
127 ``max_size_bytes``.
128 """
129 spec_path = Path(spec_path).expanduser().resolve()
130 out_path = Path(out_path).expanduser().resolve()
131
132 if out_path.exists():
133 raise PackError(f"refusing to overwrite existing pack at {out_path} — delete it first")
134
135 spec = load_spec(spec_path)
136
137 # Build the tarball in-memory first so we can size-cap before
138 # writing (the alternative — write then check + delete — risks
139 # a half-written file on disk if the cap fails).
140 buf = io.BytesIO()
141 section_bytes = 0
142 null_stats_count = 0
143 null_stats_keys: list[str] = []
144 with tarfile.open(fileobj=buf, mode="w:gz") as tar:
145 # 1) Spec verbatim.
146 _add_file_bytes(
147 tar,
148 "swaypack/sway.yaml",
149 spec_path.read_bytes(),
150 mtime=time.time(),
151 )
152
153 # 2) Source .dlm if the spec carries one.
154 if spec.dlm_source:
155 section_bytes = _add_dlm_source(tar, spec, spec_path)
156
157 # 3) Null-stats cache (optional).
158 if include_null_cache:
159 null_stats_count, null_stats_keys = _add_null_cache(tar, spec)
160
161 # 4) Golden report (optional).
162 golden_included = False
163 if include_golden is not None:
164 golden_included = _add_golden(tar, include_golden)
165
166 # 5) Manifest last so it sees the truth about what we packed.
167 manifest = {
168 "swaypack_version": SWAYPACK_VERSION,
169 "sway_version": __version__,
170 "packed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
171 "spec_filename": "sway.yaml",
172 "section_bytes": section_bytes,
173 "null_stats_count": null_stats_count,
174 "null_stats_keys": null_stats_keys,
175 "golden_included": golden_included,
176 "dlm_source_packed": spec.dlm_source is not None and section_bytes > 0,
177 }
178 _add_file_bytes(
179 tar,
180 "swaypack/manifest.json",
181 json.dumps(manifest, indent=2, sort_keys=True).encode("utf-8") + b"\n",
182 mtime=time.time(),
183 )
184
185 size = buf.tell()
186 if size > max_size_bytes:
187 mb = size / (1024 * 1024)
188 cap_mb = max_size_bytes / (1024 * 1024)
189 raise PackError(
190 f"pack would be {mb:.1f} MB which exceeds the cap of {cap_mb:.1f} MB. "
191 f"Pass --max-size <bytes> to override, or drop --include-null-cache."
192 )
193
194 out_path.parent.mkdir(parents=True, exist_ok=True)
195 out_path.write_bytes(buf.getvalue())
196
197 return PackReport(
198 out_path=out_path,
199 size_bytes=size,
200 spec_path=spec_path,
201 section_bytes=section_bytes,
202 null_stats_count=null_stats_count,
203 golden_included=golden_included,
204 )
205
206
207 def _add_file_bytes(tar: tarfile.TarFile, arcname: str, data: bytes, *, mtime: float) -> None:
208 info = tarfile.TarInfo(name=arcname)
209 info.size = len(data)
210 info.mtime = int(mtime)
211 info.mode = 0o644
212 tar.addfile(info, io.BytesIO(data))
213
214
215 def _add_dlm_source(tar: tarfile.TarFile, spec: SwaySpec, spec_path: Path) -> int:
216 """Copy the spec's ``dlm_source`` into the pack.
217
218 Resolution: if dlm_source is absolute, use it. If relative, resolve
219 against the spec file's directory (matches the runner's autogen
220 convention).
221 """
222 if spec.dlm_source is None:
223 return 0
224 src = Path(spec.dlm_source).expanduser()
225 if not src.is_absolute():
226 src = (spec_path.parent / src).resolve()
227 if not src.exists():
228 logger.warning(
229 "dlm_source=%s doesn't exist on disk; pack will be missing source.dlm",
230 spec.dlm_source,
231 )
232 return 0
233 data = src.read_bytes()
234 _add_file_bytes(tar, "swaypack/source.dlm", data, mtime=src.stat().st_mtime)
235 return len(data)
236
237
238 def _add_null_cache(tar: tarfile.TarFile, spec: SwaySpec) -> tuple[int, list[str]]:
239 """Copy null-stats JSON entries that the spec's probes might query.
240
241 Conservative scope: copy *every* JSON in
242 ``$XDG_CACHE_HOME/dlm-sway/null-stats/`` (or the legacy home cache).
243 Per-probe key matching is over-engineering — most users have
244 O(10) cached entries, so the pack's max-size cap catches blowups.
245 """
246 del spec # We pack the whole cache; per-spec filtering deferred.
247
248 # Same root resolution as _null_cache._cache_root, but without
249 # honoring SWAY_NULL_CACHE_DIR (we pack from the user's HOME
250 # cache, not from another pack — that would be a no-op cycle).
251 import os
252
253 xdg = os.environ.get("XDG_CACHE_HOME")
254 if xdg:
255 cache_root = Path(xdg).expanduser() / "dlm-sway" / "null-stats"
256 else:
257 cache_root = Path.home() / ".dlm-sway" / "null-stats"
258
259 if not cache_root.exists() or not cache_root.is_dir():
260 return 0, []
261
262 count = 0
263 keys: list[str] = []
264 for entry in sorted(cache_root.iterdir()):
265 if not entry.is_file() or entry.suffix != ".json":
266 continue
267 data = entry.read_bytes()
268 _add_file_bytes(
269 tar,
270 f"swaypack/null-stats/{entry.name}",
271 data,
272 mtime=entry.stat().st_mtime,
273 )
274 count += 1
275 keys.append(entry.stem)
276 return count, keys
277
278
279 def _add_golden(tar: tarfile.TarFile, golden_path: Path) -> bool:
280 """Bundle a known-good ``sway run`` report for verification."""
281 golden_path = Path(golden_path).expanduser()
282 if not golden_path.exists():
283 raise PackError(f"--include-golden path does not exist: {golden_path}")
284 if not golden_path.is_file():
285 raise PackError(f"--include-golden must be a file, got: {golden_path}")
286 data = golden_path.read_bytes()
287 _add_file_bytes(
288 tar,
289 "swaypack/golden.json",
290 data,
291 mtime=golden_path.stat().st_mtime,
292 )
293 return True