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