@@ -0,0 +1,293 @@ |
| 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 |