tenseleyflow/sway / ba0fe6e

Browse files

cli/_pack: build a swaypack tarball (spec + .dlm + null-stats + golden) (S26 X3-P3)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
ba0fe6e5cce2dc33543b7b5e15cd3f47320deb0d
Parents
36d3f1c
Tree
4886047

1 changed file

StatusFile+-
A src/dlm_sway/cli/_pack.py 293 0
src/dlm_sway/cli/_pack.pyadded
@@ -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