gabriel / muse public
cli_test_helper.py python
209 lines 6.8 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
1 """Argparse-compatible CliRunner replacement for Muse test suite.
2
3 Replaces ``typer.testing.CliRunner`` so tests can call ``runner.invoke(cli,
4 args)`` without modification after the typer → argparse migration. The first
5 argument (``cli``) is always ``None`` (a stub) after migration; it is accepted
6 but ignored, and ``muse.cli.app.main`` is always the target.
7 """
8
9 from __future__ import annotations
10
11 import contextlib
12 import io
13 import os
14 import re
15 import sys
16 import threading
17 import traceback
18
19 from muse.cli.app import main
20 from muse.core.types import Manifest
21
22 type _EnvSaved = dict[str, str | None]
23
24 _ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
25
26
27 def _strip_ansi(text: str) -> str:
28 """Remove ANSI escape sequences — typer's CliRunner did this automatically."""
29 return _ANSI_ESCAPE.sub("", text)
30
31
32 class _StdinWithBuffer(io.StringIO):
33 """Text-mode stdin backed by StringIO, with a ``.buffer`` BytesIO sibling.
34
35 Accepts either a ``str`` (text) or ``bytes`` (binary). When binary is
36 provided the text surface is left empty — commands that read from
37 ``sys.stdin.buffer`` (e.g. ``unpack-objects``) get the raw bytes while
38 text-mode reads get an empty string.
39 """
40
41 def __init__(self, text: str | bytes) -> None:
42 if isinstance(text, bytes):
43 super().__init__("")
44 self.buffer = io.BytesIO(text)
45 else:
46 super().__init__(text)
47 self.buffer = io.BytesIO(text.encode())
48
49 def isatty(self) -> bool:
50 return False
51
52
53 class _StdoutCapture(io.StringIO):
54 """Text-mode stdout backed by StringIO, with a ``.buffer`` BytesIO sibling.
55
56 Some commands (e.g. ``cat-object``) write raw bytes to
57 ``sys.stdout.buffer``. Subclassing ``StringIO`` makes this assignable to
58 ``sys.stdout`` (and passable to ``contextlib.redirect_stdout``) without
59 any type annotation workaround. Binary output is decoded and appended to
60 the text output in ``getvalue()``.
61 """
62
63 def __init__(self) -> None:
64 super().__init__()
65 self.buffer = io.BytesIO()
66
67 def isatty(self) -> bool:
68 return False
69
70 def getvalue(self) -> str:
71 text_out = super().getvalue()
72 bytes_out = self.buffer.getvalue()
73 if bytes_out:
74 try:
75 text_out += bytes_out.decode("utf-8", errors="replace")
76 except Exception:
77 pass
78 return text_out
79
80
81 def _restore_env(saved: _EnvSaved) -> None:
82 """Restore environment variables to their pre-invoke state."""
83 for k, orig in saved.items():
84 if orig is None:
85 os.environ.pop(k, None)
86 else:
87 os.environ[k] = orig
88
89
90 class InvokeResult:
91 """Mirrors the fields that typer.testing.Result exposed."""
92
93 def __init__(
94 self,
95 exit_code: int,
96 output: str,
97 stderr_output: str = "",
98 stdout_bytes: bytes = b"",
99 ) -> None:
100 self.exit_code = exit_code
101 self.output = output
102 self.stdout = output
103 self.stderr = stderr_output
104 self.stdout_bytes = stdout_bytes
105 self.exception: BaseException | None = None
106
107 def __repr__(self) -> str:
108 return f"InvokeResult(exit_code={self.exit_code}, output={self.output!r})"
109
110
111 class CliRunner:
112 """Drop-in replacement for ``typer.testing.CliRunner``.
113
114 Captures stdout and stderr, calls ``main(args)``, and returns an
115 ``InvokeResult`` whose interface matches the typer equivalent closely
116 enough for the existing test suite to run without changes.
117
118 Honoured parameters:
119 - ``env``: key/value pairs set in ``os.environ`` for the duration of the
120 call and restored afterward.
121 - ``input``: string fed to ``sys.stdin`` (needed by ``unpack-objects``).
122 - ``catch_exceptions``: when False, exceptions propagate to the caller.
123
124 Thread safety: ``contextlib.redirect_stdout`` mutates ``sys.stdout``
125 globally, so concurrent ``invoke`` calls on different threads would
126 overwrite each other's capture buffer. A class-level lock serialises
127 the stdout/stderr redirect + main() call so each invocation gets its
128 own isolated capture.
129 """
130
131 _lock: threading.Lock = threading.Lock()
132
133 def invoke(
134 self,
135 _cli: None,
136 args: list[str],
137 catch_exceptions: bool = True,
138 input: str | bytes | None = None,
139 env: Manifest | None = None,
140 cwd: "pathlib.Path | str | None" = None,
141 ) -> InvokeResult:
142 """Invoke ``main(args)`` and return captured output + exit code."""
143 import pathlib as _pathlib
144 stdout_cap = _StdoutCapture()
145 stderr_buf = io.StringIO()
146 exit_code = 0
147
148 with self._lock:
149 # All global-state mutations (env, cwd, sys.stdin, sys.stdout,
150 # sys.stderr) are confined to the lock so concurrent invoke() calls
151 # on different threads do not race on shared process-global variables.
152 saved: _EnvSaved = {}
153 if env:
154 for k, v in env.items():
155 saved[k] = os.environ.get(k)
156 os.environ[k] = v
157
158 orig_cwd = os.getcwd()
159 if cwd is not None:
160 os.chdir(cwd)
161
162 orig_stdin = sys.stdin
163 if input is not None:
164 sys.stdin = _StdinWithBuffer(input)
165
166 try:
167 with contextlib.redirect_stdout(stdout_cap):
168 with contextlib.redirect_stderr(stderr_buf):
169 main(list(args))
170 except SystemExit as exc:
171 raw = exc.code
172 if isinstance(raw, int):
173 exit_code = raw
174 elif hasattr(raw, "value"):
175 exit_code = int(raw.value)
176 elif raw is None:
177 exit_code = 0
178 else:
179 exit_code = int(raw)
180 except Exception as exc:
181 if not catch_exceptions:
182 sys.stdin = orig_stdin
183 _restore_env(saved)
184 raise
185 stderr_buf.write(traceback.format_exc())
186 exit_code = 1
187 result = InvokeResult(
188 exit_code,
189 _strip_ansi(stdout_cap.getvalue()),
190 _strip_ansi(stderr_buf.getvalue()),
191 stdout_bytes=stdout_cap.buffer.getvalue(),
192 )
193 result.exception = exc
194 sys.stdin = orig_stdin
195 _restore_env(saved)
196 return result
197 finally:
198 sys.stdin = orig_stdin
199 if cwd is not None:
200 os.chdir(orig_cwd)
201 _restore_env(saved)
202
203 raw_bytes = stdout_cap.buffer.getvalue()
204 return InvokeResult(
205 exit_code,
206 _strip_ansi(stdout_cap.getvalue()),
207 _strip_ansi(stderr_buf.getvalue()),
208 stdout_bytes=raw_bytes,
209 )
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago