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/output.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Output contract (spec §5.2): mode resolution, stdout/stderr routing, artifacts, heartbeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import contextlib
|
|
7
|
+
import dataclasses
|
|
8
|
+
import datetime
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from linc_cli_kit.errors import Outcome, ToolError, classify, classify_exception, safe_str
|
|
22
|
+
|
|
23
|
+
MODES = ("json", "human", "jsonl")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_mode(flag: str | None, env: Mapping[str, str] | None = None) -> str:
|
|
27
|
+
if flag in MODES:
|
|
28
|
+
return flag # type: ignore[return-value]
|
|
29
|
+
env = os.environ if env is None else env
|
|
30
|
+
value = env.get("LINC_OUTPUT", "").lower()
|
|
31
|
+
return value if value in MODES else "json"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pretty() -> bool:
|
|
35
|
+
return bool(getattr(sys.stdout, "isatty", lambda: False)())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def json_default(obj: Any) -> Any:
|
|
39
|
+
"""`json.dumps(default=...)` for tool results: structured types become structured JSON.
|
|
40
|
+
|
|
41
|
+
- dataclass instance -> object of its fields (shallow; nested values recurse through
|
|
42
|
+
this hook, so nothing is deep-copied)
|
|
43
|
+
- pydantic `BaseModel` instance -> `model_dump(mode="json")`
|
|
44
|
+
- set / frozenset -> sorted list (by `repr` when the members are not mutually orderable)
|
|
45
|
+
- bytes / bytearray / memoryview -> base64 string (standard alphabet, padded). Binary
|
|
46
|
+
content large enough to matter should be an artifact (`-o/--output`), not a payload.
|
|
47
|
+
- datetime / date / time -> ISO 8601 (`isoformat()`)
|
|
48
|
+
- anything else -> `str(obj)` (Path, Decimal, UUID, Enum ... as before)
|
|
49
|
+
"""
|
|
50
|
+
if dataclasses.is_dataclass(obj) and not isinstance(obj, type):
|
|
51
|
+
return {f.name: getattr(obj, f.name) for f in dataclasses.fields(obj)}
|
|
52
|
+
# A real pydantic model only, never a duck-typed `model_dump` attribute: a MagicMock
|
|
53
|
+
# answers every attribute with a callable returning another mock, which recursed without
|
|
54
|
+
# end. Looked up in sys.modules so the kit never imports pydantic itself.
|
|
55
|
+
pydantic = sys.modules.get("pydantic")
|
|
56
|
+
if pydantic is not None and isinstance(obj, pydantic.BaseModel):
|
|
57
|
+
return obj.model_dump(mode="json")
|
|
58
|
+
if isinstance(obj, (set, frozenset)):
|
|
59
|
+
try:
|
|
60
|
+
return sorted(obj)
|
|
61
|
+
except TypeError:
|
|
62
|
+
return sorted(obj, key=repr)
|
|
63
|
+
if isinstance(obj, (bytes, bytearray, memoryview)):
|
|
64
|
+
return base64.b64encode(bytes(obj)).decode("ascii")
|
|
65
|
+
if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
|
|
66
|
+
return obj.isoformat()
|
|
67
|
+
return str(obj)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_json(doc: Any, *, indent: int | None = None) -> str:
|
|
71
|
+
"""Strict JSON: typed `json_default` and `allow_nan=False`.
|
|
72
|
+
|
|
73
|
+
NaN/Infinity are not JSON (jq and strict parsers reject the whole document), so a
|
|
74
|
+
non-finite float raises ValueError here; `emit` turns that into a NON_JSON_VALUE
|
|
75
|
+
envelope instead of writing invalid output.
|
|
76
|
+
"""
|
|
77
|
+
return json.dumps(doc, default=json_default, allow_nan=False, indent=indent)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _non_json(exc: Exception) -> ToolError:
|
|
81
|
+
return ToolError(
|
|
82
|
+
f"result is not representable as JSON: {exc}",
|
|
83
|
+
code="NON_JSON_VALUE",
|
|
84
|
+
hint="return JSON-compatible values: None instead of NaN/Infinity, string dict keys",
|
|
85
|
+
category="internal",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _finite(doc: Any, _seen: frozenset[int] = frozenset()) -> Any:
|
|
90
|
+
"""Make envelope details (tool data) always encodable: non-finite floats become strings
|
|
91
|
+
("nan"), other values go through `json_default` and are recursed into, non-JSON dict
|
|
92
|
+
keys become `str(key)`, and a reference cycle becomes "<cycle>". The failure path must
|
|
93
|
+
never fail, nor drop good fields because one nested value is bad."""
|
|
94
|
+
if isinstance(doc, float):
|
|
95
|
+
return doc if math.isfinite(doc) else repr(doc)
|
|
96
|
+
if doc is None or isinstance(doc, (str, int, bool)):
|
|
97
|
+
return doc
|
|
98
|
+
if id(doc) in _seen:
|
|
99
|
+
return "<cycle>"
|
|
100
|
+
seen = _seen | {id(doc)}
|
|
101
|
+
if isinstance(doc, dict):
|
|
102
|
+
out: dict[Any, Any] = {}
|
|
103
|
+
for k, v in doc.items():
|
|
104
|
+
key = _finite_key(k)
|
|
105
|
+
if key in out: # only via conversion, e.g. a NaN key vs a real "nan" key
|
|
106
|
+
n = 2
|
|
107
|
+
while f"{key}#{n}" in out:
|
|
108
|
+
n += 1
|
|
109
|
+
key = f"{key}#{n}"
|
|
110
|
+
out[key] = _finite(v, seen)
|
|
111
|
+
return out
|
|
112
|
+
if isinstance(doc, (list, tuple)):
|
|
113
|
+
return [_finite(v, seen) for v in doc]
|
|
114
|
+
try:
|
|
115
|
+
converted = json_default(doc)
|
|
116
|
+
except Exception: # a hostile __str__, an unset dataclass field ...
|
|
117
|
+
return f"<unencodable {type(doc).__name__}>"
|
|
118
|
+
return converted if isinstance(converted, str) else _finite(converted, seen)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _finite_key(key: Any) -> str:
|
|
122
|
+
"""The key exactly as it will be spelled in the JSON text, so collisions are visible
|
|
123
|
+
here: json.dumps itself writes 1 / None / True keys as "1" / "null" / "true"."""
|
|
124
|
+
if isinstance(key, str):
|
|
125
|
+
return key
|
|
126
|
+
if key is None or isinstance(key, bool):
|
|
127
|
+
return json.dumps(key)
|
|
128
|
+
if isinstance(key, float) and not math.isfinite(key):
|
|
129
|
+
return safe_str(float(key)) # "nan" / "inf", even for a float subclass
|
|
130
|
+
if isinstance(key, (int, float)):
|
|
131
|
+
return json.dumps(key)
|
|
132
|
+
return safe_str(key)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _dumps(doc: Any, *, force_pretty: bool = False) -> str:
|
|
136
|
+
indent = 2 if (force_pretty or _pretty()) else None
|
|
137
|
+
return to_json(doc, indent=indent)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _write_envelope(envelope: dict[str, Any], mode: str) -> None:
|
|
141
|
+
if mode == "human":
|
|
142
|
+
err = envelope["error"]
|
|
143
|
+
sys.stderr.write(f"Error: {err['message']}\n")
|
|
144
|
+
if err.get("hint"):
|
|
145
|
+
sys.stderr.write(f"Hint: {err['hint']}\n")
|
|
146
|
+
else:
|
|
147
|
+
try:
|
|
148
|
+
line = to_json(_finite(envelope))
|
|
149
|
+
except Exception: # never let the failure path fail; keep the error itself
|
|
150
|
+
err = dict(envelope.get("error", {}))
|
|
151
|
+
err["details"] = {}
|
|
152
|
+
line = to_json(_finite({"error": err}))
|
|
153
|
+
sys.stderr.write(line + "\n")
|
|
154
|
+
sys.stderr.flush()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _artifact_reference(
|
|
158
|
+
image: Any, output_path: str | None, tool: str
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
content = image.to_image_content()
|
|
161
|
+
raw = base64.b64decode(content.data)
|
|
162
|
+
mime = content.mimeType
|
|
163
|
+
ext = mime.split("/")[-1]
|
|
164
|
+
if output_path is None:
|
|
165
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
166
|
+
output_path = str(Path.home() / ".linc" / "artifacts" / tool / f"{stamp}.{ext}")
|
|
167
|
+
path = Path(output_path)
|
|
168
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
path.write_bytes(raw)
|
|
170
|
+
return {
|
|
171
|
+
"type": mime,
|
|
172
|
+
"path": str(path),
|
|
173
|
+
"bytes": len(raw),
|
|
174
|
+
"sha256": hashlib.sha256(raw).hexdigest(),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _normalize(result: Any, output_path: str | None, tool: str) -> Any:
|
|
179
|
+
"""Turn a FastMCP content list [dict, Image] into {"result":..., "artifact":...}."""
|
|
180
|
+
if isinstance(result, list) and any(
|
|
181
|
+
hasattr(item, "to_image_content") for item in result
|
|
182
|
+
):
|
|
183
|
+
data = next((item for item in result if isinstance(item, dict)), {})
|
|
184
|
+
image = next(item for item in result if hasattr(item, "to_image_content"))
|
|
185
|
+
return {
|
|
186
|
+
"result": data,
|
|
187
|
+
"artifact": _artifact_reference(image, output_path, tool),
|
|
188
|
+
}
|
|
189
|
+
return result
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def emit(
|
|
193
|
+
result: Any,
|
|
194
|
+
*,
|
|
195
|
+
mode: str,
|
|
196
|
+
output_path: str | None = None,
|
|
197
|
+
renderer: Callable[[Any], str] | None = None,
|
|
198
|
+
tool: str = "tool",
|
|
199
|
+
) -> int:
|
|
200
|
+
"""Write result per the contract and return the exit code. Never raises."""
|
|
201
|
+
if mode == "jsonl" and isinstance(result, Iterator):
|
|
202
|
+
count = 0
|
|
203
|
+
for record in result:
|
|
204
|
+
try:
|
|
205
|
+
line = to_json(record)
|
|
206
|
+
except Exception as exc: # NaN, bad keys, a hostile __str__, absurd nesting
|
|
207
|
+
_write_envelope(_non_json(exc).envelope(), mode)
|
|
208
|
+
return 1
|
|
209
|
+
sys.stdout.write(line + "\n")
|
|
210
|
+
sys.stdout.flush()
|
|
211
|
+
count += 1
|
|
212
|
+
sys.stdout.write(
|
|
213
|
+
json.dumps({"event": "summary", "records": count, "exit_code": 0}) + "\n"
|
|
214
|
+
)
|
|
215
|
+
sys.stdout.flush()
|
|
216
|
+
return 0
|
|
217
|
+
if isinstance(result, Iterator) and mode != "jsonl":
|
|
218
|
+
result = list(result)
|
|
219
|
+
try:
|
|
220
|
+
normalized = _normalize(result, output_path, tool)
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
err = ToolError(
|
|
223
|
+
f"could not write artifact: {exc}",
|
|
224
|
+
code="ARTIFACT_WRITE_FAILED",
|
|
225
|
+
category="internal",
|
|
226
|
+
)
|
|
227
|
+
_write_envelope(err.envelope(), mode)
|
|
228
|
+
return 1
|
|
229
|
+
outcome: Outcome = classify(normalized)
|
|
230
|
+
if outcome.exit_code != 0:
|
|
231
|
+
_write_envelope(outcome.envelope or {}, mode)
|
|
232
|
+
return outcome.exit_code
|
|
233
|
+
try:
|
|
234
|
+
if mode == "human":
|
|
235
|
+
# Human output is not the JSON contract: NaN reads fine there.
|
|
236
|
+
text = (
|
|
237
|
+
renderer(outcome.payload)
|
|
238
|
+
if renderer
|
|
239
|
+
else json.dumps(outcome.payload, default=json_default, indent=2)
|
|
240
|
+
)
|
|
241
|
+
text = text.rstrip("\n")
|
|
242
|
+
elif mode == "jsonl":
|
|
243
|
+
text = to_json(outcome.payload)
|
|
244
|
+
else:
|
|
245
|
+
text = _dumps(outcome.payload)
|
|
246
|
+
except Exception as exc: # NaN, bad keys, a hostile __str__, absurd nesting
|
|
247
|
+
if mode == "human" and renderer is not None:
|
|
248
|
+
raise # a renderer bug, not an encoding problem: mount's handler envelopes it
|
|
249
|
+
_write_envelope(_non_json(exc).envelope(), mode)
|
|
250
|
+
return 1
|
|
251
|
+
sys.stdout.write(text + "\n")
|
|
252
|
+
sys.stdout.flush()
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def emit_exception(exc: BaseException, *, mode: str) -> int:
|
|
257
|
+
outcome = classify_exception(exc)
|
|
258
|
+
_write_envelope(outcome.envelope or {}, mode)
|
|
259
|
+
return outcome.exit_code
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@contextlib.contextmanager
|
|
263
|
+
def heartbeat(
|
|
264
|
+
interval: float = 10.0, label: str = "working", *, mode: str | None = None
|
|
265
|
+
) -> Iterator[None]:
|
|
266
|
+
"""Emit a stderr line every `interval` seconds so harness dead-man timers see activity.
|
|
267
|
+
|
|
268
|
+
Outside `--human` mode each beat is one JSON object
|
|
269
|
+
(`{"event": "heartbeat", "label": ..., "elapsed_s": ...}`), so every stderr line a
|
|
270
|
+
command writes parses on its own, and the error envelope stays the LAST line (see
|
|
271
|
+
`parse_error_envelope`). `mode=None` follows the invocation's `--human`/`--json` flag,
|
|
272
|
+
then `LINC_OUTPUT`, like the rest of the contract. The thread is joined on exit, so no
|
|
273
|
+
beat can land after the envelope.
|
|
274
|
+
"""
|
|
275
|
+
if mode is None:
|
|
276
|
+
# Call-time import: mount imports this module. current_mode() reads the invocation's
|
|
277
|
+
# --human/--json flag first and falls back to LINC_OUTPUT outside a Click context.
|
|
278
|
+
from linc_cli_kit.mount import current_mode
|
|
279
|
+
|
|
280
|
+
mode = current_mode()
|
|
281
|
+
as_json = resolve_mode(mode) != "human"
|
|
282
|
+
stop = threading.Event()
|
|
283
|
+
|
|
284
|
+
def _beat() -> None:
|
|
285
|
+
started = time.monotonic()
|
|
286
|
+
while not stop.wait(interval):
|
|
287
|
+
elapsed = int(time.monotonic() - started)
|
|
288
|
+
if as_json:
|
|
289
|
+
line = json.dumps({"event": "heartbeat", "label": label, "elapsed_s": elapsed})
|
|
290
|
+
else:
|
|
291
|
+
line = f"[{label}] {elapsed}s elapsed"
|
|
292
|
+
sys.stderr.write(line + "\n")
|
|
293
|
+
sys.stderr.flush()
|
|
294
|
+
|
|
295
|
+
thread = threading.Thread(target=_beat, name=f"linc-heartbeat-{label}", daemon=True)
|
|
296
|
+
thread.start()
|
|
297
|
+
try:
|
|
298
|
+
yield
|
|
299
|
+
finally:
|
|
300
|
+
stop.set()
|
|
301
|
+
# Bounded: a beat is only ever a wait() plus one write, so this returns at once
|
|
302
|
+
# unless stderr itself is blocked (and then the envelope would block too).
|
|
303
|
+
thread.join(timeout=1.0)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
_SIGNAL_GRACE_S = 10.0
|
|
307
|
+
_REPEAT_WINDOW_S = 0.5
|
|
308
|
+
|
|
309
|
+
# SIGTERM watchdog. ONE daemon thread per process, started from harden_streams() (never
|
|
310
|
+
# from the handler: Thread.start() takes threading's internal locks, which the interrupted
|
|
311
|
+
# main thread may already hold). The handler only records a deadline and notifies, under an
|
|
312
|
+
# RLock so a signal landing while the main thread holds it cannot self-deadlock.
|
|
313
|
+
_watchdog_cond = threading.Condition(threading.RLock())
|
|
314
|
+
_signal_state: dict[str, Any] = {"thread": None, "term_at": None, "deadline": None}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _signal_grace() -> float:
|
|
318
|
+
"""LINC_SIGNAL_GRACE seconds, clamped to [0, threading.TIMEOUT_MAX]: a larger value (or
|
|
319
|
+
`inf`, "never force") would make Condition.wait raise OverflowError in the watchdog."""
|
|
320
|
+
try:
|
|
321
|
+
grace = float(os.environ.get("LINC_SIGNAL_GRACE", _SIGNAL_GRACE_S))
|
|
322
|
+
except ValueError:
|
|
323
|
+
return _SIGNAL_GRACE_S
|
|
324
|
+
if math.isnan(grace):
|
|
325
|
+
return _SIGNAL_GRACE_S
|
|
326
|
+
return min(max(0.0, grace), threading.TIMEOUT_MAX)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _watchdog_loop() -> None:
|
|
330
|
+
while True:
|
|
331
|
+
with _watchdog_cond:
|
|
332
|
+
deadline = _signal_state["deadline"]
|
|
333
|
+
if deadline is None:
|
|
334
|
+
_watchdog_cond.wait()
|
|
335
|
+
continue
|
|
336
|
+
remaining = deadline - time.monotonic()
|
|
337
|
+
if remaining > 0:
|
|
338
|
+
_watchdog_cond.wait(min(remaining, threading.TIMEOUT_MAX))
|
|
339
|
+
continue
|
|
340
|
+
# Outside the lock, and no stderr flush first: a stuck stderr pipe (the main thread
|
|
341
|
+
# blocked mid-write holding its buffer lock) is exactly the stall this breaks.
|
|
342
|
+
os._exit(143)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _ensure_watchdog() -> None:
|
|
346
|
+
thread = _signal_state["thread"]
|
|
347
|
+
if thread is None or not thread.is_alive():
|
|
348
|
+
thread = threading.Thread(target=_watchdog_loop, name="linc-signal-watchdog", daemon=True)
|
|
349
|
+
thread.start()
|
|
350
|
+
_signal_state["thread"] = thread
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _reset_signal_state() -> None:
|
|
354
|
+
"""Disarm the SIGTERM watchdog and repeat latch (each invocation, and tests)."""
|
|
355
|
+
with _watchdog_cond:
|
|
356
|
+
_signal_state.update(term_at=None, deadline=None)
|
|
357
|
+
_watchdog_cond.notify_all()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _on_sigterm(_signum: int, _frame: Any) -> None:
|
|
361
|
+
"""Raise SystemExit(143) so `finally` blocks, context managers and atexit hooks run on
|
|
362
|
+
the way out (nerve releases its device lease there) — but termination is not optional:
|
|
363
|
+
the watchdog hard-exits 143 if the unwind has not finished after LINC_SIGNAL_GRACE
|
|
364
|
+
seconds (default 10; blocking cleanup, an event-loop worker thread, code that swallows
|
|
365
|
+
SystemExit), and a repeat SIGTERM hard-exits at once. A repeat inside 0.5s is taken as
|
|
366
|
+
the same signal fanned out by a wrapper in the process group and ignored."""
|
|
367
|
+
now = time.monotonic()
|
|
368
|
+
with _watchdog_cond:
|
|
369
|
+
first = _signal_state["term_at"]
|
|
370
|
+
if first is None:
|
|
371
|
+
_signal_state.update(term_at=now, deadline=now + _signal_grace())
|
|
372
|
+
_watchdog_cond.notify_all()
|
|
373
|
+
elif now - first < _REPEAT_WINDOW_S:
|
|
374
|
+
return
|
|
375
|
+
if first is not None:
|
|
376
|
+
os._exit(143) # outside the lock, no flush (see _watchdog_loop)
|
|
377
|
+
raise SystemExit(143)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _on_sigint(_signum: int, _frame: Any) -> None:
|
|
381
|
+
# Python's own semantics: every Ctrl-C raises KeyboardInterrupt (a command may catch it
|
|
382
|
+
# and carry on, e.g. `index watch`). Click turns it into Abort; install_globals' main
|
|
383
|
+
# maps that to the INTERRUPTED envelope and exit 130.
|
|
384
|
+
raise KeyboardInterrupt
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def harden_streams() -> None:
|
|
388
|
+
"""Line-buffer stdout/stderr; make SIGTERM/SIGINT unwind the stack (exit 143/130).
|
|
389
|
+
|
|
390
|
+
The handlers raise `SystemExit(143)` / `KeyboardInterrupt` rather than calling
|
|
391
|
+
`os._exit`, so cleanup code runs; for SIGTERM a watchdog and a repeat signal still
|
|
392
|
+
guarantee the process ends (see `_on_sigterm`). A tool that must not be interrupted
|
|
393
|
+
mid-operation should shield that section itself.
|
|
394
|
+
"""
|
|
395
|
+
_reset_signal_state()
|
|
396
|
+
for stream in (sys.stdout, sys.stderr):
|
|
397
|
+
if hasattr(stream, "reconfigure"):
|
|
398
|
+
with contextlib.suppress(Exception):
|
|
399
|
+
stream.reconfigure(line_buffering=True)
|
|
400
|
+
|
|
401
|
+
with contextlib.suppress(ValueError): # not in main thread (CliRunner)
|
|
402
|
+
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
403
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
404
|
+
_ensure_watchdog()
|
linc_cli_kit/registry.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""The only module allowed to touch FastMCP's private tool manager.
|
|
2
|
+
|
|
3
|
+
`_tool_manager` is private SDK API (renamed in the SDK's v2 line). Everything
|
|
4
|
+
else in the kit consumes ToolSpec, so an SDK change is fixed in one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ToolSpec:
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
parameters: dict[str, Any]
|
|
19
|
+
fn: Callable[..., Any]
|
|
20
|
+
is_async: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def registry_of(server: Any) -> list[ToolSpec]:
|
|
24
|
+
"""Return every tool registered on a FastMCP server as a ToolSpec."""
|
|
25
|
+
manager = getattr(server, "_tool_manager", None)
|
|
26
|
+
list_tools = getattr(manager, "list_tools", None)
|
|
27
|
+
if manager is None or list_tools is None:
|
|
28
|
+
raise TypeError(
|
|
29
|
+
"registry_of() expects a FastMCP server with a FastMCP tool registry "
|
|
30
|
+
f"(_tool_manager.list_tools); got {type(server).__name__}"
|
|
31
|
+
)
|
|
32
|
+
specs: list[ToolSpec] = []
|
|
33
|
+
for tool in list_tools():
|
|
34
|
+
for attr in ("name", "fn", "parameters", "is_async"):
|
|
35
|
+
if not hasattr(tool, attr):
|
|
36
|
+
raise TypeError(
|
|
37
|
+
f"FastMCP Tool lacks '{attr}' — SDK shape changed; update registry.py"
|
|
38
|
+
)
|
|
39
|
+
specs.append(
|
|
40
|
+
ToolSpec(
|
|
41
|
+
name=tool.name,
|
|
42
|
+
description=(tool.description or "").strip(),
|
|
43
|
+
parameters=dict(tool.parameters or {}),
|
|
44
|
+
fn=tool.fn,
|
|
45
|
+
is_async=bool(tool.is_async),
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
return specs
|
linc_cli_kit/schema.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""JSON-schema property → click.Option (spec §5.3)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
RESERVED = frozenset({"json", "human", "stream", "output", "timeout", "log_level", "help", "yes"})
|
|
11
|
+
_SCALARS = {"integer": click.INT, "number": click.FLOAT, "string": click.STRING}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class JsonType(click.ParamType):
|
|
15
|
+
name = "json"
|
|
16
|
+
|
|
17
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
18
|
+
if value is None or isinstance(value, (dict, list)):
|
|
19
|
+
return value
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(value)
|
|
22
|
+
except json.JSONDecodeError as exc:
|
|
23
|
+
self.fail(f"must be valid JSON: {exc}", param, ctx)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CoerceType(click.ParamType):
|
|
27
|
+
"""Try each member type of a scalar union in canonical order."""
|
|
28
|
+
|
|
29
|
+
name = "value"
|
|
30
|
+
|
|
31
|
+
def __init__(self, members: list[dict[str, Any]]) -> None:
|
|
32
|
+
canonical = ("integer", "number", "boolean", "object", "array", "string")
|
|
33
|
+
|
|
34
|
+
def sort_key(m: dict[str, Any]) -> int:
|
|
35
|
+
mtype = m.get("type")
|
|
36
|
+
return canonical.index(mtype) if mtype in canonical else len(canonical)
|
|
37
|
+
|
|
38
|
+
self.members = sorted(members, key=sort_key)
|
|
39
|
+
|
|
40
|
+
def convert(self, value: Any, param: Any, ctx: Any) -> Any:
|
|
41
|
+
for member in self.members:
|
|
42
|
+
kind = member.get("type")
|
|
43
|
+
try:
|
|
44
|
+
if kind == "integer":
|
|
45
|
+
return int(value)
|
|
46
|
+
if kind == "number":
|
|
47
|
+
return float(value)
|
|
48
|
+
if kind == "boolean":
|
|
49
|
+
return click.BOOL.convert(value, param, ctx)
|
|
50
|
+
if kind in ("object", "array"):
|
|
51
|
+
return json.loads(value)
|
|
52
|
+
if kind == "string":
|
|
53
|
+
return str(value)
|
|
54
|
+
except (ValueError, TypeError, json.JSONDecodeError, click.BadParameter):
|
|
55
|
+
continue
|
|
56
|
+
self.fail(
|
|
57
|
+
f"could not coerce {value!r} to any of {[m.get('type') for m in self.members]}",
|
|
58
|
+
param,
|
|
59
|
+
ctx,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cli_name(tool_name: str, prefix: str) -> str:
|
|
64
|
+
name = tool_name[len(prefix):] if prefix and tool_name.startswith(prefix) else tool_name
|
|
65
|
+
return name.replace("_", "-")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _flag(name: str) -> str:
|
|
69
|
+
base = name.replace("_", "-")
|
|
70
|
+
return f"--arg-{base}" if name in RESERVED else f"--{base}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _members(prop: dict[str, Any]) -> list[dict[str, Any]]:
|
|
74
|
+
return [m for m in (prop.get("anyOf") or prop.get("oneOf") or []) if m.get("type") != "null"]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def option_for(name: str, prop: dict[str, Any], required: bool) -> click.Option:
|
|
78
|
+
help_text = (prop.get("description") or "").strip()
|
|
79
|
+
default = prop.get("default")
|
|
80
|
+
kind = prop.get("type")
|
|
81
|
+
members = _members(prop)
|
|
82
|
+
flag = _flag(name)
|
|
83
|
+
|
|
84
|
+
def build_kwargs() -> dict[str, Any]:
|
|
85
|
+
kwargs: dict[str, Any] = {"required": required, "help": help_text}
|
|
86
|
+
if not required and default is not None:
|
|
87
|
+
kwargs["default"] = default
|
|
88
|
+
kwargs["show_default"] = True
|
|
89
|
+
return kwargs
|
|
90
|
+
|
|
91
|
+
if "enum" in prop:
|
|
92
|
+
kwargs = build_kwargs()
|
|
93
|
+
kwargs["type"] = click.Choice([str(c) for c in prop["enum"]])
|
|
94
|
+
return click.Option([flag, name], **kwargs)
|
|
95
|
+
if kind == "boolean":
|
|
96
|
+
kwargs = build_kwargs()
|
|
97
|
+
kwargs["is_flag"] = True
|
|
98
|
+
if not required:
|
|
99
|
+
kwargs["default"] = bool(default) if default is not None else False
|
|
100
|
+
return click.Option([f"{flag}/--no-{flag.lstrip('-')}", name], **kwargs)
|
|
101
|
+
if kind in _SCALARS:
|
|
102
|
+
kwargs = build_kwargs()
|
|
103
|
+
kwargs["type"] = _SCALARS[kind]
|
|
104
|
+
if not required:
|
|
105
|
+
kwargs["show_default"] = default is not None
|
|
106
|
+
return click.Option([flag, name], **kwargs)
|
|
107
|
+
if kind == "array":
|
|
108
|
+
item_kind = (prop.get("items") or {}).get("type")
|
|
109
|
+
if item_kind in _SCALARS:
|
|
110
|
+
kwargs = build_kwargs()
|
|
111
|
+
kwargs["type"] = _SCALARS[item_kind]
|
|
112
|
+
kwargs["multiple"] = True
|
|
113
|
+
if not required and isinstance(default, list):
|
|
114
|
+
kwargs["default"] = tuple(default)
|
|
115
|
+
return click.Option([flag, name], **kwargs)
|
|
116
|
+
kwargs = build_kwargs()
|
|
117
|
+
kwargs["type"] = JsonType()
|
|
118
|
+
if not required:
|
|
119
|
+
kwargs["default"] = default
|
|
120
|
+
return click.Option([f"{flag}-json", name], **kwargs)
|
|
121
|
+
if kind == "object" or any(m.get("type") in ("object", "array") for m in members):
|
|
122
|
+
kwargs = build_kwargs()
|
|
123
|
+
kwargs["type"] = JsonType()
|
|
124
|
+
if not required:
|
|
125
|
+
kwargs["default"] = default
|
|
126
|
+
return click.Option([f"{flag}-json", name], **kwargs)
|
|
127
|
+
if members:
|
|
128
|
+
kwargs = build_kwargs()
|
|
129
|
+
kwargs["type"] = CoerceType(members)
|
|
130
|
+
if not required:
|
|
131
|
+
kwargs["default"] = default
|
|
132
|
+
return click.Option([flag, name], **kwargs)
|
|
133
|
+
kwargs = build_kwargs()
|
|
134
|
+
kwargs["type"] = click.STRING
|
|
135
|
+
if not required:
|
|
136
|
+
kwargs["show_default"] = default is not None
|
|
137
|
+
return click.Option([flag, name], **kwargs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def options_for(parameters: dict[str, Any]) -> list[click.Option]:
|
|
141
|
+
required = set(parameters.get("required", []))
|
|
142
|
+
props = parameters.get("properties") or {}
|
|
143
|
+
return [option_for(n, p, n in required) for n, p in props.items()]
|