cat_object.py python
257 lines 8.5 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
1 """muse plumbing cat-object — read a stored object from the object store.
2
3 Reads the raw bytes of a content-addressed object and writes them to stdout.
4 Useful for inspecting stored blobs, verifying round-trips, or piping raw
5 content to other tools.
6
7 Single-object mode (default)
8 -----------------------------
9
10 With ``--format raw`` (default): bytes streamed directly to stdout at 64 KiB
11 at a time — no heap spike, no size ceiling.
12
13 With ``--format info`` / ``--json``: JSON metadata about the object (no
14 content emitted).
15
16 {"object_id": "<sha256>", "size_bytes": 1234, "present": true}
17
18 Batch mode
19 ----------
20
21 ``--batch`` reads object IDs from stdin (one per line) and for each emits the
22 batch-protocol header followed by the raw content::
23
24 <oid> blob <size>\n
25 <raw-content-bytes>\n
26
27 For missing or invalid OIDs the output is::
28
29 <oid> missing\n
30
31 ``--batch-check`` is the header-only variant — same protocol but no content
32 bytes are emitted. Useful for bulk presence checks without reading blobs.
33
34 Batch mode is analogous to ``git cat-file --batch`` / ``--batch-check`` and
35 is intended for agent pipelines and migration tools that need to stream many
36 objects efficiently from a single long-running process.
37
38 Plumbing contract
39 -----------------
40
41 - Exit 0: found — bytes written to stdout or metadata printed.
42 - Exit 1: not found in the store, or invalid object-id format.
43 - Exit 3: I/O error reading from the store.
44 - Batch mode always exits 0 (missing objects are reported inline, not as errors).
45
46 Agent use
47 ---------
48
49 Agents that only need metadata (size, presence check) should prefer
50 ``--json`` over ``--format raw`` to avoid pulling large blobs into their
51 context::
52
53 muse cat-object --json <oid>
54
55 For bulk reads in agent pipelines, use batch mode::
56
57 printf '%s\\n' <oid1> <oid2> | muse cat-object --batch
58 printf '%s\\n' <oid1> <oid2> | muse cat-object --batch-check
59 """
60
61 from __future__ import annotations
62
63 import argparse
64 import json
65 import logging
66 import pathlib
67 import sys
68
69 from muse.core.errors import ExitCode
70 from muse.core.object_store import has_object, object_path
71 from muse.core.repo import require_repo
72 from muse.core.validation import sanitize_display, validate_object_id
73
74 logger = logging.getLogger(__name__)
75
76 _FORMAT_CHOICES = ("raw", "info")
77 _CHUNK = 65536
78
79
80 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
81 """Register the cat-object subcommand."""
82 parser = subparsers.add_parser(
83 "cat-object",
84 help="Emit raw bytes of a stored object to stdout.",
85 description=__doc__,
86 formatter_class=argparse.RawDescriptionHelpFormatter,
87 )
88 parser.add_argument(
89 "object_id",
90 nargs="?",
91 default=None,
92 help=(
93 "SHA-256 object ID to read (64 hex chars). "
94 "Required in single-object mode; omit when using --batch or --batch-check."
95 ),
96 )
97
98 batch_group = parser.add_mutually_exclusive_group()
99 batch_group.add_argument(
100 "--batch",
101 action="store_true",
102 dest="batch",
103 help=(
104 "Batch mode: read object IDs from stdin (one per line) and emit "
105 "'<oid> blob <size>\\n<content>\\n' for each. "
106 "Missing or invalid OIDs emit '<oid> missing\\n'."
107 ),
108 )
109 batch_group.add_argument(
110 "--batch-check",
111 action="store_true",
112 dest="batch_check",
113 help=(
114 "Batch-check mode: like --batch but emits only the header line "
115 "'<oid> blob <size>\\n' — no content bytes. "
116 "Efficient for bulk presence checks."
117 ),
118 )
119
120 parser.add_argument(
121 "--format", "-f",
122 dest="fmt",
123 default="raw",
124 metavar="FORMAT",
125 help="Output format for single-object mode: raw (bytes to stdout) or info (JSON metadata). (default: raw)",
126 )
127 parser.add_argument(
128 "--json", action="store_const", const="info", dest="fmt",
129 help="Emit JSON metadata instead of raw bytes (alias for --format info).",
130 )
131 parser.set_defaults(func=run)
132
133
134 def _run_batch(root: "pathlib.Path", check_only: bool) -> None:
135 """Process OIDs from stdin in the git cat-file --batch protocol."""
136 out = sys.stdout.buffer
137
138 for raw_line in sys.stdin:
139 oid = raw_line.strip()
140 if not oid:
141 continue
142
143 # Validate format — invalid OIDs are reported as missing, not errors.
144 try:
145 validate_object_id(oid)
146 except ValueError:
147 out.write(f"{oid} missing\n".encode())
148 out.flush()
149 continue
150
151 if not has_object(root, oid):
152 out.write(f"{oid} missing\n".encode())
153 out.flush()
154 continue
155
156 obj = object_path(root, oid)
157 size = obj.stat().st_size
158 out.write(f"{oid} blob {size}\n".encode())
159
160 if not check_only:
161 with obj.open("rb") as fh:
162 for chunk in iter(lambda: fh.read(_CHUNK), b""):
163 out.write(chunk)
164 out.write(b"\n")
165
166 out.flush()
167
168
169 def run(args: argparse.Namespace) -> None:
170 """Read a stored object from the content-addressed object store.
171
172 Analogous to ``git cat-file``.
173
174 Single-object mode: with ``--format raw`` (default) the raw bytes are
175 streamed to stdout at 64 KiB at a time — suitable for piping or
176 redirection with no heap spike and no size ceiling. With ``--format info``
177 (or ``--json``) a JSON summary is printed without emitting the contents.
178
179 Batch mode (``--batch`` / ``--batch-check``): reads OIDs from stdin and
180 emits the batch protocol for each — one long-running process replaces N
181 subprocess spawns. Analogous to ``git cat-file --batch``.
182 """
183 batch: bool = args.batch
184 batch_check: bool = args.batch_check
185 fmt: str = args.fmt
186 object_id: str | None = args.object_id
187
188 # ── Batch mode ────────────────────────────────────────────────────────────
189 if batch or batch_check:
190 root = require_repo()
191 _run_batch(root, check_only=batch_check)
192 return
193
194 # ── Single-object mode ────────────────────────────────────────────────────
195 if object_id is None:
196 print(
197 "❌ object_id is required in single-object mode "
198 "(or use --batch / --batch-check for stdin processing).",
199 file=sys.stderr,
200 )
201 raise SystemExit(ExitCode.USER_ERROR)
202
203 if fmt not in _FORMAT_CHOICES:
204 print(
205 f"❌ Unknown format {fmt!r}. Valid choices: {', '.join(_FORMAT_CHOICES)}",
206 file=sys.stderr,
207 )
208 raise SystemExit(ExitCode.USER_ERROR)
209
210 try:
211 validate_object_id(object_id)
212 except ValueError as exc:
213 print(f"❌ Invalid object ID: {sanitize_display(str(exc))}", file=sys.stderr)
214 raise SystemExit(ExitCode.USER_ERROR)
215
216 root = require_repo()
217
218 if not has_object(root, object_id):
219 if fmt == "info":
220 print(json.dumps({"object_id": object_id, "present": False, "size_bytes": 0}))
221 else:
222 print(f"❌ Object not found: {object_id}", file=sys.stderr)
223 raise SystemExit(ExitCode.USER_ERROR)
224
225 obj = object_path(root, object_id)
226
227 if fmt == "info":
228 size = obj.stat().st_size
229 print(json.dumps({"object_id": object_id, "present": True, "size_bytes": size}))
230 return
231
232 # Raw: two-pass streaming — verify integrity before writing any output.
233 try:
234 import hashlib as _hashlib
235 h = _hashlib.sha256()
236 with obj.open("rb") as fh:
237 for chunk in iter(lambda: fh.read(_CHUNK), b""):
238 h.update(chunk)
239 actual = h.hexdigest()
240 if actual != object_id:
241 print(
242 f"❌ Object {object_id[:8]} failed SHA-256 integrity check — "
243 "store may be corrupt. Run `muse plumbing verify-pack` to audit.",
244 file=sys.stderr,
245 )
246 raise SystemExit(ExitCode.INTERNAL_ERROR)
247 with obj.open("rb") as fh:
248 for chunk in iter(lambda: fh.read(_CHUNK), b""):
249 sys.stdout.buffer.write(chunk)
250 except SystemExit:
251 raise
252 except OSError as exc:
253 print(
254 f"❌ Failed to read object: {sanitize_display(str(exc))}",
255 file=sys.stderr,
256 )
257 raise SystemExit(ExitCode.INTERNAL_ERROR)
File History 2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 33 days ago