linc-cli-kit 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linc_cli_kit/__init__.py +65 -0
- linc_cli_kit/errors.py +136 -0
- linc_cli_kit/logging.py +34 -0
- linc_cli_kit/mount.py +287 -0
- linc_cli_kit/output.py +404 -0
- linc_cli_kit/registry.py +48 -0
- linc_cli_kit/schema.py +143 -0
- linc_cli_kit/testing.py +459 -0
- linc_cli_kit/transport.py +142 -0
- linc_cli_kit-0.1.1.dist-info/METADATA +201 -0
- linc_cli_kit-0.1.1.dist-info/RECORD +14 -0
- linc_cli_kit-0.1.1.dist-info/WHEEL +4 -0
- linc_cli_kit-0.1.1.dist-info/licenses/LICENSE +202 -0
- linc_cli_kit-0.1.1.dist-info/licenses/NOTICE +2 -0
linc_cli_kit/testing.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Importable conformance suite (spec §5.5). Each repo calls assert_cli_parity in one test.
|
|
2
|
+
|
|
3
|
+
Nine checks, run per mounted tool (unless justified-skipped):
|
|
4
|
+
1. coverage + justified skips
|
|
5
|
+
2. --help exits 0 and names every required option
|
|
6
|
+
3. stubbed success -> bare JSON on stdout, no ANSI, empty stderr, AND the payload the
|
|
7
|
+
command handed the transport is exactly the sample (plus schema defaults) — a command
|
|
8
|
+
that drops, renames, or invents a key is caught here, not in production
|
|
9
|
+
4. stubbed validation error -> exit 2, empty stdout, envelope on stderr
|
|
10
|
+
5. overrides (hand-written commands) pass a JSON-output check instead of 6/7 — and must
|
|
11
|
+
actually call the transport (an override that hard-codes its output fails) — but DO get
|
|
12
|
+
the same 3/4/8 output-contract coverage as generated commands whenever a sample is
|
|
13
|
+
supplied for them (an override with no sample only gets the success-shaped half of 3,
|
|
14
|
+
since there is no way to know a valid invocation to error-test against — see Deviations)
|
|
15
|
+
6. option parity between the tool schema and the generated Click options, plus the
|
|
16
|
+
reserved-name `--arg-*` remap
|
|
17
|
+
7. execution consistency: a direct `LocalTransport().call(...)` matches the full CLI
|
|
18
|
+
round trip through the real transport. That round trip also re-runs check 3's output
|
|
19
|
+
contract (bare JSON, no ANSI) with the PROCESS stdout captured at the file-descriptor
|
|
20
|
+
level: anything a tool writes there (a structlog logger bound to stdout at import, a
|
|
21
|
+
child process inheriting fd 1) would corrupt the payload in a real CLI process, and
|
|
22
|
+
CliRunner alone cannot see it
|
|
23
|
+
8. stubbed generic error -> exit 1, empty stdout, envelope on stderr
|
|
24
|
+
9. `schema --json` lists every mounted command
|
|
25
|
+
|
|
26
|
+
Every JSON parse goes through a named assertion that reports the tool and the stream.
|
|
27
|
+
|
|
28
|
+
Deviations:
|
|
29
|
+
- Check 2's "names every required option" sub-check applies to generated commands only.
|
|
30
|
+
Overrides are hand-written commands free to keep their own signature (check 5) — a
|
|
31
|
+
schema-derived required-flag name may not exist on them at all, so scanning their
|
|
32
|
+
--help output for it would fail on an override's *shape*, not on the JSON contract
|
|
33
|
+
the suite actually cares about there.
|
|
34
|
+
- Fail-closed rule for overrides: a `sample is None` override that cannot be invoked
|
|
35
|
+
with zero arguments (i.e. it has its own required options and exits non-zero) is
|
|
36
|
+
reported as a suite failure naming the tool, not silently treated as "nothing to
|
|
37
|
+
check" — see `_check_override`. Provide a `samples[<tool>]` entry to unblock it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import contextlib
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import signal
|
|
48
|
+
import sys
|
|
49
|
+
import tempfile
|
|
50
|
+
from collections.abc import Callable, Iterator
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from typing import Any
|
|
53
|
+
from unittest.mock import patch
|
|
54
|
+
|
|
55
|
+
import click
|
|
56
|
+
from click.testing import CliRunner
|
|
57
|
+
|
|
58
|
+
from linc_cli_kit.output import to_json
|
|
59
|
+
from linc_cli_kit.registry import ToolSpec, registry_of
|
|
60
|
+
from linc_cli_kit.schema import RESERVED, cli_name
|
|
61
|
+
from linc_cli_kit.transport import LocalTransport
|
|
62
|
+
|
|
63
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
64
|
+
|
|
65
|
+
# The two error-shaped stub payloads every command must translate into the right exit
|
|
66
|
+
# code + empty stdout + stderr envelope (checks 4/8), paired with their expected exit code.
|
|
67
|
+
_ERROR_STUBS = (({"error": "x", "validation_error": True}, 2), ({"error": "x"}, 1))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ParityReport:
|
|
72
|
+
tools: int
|
|
73
|
+
commands: int
|
|
74
|
+
skipped: int
|
|
75
|
+
overrides: list[str] = field(default_factory=list)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _loads(text: str, label: str, stream: str) -> Any:
|
|
79
|
+
"""json.loads as a named contract assertion: the tool, the stream and what it held."""
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(text)
|
|
82
|
+
except json.JSONDecodeError as exc:
|
|
83
|
+
raise AssertionError(
|
|
84
|
+
f"{label}: {stream} is not valid JSON ({exc.msg}); got {text[:200]!r}"
|
|
85
|
+
) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _stream_fd(stream: Any) -> int | None:
|
|
89
|
+
try:
|
|
90
|
+
fd = stream.fileno()
|
|
91
|
+
except (AttributeError, OSError, ValueError): # io.UnsupportedOperation is both
|
|
92
|
+
return None
|
|
93
|
+
return fd if isinstance(fd, int) and fd >= 0 else None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@contextlib.contextmanager
|
|
97
|
+
def _process_stdout_leak() -> Iterator[Callable[[], str]]:
|
|
98
|
+
"""Capture what reaches the PROCESS stdout while CliRunner owns `sys.stdout`.
|
|
99
|
+
|
|
100
|
+
CliRunner swaps `sys.stdout` for its own buffer, so a write through an object bound
|
|
101
|
+
before the invocation (a structlog PrintLogger created at import, which holds the
|
|
102
|
+
then-current `sys.stdout`) or straight to fd 1 (a child process) bypasses the result's
|
|
103
|
+
`stdout` entirely. Every file descriptor behind the pre-invocation stdout objects, plus
|
|
104
|
+
fd 1, is pointed at one temp file for the duration; a stdout object with no descriptor
|
|
105
|
+
(an in-memory capture buffer) is diffed by length instead. Yields a callable returning
|
|
106
|
+
whatever leaked.
|
|
107
|
+
"""
|
|
108
|
+
streams = [s for s in (sys.stdout, sys.__stdout__) if s is not None]
|
|
109
|
+
fds = {1}
|
|
110
|
+
buffers: list[tuple[Any, int]] = []
|
|
111
|
+
for stream in streams:
|
|
112
|
+
with contextlib.suppress(Exception):
|
|
113
|
+
stream.flush()
|
|
114
|
+
fd = _stream_fd(stream)
|
|
115
|
+
if fd is not None:
|
|
116
|
+
fds.add(fd)
|
|
117
|
+
elif hasattr(stream, "getvalue"):
|
|
118
|
+
buffers.append((stream, len(stream.getvalue())))
|
|
119
|
+
leaked: list[str] = []
|
|
120
|
+
with tempfile.TemporaryFile() as sink:
|
|
121
|
+
saved = {fd: os.dup(fd) for fd in sorted(fds)}
|
|
122
|
+
try:
|
|
123
|
+
for fd in saved:
|
|
124
|
+
os.dup2(sink.fileno(), fd)
|
|
125
|
+
yield lambda: "".join(leaked)
|
|
126
|
+
finally:
|
|
127
|
+
for stream in streams:
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
stream.flush()
|
|
130
|
+
for fd, copy in saved.items():
|
|
131
|
+
os.dup2(copy, fd)
|
|
132
|
+
os.close(copy)
|
|
133
|
+
sink.seek(0)
|
|
134
|
+
leaked.append(sink.read().decode("utf-8", "replace"))
|
|
135
|
+
for stream, before in buffers:
|
|
136
|
+
leaked.append(stream.getvalue()[before:])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _invoke(cli: click.Group, args: list[str], env: dict[str, str] | None = None) -> Any:
|
|
140
|
+
runner = CliRunner()
|
|
141
|
+
return runner.invoke(cli, args, env=env, catch_exceptions=False)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _invoke_stubbed(
|
|
145
|
+
cli: click.Group, transport_holder: Any, name: str, args: list[str], stub: Any
|
|
146
|
+
) -> tuple[Any, Any]:
|
|
147
|
+
"""Invoke `name` with `transport_holder.linc_transport.call` stubbed to return `stub`.
|
|
148
|
+
|
|
149
|
+
Returns `(result, mock)` — the mock is kept so callers can assert on the payload the
|
|
150
|
+
command actually sent (`mock.call_args.args[1]`), not just on what came back.
|
|
151
|
+
|
|
152
|
+
`transport_holder` is whichever object carries the live `.linc_transport` to patch:
|
|
153
|
+
the generated `cmd` (ruling 1 attaches `cmd.linc_transport`), or the `cli` group itself
|
|
154
|
+
for an override (a hand-written command has no `cmd.linc_transport` of its own, so
|
|
155
|
+
overrides are stubbed via `cli.linc_transport` — ruling 1)."""
|
|
156
|
+
with patch.object(type(transport_holder.linc_transport), "call", return_value=stub) as mock:
|
|
157
|
+
return _invoke(cli, [name, *args]), mock
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _sample_args(cmd: click.Command, sample: dict[str, Any]) -> list[str]:
|
|
161
|
+
"""Build CLI argv for `sample`. Arguments go positionally in param order (ruling 2);
|
|
162
|
+
only Options are addressed by `param.opts[0]`."""
|
|
163
|
+
positional: list[str] = []
|
|
164
|
+
options: list[str] = []
|
|
165
|
+
for param in cmd.params:
|
|
166
|
+
if param.name not in sample:
|
|
167
|
+
continue
|
|
168
|
+
value = sample[param.name]
|
|
169
|
+
if isinstance(param, click.Argument):
|
|
170
|
+
positional.append(str(value))
|
|
171
|
+
continue
|
|
172
|
+
if not isinstance(param, click.Option):
|
|
173
|
+
continue
|
|
174
|
+
flag = param.opts[0]
|
|
175
|
+
if param.is_flag:
|
|
176
|
+
if value:
|
|
177
|
+
options.append(flag)
|
|
178
|
+
elif param.secondary_opts:
|
|
179
|
+
options.append(param.secondary_opts[0])
|
|
180
|
+
elif isinstance(value, (list, tuple)) and getattr(param, "multiple", False):
|
|
181
|
+
for item in value:
|
|
182
|
+
options += [flag, str(item)]
|
|
183
|
+
elif isinstance(value, (dict, list)):
|
|
184
|
+
options += [flag, json.dumps(value)]
|
|
185
|
+
else:
|
|
186
|
+
options += [flag, str(value)]
|
|
187
|
+
return positional + options
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _norm(value: Any) -> Any:
|
|
191
|
+
"""Click hands `multiple=True` options a tuple; samples are written as lists."""
|
|
192
|
+
if isinstance(value, dict):
|
|
193
|
+
return {k: _norm(v) for k, v in value.items()}
|
|
194
|
+
if isinstance(value, (list, tuple)):
|
|
195
|
+
return [_norm(item) for item in value]
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _expected_payload(cmd: click.Command, sample: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
"""What a correct generated command must hand the transport for `sample`: the sample's
|
|
201
|
+
own values plus every non-required option's schema default, minus the Nones the callback
|
|
202
|
+
filters out."""
|
|
203
|
+
expected: dict[str, Any] = {}
|
|
204
|
+
for param in cmd.params:
|
|
205
|
+
if param.name in sample:
|
|
206
|
+
expected[param.name] = sample[param.name]
|
|
207
|
+
elif isinstance(param, click.Option) and not param.required:
|
|
208
|
+
expected[param.name] = param.default
|
|
209
|
+
return {k: v for k, v in expected.items() if v is not None and v != ()}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _assert_payload(mock: Any, cmd: click.Command, spec: ToolSpec, sample: dict[str, Any]) -> None:
|
|
213
|
+
assert mock.call_args is not None, f"{spec.name}: command never called the transport"
|
|
214
|
+
sent = mock.call_args.args[1]
|
|
215
|
+
expected = _expected_payload(cmd, sample)
|
|
216
|
+
assert _norm(sent) == _norm(expected), (
|
|
217
|
+
f"{spec.name}: payload sent to the transport {sent!r} != expected {expected!r} "
|
|
218
|
+
f"for sample {sample!r}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _minimal_sample(spec: ToolSpec) -> dict[str, Any]:
|
|
223
|
+
"""A throwaway argv-shaped sample covering every required property, used only to drive
|
|
224
|
+
the stubbed output-contract checks (3/4/8) when the caller didn't supply one."""
|
|
225
|
+
out: dict[str, Any] = {}
|
|
226
|
+
props = spec.parameters.get("properties", {})
|
|
227
|
+
defaults = {"integer": 1, "number": 1.0, "boolean": True, "array": ["x"], "object": {}}
|
|
228
|
+
for name in spec.parameters.get("required", []):
|
|
229
|
+
kind = props.get(name, {}).get("type")
|
|
230
|
+
out[name] = defaults.get(kind, "x")
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _check_help_and_required(
|
|
235
|
+
cli: click.Group, cmd: click.Command, spec: ToolSpec, name: str, generated: bool
|
|
236
|
+
) -> None:
|
|
237
|
+
# 2. --help exits 0 and (for generated commands, whose signature is schema-derived —
|
|
238
|
+
# see the Deviations note above) names every required option.
|
|
239
|
+
r = _invoke(cli, [name, "--help"])
|
|
240
|
+
assert r.exit_code == 0, f"{spec.name}: --help exited {r.exit_code}: {r.output}"
|
|
241
|
+
if not generated:
|
|
242
|
+
return
|
|
243
|
+
for req in spec.parameters.get("required", []):
|
|
244
|
+
opt = next(
|
|
245
|
+
(p for p in cmd.params if isinstance(p, click.Option) and p.name == req), None
|
|
246
|
+
)
|
|
247
|
+
assert opt is not None, f"{spec.name}: no CLI option corresponds to required '{req}'"
|
|
248
|
+
flag = opt.opts[0]
|
|
249
|
+
assert flag in r.output, f"{spec.name}: --help does not mention required {flag}"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _check_option_parity(cmd: click.Command, spec: ToolSpec) -> None:
|
|
253
|
+
# 6. option parity + reserved-name remap (generated commands only; overrides keep
|
|
254
|
+
# their own signature).
|
|
255
|
+
want = set(spec.parameters.get("properties", {}))
|
|
256
|
+
have = {p.name for p in cmd.params if isinstance(p, click.Option)}
|
|
257
|
+
assert want == have, f"{spec.name}: option mismatch schema={sorted(want)} cli={sorted(have)}"
|
|
258
|
+
for p in cmd.params:
|
|
259
|
+
if p.name in RESERVED:
|
|
260
|
+
assert p.opts[0].startswith("--arg-"), (
|
|
261
|
+
f"{spec.name}: reserved name {p.name} not remapped"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _assert_error_stubs(
|
|
266
|
+
cli: click.Group, transport_holder: Any, name: str, args: list[str], label: str
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Checks 4/8: stubbed validation error (exit 2) and generic error (exit 1) each
|
|
269
|
+
produce empty stdout and a JSON envelope with a truthy "error" key on stderr. Shared
|
|
270
|
+
by generated commands and sampled overrides (Important 2)."""
|
|
271
|
+
for stub, code in _ERROR_STUBS:
|
|
272
|
+
r, _ = _invoke_stubbed(cli, transport_holder, name, args, stub)
|
|
273
|
+
assert r.exit_code == code, (
|
|
274
|
+
f"{label}: expected exit {code} got {r.exit_code}: {r.output}"
|
|
275
|
+
)
|
|
276
|
+
assert r.stdout == "", f"{label}: stdout not empty on failure (expected exit {code})"
|
|
277
|
+
doc = _loads(r.stderr, label, "stderr")
|
|
278
|
+
assert isinstance(doc, dict) and doc.get("error"), (
|
|
279
|
+
f"{label}: no envelope on stderr (expected exit {code}); got {r.stderr[:200]!r}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_stubbed_output_contract(
|
|
284
|
+
cli: click.Group, cmd: click.Command, spec: ToolSpec, name: str, sample: dict[str, Any]
|
|
285
|
+
) -> None:
|
|
286
|
+
# 3. stubbed success -> bare JSON on stdout, no ANSI, empty stderr.
|
|
287
|
+
args = _sample_args(cmd, sample)
|
|
288
|
+
stub_ok = {"ok": True}
|
|
289
|
+
r, mock = _invoke_stubbed(cli, cmd, name, args, stub_ok)
|
|
290
|
+
assert r.exit_code == 0, f"{spec.name}: stubbed success exited {r.exit_code}: {r.output}"
|
|
291
|
+
assert not _ANSI.search(r.stdout), f"{spec.name}: ANSI escape on stdout"
|
|
292
|
+
assert r.stderr == "", f"{spec.name}: stderr not empty on stubbed success"
|
|
293
|
+
assert _loads(r.stdout, spec.name, "stdout") == stub_ok, (
|
|
294
|
+
f"{spec.name}: stdout is not the bare JSON payload"
|
|
295
|
+
)
|
|
296
|
+
_assert_payload(mock, cmd, spec, sample)
|
|
297
|
+
|
|
298
|
+
# 4/8.
|
|
299
|
+
_assert_error_stubs(cli, cmd, name, args, spec.name)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _check_execution_consistency(
|
|
303
|
+
cli: click.Group, cmd: click.Command, spec: ToolSpec, name: str, sample: dict[str, Any]
|
|
304
|
+
) -> None:
|
|
305
|
+
# 7. direct LocalTransport().call (handles async) vs. the full CLI round trip through
|
|
306
|
+
# the real (unstubbed) transport.
|
|
307
|
+
direct = LocalTransport().call(spec, sample)
|
|
308
|
+
with _process_stdout_leak() as leaked:
|
|
309
|
+
r = _invoke(cli, [name, *_sample_args(cmd, sample)])
|
|
310
|
+
assert not leaked(), (
|
|
311
|
+
f"{spec.name}: the real call wrote {leaked()[:200]!r} to the process stdout, which "
|
|
312
|
+
"would corrupt the JSON payload in a real CLI process — send logs to stderr "
|
|
313
|
+
"(configure_structlog_stderr / configure_stderr_logging) above the package imports"
|
|
314
|
+
)
|
|
315
|
+
assert r.exit_code == 0, (
|
|
316
|
+
f"{spec.name}: execution-consistency call exited {r.exit_code}: {r.output}"
|
|
317
|
+
)
|
|
318
|
+
# Check 3's output contract, through the real transport this time.
|
|
319
|
+
assert not _ANSI.search(r.stdout), f"{spec.name}: ANSI escape on stdout (real call)"
|
|
320
|
+
got = _loads(r.stdout, spec.name, "stdout")
|
|
321
|
+
assert got == json.loads(to_json(direct)), (
|
|
322
|
+
f"{spec.name}: CLI output != direct transport call"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _check_override(
|
|
327
|
+
cli: click.Group, cmd: click.Command, spec: ToolSpec, name: str, sample: dict[str, Any] | None
|
|
328
|
+
) -> None:
|
|
329
|
+
# 5. overrides keep their own signature (checks 2's required-flag scan, 6, 7 are
|
|
330
|
+
# skipped for them) but must still speak the JSON contract, and — when a sample is
|
|
331
|
+
# supplied — get the same success/error stub coverage (3/4/8) as generated commands.
|
|
332
|
+
label = f"{name} (override)"
|
|
333
|
+
args = _sample_args(cmd, sample) if sample is not None else []
|
|
334
|
+
r, mock = _invoke_stubbed(cli, cli, name, args, {"ok": True})
|
|
335
|
+
if r.exit_code != 0:
|
|
336
|
+
if sample is not None:
|
|
337
|
+
raise AssertionError(f"{label}: exited {r.exit_code} with sample args {args}")
|
|
338
|
+
# Important 1 (fail closed): an override with its own required arguments and no
|
|
339
|
+
# caller-supplied sample must not be silently skipped — it would otherwise be
|
|
340
|
+
# reported as "verified" without ever exercising its JSON contract.
|
|
341
|
+
raise AssertionError(
|
|
342
|
+
f"{spec.name}: override '{name}' could not be invoked without arguments "
|
|
343
|
+
f"(exit {r.exit_code}); add a samples[{spec.name!r}] entry so the suite "
|
|
344
|
+
"can verify its JSON contract"
|
|
345
|
+
)
|
|
346
|
+
_loads(r.stdout, label, "stdout")
|
|
347
|
+
assert r.stderr == "", f"{label}: stderr not empty on success"
|
|
348
|
+
|
|
349
|
+
# Important 2: a sampled override gets the same error-stub coverage (4/8) a generated
|
|
350
|
+
# command gets — otherwise an override that ignores transport errors entirely (always
|
|
351
|
+
# prints success) goes completely unverified.
|
|
352
|
+
if sample is not None:
|
|
353
|
+
_assert_error_stubs(cli, cli, name, args, label)
|
|
354
|
+
|
|
355
|
+
# An override that hard-codes its output (or answers from local state) passes every
|
|
356
|
+
# check above without ever exercising the transport — the README requires overrides to
|
|
357
|
+
# route through `group.linc_transport`. Checked last so a sampled override that ignores
|
|
358
|
+
# transport errors keeps its more specific exit-code message.
|
|
359
|
+
assert mock.called, (
|
|
360
|
+
f"{label}: never called the transport — route it through "
|
|
361
|
+
"click.get_current_context().find_root().command.linc_transport, or delete it and "
|
|
362
|
+
"keep its rendering as mount_tools(renderers=...)"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
@contextlib.contextmanager
|
|
367
|
+
def _preserved_process_state() -> Any:
|
|
368
|
+
"""Restore the signal handlers and root-logger state the CLI installs.
|
|
369
|
+
|
|
370
|
+
`harden_streams()` installs `os._exit` SIGTERM/SIGINT handlers and
|
|
371
|
+
`configure_stderr_logging()` strips the root logger's handlers. Both are correct for a
|
|
372
|
+
real CLI process and hostile inside an adopter's pytest session, which keeps running
|
|
373
|
+
after this suite returns."""
|
|
374
|
+
sigterm = signal.getsignal(signal.SIGTERM)
|
|
375
|
+
sigint = signal.getsignal(signal.SIGINT)
|
|
376
|
+
root = logging.getLogger()
|
|
377
|
+
handlers = list(root.handlers)
|
|
378
|
+
level = root.level
|
|
379
|
+
try:
|
|
380
|
+
yield
|
|
381
|
+
finally:
|
|
382
|
+
for sig, handler in ((signal.SIGTERM, sigterm), (signal.SIGINT, sigint)):
|
|
383
|
+
# getsignal() returns None for a handler not installed from Python, and
|
|
384
|
+
# signal.signal() refuses None; ValueError if we are off the main thread.
|
|
385
|
+
if handler is not None:
|
|
386
|
+
with contextlib.suppress(ValueError, TypeError, OSError):
|
|
387
|
+
signal.signal(sig, handler)
|
|
388
|
+
root.handlers[:] = handlers
|
|
389
|
+
root.setLevel(level)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def assert_cli_parity(
|
|
393
|
+
cli: click.Group,
|
|
394
|
+
server: Any,
|
|
395
|
+
*,
|
|
396
|
+
prefix: str,
|
|
397
|
+
skip: dict[str, str] | None = None,
|
|
398
|
+
samples: dict[str, dict[str, Any]] | None = None,
|
|
399
|
+
) -> ParityReport:
|
|
400
|
+
with _preserved_process_state():
|
|
401
|
+
return _run_parity(cli, server, prefix=prefix, skip=skip, samples=samples)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _run_parity(
|
|
405
|
+
cli: click.Group,
|
|
406
|
+
server: Any,
|
|
407
|
+
*,
|
|
408
|
+
prefix: str,
|
|
409
|
+
skip: dict[str, str] | None = None,
|
|
410
|
+
samples: dict[str, dict[str, Any]] | None = None,
|
|
411
|
+
) -> ParityReport:
|
|
412
|
+
skip = skip or {}
|
|
413
|
+
samples = samples or {}
|
|
414
|
+
specs = registry_of(server)
|
|
415
|
+
overrides: list[str] = []
|
|
416
|
+
mounted_names = {cli_name(s.name, prefix) for s in specs if s.name not in skip}
|
|
417
|
+
|
|
418
|
+
# 1. coverage + justified skips.
|
|
419
|
+
for tool, why in skip.items():
|
|
420
|
+
assert why.strip(), f"skip for {tool} has no justification"
|
|
421
|
+
for spec in specs:
|
|
422
|
+
if spec.name in skip:
|
|
423
|
+
continue
|
|
424
|
+
name = cli_name(spec.name, prefix)
|
|
425
|
+
assert name in cli.commands, (
|
|
426
|
+
f"{spec.name}: no CLI command '{name}' (mount_tools missing it, "
|
|
427
|
+
"or add a justified skip)"
|
|
428
|
+
)
|
|
429
|
+
cmd = cli.commands[name]
|
|
430
|
+
# Attribute, not callback.__module__ (which a decorator copies or clobbers):
|
|
431
|
+
# _build_command stamps every generated command with linc_kit_generated.
|
|
432
|
+
generated = getattr(cmd, "linc_kit_generated", False)
|
|
433
|
+
|
|
434
|
+
_check_help_and_required(cli, cmd, spec, name, generated)
|
|
435
|
+
|
|
436
|
+
if not generated:
|
|
437
|
+
overrides.append(name)
|
|
438
|
+
_check_override(cli, cmd, spec, name, samples.get(spec.name))
|
|
439
|
+
continue
|
|
440
|
+
|
|
441
|
+
_check_option_parity(cmd, spec)
|
|
442
|
+
sample = samples.get(spec.name)
|
|
443
|
+
_check_stubbed_output_contract(cli, cmd, spec, name, sample or _minimal_sample(spec))
|
|
444
|
+
if sample is not None:
|
|
445
|
+
_check_execution_consistency(cli, cmd, spec, name, sample)
|
|
446
|
+
|
|
447
|
+
# 9. schema --json lists every mounted command.
|
|
448
|
+
r = _invoke(cli, ["schema", "--json"])
|
|
449
|
+
assert r.exit_code == 0, f"schema --json failed: {r.output}"
|
|
450
|
+
doc = _loads(r.stdout, "schema --json", "stdout")
|
|
451
|
+
assert isinstance(doc, dict) and isinstance(doc.get("commands"), dict), (
|
|
452
|
+
f"schema --json: stdout has no 'commands' object (got {r.stdout[:200]!r})"
|
|
453
|
+
)
|
|
454
|
+
listed = set(doc["commands"])
|
|
455
|
+
assert mounted_names <= listed, f"schema --json missing {sorted(mounted_names - listed)}"
|
|
456
|
+
|
|
457
|
+
return ParityReport(
|
|
458
|
+
tools=len(specs), commands=len(cli.commands), skipped=len(skip), overrides=overrides
|
|
459
|
+
)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Transports: in-process local call, and daemon-first HTTP with local fallback (spec §5.4)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Protocol
|
|
11
|
+
|
|
12
|
+
from linc_cli_kit.errors import ToolError, UsageError
|
|
13
|
+
from linc_cli_kit.registry import ToolSpec
|
|
14
|
+
|
|
15
|
+
# httpx and anyio are imported where they are used, not here: `import linc_cli_kit` sits on
|
|
16
|
+
# every adopter CLI's startup path, and most invocations need neither (a local sync tool
|
|
17
|
+
# never touches anyio; --help never touches httpx).
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Transport(Protocol):
|
|
21
|
+
def call(
|
|
22
|
+
self, spec: ToolSpec, payload: dict[str, Any], *, timeout: float | None = None
|
|
23
|
+
) -> Any: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LocalTransport:
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
fn_for: Callable[[ToolSpec], Callable[..., Any]] | None = None,
|
|
30
|
+
before: Callable[[], None] | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
self._fn_for = fn_for or (lambda spec: spec.fn)
|
|
33
|
+
self._before = before
|
|
34
|
+
|
|
35
|
+
def call(
|
|
36
|
+
self, spec: ToolSpec, payload: dict[str, Any], *, timeout: float | None = None
|
|
37
|
+
) -> Any:
|
|
38
|
+
# timeout is a no-op for an in-process call; accepted to satisfy the Transport protocol.
|
|
39
|
+
if self._before is not None:
|
|
40
|
+
self._before()
|
|
41
|
+
fn = self._fn_for(spec)
|
|
42
|
+
if inspect.iscoroutinefunction(fn):
|
|
43
|
+
import anyio
|
|
44
|
+
|
|
45
|
+
return anyio.run(functools.partial(fn, **payload))
|
|
46
|
+
result = fn(**payload)
|
|
47
|
+
if inspect.iscoroutine(result):
|
|
48
|
+
import anyio
|
|
49
|
+
|
|
50
|
+
return anyio.run(lambda: result)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class HttpDaemonTransport:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
tool: str,
|
|
58
|
+
default_url: str,
|
|
59
|
+
*,
|
|
60
|
+
fallback: Transport | None = None,
|
|
61
|
+
timeout: float = 30.0,
|
|
62
|
+
daemon_only: frozenset[str] = frozenset(),
|
|
63
|
+
client_factory: Callable[[], Any] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.tool = tool
|
|
66
|
+
self.default_url = default_url.rstrip("/")
|
|
67
|
+
self.fallback = fallback
|
|
68
|
+
self.timeout = timeout
|
|
69
|
+
self.daemon_only = daemon_only
|
|
70
|
+
self._client_factory = client_factory
|
|
71
|
+
|
|
72
|
+
def port_file(self) -> Path:
|
|
73
|
+
"""The daemon's port file: ``$LINC_HOME/<tool>/daemon.port`` (default ``~/.linc``)."""
|
|
74
|
+
linc_home = os.environ.get("LINC_HOME") or str(Path.home() / ".linc")
|
|
75
|
+
return Path(linc_home) / self.tool / "daemon.port"
|
|
76
|
+
|
|
77
|
+
def base_url(self) -> str:
|
|
78
|
+
env = os.environ.get(f"LINC_{self.tool.upper()}_DAEMON")
|
|
79
|
+
if env:
|
|
80
|
+
return env.rstrip("/")
|
|
81
|
+
port_file = self.port_file()
|
|
82
|
+
if port_file.is_file():
|
|
83
|
+
try:
|
|
84
|
+
port = port_file.read_text().strip()
|
|
85
|
+
except OSError:
|
|
86
|
+
pass
|
|
87
|
+
else:
|
|
88
|
+
if port.isdigit():
|
|
89
|
+
return f"http://127.0.0.1:{port}"
|
|
90
|
+
return self.default_url
|
|
91
|
+
|
|
92
|
+
def call(
|
|
93
|
+
self, spec: ToolSpec, payload: dict[str, Any], *, timeout: float | None = None
|
|
94
|
+
) -> Any:
|
|
95
|
+
import httpx
|
|
96
|
+
|
|
97
|
+
effective_timeout = timeout if timeout is not None else self.timeout
|
|
98
|
+
url = f"{self.base_url()}/tools/{spec.name}"
|
|
99
|
+
client_factory = self._client_factory or httpx.Client
|
|
100
|
+
try:
|
|
101
|
+
with client_factory() as client:
|
|
102
|
+
resp = client.post(url, json=payload, timeout=effective_timeout)
|
|
103
|
+
except httpx.TimeoutException as exc:
|
|
104
|
+
# Never fall back on a timeout: a slow daemon is still executing the tool, so a
|
|
105
|
+
# local retry would run it a second time. Timeout is retryable by the CALLER only.
|
|
106
|
+
raise ToolError(
|
|
107
|
+
f"{self.tool} daemon timed out after {effective_timeout}s at {url}",
|
|
108
|
+
code="UPSTREAM_TIMEOUT",
|
|
109
|
+
category="upstream_timeout",
|
|
110
|
+
retryable=True,
|
|
111
|
+
) from exc
|
|
112
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
113
|
+
if spec.name in self.daemon_only or self.fallback is None:
|
|
114
|
+
raise ToolError(
|
|
115
|
+
f"{self.tool} daemon not reachable at {url}: {exc}",
|
|
116
|
+
code="DAEMON_UNAVAILABLE",
|
|
117
|
+
category="unavailable",
|
|
118
|
+
hint=f"start it with `{self.tool} daemon`",
|
|
119
|
+
) from exc
|
|
120
|
+
return self.fallback.call(spec, payload)
|
|
121
|
+
try:
|
|
122
|
+
body = resp.json() if resp.content else {}
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
raise ToolError(
|
|
125
|
+
f"{self.tool} daemon returned a non-JSON body (HTTP {resp.status_code})",
|
|
126
|
+
code="DAEMON_ERROR",
|
|
127
|
+
details={"status": resp.status_code},
|
|
128
|
+
) from exc
|
|
129
|
+
if not isinstance(body, dict):
|
|
130
|
+
raise ToolError(
|
|
131
|
+
f"{self.tool} daemon returned a non-object body (HTTP {resp.status_code})",
|
|
132
|
+
code="DAEMON_ERROR",
|
|
133
|
+
details={"status": resp.status_code},
|
|
134
|
+
)
|
|
135
|
+
if resp.status_code == 200:
|
|
136
|
+
return body.get("result", body)
|
|
137
|
+
message = str(body.get("error", resp.text))
|
|
138
|
+
if resp.status_code == 400:
|
|
139
|
+
raise UsageError(message)
|
|
140
|
+
raise ToolError(
|
|
141
|
+
message, code="DAEMON_ERROR", details={"status": resp.status_code}
|
|
142
|
+
)
|