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.
@@ -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
+ )