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,65 @@
1
+ """linc-cli-kit — FastMCP registry → Click commands under one output contract.
2
+
3
+ Import cost is paid by every adopter CLI invocation, `--help` included, so the conformance
4
+ suite (`assert_cli_parity`, `ParityReport`: click.testing + unittest.mock) loads on first
5
+ attribute access (PEP 562), and `transport` imports httpx/anyio only when a call needs them.
6
+ """
7
+
8
+ from typing import TYPE_CHECKING, Any
9
+
10
+ from linc_cli_kit.errors import ToolError, UsageError, parse_error_envelope
11
+ from linc_cli_kit.logging import configure_stderr_logging, configure_structlog_stderr
12
+ from linc_cli_kit.mount import (
13
+ current_mode,
14
+ current_output,
15
+ current_timeout,
16
+ install_globals,
17
+ mount_tools,
18
+ )
19
+ from linc_cli_kit.output import emit, emit_exception, heartbeat, json_default, to_json
20
+ from linc_cli_kit.registry import ToolSpec, registry_of
21
+ from linc_cli_kit.transport import HttpDaemonTransport, LocalTransport
22
+
23
+ if TYPE_CHECKING:
24
+ from linc_cli_kit.testing import ParityReport, assert_cli_parity
25
+
26
+ _LAZY = {"ParityReport": "linc_cli_kit.testing", "assert_cli_parity": "linc_cli_kit.testing"}
27
+
28
+ __all__ = [
29
+ "HttpDaemonTransport",
30
+ "LocalTransport",
31
+ "ParityReport",
32
+ "ToolError",
33
+ "ToolSpec",
34
+ "UsageError",
35
+ "assert_cli_parity",
36
+ "configure_stderr_logging",
37
+ "configure_structlog_stderr",
38
+ "current_mode",
39
+ "current_output",
40
+ "current_timeout",
41
+ "emit",
42
+ "emit_exception",
43
+ "heartbeat",
44
+ "install_globals",
45
+ "json_default",
46
+ "mount_tools",
47
+ "parse_error_envelope",
48
+ "registry_of",
49
+ "to_json",
50
+ ]
51
+
52
+
53
+ def __getattr__(name: str) -> Any:
54
+ module = _LAZY.get(name)
55
+ if module is None:
56
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
57
+ import importlib
58
+
59
+ value = getattr(importlib.import_module(module), name)
60
+ globals()[name] = value # cache: later lookups skip __getattr__
61
+ return value
62
+
63
+
64
+ def __dir__() -> list[str]:
65
+ return sorted(set(globals()) | set(__all__))
linc_cli_kit/errors.py ADDED
@@ -0,0 +1,136 @@
1
+ """Error envelope + exit-code classification (spec §5.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ CATEGORIES = (
11
+ "validation",
12
+ "not_found",
13
+ "permission",
14
+ "unavailable",
15
+ "upstream_timeout",
16
+ "internal",
17
+ )
18
+ _RETRYABLE = {"unavailable", "upstream_timeout"}
19
+
20
+
21
+ class ToolError(Exception):
22
+ def __init__(
23
+ self,
24
+ message: str,
25
+ *,
26
+ code: str = "TOOL_ERROR",
27
+ hint: str = "",
28
+ category: str = "internal",
29
+ retryable: bool | None = None,
30
+ details: dict[str, Any] | None = None,
31
+ exit_code: int = 1,
32
+ ) -> None:
33
+ super().__init__(message)
34
+ self.message = message
35
+ self.code = code
36
+ self.hint = hint
37
+ self.category = category if category in CATEGORIES else "internal"
38
+ self.retryable = (self.category in _RETRYABLE) if retryable is None else retryable
39
+ self.details = details or {}
40
+ self.exit_code = exit_code
41
+
42
+ def envelope(self) -> dict[str, Any]:
43
+ return {"error": {
44
+ "code": self.code, "message": self.message, "hint": self.hint,
45
+ "category": self.category, "retryable": self.retryable, "details": self.details,
46
+ }}
47
+
48
+
49
+ class UsageError(ToolError):
50
+ def __init__(
51
+ self,
52
+ message: str,
53
+ *,
54
+ hint: str = "",
55
+ details: dict[str, Any] | None = None,
56
+ ) -> None:
57
+ super().__init__(
58
+ message,
59
+ code="USAGE_ERROR",
60
+ hint=hint,
61
+ category="validation",
62
+ retryable=False,
63
+ details=details,
64
+ exit_code=2,
65
+ )
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Outcome:
70
+ exit_code: int
71
+ payload: Any
72
+ envelope: dict[str, Any] | None = field(default=None)
73
+
74
+
75
+ def safe_str(value: Any) -> str:
76
+ """str() that cannot raise: the error path must not fail on a hostile __str__."""
77
+ try:
78
+ return str(value)
79
+ except Exception:
80
+ try:
81
+ return repr(value)
82
+ except Exception:
83
+ return f"<unprintable {type(value).__name__}>"
84
+
85
+
86
+ def _upper_snake(name: str) -> str:
87
+ return re.sub(r"(?<!^)(?=[A-Z])", "_", name).upper()
88
+
89
+
90
+ def classify(result: Any) -> Outcome:
91
+ """Map a tool's return value to an Outcome using the fleet's dict conventions."""
92
+ if result is None:
93
+ return Outcome(0, {}, None)
94
+ # Truthiness, not key presence: tools that always include an "error" key and set it to
95
+ # None/"" on success must classify as success (fleet convention).
96
+ if isinstance(result, dict) and result.get("error"):
97
+ validation = bool(result.get("validation_error"))
98
+ err = ToolError(
99
+ safe_str(result["error"]),
100
+ code="VALIDATION_ERROR" if validation else "TOOL_ERROR",
101
+ category="validation" if validation else "internal",
102
+ details={k: v for k, v in result.items() if k not in ("error", "validation_error")},
103
+ exit_code=2 if validation else 1,
104
+ )
105
+ return Outcome(err.exit_code, None, err.envelope())
106
+ return Outcome(0, result, None)
107
+
108
+
109
+ def parse_error_envelope(stderr: str) -> dict[str, Any] | None:
110
+ """Return the error envelope from a command's stderr, or None if it wrote none.
111
+
112
+ The contract: on failure the envelope is the LAST line of stderr. Lines before it may be
113
+ JSON heartbeats (`{"event": "heartbeat", ...}`) or stderr logging, so never
114
+ `json.loads` the whole stream — take the last line that parses as an object with an
115
+ "error" key, which is what this does.
116
+ """
117
+ for line in reversed(stderr.splitlines()):
118
+ line = line.strip()
119
+ if not line.startswith("{"):
120
+ continue
121
+ try:
122
+ doc = json.loads(line)
123
+ except ValueError:
124
+ continue
125
+ if isinstance(doc, dict) and isinstance(doc.get("error"), dict):
126
+ return doc
127
+ return None
128
+
129
+
130
+ def classify_exception(exc: BaseException) -> Outcome:
131
+ if isinstance(exc, ToolError):
132
+ return Outcome(exc.exit_code, None, exc.envelope())
133
+ err = ToolError(
134
+ safe_str(exc) or exc.__class__.__name__, code=_upper_snake(exc.__class__.__name__)
135
+ )
136
+ return Outcome(1, None, err.envelope())
@@ -0,0 +1,34 @@
1
+ """stderr-only logging, the sentinel pattern."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+
9
+
10
+ def configure_stderr_logging(level: str | None = None) -> None:
11
+ name = (level or os.environ.get("LINC_LOG_LEVEL") or "WARNING").upper()
12
+ root = logging.getLogger()
13
+ for handler in list(root.handlers):
14
+ root.removeHandler(handler)
15
+ handler = logging.StreamHandler(sys.stderr)
16
+ handler.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s"))
17
+ root.addHandler(handler)
18
+ root.setLevel(getattr(logging, name, logging.WARNING))
19
+
20
+
21
+ def configure_structlog_stderr() -> bool:
22
+ """Point structlog's default logger factory at stderr; no-op if structlog is absent.
23
+
24
+ Returns True if structlog was configured, False if it is not installed. The kit has no
25
+ hard dependency on structlog — the import happens inside the function so adopters that
26
+ do not use it pay nothing. Call this ABOVE your package imports: modules bind their
27
+ logger at import time, so a later call leaves already-bound loggers on stdout.
28
+ """
29
+ try:
30
+ import structlog
31
+ except ImportError:
32
+ return False
33
+ structlog.configure(logger_factory=structlog.PrintLoggerFactory(file=sys.stderr))
34
+ return True
linc_cli_kit/mount.py ADDED
@@ -0,0 +1,287 @@
1
+ """Mount a FastMCP registry onto a Click group (spec §5.1, §5.3, §5.5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import sys
7
+ from collections.abc import Callable
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from linc_cli_kit.errors import ToolError, UsageError
13
+ from linc_cli_kit.logging import configure_stderr_logging
14
+ from linc_cli_kit.output import (
15
+ emit,
16
+ emit_exception,
17
+ harden_streams,
18
+ heartbeat,
19
+ resolve_mode,
20
+ to_json,
21
+ )
22
+ from linc_cli_kit.registry import ToolSpec, registry_of
23
+ from linc_cli_kit.schema import cli_name, options_for
24
+ from linc_cli_kit.transport import LocalTransport, Transport
25
+
26
+ _META = "linc_cli_kit."
27
+
28
+ # click.ParamType.name -> the schema doc's normalized "type" string (ruling 4).
29
+ _TYPE_NAMES = {
30
+ "text": "STRING",
31
+ "integer": "INT",
32
+ "float": "FLOAT",
33
+ "boolean": "BOOL",
34
+ "choice": "CHOICE",
35
+ "json": "JSON",
36
+ "value": "VALUE",
37
+ }
38
+
39
+
40
+ def current_mode() -> str:
41
+ ctx = click.get_current_context(silent=True)
42
+ if ctx is not None:
43
+ mode = ctx.find_root().meta.get(_META + "mode")
44
+ if mode is not None:
45
+ return mode
46
+ return resolve_mode(None)
47
+
48
+
49
+ def current_output() -> str | None:
50
+ ctx = click.get_current_context(silent=True)
51
+ return ctx.find_root().meta.get(_META + "output") if ctx is not None else None
52
+
53
+
54
+ def current_timeout() -> float | None:
55
+ ctx = click.get_current_context(silent=True)
56
+ return ctx.find_root().meta.get(_META + "timeout") if ctx is not None else None
57
+
58
+
59
+ def install_globals(group: click.Group) -> click.Group:
60
+ """Add the kit's global options and stream/logging hardening to an existing group."""
61
+ if any(getattr(p, "name", None) == "linc_mode" for p in group.params):
62
+ return group
63
+
64
+ # Built fresh per call (ruling 1) — Option instances must not be shared across groups.
65
+ global_options: list[click.Option] = [
66
+ click.Option(
67
+ ["--json", "linc_mode"], flag_value="json", default=None,
68
+ help="JSON on stdout (default).",
69
+ ),
70
+ click.Option(["--human", "linc_mode"], flag_value="human", help="Human rendering."),
71
+ click.Option(
72
+ ["--stream", "linc_mode"], flag_value="jsonl",
73
+ help="JSON Lines, one record per line.",
74
+ ),
75
+ click.Option(
76
+ ["--output", "-o", "linc_output"], default=None, help="Path for binary artifacts.",
77
+ ),
78
+ click.Option(
79
+ ["--timeout", "linc_timeout"], type=float, default=None,
80
+ help="Per-call timeout seconds.",
81
+ ),
82
+ click.Option(
83
+ ["--log-level", "linc_log_level"], default=None, help="LINC_LOG_LEVEL override.",
84
+ ),
85
+ ]
86
+ group.params.extend(global_options)
87
+
88
+ # The invocation's resolved mode, kept past the context's teardown so an exception that
89
+ # escapes to main() below is rendered the way the caller asked (--human vs JSON).
90
+ invocation: dict[str, str | None] = {"mode": None}
91
+
92
+ original_callback = group.callback
93
+
94
+ def callback(**kwargs: Any) -> Any:
95
+ ctx = click.get_current_context()
96
+ ctx.meta[_META + "mode"] = resolve_mode(kwargs.pop("linc_mode", None))
97
+ invocation["mode"] = ctx.meta[_META + "mode"]
98
+ ctx.meta[_META + "output"] = kwargs.pop("linc_output", None)
99
+ ctx.meta[_META + "timeout"] = kwargs.pop("linc_timeout", None)
100
+ configure_stderr_logging(kwargs.pop("linc_log_level", None))
101
+ harden_streams()
102
+ return original_callback(**kwargs) if original_callback else None
103
+
104
+ if original_callback is not None:
105
+ callback = functools.wraps(original_callback)(callback)
106
+ group.callback = callback
107
+
108
+ original_main = group.main
109
+
110
+ def main(*args: Any, **kwargs: Any) -> Any:
111
+ # Hard-assigned, not setdefault: a caller passing standalone_mode=True (e.g. a
112
+ # CliRunner.invoke(..., standalone_mode=True) test) must not be able to bypass the
113
+ # envelope contract (ruled fix, finding 2).
114
+ kwargs["standalone_mode"] = False
115
+ invocation["mode"] = None
116
+ try:
117
+ rv = original_main(*args, **kwargs)
118
+ except click.UsageError as exc:
119
+ emit_exception(UsageError(exc.format_message()), mode=_mode())
120
+ sys.exit(2)
121
+ except click.exceptions.Exit as exc:
122
+ sys.exit(exc.exit_code)
123
+ except click.exceptions.Abort as exc:
124
+ # Click converts KeyboardInterrupt (our SIGINT handler) into Abort.
125
+ interrupted = isinstance(exc.__cause__ or exc.__context__, KeyboardInterrupt)
126
+ err = ToolError(
127
+ "interrupted" if interrupted else "aborted",
128
+ code="INTERRUPTED" if interrupted else "ABORTED",
129
+ )
130
+ emit_exception(err, mode=_mode())
131
+ sys.exit(130 if interrupted else 1)
132
+ except Exception as exc:
133
+ # A hand-written override or group callback that raises must still honour the
134
+ # contract: envelope on stderr, empty stdout, no traceback. Generated commands
135
+ # already catch inside their callback; this is the net for everything else.
136
+ # SystemExit (e.g. our SIGTERM handler) is a BaseException and passes through.
137
+ sys.exit(emit_exception(exc, mode=_mode()))
138
+ else:
139
+ # type(rv) is int, not isinstance: bool is an int subclass, and a callback that
140
+ # happens to return True/False must not be mistaken for an exit code (minor 7).
141
+ sys.exit(rv if type(rv) is int else 0)
142
+
143
+ def _mode() -> str:
144
+ return invocation["mode"] or resolve_mode(None)
145
+
146
+ group.main = main
147
+ return group
148
+
149
+
150
+ def _example_line(description: str) -> str | None:
151
+ for line in description.splitlines():
152
+ idx = line.find("Example:")
153
+ if idx != -1:
154
+ return line[idx:].strip()
155
+ return None
156
+
157
+
158
+ def _build_command(
159
+ spec: ToolSpec, name: str, transport: Transport, renderer: Callable[[Any], str] | None
160
+ ) -> click.Command:
161
+ options = options_for(spec.parameters) # never mutates spec.parameters (ruling 9)
162
+ options.sort(key=lambda o: not o.required) # required before optional
163
+
164
+ def callback(**kwargs: Any) -> None:
165
+ ctx = click.get_current_context()
166
+ payload = {k: v for k, v in kwargs.items() if v is not None and v != ()}
167
+ mode = current_mode()
168
+ try:
169
+ with heartbeat(label=name, mode=mode):
170
+ result = transport.call(spec, payload, timeout=current_timeout())
171
+ # emit() runs INSIDE the try (finding 1): a renderer (--human) or a mid-stream
172
+ # generator (--stream) that raises must still produce an envelope, not a bare
173
+ # traceback with empty stdout/stderr.
174
+ ctx.exit(
175
+ emit(result, mode=mode, output_path=current_output(), renderer=renderer,
176
+ tool=name)
177
+ )
178
+ except click.exceptions.Exit:
179
+ # ctx.exit() above raises this on the success path — it is not an error to wrap.
180
+ raise
181
+ except click.UsageError as exc: # narrowed from ClickException (minor 8): a
182
+ # click.FileError etc. is not a usage error and should fall through below.
183
+ ctx.exit(emit_exception(UsageError(exc.format_message()), mode=mode))
184
+ except Exception as exc: # contract: never a raw traceback on stdout
185
+ ctx.exit(emit_exception(exc, mode=mode))
186
+
187
+ example = _example_line(spec.description)
188
+ help_text = spec.description.split("\n\n")[0]
189
+ cmd = click.Command(
190
+ name, params=list(options), callback=callback, help=help_text, epilog=example,
191
+ )
192
+ cmd.linc_transport = transport # ruling 7
193
+ # Marks the command as kit-generated. The conformance suite classifies on THIS attribute,
194
+ # not on callback.__module__ — an adopter is free to wrap or replace the callback.
195
+ cmd.linc_kit_generated = True
196
+ return cmd
197
+
198
+
199
+ def _normalized_type(ptype: click.ParamType) -> str:
200
+ return _TYPE_NAMES.get(ptype.name, ptype.name.upper())
201
+
202
+
203
+ def _opt_doc(o: click.Parameter) -> dict[str, Any]:
204
+ choices = list(o.type.choices) if isinstance(o.type, click.Choice) else None
205
+ default = o.default
206
+ if default == () or default is getattr(click.core, "UNSET", object()):
207
+ default = None
208
+ return {
209
+ "flag": (o.opts or [o.name])[0],
210
+ "type": _normalized_type(o.type),
211
+ "required": bool(o.required),
212
+ "default": default,
213
+ "choices": choices,
214
+ "help": (getattr(o, "help", "") or ""),
215
+ }
216
+
217
+
218
+ def _schema_command(group: click.Group) -> click.Command:
219
+ @click.command("schema", help="Emit the whole command tree as JSON (no --help crawl).")
220
+ @click.option("--json", "as_json", is_flag=True, default=True, help="Always JSON.")
221
+ def schema(as_json: bool) -> None:
222
+ ctx = click.get_current_context(silent=True)
223
+ root_name = ctx.find_root().info_name if ctx is not None else None
224
+ doc = {
225
+ "name": root_name or group.name,
226
+ "globals": {
227
+ (o.opts or [o.name])[0]: (o.help or "")
228
+ for o in group.params
229
+ if isinstance(o, click.Option)
230
+ },
231
+ "commands": {
232
+ cname: {
233
+ "help": (cmd.help or "").split("\n")[0],
234
+ "options": {
235
+ p.name: _opt_doc(p) for p in cmd.params if isinstance(p, click.Option)
236
+ },
237
+ }
238
+ for cname, cmd in sorted(group.commands.items())
239
+ },
240
+ }
241
+ click.echo(to_json(doc))
242
+
243
+ schema.linc_kit_generated = True # marks "ours" so a re-mount on the same group won't collide
244
+ return schema
245
+
246
+
247
+ def mount_tools(
248
+ group: click.Group,
249
+ server: Any,
250
+ *,
251
+ prefix: str = "",
252
+ transport: Transport | None = None,
253
+ skip: dict[str, str] | None = None,
254
+ renderers: dict[str, Callable[[Any], str]] | None = None,
255
+ ) -> list[str]:
256
+ """Reflect every registered tool into `group`. Hand-written commands win on name collision.
257
+
258
+ Daemon-only marking is NOT a `mount_tools` concern (finding 3, spec §5.4) — it belongs to
259
+ the `HttpDaemonTransport` a caller passes in via `transport=`, which already implements it.
260
+ """
261
+ if not any(getattr(p, "name", None) == "linc_mode" for p in group.params):
262
+ raise RuntimeError("call install_globals(group) before mount_tools")
263
+ transport = transport or LocalTransport()
264
+ skip = skip or {}
265
+ renderers = renderers or {}
266
+ existing_schema = group.commands.get("schema")
267
+ if existing_schema is not None and not getattr(existing_schema, "linc_kit_generated", False):
268
+ raise ValueError("group already defines 'schema'; the kit reserves that command name")
269
+ for tool_name, why in skip.items():
270
+ if not why.strip():
271
+ raise ValueError(f"skip['{tool_name}'] needs a justification string")
272
+ specs = registry_of(server)
273
+ unknown_skips = sorted(set(skip) - {spec.name for spec in specs})
274
+ if unknown_skips:
275
+ raise ValueError(f"skip references unknown tool(s): {unknown_skips}")
276
+ mounted: list[str] = []
277
+ for spec in specs:
278
+ if spec.name in skip:
279
+ continue
280
+ name = cli_name(spec.name, prefix)
281
+ if name in group.commands:
282
+ continue
283
+ group.add_command(_build_command(spec, name, transport, renderers.get(name)), name)
284
+ mounted.append(name)
285
+ group.add_command(_schema_command(group), "schema")
286
+ group.linc_transport = transport # ruling 7
287
+ return mounted