From 191bb62fadfd14308ec1f9d6110bd48079d808aa Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Tue, 16 Jun 2026 14:29:42 +0200 Subject: [PATCH 1/2] feat: agent-ready CLI and embedded MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make `fic` zero-shot drivable by coding agents (Phases 2+3 of the design): - Structured JSON error envelope on stderr in json mode (the default): {"error":{"code","message","hint"?,"fields"?,"totals"?}}; table mode keeps plaintext. `code` is the error's canonical identity (fic.core.errors.error_code), shared by the CLI envelope and the MCP tool-error surface. Exit codes 0-7 unchanged. - `fic agent-docs`: self-describing manual (JSON / markdown) walking the live Typer tree — every command, flag, exit code, and the error-envelope shape. - `fic mcp serve`: embedded MCP stdio server (fic/mcp/) exposing 13 tools over fic.core via per-resource dispatch adapters. Reads are free; the 6 write tools take a required boolean `confirm` (fail-closed, checked before any network), and errors carry the code prefixed into the tool-error message. The mcp SDK is an optional extra, imported lazily so the base CLI never needs it. - Console entry point `fic.cli.main:main` renders click usage errors (exit 2) through the same envelope. Write-gate parity: `docs convert` and `einvoice send` now require `--yes` (extracted to a shared `require_yes` helper); a bad/missing document type now surfaces as a recoverable `validation` error (exit 4). - Command descriptions and allowed `--type` values added so the CLI surface is self-sufficient via agent-docs. Tests: error-envelope contract, agent-docs structure, MCP dispatch/gate/inventory (incl. the confirm gate driven through the real call_tool path), TTY guard. No network in tests; core purity guard intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- fic/cli/_args.py | 9 +- fic/cli/_errors.py | 105 +++++++- fic/cli/agentdocs.py | 142 +++++++++++ fic/cli/documents.py | 97 +++++--- fic/cli/info.py | 2 +- fic/cli/main.py | 41 ++- fic/cli/mcp_serve.py | 77 ++++++ fic/cli/registry.py | 13 +- fic/core/documents.py | 18 +- fic/core/errors.py | 16 ++ fic/mcp/__init__.py | 8 + fic/mcp/server.py | 395 +++++++++++++++++++++++++++++ pyproject.toml | 5 +- tests/cli/test_agentdocs.py | 58 +++++ tests/cli/test_app.py | 11 +- tests/cli/test_documents.py | 15 +- tests/cli/test_errors.py | 103 ++++++++ tests/cli/test_main.py | 61 +++++ tests/cli/test_mcp_serve.py | 50 ++++ tests/mcp/__init__.py | 0 tests/mcp/test_server.py | 172 +++++++++++++ uv.lock | 483 +++++++++++++++++++++++++++++++++++- 22 files changed, 1810 insertions(+), 71 deletions(-) create mode 100644 fic/cli/agentdocs.py create mode 100644 fic/cli/mcp_serve.py create mode 100644 fic/mcp/__init__.py create mode 100644 fic/mcp/server.py create mode 100644 tests/cli/test_agentdocs.py create mode 100644 tests/cli/test_errors.py create mode 100644 tests/cli/test_main.py create mode 100644 tests/cli/test_mcp_serve.py create mode 100644 tests/mcp/__init__.py create mode 100644 tests/mcp/test_server.py diff --git a/fic/cli/_args.py b/fic/cli/_args.py index 0c06c1e..a3e2111 100644 --- a/fic/cli/_args.py +++ b/fic/cli/_args.py @@ -1,4 +1,4 @@ -"""Shared CLI helpers: read a JSON payload from a file or stdin.""" +"""Shared CLI helpers: read a JSON payload from a file or stdin; gate destructive ops.""" from __future__ import annotations import json @@ -8,6 +8,13 @@ import typer +def require_yes(yes: bool, action: str) -> None: + """Gate a destructive command behind --yes (the CLI consent gate; the MCP server's + confirm=true is the mirror). Raises a usage error (exit 2) when --yes is absent.""" + if not yes: + raise typer.BadParameter(f"refusing to {action} without --yes") + + def read_payload(file: str | None) -> dict: if file in (None, "-"): raw = sys.stdin.read() diff --git a/fic/cli/_errors.py b/fic/cli/_errors.py index 0706105..7861f78 100644 --- a/fic/cli/_errors.py +++ b/fic/cli/_errors.py @@ -1,7 +1,18 @@ -"""Map core FicError exceptions to stderr messages + documented exit codes (spec §6).""" +"""The agent-facing error contract: exit codes + the JSON error envelope. + +`handle` is the single renderer for core `FicError`s (spec §6). In ``--output json`` +(the default, what agents use) an error renders as exactly one envelope line on stderr:: + + {"error":{"code":"...","message":"...","hint":"...","fields":{...},"totals":{...}}} + +In ``--output table`` it renders as human ``error: `` text. stdout stays empty on +error. Exit codes are unchanged. `main()` (cli/main.py) renders click *usage* errors +(exit 2) through `render_usage_error` so even bad invocations emit the envelope. +""" from __future__ import annotations import functools +import json import sys from collections.abc import Callable from typing import Any @@ -10,7 +21,8 @@ from fic.core import errors -_EXIT = { +# FicError type -> process exit code (spec §6; unchanged public contract). +_EXIT: dict[type, int] = { errors.ConfigError: 3, errors.AuthError: 3, errors.ValidationError: 4, @@ -19,8 +31,56 @@ errors.PermissionDenied: 7, } +# The public exit-code / error-code tables `agent-docs` prints. Single source of truth. +EXIT_CODES: dict[str, str] = { + "0": "success", + "1": "error (generic / unexpected)", + "2": "usage error (bad arguments)", + "3": "auth or config (missing or rejected credentials)", + "4": "validation (HTTP 422 / invalid input)", + "5": "rate limit exhausted (after retries)", + "6": "not found (HTTP 404)", + "7": "forbidden (HTTP 403 — missing scope/permission)", +} +ERROR_CODES: list[str] = [ + "error", "usage", "config", "auth", "validation", "rate_limited", "not_found", "forbidden", +] +# The literal envelope shape, surfaced verbatim by `agent-docs`. +ERROR_ENVELOPE_SHAPE = '{"error":{"code":"","message":"","hint":""}}' + + +def exit_code(exc: errors.FicError) -> int: + return _EXIT.get(type(exc), 1) + + +def _hint(exc: errors.FicError) -> str | None: + # ConfigError deliberately has no hint: its message already carries the fix (R12). + if isinstance(exc, errors.AuthError): + return "Set FIC_ACCESS_TOKEN (and FIC_COMPANY_ID), or run `fic auth device`." + if isinstance(exc, errors.NotFound): + return "Check the id and that --company-id is correct." + if isinstance(exc, errors.PermissionDenied): + return "The token lacks the required scope/permission for this operation." + if isinstance(exc, errors.RateLimited) and exc.retry_after is not None: + return f"retry-after: {exc.retry_after}s" + return None + + +def envelope(exc: errors.FicError) -> dict[str, Any]: + """Build the JSON error envelope for a core error.""" + err: dict[str, Any] = {"code": errors.error_code(exc), "message": str(exc)} + hint = _hint(exc) + if hint: + err["hint"] = hint + if isinstance(exc, errors.ValidationError): + if exc.messages: # per-field messages so an agent can self-correct (R7) + err["fields"] = exc.messages + if exc.totals is not None: + err["totals"] = exc.totals + return {"error": err} -def _format(exc: errors.FicError) -> str: + +def _plaintext(exc: errors.FicError) -> str: if isinstance(exc, errors.ValidationError) and exc.totals is not None: return f"error: {exc}\ncomputed totals: {exc.totals}" if isinstance(exc, errors.RateLimited) and exc.retry_after is not None: @@ -28,13 +88,48 @@ def _format(exc: errors.FicError) -> str: return f"error: {exc}" +def _write_json(obj: dict[str, Any]) -> None: + json.dump(obj, sys.stderr, ensure_ascii=False, default=str) + sys.stderr.write("\n") + + +def render(exc: errors.FicError, *, output: str) -> None: + """Render a core error to stderr, once, in the given output mode.""" + if output == "table": + print(_plaintext(exc), file=sys.stderr) + else: + _write_json(envelope(exc)) + + +def render_usage_error(message: str, *, output: str) -> None: + """Render a click usage error (exit 2) in the same contract as core errors.""" + if output == "table": + print(f"error: {message}", file=sys.stderr) + else: + _write_json({"error": {"code": "usage", "message": message}}) + + +def _output_mode(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: + """Resolve the --output mode from a command's typer.Context arg; default json. + + Commands take ``ctx: typer.Context`` whose ``ctx.obj`` is the AppCtx carrying + ``output``. Bare test apps (and any future caller) have no context -> json, + which is the agent default. + """ + for value in (*args, *kwargs.values()): + mode = getattr(getattr(value, "obj", None), "output", None) + if mode in ("json", "table"): + return mode + return "json" + + def handle(func: Callable) -> Callable: @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: try: return func(*args, **kwargs) except errors.FicError as exc: - print(_format(exc), file=sys.stderr) - raise typer.Exit(_EXIT.get(type(exc), 1)) from exc + render(exc, output=_output_mode(args, kwargs)) + raise typer.Exit(exit_code(exc)) from exc return wrapper diff --git a/fic/cli/agentdocs.py b/fic/cli/agentdocs.py new file mode 100644 index 0000000..570eba6 --- /dev/null +++ b/fic/cli/agentdocs.py @@ -0,0 +1,142 @@ +"""`fic agent-docs`: the CLI's machine-readable self-description for coding agents. + +Walks the live Typer/click command tree (so it can never drift from the real surface) +and emits, per ``--output``: a single JSON object (default) or an llms.txt-style markdown +manual. The JSON carries the intro, the exit-code + error-code contract, the error-envelope +shape, and every command with its arguments and flags. +""" +from __future__ import annotations + +import json +import sys + +import typer + +from fic.cli._errors import ( + ERROR_CODES, + ERROR_ENVELOPE_SHAPE, + EXIT_CODES, +) + +INTRO = ( + "`fic` is an unofficial, JSON-first CLI for the Fatture in Cloud (TeamSystem) API v2, " + "built to be driven by coding agents. Pass `--output json` (the default) for a stable, " + "single JSON object on stdout; progress and errors go to stderr. Branch on the exit code; " + "on failure stderr carries exactly one error-envelope line. Every call needs a token " + "(env FIC_ACCESS_TOKEN / FIC_COMPANY_ID, or ~/.config/fic/config.toml, or `fic auth device`). " + "Destructive commands (delete / pay / convert / einvoice send) require " + "`--yes`. The same operations are available over MCP via `fic mcp serve`." +) + +# Options that are framework noise for an agent driving fic. +_SKIP_OPTS = {"help", "install_completion", "show_completion"} + + +def _flag(param) -> dict: + opts = list(param.opts) + longs = [o for o in opts if o.startswith("--")] + shorts = [o for o in opts if not o.startswith("--")] + flag: dict = {"name": longs[0] if longs else opts[0]} + if shorts: + flag["shorthand"] = shorts[0] + if param.help: + flag["usage"] = param.help + if param.required: + flag["required"] = True + # Emit a default only when it carries information (not None, not a flag's implicit False). + elif not getattr(param, "is_flag", False) and param.default is not None: + flag["default"] = param.default + return flag + + +def _describe(cmd, path: str) -> dict: + arguments: list[dict] = [] + flags: list[dict] = [] + for param in cmd.params: + if param.param_type_name == "argument": + arguments.append({"name": param.name, "required": bool(param.required)}) + elif param.param_type_name == "option" and param.name not in _SKIP_OPTS: + flags.append(_flag(param)) + out: dict = {"path": path, "description": (cmd.help or cmd.short_help or "").strip()} + if arguments: + out["arguments"] = arguments + if flags: + out["flags"] = flags + return out + + +def collect(root) -> dict: + """Walk the command tree depth-first (sorted) into the agent-docs JSON contract.""" + commands: list[dict] = [] + + def walk(cmd, path: str) -> None: + commands.append(_describe(cmd, path)) + for name in sorted(getattr(cmd, "commands", {})): + walk(cmd.commands[name], f"{path} {name}") + + walk(root, "fic") + return { + "intro": INTRO, + "exitCodes": EXIT_CODES, + "errorCodes": ERROR_CODES, + "errorEnvelope": ERROR_ENVELOPE_SHAPE, + "commands": commands, + } + + +def _usage_line(cmd: dict) -> str: + parts = [cmd["path"]] + for arg in cmd.get("arguments", []): + token = arg["name"].upper() + parts.append(token if arg["required"] else f"[{token}]") + if cmd.get("flags"): + parts.append("[OPTIONS]") + return " ".join(parts) + + +def render_markdown(docs: dict) -> str: + out: list[str] = ["# fic — agent manual", "", docs["intro"], ""] + out += ["## Exit codes", "", "| Code | Meaning |", "| --- | --- |"] + out += [f"| {code} | {meaning} |" for code, meaning in docs["exitCodes"].items()] + out += [ + "", + "On failure with `--output json`, stderr carries one error-envelope line:", + "", + "```json", + docs["errorEnvelope"], + "```", + "", + f"Error codes: {', '.join('`' + c + '`' for c in docs['errorCodes'])}. " + "`hint`, `fields` and `totals` are present only when relevant.", + "", + "## Commands", + ] + for cmd in docs["commands"]: + out += ["", f"### {cmd['path']}", "", f"Usage: `{_usage_line(cmd)}`"] + if cmd["description"]: + out += ["", cmd["description"]] + for arg in cmd.get("arguments", []): + req = "required" if arg["required"] else "optional" + out.append(f"- `{arg['name']}` (argument, {req})") + for flag in cmd.get("flags", []): + name = flag["name"] + if flag.get("shorthand"): + name = f"{flag['shorthand']}, {name}" + extra = [] + if flag.get("required"): + extra.append("required") + if "default" in flag: + extra.append(f"default `{flag['default']}`") + tail = f" ({', '.join(extra)})" if extra else "" + out.append(f"- `{name}`{tail}: {flag.get('usage', '')}".rstrip()) + return "\n".join(out) + + +def agent_docs(ctx: typer.Context) -> None: + """Print the agent-facing manual: every command, flag, exit code and the error envelope.""" + docs = collect(ctx.find_root().command) + if ctx.obj is not None and ctx.obj.output == "table": + typer.echo(render_markdown(docs)) + else: + json.dump(docs, sys.stdout, ensure_ascii=False, default=str) + sys.stdout.write("\n") diff --git a/fic/cli/documents.py b/fic/cli/documents.py index 2c9fa0c..3bd4cad 100644 --- a/fic/cli/documents.py +++ b/fic/cli/documents.py @@ -7,7 +7,7 @@ import httpx import typer -from fic.cli._args import read_payload +from fic.cli._args import read_payload, require_yes from fic.cli._errors import handle from fic.core import documents from fic.core.errors import FicError @@ -16,20 +16,28 @@ received_app = typer.Typer(no_args_is_help=True) einvoice_app = typer.Typer(no_args_is_help=True) +# Surface the allowed type values in --help / agent-docs so an agent need not guess. +_ISSUED_TYPE_HELP = "Issued document type (required). One of: " + ", ".join( + sorted(documents.ISSUED_TYPES) +) + "." +_RECEIVED_TYPE_HELP = "Received document type (required). One of: " + ", ".join( + sorted(documents.RECEIVED_TYPES) +) + "." + # --- issued docs --- -@docs_app.command("list") +@docs_app.command("list", help="List issued documents of a given type (one page unless --all).") @handle def docs_list( ctx: typer.Context, - type: str = typer.Option(..., "--type", help="Document type (required)."), - all: bool = typer.Option(False, "--all"), + type: str = typer.Option(..., "--type", help=_ISSUED_TYPE_HELP), + all: bool = typer.Option(False, "--all", help="Auto-paginate all pages."), year: int = typer.Option(None, "--year", help="Sugar: filter by year via `q`."), - query: str = typer.Option(None, "--query"), + query: str = typer.Option(None, "--query", help="Filter (maps to the API `q` param)."), page: int = typer.Option(None, "--page"), - per_page: int = typer.Option(None, "--per-page"), + per_page: int = typer.Option(None, "--per-page", help="Max 100."), sort: str = typer.Option(None, "--sort"), - fieldset: str = typer.Option(None, "--fieldset"), + fieldset: str = typer.Option(None, "--fieldset", help="basic|detailed|fic_view"), ): with ctx.obj.client() as c: data = documents.list_issued( @@ -39,26 +47,26 @@ def docs_list( ctx.obj.emit({"data": data} if all else data) -@docs_app.command("get") +@docs_app.command("get", help="Get a single issued document by id (includes ei_status).") @handle def docs_get(ctx: typer.Context, id: int): with ctx.obj.client() as c: ctx.obj.emit(documents.get_issued(c, id)) -@docs_app.command("create") +@docs_app.command("create", help="Create an issued document from a JSON payload.") @handle def docs_create( ctx: typer.Context, - type: str = typer.Option(..., "--type"), - file: str = typer.Option("-", "-f", "--file"), + type: str = typer.Option(..., "--type", help=_ISSUED_TYPE_HELP), + file: str = typer.Option("-", "-f", "--file", help="JSON file or - for stdin."), ): payload = read_payload(file) with ctx.obj.client() as c: ctx.obj.emit(documents.create_issued(c, type=type, payload=payload)) -@docs_app.command("update") +@docs_app.command("update", help="Update an issued document by id from a JSON payload.") @handle def docs_update(ctx: typer.Context, id: int, file: str = typer.Option("-", "-f", "--file")): payload = read_payload(file) @@ -66,17 +74,16 @@ def docs_update(ctx: typer.Context, id: int, file: str = typer.Option("-", "-f", ctx.obj.emit(documents.update_issued(c, id, payload)) -@docs_app.command("delete") +@docs_app.command("delete", help="Delete an issued document by id (requires --yes).") @handle def docs_delete(ctx: typer.Context, id: int, yes: bool = typer.Option(False, "--yes")): - if not yes: - raise typer.BadParameter("refusing to delete without --yes") + require_yes(yes, "delete") with ctx.obj.client() as c: documents.delete_issued(c, id) ctx.obj.emit({"deleted": id}) -@docs_app.command("convert") +@docs_app.command("convert", help="Convert (transform) an issued document to a new type.") @handle def docs_convert( ctx: typer.Context, @@ -85,7 +92,9 @@ def docs_convert( from_type: str = typer.Option(None, "--from", help="Current type."), e_invoice: bool = typer.Option(False, "--e-invoice"), keep_copy: bool = typer.Option(False, "--keep-copy"), + yes: bool = typer.Option(False, "--yes", help="Confirm the write."), ): + require_yes(yes, "convert") with ctx.obj.client() as c: ctx.obj.emit( documents.convert(c, id, new_type=to, from_type=from_type, @@ -93,7 +102,7 @@ def docs_convert( ) -@docs_app.command("pdf") +@docs_app.command("pdf", help="Download an issued document's PDF to a file or stdout.") @handle def docs_pdf( ctx: typer.Context, @@ -114,18 +123,23 @@ def docs_pdf( sys.stdout.buffer.write(content) -@docs_app.command("pay") +@docs_app.command( + "pay", + help="Mark a payment on an issued document as paid (read-modify-write; requires --yes). " + "Omitting --amount marks the first unpaid payment line.", +) @handle def docs_pay( ctx: typer.Context, id: int, - payment_account_id: int = typer.Option(..., "--payment-account-id"), + payment_account_id: int = typer.Option( + ..., "--payment-account-id", help="See `fic info payment-accounts`." + ), date: str = typer.Option(None, "--date", help="Paid date YYYY-MM-DD."), - amount: float = typer.Option(None, "--amount"), + amount: float = typer.Option(None, "--amount", help="Amount of the line to mark paid."), yes: bool = typer.Option(False, "--yes"), ): - if not yes: - raise typer.BadParameter("refusing to modify payments without --yes") + require_yes(yes, "modify payments") with ctx.obj.client() as c: ctx.obj.emit( documents.pay(c, id, payment_account_id=payment_account_id, date=date, amount=amount) @@ -133,17 +147,17 @@ def docs_pay( # --- received docs --- -@received_app.command("list") +@received_app.command("list", help="List received documents of a type (one page unless --all).") @handle def received_list( ctx: typer.Context, - type: str = typer.Option(..., "--type"), - all: bool = typer.Option(False, "--all"), - query: str = typer.Option(None, "--query"), + type: str = typer.Option(..., "--type", help=_RECEIVED_TYPE_HELP), + all: bool = typer.Option(False, "--all", help="Auto-paginate all pages."), + query: str = typer.Option(None, "--query", help="Filter (maps to the API `q` param)."), page: int = typer.Option(None, "--page"), - per_page: int = typer.Option(None, "--per-page"), + per_page: int = typer.Option(None, "--per-page", help="Max 100."), sort: str = typer.Option(None, "--sort"), - fieldset: str = typer.Option(None, "--fieldset"), + fieldset: str = typer.Option(None, "--fieldset", help="basic|detailed|fic_view"), ): with ctx.obj.client() as c: data = documents.list_received( @@ -153,26 +167,26 @@ def received_list( ctx.obj.emit({"data": data} if all else data) -@received_app.command("get") +@received_app.command("get", help="Get a single received document by id.") @handle def received_get(ctx: typer.Context, id: int): with ctx.obj.client() as c: ctx.obj.emit(documents.get_received(c, id)) -@received_app.command("create") +@received_app.command("create", help="Create a received document from a JSON payload.") @handle def received_create( ctx: typer.Context, - type: str = typer.Option(..., "--type"), - file: str = typer.Option("-", "-f", "--file"), + type: str = typer.Option(..., "--type", help=_RECEIVED_TYPE_HELP), + file: str = typer.Option("-", "-f", "--file", help="JSON file or - for stdin."), ): payload = read_payload(file) with ctx.obj.client() as c: ctx.obj.emit(documents.create_received(c, type=type, payload=payload)) -@received_app.command("update") +@received_app.command("update", help="Update a received document by id from a JSON payload.") @handle def received_update(ctx: typer.Context, id: int, file: str = typer.Option("-", "-f", "--file")): payload = read_payload(file) @@ -180,25 +194,30 @@ def received_update(ctx: typer.Context, id: int, file: str = typer.Option("-", " ctx.obj.emit(documents.update_received(c, id, payload)) -@received_app.command("delete") +@received_app.command("delete", help="Delete a received document by id (requires --yes).") @handle def received_delete(ctx: typer.Context, id: int, yes: bool = typer.Option(False, "--yes")): - if not yes: - raise typer.BadParameter("refusing to delete without --yes") + require_yes(yes, "delete") with ctx.obj.client() as c: documents.delete_received(c, id) ctx.obj.emit({"deleted": id}) # --- e-invoice --- -@einvoice_app.command("send") +@einvoice_app.command("send", help="Send an issued document to SDI (calls the send endpoint).") @handle -def einvoice_send(ctx: typer.Context, id: int, dry_run: bool = typer.Option(False, "--dry-run")): +def einvoice_send( + ctx: typer.Context, + id: int, + dry_run: bool = typer.Option(False, "--dry-run", help="Validate via SDI without sending."), + yes: bool = typer.Option(False, "--yes", help="Confirm the send (also for --dry-run)."), +): + require_yes(yes, "send to SDI") with ctx.obj.client() as c: ctx.obj.emit(documents.einvoice_send(c, id, dry_run=dry_run)) -@einvoice_app.command("status") +@einvoice_app.command("status", help="Show an issued document's SDI e-invoice status (ei_status).") @handle def einvoice_status(ctx: typer.Context, id: int): with ctx.obj.client() as c: diff --git a/fic/cli/info.py b/fic/cli/info.py index 65ded0c..51ff61e 100644 --- a/fic/cli/info.py +++ b/fic/cli/info.py @@ -12,7 +12,7 @@ def _make(name: str) -> None: - @app.command(name) + @app.command(name, help=f"List {name.replace('-', ' ')} (reference data, not paginated).") @handle def _cmd(ctx: typer.Context): fn = getattr(_info, name.replace("-", "_")) diff --git a/fic/cli/main.py b/fic/cli/main.py index fc674ff..5a723ce 100644 --- a/fic/cli/main.py +++ b/fic/cli/main.py @@ -10,8 +10,11 @@ from fic.cli import companies as companies_cli from fic.cli import documents as documents_cli from fic.cli import info as info_cli +from fic.cli import mcp_serve as mcp_serve_cli from fic.cli import registry as registry_cli +from fic.cli._errors import render_usage_error from fic.cli._output import emit +from fic.cli.agentdocs import agent_docs from fic.core import auth as core_auth from fic.core.client import FicClient @@ -36,7 +39,7 @@ def client(self) -> FicClient: @app.callback() -def main( +def _root_callback( ctx: typer.Context, output: str = typer.Option("json", "--output", help="Output format."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose diagnostics on stderr."), @@ -56,6 +59,8 @@ def main( app.add_typer(documents_cli.received_app, name="received", help="Received documents.") app.add_typer(documents_cli.einvoice_app, name="einvoice", help="E-invoicing / SDI.") app.add_typer(info_cli.app, name="info", help="Reference data.") +app.add_typer(mcp_serve_cli.app, name="mcp", help="Run fic as an MCP server for AI agents.") +app.command("agent-docs")(agent_docs) @app.command() @@ -66,5 +71,37 @@ def version() -> None: typer.echo(__version__) +def _wants_table(argv: list[str]) -> bool: + """Pre-parse scan for `--output table`: ctx.obj isn't built when a usage error fires, + and the default output is json, so we only switch to plaintext on an explicit token.""" + for i, arg in enumerate(argv): + if arg == "--output=table": + return True + if arg == "--output" and i + 1 < len(argv) and argv[i + 1] == "table": + return True + return False + + +def main(argv: list[str] | None = None) -> None: + """Console entry point. Runs the Typer app non-standalone so that even click usage + errors (exit 2) render through the agent error contract, while FicError exit codes + (rendered already by @handle) and SIGINT propagate unchanged.""" + from typer._click.exceptions import ClickException # click is vendored under typer + + args = sys.argv[1:] if argv is None else argv + command = typer.main.get_command(app) + try: + # In non-standalone mode typer RETURNS the exit code for a raised typer.Exit + # (typer/core.py: `except Exit as e: ... return e.exit_code`), so @handle's + # typer.Exit(code) for a FicError arrives here as `code`, not as an exception. + code = command(args=args, standalone_mode=False) + except ClickException as exc: + render_usage_error(exc.format_message(), output="table" if _wants_table(args) else "json") + raise SystemExit(exc.exit_code or 2) from exc + except typer.Abort: + raise SystemExit(1) from None + raise SystemExit(code or 0) + + if __name__ == "__main__": - app() + main() diff --git a/fic/cli/mcp_serve.py b/fic/cli/mcp_serve.py new file mode 100644 index 0000000..a27b7fb --- /dev/null +++ b/fic/cli/mcp_serve.py @@ -0,0 +1,77 @@ +"""`fic mcp serve`: glue that runs the embedded MCP stdio server. + +The `mcp` SDK is an optional dependency, imported only when the server actually starts, +so the base CLI works without it. An interactive shell is never the MCP client, so when +stdin is a terminal we print ready-to-paste setup instead of hijacking it with JSON-RPC. +""" +from __future__ import annotations + +import sys + +import typer + +app = typer.Typer(no_args_is_help=True) + +_SETUP_INSTRUCTIONS = """\ +fic mcp serve — run fic as an MCP (Model Context Protocol) server + +stdin is a terminal, so the stdio server was not started. Register the command with +your MCP client and let it launch the server (it speaks JSON-RPC over stdin/stdout): + +Claude Code: + + claude mcp add fic -- fic mcp serve + +Cursor — add to ~/.cursor/mcp.json: + + { + "mcpServers": { + "fic": { "command": "fic", "args": ["mcp", "serve"] } + } + } + +Any other stdio MCP client can launch `fic mcp serve` directly. Pass --stdio to force +the server even when stdin is a terminal. The server reads credentials the same way the +CLI does (FIC_ACCESS_TOKEN / config / device login); --company-id on `fic` is honored. +""" + +_MISSING_DEP = ( + "error: the MCP server needs the optional 'mcp' dependency. " + "Install it with: pip install 'fic[mcp]' (or: uv sync --extra mcp)" +) + + +def setup_instructions() -> str: + return _SETUP_INSTRUCTIONS + + +def _stdin_is_tty() -> bool: + """Seam over isatty so tests can fake `mcp serve` on/off a terminal.""" + return sys.stdin.isatty() + + +@app.command("serve") +def serve( + ctx: typer.Context, + stdio: bool = typer.Option( + False, "--stdio", help="Run the stdio server even when stdin is a terminal." + ), +) -> None: + """Run fic as an MCP stdio server, exposing its tools to an AI agent.""" + if not stdio and _stdin_is_tty(): + typer.echo(setup_instructions()) + return + + # build_server imports the mcp SDK lazily (FastMCP), so `from fic.mcp import ...` itself + # never fails on a missing extra — only the build does. Attribute that to the extra. + from fic.mcp import build_server + + company_id = ctx.obj.company_id_override if ctx.obj is not None else None + try: + server = build_server(company_id=company_id) + except ImportError as exc: + if (exc.name or "").split(".")[0] != "mcp": + raise # an unrelated ImportError should surface as itself + typer.echo(_MISSING_DEP, err=True) + raise typer.Exit(1) from exc + server.run(transport="stdio") diff --git a/fic/cli/registry.py b/fic/cli/registry.py index 19fd5a3..1272982 100644 --- a/fic/cli/registry.py +++ b/fic/cli/registry.py @@ -3,7 +3,7 @@ import typer -from fic.cli._args import read_payload +from fic.cli._args import read_payload, require_yes from fic.cli._errors import handle from fic.core import registry as _reg @@ -29,13 +29,13 @@ def list_( per_page=per_page, sort=sort, fieldset=fieldset) ctx.obj.emit({"data": data} if all else data) - @sub.command("get") + @sub.command("get", help="Get a single row by id.") @handle def get(ctx: typer.Context, id: int): with ctx.obj.client() as c: ctx.obj.emit(get_fn(c, id)) - @sub.command("create") + @sub.command("create", help="Create a row from a JSON payload.") @handle def create( ctx: typer.Context, @@ -45,22 +45,21 @@ def create( with ctx.obj.client() as c: ctx.obj.emit(create_fn(c, payload)) - @sub.command("update") + @sub.command("update", help="Update a row by id from a JSON payload.") @handle def update(ctx: typer.Context, id: int, file: str = typer.Option("-", "-f", "--file")): payload = read_payload(file) with ctx.obj.client() as c: ctx.obj.emit(update_fn(c, id, payload)) - @sub.command("delete") + @sub.command("delete", help="Delete a row by id (requires --yes).") @handle def delete( ctx: typer.Context, id: int, yes: bool = typer.Option(False, "--yes", help="Confirm deletion."), ): - if not yes: - raise typer.BadParameter("refusing to delete without --yes") + require_yes(yes, "delete") with ctx.obj.client() as c: delete_fn(c, id) ctx.obj.emit({"deleted": id}) diff --git a/fic/core/documents.py b/fic/core/documents.py index 014ac2e..1bedc07 100644 --- a/fic/core/documents.py +++ b/fic/core/documents.py @@ -16,7 +16,7 @@ SendEInvoiceRequest, ) -from .errors import FicError +from .errors import FicError, ValidationError from .serialize import to_jsonable ISSUED_TYPES = {e.value for e in IssuedDocumentType} @@ -24,14 +24,14 @@ def _require_type(value: str | None, allowed: set[str], kind: str) -> str: + # Raise ValidationError (-> code `validation`, exit 4): a missing/bad type is a + # recoverable input error, and the allowed set in the message lets an agent self-correct. if not value: - raise FicError( - f"--type is required for {kind}. One of: {', '.join(sorted(allowed))}." - ) + msg = f"A document type is required for {kind}. One of: {', '.join(sorted(allowed))}." + raise ValidationError(msg, {"type": [msg]}, None) if value not in allowed: - raise FicError( - f"Invalid {kind} type '{value}'. One of: {', '.join(sorted(allowed))}." - ) + msg = f"Invalid {kind} type '{value}'. One of: {', '.join(sorted(allowed))}." + raise ValidationError(msg, {"type": [msg]}, None) return value @@ -48,7 +48,7 @@ def get_issued(client: Any, doc_id: int) -> Any: return client.get_resource(IssuedDocumentsApi, "get_issued_document", doc_id) -def create_issued(client: Any, *, type: str, payload: dict) -> Any: +def create_issued(client: Any, *, type: str | None, payload: dict) -> Any: _require_type(type, ISSUED_TYPES, "docs") payload = {**payload, "type": type} return client.create_resource( @@ -150,7 +150,7 @@ def get_received(client: Any, doc_id: int) -> Any: return client.get_resource(ReceivedDocumentsApi, "get_received_document", doc_id) -def create_received(client: Any, *, type: str, payload: dict) -> Any: +def create_received(client: Any, *, type: str | None, payload: dict) -> Any: _require_type(type, RECEIVED_TYPES, "received") payload = {**payload, "type": type} return client.create_resource( diff --git a/fic/core/errors.py b/fic/core/errors.py index 3074f80..bc0ce34 100644 --- a/fic/core/errors.py +++ b/fic/core/errors.py @@ -77,3 +77,19 @@ def from_api_exception(cls, exc: Any) -> ValidationError: detail = "; ".join(f"{k}: {', '.join(str(x) for x in v)}" for k, v in messages.items()) full = f"{message} {detail}".strip() return cls(full, messages, totals) + + +# The machine-branchable code for each error type — the error's canonical identity, +# shared by the CLI error envelope and the MCP tool-error surface so they agree. +_CODES: dict[type, str] = { + ConfigError: "config", + AuthError: "auth", + ValidationError: "validation", + RateLimited: "rate_limited", + NotFound: "not_found", + PermissionDenied: "forbidden", +} + + +def error_code(exc: FicError) -> str: + return _CODES.get(type(exc), "error") diff --git a/fic/mcp/__init__.py b/fic/mcp/__init__.py new file mode 100644 index 0000000..43ec562 --- /dev/null +++ b/fic/mcp/__init__.py @@ -0,0 +1,8 @@ +"""Embedded MCP server for fic. + +Confined here so the `mcp` SDK stays an optional dependency and `fic.core` keeps its +purity. `cli/mcp_serve.py` is the only glue into the CLI. +""" +from fic.mcp.server import SERVER_INSTRUCTIONS, build_server + +__all__ = ["build_server", "SERVER_INSTRUCTIONS"] diff --git a/fic/mcp/server.py b/fic/mcp/server.py new file mode 100644 index 0000000..8e557da --- /dev/null +++ b/fic/mcp/server.py @@ -0,0 +1,395 @@ +"""The fic MCP server: ~13 tools wrapping `fic.core`, so the MCP and CLI surfaces +can never disagree (same client, same retries, same error mapping). + +Design (see docs/superpowers/specs/2026-06-16-fic-agent-readiness.md): +- Reads are free; writes require ``confirm=true`` — mirrors the CLI's ``--yes`` gate. + The gate is strict (passes only on boolean ``True``), it RAISES (so a refusal is an + MCP tool error, never a success-shaped result), and it is checked BEFORE any + credential resolution or network call. +- Dispatch is a table of per-resource adapters, because the core signatures are + heterogeneous (registry creates take a positional payload; document creates take + keyword ``type`` + ``payload``; ``convert`` uses ``new_type``). +- Document ``type`` is required for list/create on issued/received documents and is + not accepted by get/update/delete (the core ``_require_type`` enforces it). +""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, Literal + +from fic.core import auth as core_auth +from fic.core import documents, info, registry +from fic.core.client import FicClient +from fic.core.documents import ISSUED_TYPES, RECEIVED_TYPES +from fic.core.errors import FicError, ValidationError, error_code +from fic.core.serialize import to_jsonable + +ClientFactory = Callable[[], FicClient] + +# Canonical resources + the CLI-name aliases an agent is likely to guess (R11/N7). +RESOURCES = ["clients", "suppliers", "products", "issued_documents", "received_documents"] +_ALIASES = {"docs": "issued_documents", "received": "received_documents"} + +Resource = Literal[ + "clients", "suppliers", "products", + "issued_documents", "received_documents", "docs", "received", +] +InfoKind = Literal["vat_types", "payment_accounts", "payment_methods", "units", "currencies"] +AccountKind = Literal["whoami", "companies", "company_info"] + +_INFO_FNS = { + "vat_types": info.vat_types, + "payment_accounts": info.payment_accounts, + "payment_methods": info.payment_methods, + "units": info.units, + "currencies": info.currencies, +} +_ACCOUNT_FNS = { + "whoami": info.whoami, + "companies": info.companies, + "company_info": info.company_info, +} + +SERVER_INSTRUCTIONS = ( + "fic is an unofficial client for the Fatture in Cloud (TeamSystem) API v2 — Italian " + "invoicing, e-invoicing and SDI. Reads (fic_list, fic_get, fic_info, fic_account, " + "fic_einvoice_status, fic_einvoice_rejection, fic_docs_pdf_url) run freely. Writes " + "(fic_create, fic_update, fic_delete, fic_docs_convert, fic_docs_pay, fic_einvoice_send) " + "mutate real accounting data and REQUIRE confirm=true; called without it they refuse and " + "do nothing — re-call with confirm=true once the user agrees. A document `type` " + "(e.g. invoice, quote, receipt for issued; expense for received) is required for fic_list " + "and fic_create on issued_documents/received_documents, and is not used by get/update/delete. " + "Lists return one page unless all=true (auto-paginates). Failures come back as tool errors " + "whose message is prefixed with a bracketed code — [validation] (fixable input, e.g. a bad " + "type), [not_found], [forbidden], [rate_limited], [auth], or [error] — read it, fix, retry. " + "The `fic` CLI has the same data; run `fic agent-docs` for its manual." +) + + +# --------------------------------------------------------------------------- gate + +def require_confirm(operation: str, confirm: bool) -> None: + """Fail-closed write gate. Passes ONLY on boolean True; anything else refuses. + + Raised (not returned) and evaluated before any credential/network work, so a + refusal mutates nothing — the MCP/CLI parity of the CLI's ``--yes``. + """ + if confirm is not True: + raise FicError( + f"Refusing to {operation}: this is a write against live accounting data. " + "Re-call with confirm=true to proceed." + ) + + +# --------------------------------------------------------------------- dispatch + +def _norm(resource: str) -> str: + return _ALIASES.get(resource, resource) + + +def _unknown(resource: str) -> ValidationError: + msg = ( + f"Unknown resource '{resource}'. One of: {', '.join(RESOURCES)} " + "(aliases: docs, received)." + ) + return ValidationError(msg, {"resource": [msg]}, None) + + +def dispatch_list(client: Any, resource: str, **kw: Any) -> Any: + resource = _norm(resource) + doc_type = kw.pop("type", None) + year = kw.pop("year", None) + if resource == "clients": + return registry.list_clients(client, **kw) + if resource == "suppliers": + return registry.list_suppliers(client, **kw) + if resource == "products": + return registry.list_products(client, **kw) + if resource == "issued_documents": + return documents.list_issued(client, type=doc_type, year=year, **kw) + if resource == "received_documents": + return documents.list_received(client, type=doc_type, **kw) + raise _unknown(resource) + + +def dispatch_get(client: Any, resource: str, resource_id: int) -> Any: + resource = _norm(resource) + fns = { + "clients": registry.get_client, + "suppliers": registry.get_supplier, + "products": registry.get_product, + "issued_documents": documents.get_issued, + "received_documents": documents.get_received, + } + if resource not in fns: + raise _unknown(resource) + return fns[resource](client, resource_id) + + +def dispatch_create(client: Any, resource: str, data: dict, doc_type: str | None) -> Any: + resource = _norm(resource) + if resource == "clients": + return registry.create_client(client, data) + if resource == "suppliers": + return registry.create_supplier(client, data) + if resource == "products": + return registry.create_product(client, data) + if resource == "issued_documents": + return documents.create_issued(client, type=doc_type, payload=data) + if resource == "received_documents": + return documents.create_received(client, type=doc_type, payload=data) + raise _unknown(resource) + + +def dispatch_update(client: Any, resource: str, resource_id: int, data: dict) -> Any: + resource = _norm(resource) + fns = { + "clients": registry.update_client, + "suppliers": registry.update_supplier, + "products": registry.update_product, + "issued_documents": documents.update_issued, + "received_documents": documents.update_received, + } + if resource not in fns: + raise _unknown(resource) + return fns[resource](client, resource_id, data) + + +def dispatch_delete(client: Any, resource: str, resource_id: int) -> Any: + resource = _norm(resource) + fns = { + "clients": registry.delete_client, + "suppliers": registry.delete_supplier, + "products": registry.delete_product, + "issued_documents": documents.delete_issued, + "received_documents": documents.delete_received, + } + if resource not in fns: + raise _unknown(resource) + return fns[resource](client, resource_id) + + +# --------------------------------------------------------------------- runners + +def _coded(exc: FicError) -> FicError: + """Re-stamp the error with its machine-branchable code prefixed into the message, so + it survives FastMCP's ToolError wrapping (the SDK only stringifies the message) — this + is what makes the server-instructions "errors carry a code" contract true over MCP.""" + return FicError(f"[{error_code(exc)}] {exc}") + + +def run_read(factory: ClientFactory, call: Callable[[FicClient], Any]) -> Any: + try: + with factory() as client: + return to_jsonable(call(client)) + except FicError as exc: + raise _coded(exc) from exc + + +def run_write( + factory: ClientFactory, operation: str, confirm: bool, call: Callable[[FicClient], Any] +) -> Any: + try: + require_confirm(operation, confirm) # before any creds/network (R4) + with factory() as client: + return to_jsonable(call(client)) + except FicError as exc: + raise _coded(exc) from exc + + +def _default_client_factory(company_id: int | None) -> ClientFactory: + def factory() -> FicClient: + creds = core_auth.resolve() + return FicClient(creds.access_token, company_id=company_id or creds.company_id) + + return factory + + +# ------------------------------------------------------------------------ build + +def build_server(*, company_id: int | None = None, client_factory: ClientFactory | None = None): + """Construct the FastMCP server. ``client_factory`` is injectable for tests.""" + from mcp.server.fastmcp import FastMCP + + factory = client_factory or _default_client_factory(company_id) + mcp = FastMCP(name="fic", instructions=SERVER_INSTRUCTIONS) + _issued = ", ".join(sorted(ISSUED_TYPES)) + _received = ", ".join(sorted(RECEIVED_TYPES)) + + # -------- reads -------- + @mcp.tool( + description=( + "List rows for a resource. resource is one of clients, suppliers, products, " + "issued_documents, received_documents (aliases: docs, received). For " + "issued_documents/received_documents a document `type` is REQUIRED " + f"(issued: {_issued}; received: {_received}). One page unless all=true " + "(auto-paginates every page). query maps to the API `q` filter; year is sugar " + "for issued documents only." + ) + ) + def fic_list( + resource: Resource, + type: str | None = None, + query: str | None = None, + year: int | None = None, + page: int | None = None, + per_page: int | None = None, + all: bool = False, + sort: str | None = None, + fieldset: str | None = None, + ) -> Any: + def call(c: FicClient) -> Any: + rows = dispatch_list( + c, resource, type=type, query=query, year=year, page=page, + per_page=per_page, all=all, sort=sort, fieldset=fieldset, + ) + return {"data": rows} if all else rows + + return run_read(factory, call) + + @mcp.tool( + description=( + "Fetch one resource by id. resource is one of clients, suppliers, products, " + "issued_documents, received_documents (aliases: docs, received). No `type` needed. " + "Issued-document results include ei_status (the SDI state)." + ) + ) + def fic_get(resource: Resource, id: int) -> Any: + return run_read(factory, lambda c: dispatch_get(c, resource, id)) + + @mcp.tool( + description=( + "Reference data lookups. kind is one of vat_types, payment_accounts, " + "payment_methods, units, currencies. (CLI: `fic info `.)" + ) + ) + def fic_info(kind: InfoKind) -> Any: + return run_read(factory, lambda c: _INFO_FNS[kind](c)) + + @mcp.tool( + description=( + "Account / company info. kind is one of whoami (current user), companies " + "(companies linked to the token), company_info (the active company). " + "(CLI: `fic auth whoami`, `fic companies list`, `fic companies info`.)" + ) + ) + def fic_account(kind: AccountKind) -> Any: + return run_read(factory, lambda c: _ACCOUNT_FNS[kind](c)) + + @mcp.tool( + description=( + "The SDI e-invoice status of an issued document: returns {id, ei_status}. Derived " + "from the document (there is no live SDI poll). ei_status values include not_sent, " + "sent, accepted, rejected, error." + ) + ) + def fic_einvoice_status(id: int) -> Any: + return run_read(factory, lambda c: documents.einvoice_status(c, id)) + + @mcp.tool(description="The SDI rejection reason for a rejected issued e-invoice.") + def fic_einvoice_rejection(id: int) -> Any: + return run_read(factory, lambda c: documents.einvoice_rejection(c, id)) + + @mcp.tool( + description=( + "The temporary PDF download URL for an issued document: returns {id, url}. FIC " + "generates it on demand; fetch promptly (it expires)." + ) + ) + def fic_docs_pdf_url(id: int) -> Any: + return run_read(factory, lambda c: {"id": id, "url": documents.pdf_url(c, id)}) + + # -------- writes (confirm=true required) -------- + @mcp.tool( + description=( + "Create a resource. WRITE — requires confirm=true. resource is one of clients, " + "suppliers, products, issued_documents, received_documents. data is the resource " + "object (the API request `data` body). For issued/received documents `type` is " + f"REQUIRED (issued: {_issued}; received: {_received})." + ) + ) + def fic_create( + resource: Resource, data: dict, confirm: bool, type: str | None = None + ) -> Any: + return run_write( + factory, f"create {resource}", confirm, + lambda c: dispatch_create(c, resource, data, type), + ) + + @mcp.tool( + description=( + "Update a resource by id (full modify). WRITE — requires confirm=true. data is the " + "resource object. No `type` argument for documents." + ) + ) + def fic_update(resource: Resource, id: int, data: dict, confirm: bool) -> Any: + return run_write( + factory, f"update {resource} {id}", confirm, + lambda c: dispatch_update(c, resource, id, data), + ) + + @mcp.tool(description="Delete a resource by id. WRITE — requires confirm=true. Irreversible.") + def fic_delete(resource: Resource, id: int, confirm: bool) -> Any: + return run_write( + factory, f"delete {resource} {id}", confirm, + lambda c: dispatch_delete(c, resource, id), + ) + + @mcp.tool( + description=( + "Convert an issued document to a new type (e.g. quote -> invoice). WRITE — requires " + f"confirm=true. to is the new type ({_issued}); from_type is the current type if " + "ambiguous; e_invoice marks the result as an e-invoice; keep_copy keeps the original." + ) + ) + def fic_docs_convert( + id: int, + to: str, + confirm: bool, + from_type: str | None = None, + e_invoice: bool = False, + keep_copy: bool = False, + ) -> Any: + return run_write( + factory, f"convert document {id} to {to}", confirm, + lambda c: documents.convert( + c, id, new_type=to, from_type=from_type, + e_invoice=e_invoice, keep_copy=keep_copy), + ) + + @mcp.tool( + description=( + "Mark a payment on an issued document as paid. WRITE — requires confirm=true. " + "Composite: reads the document then updates its payments_list. Omitting amount marks " + "the FIRST unpaid payment line — pass amount when several unpaid lines exist. " + "Requires payment_account_id; date is the paid date YYYY-MM-DD." + ) + ) + def fic_docs_pay( + id: int, + payment_account_id: int, + confirm: bool, + date: str | None = None, + amount: float | None = None, + ) -> Any: + return run_write( + factory, f"mark document {id} paid", confirm, + lambda c: documents.pay( + c, id, payment_account_id=payment_account_id, date=date, amount=amount), + ) + + @mcp.tool( + description=( + "Send an issued document to SDI as an e-invoice. WRITE — ALWAYS requires confirm=true " + "(it calls the SDI send endpoint even when validating). Set dry_run=true to have FIC " + "validate the e-invoice without transmitting to SDI; dry_run=false (default) sends." + ) + ) + def fic_einvoice_send(id: int, confirm: bool, dry_run: bool = False) -> Any: + verb = "validate (dry-run) the e-invoice for" if dry_run else "send to SDI" + return run_write( + factory, f"{verb} document {id}", confirm, + lambda c: documents.einvoice_send(c, id, dry_run=dry_run), + ) + + return mcp diff --git a/pyproject.toml b/pyproject.toml index 7f918bf..a6e0b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,10 +22,11 @@ dependencies = [ ] [project.scripts] -fic = "fic.cli.main:app" +fic = "fic.cli.main:main" [project.optional-dependencies] -dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"] +mcp = ["mcp>=1.2,<2"] +dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11", "mcp>=1.2,<2"] [build-system] requires = ["hatchling"] diff --git a/tests/cli/test_agentdocs.py b/tests/cli/test_agentdocs.py new file mode 100644 index 0000000..3949193 --- /dev/null +++ b/tests/cli/test_agentdocs.py @@ -0,0 +1,58 @@ +"""`fic agent-docs` is the CLI's machine contract for agents: the full command surface, +the exit-code / error-code tables, and the error-envelope shape. These pin the surface +(additions/removals show up) without freezing help-text prose.""" +import json + +from typer.testing import CliRunner + +from fic.cli._errors import ERROR_CODES, ERROR_ENVELOPE_SHAPE, EXIT_CODES +from fic.cli.main import app + +runner = CliRunner() + + +def _docs() -> dict: + res = runner.invoke(app, ["agent-docs"]) + assert res.exit_code == 0 + return json.loads(res.stdout) + + +def test_agent_docs_covers_the_agent_surface(): + paths = {c["path"] for c in _docs()["commands"]} + for expected in [ + "fic", "fic clients list", "fic clients create", "fic docs get", "fic docs pay", + "fic einvoice send", "fic info units", "fic auth device", + "fic mcp serve", "fic agent-docs", + ]: + assert expected in paths, expected + + +def test_agent_docs_pins_exit_and_error_contract(): + docs = _docs() + assert docs["exitCodes"] == EXIT_CODES + assert docs["errorCodes"] == ERROR_CODES + assert docs["errorEnvelope"] == ERROR_ENVELOPE_SHAPE + + +def test_agent_docs_describes_arguments_and_flags(): + docs = _docs() + get = next(c for c in docs["commands"] if c["path"] == "fic docs get") + assert {"name": "id", "required": True} in get["arguments"] + + delete = next(c for c in docs["commands"] if c["path"] == "fic clients delete") + assert "--yes" in {f["name"] for f in delete.get("flags", [])} + + +def test_agent_docs_omits_framework_noise_flags(): + root = next(c for c in _docs()["commands"] if c["path"] == "fic") + names = {f["name"] for f in root.get("flags", [])} + assert "--output" in names + assert not (names & {"--help", "--install-completion", "--show-completion"}) + + +def test_agent_docs_markdown_renders(): + res = runner.invoke(app, ["--output", "table", "agent-docs"]) + assert res.exit_code == 0 + assert "# fic — agent manual" in res.stdout + assert "## Exit codes" in res.stdout + assert "fic docs get" in res.stdout diff --git a/tests/cli/test_app.py b/tests/cli/test_app.py index ceeb0bc..dd6a75d 100644 --- a/tests/cli/test_app.py +++ b/tests/cli/test_app.py @@ -72,10 +72,15 @@ def test_exit_codes(): assert runner.invoke(app, ["boom-forbidden"]).exit_code == 7 -def test_errors_go_to_stderr(): +def test_errors_render_as_json_envelope_on_stderr(): + # Contract change (agent-readiness): json mode (the default) errors are now a single + # JSON envelope on stderr, not `error: ` plaintext. stdout stays empty. res = runner.invoke(_app(), ["boom-validation"]) - assert "bad" in res.stderr - assert "amount_due" in res.stderr # totals surfaced + payload = json.loads(res.stderr) + assert payload["error"]["code"] == "validation" + assert payload["error"]["message"] == "bad" + assert payload["error"]["fields"] == {"f": ["x"]} + assert payload["error"]["totals"] == {"amount_due": 1} assert res.stdout == "" diff --git a/tests/cli/test_documents.py b/tests/cli/test_documents.py index 0b442bb..7706920 100644 --- a/tests/cli/test_documents.py +++ b/tests/cli/test_documents.py @@ -33,11 +33,24 @@ def test_docs_convert(monkeypatch): _env(monkeypatch) with patch("fic.cli.main.FicClient", return_value=_fake()), \ patch("fic.core.documents.convert", return_value={"data": {"id": 2}}) as cv: - res = runner.invoke(app, ["docs", "convert", "1", "--to", "invoice"]) + res = runner.invoke(app, ["docs", "convert", "1", "--to", "invoice", "--yes"]) assert res.exit_code == 0 assert cv.call_args.kwargs["new_type"] == "invoice" +def test_docs_convert_requires_yes(monkeypatch): + _env(monkeypatch) + res = runner.invoke(app, ["docs", "convert", "1", "--to", "invoice"]) + assert res.exit_code == 2 # usage: refuses the write without --yes + + +def test_einvoice_send_requires_yes(monkeypatch): + _env(monkeypatch) + # even --dry-run calls the SDI send endpoint, so it is gated too + res = runner.invoke(app, ["einvoice", "send", "1", "--dry-run"]) + assert res.exit_code == 2 + + def test_docs_pdf_writes_file(monkeypatch, tmp_path): _env(monkeypatch) out = tmp_path / "inv.pdf" diff --git a/tests/cli/test_errors.py b/tests/cli/test_errors.py new file mode 100644 index 0000000..2143853 --- /dev/null +++ b/tests/cli/test_errors.py @@ -0,0 +1,103 @@ +"""The agent error contract: in json mode (default) every core error renders as one JSON +envelope line on stderr with a stable `code`; in table mode it stays human plaintext. +stdout is always empty on error. Exit codes are covered in test_app.py.""" +import json + +import typer +from typer.testing import CliRunner + +from fic.cli import _errors +from fic.cli._errors import envelope, handle +from fic.core import errors + +runner = CliRunner() + + +def _app(output: str): + """A minimal app whose callback sets the AppCtx-like output mode, so handle can read it.""" + app = typer.Typer() + + @app.callback() + def _cb(ctx: typer.Context): + ctx.obj = type("O", (), {"output": output})() + + @app.command() + @handle + def notfound(ctx: typer.Context): + raise errors.NotFound("Resource not found (404).") + + return app + + +def test_envelope_shape_for_each_code(): + assert envelope(errors.NotFound("x"))["error"]["code"] == "not_found" + assert envelope(errors.AuthError("x"))["error"]["code"] == "auth" + assert envelope(errors.ConfigError("x"))["error"]["code"] == "config" + assert envelope(errors.PermissionDenied("x"))["error"]["code"] == "forbidden" + assert envelope(errors.RateLimited(retry_after=3))["error"]["code"] == "rate_limited" + assert envelope(errors.ApiError(500, "boom"))["error"]["code"] == "error" + + +def test_auth_error_carries_a_recovery_hint(): + env = envelope(errors.AuthError("no token"))["error"] + assert "FIC_ACCESS_TOKEN" in env["hint"] + + +def test_config_error_has_no_duplicate_hint(): + # the ConfigError message already carries the fix; don't double it (R12/N5) + assert "hint" not in envelope(errors.ConfigError("set FIC_ACCESS_TOKEN ..."))["error"] + + +def test_validation_envelope_surfaces_fields_and_totals(): + exc = errors.ValidationError("bad", {"amount": ["too high"]}, totals={"amount_due": 10}) + env = envelope(exc)["error"] + assert env["code"] == "validation" + assert env["fields"] == {"amount": ["too high"]} + assert env["totals"] == {"amount_due": 10} + + +def test_rate_limited_hint_includes_retry_after(): + assert "retry-after: 7" in envelope(errors.RateLimited(retry_after=7))["error"]["hint"] + + +def test_json_mode_emits_one_envelope_line_on_stderr(): + res = runner.invoke(_app("json"), ["notfound"]) + assert res.exit_code == 6 + assert res.stdout == "" + payload = json.loads(res.stderr) + assert payload == {"error": { + "code": "not_found", + "message": "Resource not found (404).", + "hint": "Check the id and that --company-id is correct.", + }} + + +def test_table_mode_stays_plaintext(): + res = runner.invoke(_app("table"), ["notfound"]) + assert res.exit_code == 6 + assert res.stderr.strip() == "error: Resource not found (404)." + + +def test_no_context_defaults_to_json_envelope(): + # command without a usable ctx -> agent default (json). The no-op callback keeps the + # app in subcommand mode (a lone command would otherwise collapse into the root). + app = typer.Typer() + + @app.callback() + def _cb(): + pass + + @app.command() + @handle + def boom(): + raise errors.NotFound("nope") + + res = runner.invoke(app, ["boom"]) + assert json.loads(res.stderr)["error"]["code"] == "not_found" + + +def test_render_usage_error_modes(capsys): + _errors.render_usage_error("bad arg", output="json") + assert json.loads(capsys.readouterr().err) == {"error": {"code": "usage", "message": "bad arg"}} + _errors.render_usage_error("bad arg", output="table") + assert capsys.readouterr().err.strip() == "error: bad arg" diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py new file mode 100644 index 0000000..9003459 --- /dev/null +++ b/tests/cli/test_main.py @@ -0,0 +1,61 @@ +"""The console entry point `main()`: even click usage errors (exit 2) render through the +agent error envelope, while FicError exit codes and success propagate unchanged. CliRunner +exercises the app directly, so these tests drive `main()` itself (where usage rendering lives).""" +import json +from unittest.mock import MagicMock, patch + +import pytest + +from fic.cli.main import _wants_table, main +from fic.core import errors + + +def test_success_exits_zero(): + with pytest.raises(SystemExit) as exc: + main(["version"]) + assert exc.value.code == 0 + + +def test_usage_error_emits_envelope(capsys): + with pytest.raises(SystemExit) as exc: + main(["clients", "delete", "1"]) # missing --yes -> BadParameter (usage) + assert exc.value.code == 2 + payload = json.loads(capsys.readouterr().err) + assert payload["error"]["code"] == "usage" + assert "yes" in payload["error"]["message"] + + +def test_unknown_command_emits_envelope(capsys): + with pytest.raises(SystemExit) as exc: + main(["frobnicate"]) + assert exc.value.code == 2 + assert json.loads(capsys.readouterr().err)["error"]["code"] == "usage" + + +def test_table_mode_usage_error_stays_plaintext(capsys): + with pytest.raises(SystemExit) as exc: + main(["--output", "table", "clients", "delete", "1"]) + assert exc.value.code == 2 + assert capsys.readouterr().err.startswith("error:") + + +def test_ficerror_exit_code_and_envelope_propagate(monkeypatch, capsys): + monkeypatch.setenv("FIC_ACCESS_TOKEN", "abcd1234efgh") + monkeypatch.setenv("FIC_COMPANY_ID", "5") + fake = MagicMock() + fake.__enter__.return_value = fake + fake.__exit__.return_value = False + not_found = errors.NotFound("Resource not found (404).") + with patch("fic.cli.main.FicClient", return_value=fake), \ + patch("fic.core.registry.get_client", side_effect=not_found): + with pytest.raises(SystemExit) as exc: + main(["clients", "get", "1"]) + assert exc.value.code == 6 + assert json.loads(capsys.readouterr().err)["error"]["code"] == "not_found" + + +def test_wants_table_scan(): + assert _wants_table(["--output", "table", "clients", "list"]) + assert _wants_table(["--output=table"]) + assert not _wants_table(["clients", "list"]) + assert not _wants_table(["--output", "json"]) diff --git a/tests/cli/test_mcp_serve.py b/tests/cli/test_mcp_serve.py new file mode 100644 index 0000000..5fc7d83 --- /dev/null +++ b/tests/cli/test_mcp_serve.py @@ -0,0 +1,50 @@ +"""`fic mcp serve`: on a terminal it prints setup instructions (an interactive shell is +never the MCP client); off a terminal it starts the stdio server. The `mcp` SDK is only +touched when the server actually starts.""" +from typer.testing import CliRunner + +import fic.mcp +from fic.cli import mcp_serve +from fic.cli.main import app + +runner = CliRunner() + + +class _FakeServer: + """Records the transport `run` was called with; never blocks on stdio.""" + + def __init__(self): + self.transport = None + + def run(self, transport): + self.transport = transport + + +def _no_serve(**_kw): + raise AssertionError("the MCP server must not start in this case") + + +def test_tty_prints_setup_and_does_not_serve(monkeypatch): + monkeypatch.setattr(mcp_serve, "_stdin_is_tty", lambda: True) + monkeypatch.setattr(fic.mcp, "build_server", _no_serve) # would blow up if started + res = runner.invoke(app, ["mcp", "serve"]) + assert res.exit_code == 0 + assert "claude mcp add fic" in res.stdout + + +def test_non_tty_starts_stdio_server(monkeypatch): + monkeypatch.setattr(mcp_serve, "_stdin_is_tty", lambda: False) + server = _FakeServer() + monkeypatch.setattr(fic.mcp, "build_server", lambda **kw: server) + res = runner.invoke(app, ["mcp", "serve"]) + assert res.exit_code == 0 + assert server.transport == "stdio" + + +def test_stdio_flag_forces_server_even_on_tty(monkeypatch): + monkeypatch.setattr(mcp_serve, "_stdin_is_tty", lambda: True) + server = _FakeServer() + monkeypatch.setattr(fic.mcp, "build_server", lambda **kw: server) + res = runner.invoke(app, ["mcp", "serve", "--stdio"]) + assert res.exit_code == 0 + assert server.transport == "stdio" diff --git a/tests/mcp/__init__.py b/tests/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 0000000..8910846 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,172 @@ +"""MCP server contract: the write gate is fail-closed, dispatch maps to the right core +fns, and the tool inventory is stable. No network, no `mcp` SDK roundtrip beyond build.""" +import asyncio + +import pytest + +from fic.core.errors import FicError, ValidationError +from fic.mcp import server + +EXPECTED_TOOLS = { + "fic_list", "fic_get", "fic_info", "fic_account", + "fic_einvoice_status", "fic_einvoice_rejection", "fic_docs_pdf_url", + "fic_create", "fic_update", "fic_delete", + "fic_docs_convert", "fic_docs_pay", "fic_einvoice_send", +} +WRITE_TOOLS = { + "fic_create", "fic_update", "fic_delete", + "fic_docs_convert", "fic_docs_pay", "fic_einvoice_send", +} + + +# --- the write gate (R4): why it matters — an agent must not mutate accounting data +# by passing a truthy-but-not-True confirm, and a refusal must do NO work. --- + +def test_require_confirm_passes_only_on_boolean_true(): + assert server.require_confirm("delete clients 5", True) is None + + +@pytest.mark.parametrize("bad", [False, None, 0, 1, "true", "false", "yes", []]) +def test_require_confirm_refuses_anything_but_true(bad): + with pytest.raises(FicError) as exc: + server.require_confirm("delete clients 5", bad) + assert "confirm=true" in str(exc.value) + + +def test_run_write_builds_no_client_when_unconfirmed(): + """A refused write resolves no credentials and opens no client (gate before network).""" + built = [] + + def factory(): + built.append(1) + raise AssertionError("client must not be constructed for a refused write") + + with pytest.raises(FicError): + server.run_write(factory, "delete clients 5", False, lambda c: "nope") + assert built == [] + + +def test_run_write_runs_when_confirmed(): + class FakeClient: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + out = server.run_write(lambda: FakeClient(), "delete clients 5", True, lambda c: {"deleted": 5}) + assert out == {"deleted": 5} + + +# --- dispatch adapters (R6): heterogeneous core signatures, mapped per resource --- + +def test_dispatch_create_documents_uses_keyword_type_and_payload(monkeypatch): + seen = {} + monkeypatch.setattr( + server.documents, "create_issued", + lambda c, *, type, payload: seen.update(type=type, payload=payload) or "C", + ) + server.dispatch_create(object(), "issued_documents", {"x": 1}, "invoice") + assert seen == {"type": "invoice", "payload": {"x": 1}} + + +def test_dispatch_create_registry_uses_positional_payload(monkeypatch): + seen = {} + monkeypatch.setattr( + server.registry, "create_client", + lambda c, payload: seen.update(payload=payload) or "C", + ) + server.dispatch_create(object(), "clients", {"name": "x"}, None) + assert seen["payload"] == {"name": "x"} + + +def test_dispatch_get_accepts_cli_alias(monkeypatch): + seen = {} + monkeypatch.setattr(server.documents, "get_issued", lambda c, rid: seen.update(rid=rid) or "G") + server.dispatch_get(object(), "docs", 7) # `docs` is the CLI name for issued_documents + assert seen["rid"] == 7 + + +def test_dispatch_unknown_resource_is_validation_error(): + with pytest.raises(ValidationError): + server.dispatch_get(object(), "not_a_resource", 1) + + +def test_dispatch_list_issued_requires_type(): + # delegates to core _require_type -> ValidationError before any client use + with pytest.raises(ValidationError): + server.dispatch_list(object(), "issued_documents") + + +# --- tool inventory (R11/R12): structural, not byte-for-byte prose --- + +def test_tool_inventory_is_stable(): + srv = server.build_server(client_factory=lambda: None) + tools = asyncio.run(srv.list_tools()) + assert {t.name for t in tools} == EXPECTED_TOOLS + + +def test_every_write_tool_has_a_required_boolean_confirm(): + srv = server.build_server(client_factory=lambda: None) + tools = {t.name: t for t in asyncio.run(srv.list_tools())} + for name in WRITE_TOOLS: + schema = tools[name].inputSchema + confirm = schema["properties"].get("confirm") + assert confirm is not None and confirm["type"] == "boolean", name + # required in the schema, so a model must consciously decide (not default-false) + assert "confirm" in schema.get("required", []), name + + +class _FakeCtx: + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + +def test_write_tool_refuses_through_the_real_call_path(monkeypatch): + """The gate that matters: drive a write tool via FastMCP/pydantic (where confirm is + coerced), confirm=False must raise a tool error and call NO core fn.""" + from mcp.server.fastmcp.exceptions import ToolError + + called = [] + monkeypatch.setattr(server.registry, "delete_client", lambda c, rid: called.append(rid)) + srv = server.build_server(client_factory=_FakeCtx) + + with pytest.raises(ToolError, match="confirm=true"): + asyncio.run(srv.call_tool("fic_delete", {"resource": "clients", "id": 5, "confirm": False})) + assert called == [] # no mutation attempted + + +def test_write_tool_proceeds_when_confirmed(monkeypatch): + called = [] + monkeypatch.setattr( + server.registry, "delete_client", lambda c, rid: called.append(rid) or {"deleted": rid} + ) + srv = server.build_server(client_factory=_FakeCtx) + asyncio.run(srv.call_tool("fic_delete", {"resource": "clients", "id": 5, "confirm": True})) + assert called == [5] + + +def test_tool_error_message_carries_the_code(monkeypatch): + """The server-instructions contract: a failure's machine-branchable code survives into + the MCP tool-error message (FastMCP only forwards the message), so an agent can branch.""" + from mcp.server.fastmcp.exceptions import ToolError + + from fic.core.errors import NotFound + + def boom(c, rid): + raise NotFound("Resource not found (404).") + + monkeypatch.setattr(server.registry, "get_client", boom) + srv = server.build_server(client_factory=_FakeCtx) + with pytest.raises(ToolError, match=r"\[not_found\]"): + asyncio.run(srv.call_tool("fic_get", {"resource": "clients", "id": 1})) + + +def test_read_tools_have_no_confirm(): + srv = server.build_server(client_factory=lambda: None) + tools = {t.name: t for t in asyncio.run(srv.list_tools())} + for name in EXPECTED_TOOLS - WRITE_TOOLS: + assert "confirm" not in tools[name].inputSchema["properties"], name diff --git a/uv.lock b/uv.lock index ff3bcb1..9764dad 100644 --- a/uv.lock +++ b/uv.lock @@ -77,6 +77,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -86,6 +95,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -199,6 +290,62 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "fattureincloud-python-sdk" version = "2.1.5" @@ -228,16 +375,22 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "mcp" }, { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, ] +mcp = [ + { name = "mcp" }, +] [package.metadata] requires-dist = [ { name = "fattureincloud-python-sdk", specifier = ">=2.1.5,<3" }, { name = "httpx", specifier = ">=0.27" }, + { name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2,<2" }, + { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2,<2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.11" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5" }, @@ -246,7 +399,7 @@ requires-dist = [ { name = "tomli-w", specifier = ">=1.0" }, { name = "typer", specifier = ">=0.12" }, ] -provides-extras = ["dev"] +provides-extras = ["mcp", "dev"] [[package]] name = "h11" @@ -285,6 +438,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.17" @@ -303,6 +465,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "librt" version = "0.11.0" @@ -388,6 +577,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -484,6 +698,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -601,6 +824,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -610,6 +847,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -652,6 +903,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -665,6 +970,143 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" version = "0.15.15" @@ -708,6 +1150,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -815,3 +1283,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "uvicorn" +version = "0.49.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, +] From 4ce4355dfaa01ef775db1a64a0a0d3ea9365d785 Mon Sep 17 00:00:00 2001 From: Nicholas Sollazzo Date: Tue, 16 Jun 2026 14:29:54 +0200 Subject: [PATCH 2/2] docs: agent-readiness guide, SKILL.md, AGENTS.md, design spec - README: "Use with an AI agent" section (agent-docs, MCP setup for Claude Code/Cursor, the 13 tools, the error envelope), `fic[mcp]` install, and the updated core/-adapter design note (MCP server now real, not hypothetical). - skills/fic/SKILL.md: bundled skill teaching the search->inspect->act loop, document type enums, the confirm/--yes write gate, exit codes, and traps. - AGENTS.md: dev contract (core purity, the agent-scripted public contract, one-renderer/one-exit, write gates, layout). - docs/superpowers/specs: the agent-readiness design spec + its adversarial design-review resolutions. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 65 +++++ README.md | 102 +++++++- .../specs/2026-06-16-fic-agent-readiness.md | 227 ++++++++++++++++++ skills/fic/SKILL.md | 107 +++++++++ 4 files changed, 490 insertions(+), 11 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/superpowers/specs/2026-06-16-fic-agent-readiness.md create mode 100644 skills/fic/SKILL.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a4f773 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# AGENTS.md + +Guidance for coding agents working in this repository. (To *use* the `fic` CLI/MCP as a +tool, read [`skills/fic/SKILL.md`](skills/fic/SKILL.md) instead — this file is about +developing fic.) + +## Commands + +```sh +uv sync --extra dev # install deps (includes mcp + test/lint tools) +uv run pytest -q # full test suite — no live credentials, no network +uv run ruff check fic tests +uv run mypy fic +``` + +CI runs exactly the pytest / ruff / mypy above. Keep all three green. + +## Rules + +- **Core purity is load-bearing.** `fic/core/` is pure: no Typer, no `print()`, no + `sys.exit()`, no other I/O framework — it accepts plain args, returns plain data + (dicts / SDK models), and raises typed `FicError`s. It is the seam the CLI *and* the MCP + server both wrap. `tests/test_core_purity.py` enforces this; the **MCP server lives in + `fic/mcp/`, never in `fic/core/`**. +- **The agent contract is public — a change is a contract change.** These are scripted + against by agents and pinned by tests; call out any change explicitly in the PR: + - exit codes (`0..7`) and the one-line JSON error envelope + (`{"error":{"code","message","hint"?,"fields"?,"totals"?}}`) — `fic/cli/_errors.py`; + - the `fic agent-docs` JSON shape (the documented self-description); + - the MCP tool set (13 tools) and the `confirm=true` write gate — `fic/mcp/server.py`. +- **One renderer, one exit.** Commands return typed results or raise `FicError`; they never + print errors. `@handle` (`cli/_errors.py`) renders the envelope/plaintext once and sets the + exit code; `main()` renders click *usage* errors (exit 2) the same way. Don't `print()` errors + in command bodies. +- **Writes are gated.** Destructive CLI commands require `--yes`; MCP write tools require + `confirm=true` (strict `is True`, checked before any network). New mutating operations must + carry the matching gate on both surfaces. +- **No network in tests.** Mock the SDK / inject a fake client (`tests/` patterns). Tests must + pass offline. +- **Conventional commits:** `feat:`, `fix:`, `docs:`, `chore:`, `ci:`, `test:`, `refactor:`. + +## Layout + +- `fic/core/` — pure business logic over the official `fattureincloud-python-sdk`. + `client.py` (FicClient: retries/backoff, pagination, error mapping), `auth.py` + (env > config > device flow), `registry.py` (clients/suppliers/products CRUD), + `documents.py` (issued/received docs, convert, pdf-url, pay, e-invoice), + `info.py` (reference data + user/company), `errors.py`, `serialize.py`. +- `fic/cli/` — thin Typer adapters. `main.py` (app wiring + the `main()` console entry point), + `_errors.py` (exit codes + JSON error envelope), `_output.py` (json/table), `agentdocs.py` + (`fic agent-docs`), `mcp_serve.py` (`fic mcp serve` glue), plus one module per domain. +- `fic/mcp/` — the embedded MCP server (`server.py`). The `mcp` SDK is an **optional** extra, + imported lazily inside `build_server` so the base CLI runs without it. Tools are thin + wrappers over `fic.core` via per-resource dispatch adapters. +- `tests/` — `core/` (SDK mocked), `cli/` (Typer CliRunner), `mcp/` (dispatch + gate + tool + inventory), and `test_core_purity.py`. +- `docs/superpowers/specs/` — design specs; `2026-06-16-fic-agent-readiness.md` covers this + agent layer (and its adversarial-review resolutions). + +## Build order for new surfaces + +Foundation (errors/contract) first; agent-facing surfaces (`agent-docs`, MCP) last, since they +walk the finished command/core tree. When adding a domain operation, add the core function +(pure), wire the CLI command, then expose it via an MCP dispatch adapter — and gate it if it +writes. diff --git a/README.md b/README.md index c9d04a3..b31e4e5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ uv tool install . pipx install . ``` +**With the MCP server** (for AI agents — see [Use with an AI agent](#use-with-an-ai-agent)): + +```sh +uv tool install 'fic[mcp]' # or: pipx install 'fic[mcp]' / uv tool install '.[mcp]' +``` + --- ## Authentication @@ -352,6 +358,20 @@ Reference data lookups (not paginated). --- +### `fic agent-docs` + +Print the machine-readable manual (every command, flag, exit code, and the error-envelope +shape) as JSON, or as markdown with `--output table`. See +[Use with an AI agent](#use-with-an-ai-agent). + +### `fic mcp serve [--stdio]` + +Run `fic` as an MCP server over stdio for AI agents (needs the `mcp` extra). On a terminal it +prints client setup instructions instead of starting; `--stdio` forces the server. See +[Use with an AI agent](#use-with-an-ai-agent). + +--- + ## Output contract - **Default output is JSON** on stdout — a single value, jq-pipeable, no decorative chrome. @@ -380,6 +400,66 @@ fic docs get 123 | jq '.data.number' | 6 | Not found | 404 response | | 7 | Forbidden | 403 non-throttle (permission / scope error) | +On failure, JSON mode (the default) prints **exactly one error-envelope line on stderr**; +stdout stays empty: + +```json +{"error":{"code":"not_found","message":"Resource not found (404).","hint":"Check the id and that --company-id is correct."}} +``` + +`code` is one of `error`, `usage`, `config`, `auth`, `validation`, `rate_limited`, +`not_found`, `forbidden`. A `validation` error adds `fields` (per-field messages) and, for a +totals mismatch, `totals` — enough for an agent to fix the input and retry. `--output table` +keeps the human `error: ` form instead. + +--- + +## Use with an AI agent + +`fic` is built to be driven by coding agents — two surfaces, same data, same write-safety. + +### Self-describing manual + +```sh +fic agent-docs # JSON: every command, flag, exit code, and the error envelope +fic --output table agent-docs # the same as a markdown manual +``` + +Point an agent at `fic agent-docs` and it can discover the whole surface in one call. + +### MCP server + +Run `fic` as a [Model Context Protocol](https://modelcontextprotocol.io) server so an agent +calls fic operations as tools (needs the `mcp` extra — see Install): + +```sh +# Claude Code +claude mcp add fic -- fic mcp serve +``` + +```jsonc +// Cursor — ~/.cursor/mcp.json +{ "mcpServers": { "fic": { "command": "fic", "args": ["mcp", "serve"] } } } +``` + +Run `fic mcp serve` in a terminal to print setup instructions for any stdio client (it does +not start the server on a TTY; pass `--stdio` to force it). It exposes ~13 tools: + +- **Reads (free):** `fic_list`, `fic_get`, `fic_info`, `fic_account`, `fic_einvoice_status`, + `fic_einvoice_rejection`, `fic_docs_pdf_url`. +- **Writes (require `confirm=true`):** `fic_create`, `fic_update`, `fic_delete`, + `fic_docs_convert`, `fic_docs_pay`, `fic_einvoice_send`. Called without `confirm=true` + they refuse and do nothing — the MCP mirror of the CLI's `--yes`. + +The server reads credentials exactly like the CLI (env / config / device login) and honors +`--company-id` on the `fic` command. + +### Bundled skill + +[`skills/fic/SKILL.md`](skills/fic/SKILL.md) teaches an agent the search→inspect→act loop, +the document `type` enums, the destructive-op gate, and the known traps. Drop it into a +Claude/Hermes skills directory (or your agent's skill loader) and it is auto-discovered. + --- ## Gotchas @@ -431,26 +511,26 @@ requires `--yes` to confirm. Concurrent modifications to the same document may c --- -## Design: `core/` adapter and future MCP server +## Design: the `core/` adapter All business logic lives in `fic.core` — a pure Python package with **no Typer, no `print()`, no `sys.exit()`**. It accepts plain arguments and returns plain data (dicts / SDK models), raising typed exceptions (`AuthError`, `NotFound`, `ValidationError`, etc.). -The `fic.cli` package is a thin adapter layer: it parses CLI arguments, calls `fic.core` functions, -formats the output (JSON or Rich table), maps exceptions to exit codes, and writes diagnostics to -stderr. It is the only layer that does I/O and exit codes. - -This boundary means a future MCP server can import and call `fic.core` directly — without touching -the CLI layer at all: +Both surfaces are thin adapters over it: `fic.cli` (Typer — parses args, formats json/table, maps +exceptions to exit codes, writes diagnostics to stderr) and `fic.mcp` (the MCP server — wraps the +same core functions through per-resource dispatch adapters, with the `confirm=true` write gate). +Neither duplicates business logic, so the two surfaces can never disagree. The `mcp` SDK +dependency is confined to `fic/mcp/` and imported lazily, so the base CLI never requires it. ```python -# A hypothetical MCP server -from fic.core import documents, registry +# fic.mcp and fic.cli both call core like this: +from fic.core import documents from fic.core.client import FicClient -client = FicClient(access_token="...", company_id=12345) -invoices = documents.list_issued(client, type="invoice", all=True) +with FicClient(access_token="...", company_id=12345) as client: + invoices = documents.list_issued(client, type="invoice", all=True) ``` No Typer, no subprocess, no shell. The core functions are the stable, reusable API surface. +See [`AGENTS.md`](AGENTS.md) for the development contract. diff --git a/docs/superpowers/specs/2026-06-16-fic-agent-readiness.md b/docs/superpowers/specs/2026-06-16-fic-agent-readiness.md new file mode 100644 index 0000000..cbe9933 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-fic-agent-readiness.md @@ -0,0 +1,227 @@ +# `fic` — Agent-Readiness Layer — Design Spec + +> Status: **proposed** (design review in progress). +> Date: 2026-06-16. +> Builds on `2026-06-01-fic-cli-design.md` (Phase 1, shipped). This is **Phase 2 (Skill) +> + Phase 3 (MCP)**: make `fic` zero-shot drivable by a coding agent. +> Inspiration: the positronick CLI (`/Users/nick/Dev/_positronick/cli`) agent surface +> (agent-docs, embedded MCP, SKILL.md, AGENTS.md, JSON error envelope) and the +> `go-api-cli` playbook (output/exit-code contracts, write-safety gates, golden tests). + +--- + +## 1. Goals / Non-goals + +**Goals.** A coding agent with shell access can: (a) discover the whole command/flag +surface from one machine-readable call; (b) branch on a stable exit-code + JSON error +envelope; (c) optionally drive `fic` through an embedded MCP server with the same data +and the same write-safety; (d) find a bundled Skill that teaches the workflow. + +**Non-goals (v1 of this layer).** Multi-tenant auth (still single-token). PyPI/brew/npm +multi-channel publishing (document `uv tool install .` + `uvx`; leave registry publish as +a follow-up). Changing the existing exit-code set or the `core/` purity boundary. New +business endpoints — this layer only *exposes* the Phase-1 surface to agents. + +## 2. What we add + +1. **JSON error envelope** (foundation) — agents run in `--output json`; errors must be + structured, not the current `error: ` plaintext. +2. **`fic agent-docs`** — self-describing manual (JSON + markdown) walking the Typer tree. +3. **`fic mcp serve`** — embedded MCP stdio server wrapping `fic.core` (optional dep). +4. **`skills/fic/SKILL.md`** — Claude/Hermes skill, auto-discovered. +5. **`AGENTS.md`** — repo guidance for coding agents. +6. **README "Use with an AI agent"** section + MCP setup snippets. + +Foundation first; surfaces walk the finished tree last (go-api-cli step 5). + +## 3. Error envelope (contract) + +Exit codes are **unchanged** (Phase-1 spec §6, golden-pinned): `0 ok · 1 error · 2 usage · +3 auth · 4 validation · 5 rate-limit · 6 not-found · 7 forbidden`. + +On failure, stderr carries **exactly one** envelope line; stdout stays empty. + +```json +{"error":{"code":"","message":"","hint":""}} +``` + +Code mapping (single source of truth, lives next to the exit-code map in `cli/_errors.py`): + +| FicError | code | exit | hint | +|---|---|---|---| +| `ConfigError` | `config` | 3 | the fix command (already in message) | +| `AuthError` | `auth` | 3 | "Set FIC_ACCESS_TOKEN or run `fic auth device`." | +| `ValidationError` | `validation` | 4 | computed `totals` when present | +| `RateLimited` | `rate_limited` | 5 | `retry-after: s` when present | +| `NotFound` | `not_found` | 6 | "Check the id and --company-id." | +| `PermissionDenied` | `forbidden` | 7 | "Token lacks the required scope/permission." | +| `ApiError` / other | `error` | 1 | — | +| usage (click `UsageError`/`BadParameter`) | `usage` | 2 | the usage line | + +**Rendering rule:** envelope is emitted in `--output json` (default). In `--output table` +mode keep the human `error: ` text on stderr (humans don't parse JSON). Rendered +**exactly once** at the choke point. + +**Coverage of usage errors (exit 2).** Today `_errors.handle` only wraps `FicError`; +click renders its own `UsageError` (exit 2) as plaintext. To make even pre-parse failures +emit the envelope, the console entry point becomes a thin `main()` that runs the Typer app +with `standalone_mode=False` and catches `click.ClickException` → renders the envelope (or +plaintext in table mode) and exits with the mapped code. This is the positronick +"`Execute()` renders once" pattern. `handle` stays for `FicError`. Output mode is read +from the resolved `--output` (raw-argv scan as fallback when ctx isn't built yet). + +## 4. `fic agent-docs` (contract) + +Walks the Typer→click command tree (via `typer.main.get_command(app)`), depth-first, +skipping hidden/`help`. Emits, per the global `--output`: + +- **JSON:** `{"intro","exitCodes":{...},"errorCodes":[...],"errorEnvelope":"...","commands":[{"path","description","arguments":[{"name","required"}],"flags":[{"name","shorthand","usage","default","required"}]}]}` — one top-level object. (`arguments`/`flags` are omitted when empty; per-command usage is synthesized in the markdown view, not stored in the JSON.) +- **markdown** (`--output table`): an llms.txt-style manual — H1, intro, exit-code table, + error-envelope shape, then one H2 per command with usage + flags. + +The JSON shape is **golden-pinned** (contract test); a diff is a public-contract change. +`agent-docs` itself appears in the tree it prints. + +## 5. `fic mcp serve` (contract) + +**Dependency.** Optional extra `mcp` (`pip install 'fic[mcp]'` / `uv sync --extra mcp`), +the official `mcp` Python SDK (`mcp.server.fastmcp.FastMCP`), imported **lazily** inside +the serve command so the base CLI never requires it. `core/` purity is untouched — the +server lives in a new top-level package `fic/mcp/`, not in `fic/core/`. + +**TTY guard** (positronick parity). If stdin is a terminal and `--stdio` is not passed, +print ready-to-paste setup instructions (Claude Code / Cursor / generic) instead of +hijacking the terminal with JSON-RPC. `--stdio` forces the server. + +**Credentials.** Resolved via `core.auth.resolve()` **per tool call** (env > config), +honoring `FIC_COMPANY_ID`/config + a server-level `--company-id`. A resolution failure +becomes a tool error, never a crash. + +**Tools (consolidated — ~10, not one-per-endpoint, to keep the model's tool list lean).** +Dispatch maps resource→`core` fn (single lookup table, go-api-cli rule 16). User chose +**reads free + writes behind `confirm:true`**. + +Reads (no confirm): +- `fic_list(resource, type?, query?, page?, per_page?, all?, sort?, fieldset?)` — resource ∈ + `clients|suppliers|products|issued_documents|received_documents`. `type` is **required** + for `issued_documents`/`received_documents`, ignored otherwise (validated with a clear + error). `all=true` auto-paginates. +- `fic_get(resource, id)`. +- `fic_info(kind)` — kind ∈ `vat_types|payment_accounts|payment_methods|units|currencies|companies|company_info|whoami`. +- `fic_einvoice_status(id)` / `fic_einvoice_rejection(id)`. +- `fic_docs_pdf_url(id)` — returns the temporary download URL (no bytes). + +Writes (require `confirm:true`; without it the tool returns a refusal naming the gate, no +network call — mirrors the CLI's `--yes`): +- `fic_create(resource, data, type?, confirm)` / `fic_update(resource, id, data, confirm)` / + `fic_delete(resource, id, confirm)`. +- `fic_docs_convert(id, to, from_type?, e_invoice?, keep_copy?, confirm)`. +- `fic_docs_pay(id, payment_account_id, date?, amount?, confirm)`. +- `fic_einvoice_send(id, dry_run?, confirm)` — `dry_run=true` validates only and does **not** + require confirm; a real send requires `confirm:true`. + +**Server instructions** (initialize result): workflow altitude only — the type-required +rule, the confirm gate, pagination, "errors come back as tool errors with the message; +correct and retry." Per-tool caveats live in tool descriptions. + +**Tool results.** Structured JSON content (the `to_jsonable(core_result)` payload). Errors +raise so the SDK returns an MCP tool error carrying the `FicError` message + code. + +## 6. SKILL.md (`skills/fic/SKILL.md`) + +Frontmatter `name: fic`, description triggering on Fatture in Cloud / fic CLI / Italian +invoicing / e-invoicing tasks. Body: the two surfaces (MCP tools when connected, else the +CLI; `fic agent-docs` for the full manual), the search→get→act loop, the **enum gotchas** +(issued vs received `type` value sets, `ei_status`, payment status — from Phase-1 spec §9), +the destructive-op gate (`--yes` / `confirm:true`), exit codes + error envelope, and the +known traps (totals-422, no PDF/e-invoice-status endpoint, device-flow caveat, 403 overload). + +## 7. AGENTS.md + +Repo guidance: commands (`uv run pytest -q`, `ruff`, `mypy`), the **core purity rule** +(`tests/test_core_purity.py` — no Typer/print/exit/I/O in `core/`; MCP lives in `fic/mcp/`), +the **contract rule** (exit codes + JSON envelope + `agent-docs` JSON + MCP tool list are +agent-scripted — a golden diff is a public-contract change), layout, and the +foundation→surfaces build order. + +## 8. Testing (TDD — Rule 9; go-api-cli rule 13/14) + +- Error envelope: a test per code that fails if the gate regresses (e.g. NotFound→exit 6 + + `{"error":{"code":"not_found",...}}`; usage error→exit 2 + `code:"usage"`). Table mode + keeps plaintext. +- `agent-docs`: golden JSON (every command present, exit codes, envelope) + markdown smoke. +- MCP: no-network unit tests with `core` patched — read tool returns structured data; a + **write tool without `confirm` performs no core call and returns a refusal** (incident + test, go-api-cli rule 13); `einvoice_send dry_run` skips the confirm gate; TTY guard + prints setup instead of serving; tool-list/instructions golden-pinned. SDK never hit. +- Purity guard stays green; full suite + ruff + mypy green. + +## 9. Build order + +1. **Foundation:** error envelope in `cli/_errors.py` + `main()` entry point + tests. +2. **agent-docs** command + golden tests. +3. **MCP** package (`fic/mcp/server.py`, dispatch table, instructions) + `cli/mcp_serve.py` + serve command + lazy import + `pyproject` `[mcp]` extra + tests. +4. **Docs:** SKILL.md, AGENTS.md, README section. +5. Adversarial verification (write-safety bypass hunt, contract accuracy vs `core`, purity). + +## 10. Design-review resolutions (folded in 2026-06-16) + +A 4-lens adversarial review (contract / agent-UX / write-safety / YAGNI) verified the above +against source and found defects. Resolutions, now binding: + +- **R1 (B1).** Typer 0.26.5 vendors click — `import click` fails. `main()` catches + `typer._click.exceptions.ClickException` (usage/exit-2). `typer.Exit` is a `RuntimeError` + (not a ClickException) raised by `@handle` after it already rendered — `main()` maps it to + `SystemExit(exc.exit_code)`; it must not swallow it. +- **R2 (B2).** **`cli/_errors.handle` is the single FicError renderer** — JSON envelope in + json mode, plaintext in table mode, on stderr, once. `main()` renders ONLY ClickException + usage errors. `CliRunner.invoke(app, …)` exercises `handle` (so the envelope is tested + there); `main()`/usage tests call `main([...])` or run the binary. `tests/cli/test_app.py` + error assertions are updated to the envelope shape (a deliberate contract change). +- **R3 (B3).** `einvoice_send` always POSTs even with `dry_run=true` (no local short-circuit). + So **`fic_einvoice_send` always requires `confirm:true`**, regardless of `dry_run`; `dry_run` + only selects server-side validation (transmits nothing to SDI) vs a real send. Not framed + as side-effect-free. +- **R4 (B4).** Confirm gate: `confirm` is a **required boolean** on every write tool (the + schema marks it required, so a model must consciously decide; the runtime check + `confirm is not True` is authoritative). A genuine `false`/omitted refuses (no network); + pydantic coerces an affirmative scalar (`true`/`1`/`"yes"`) to boolean `true`, which proceeds + — that is explicit intent, not an accidental bypass; **no falsy/negative value ever + proceeds** (fail-closed). The refusal **raises** (MCP tool error), naming the op and saying + "re-call with confirm=true" — never a soft success-shaped result. The check runs **before** + credential resolution / `FicClient` construction, so a refusal does no auth and no network. + Tested through the real `call_tool`/pydantic path, not just the helper. **CLI parity:** the + destructive CLI commands that mutate — `delete`, `pay`, `convert`, and `einvoice send` + (gated even with `--dry-run`, since it still POSTs to SDI) — all require `--yes`. +- **R5 (B5).** `core.documents._require_type` raises **`ValidationError`** (code `validation`, + exit 4) with the allowed set in the message + `messages={"type":[…]}`, so a bad/missing doc + `type` is a recoverable input error an agent can self-correct, not a generic `error`. +- **R6 (M1).** MCP dispatch is a table of **per-resource adapters** (each maps tool args to the + core fn's real positional/keyword shape: registry creates take positional `payload`; document + creates take keyword `type`+`payload`; `convert` remaps `to`→`new_type`, `from_type`→`type`). +- **R7 (M2).** Validation envelope gains an optional `fields` key from `ValidationError.messages`: + `{"error":{"code":"validation","message":…,"fields":{…},"totals":{…}}}`. +- **R8 (M3).** The type rule is **per-tool**: required for `fic_list`/`fic_create` on + `issued_documents`/`received_documents`; not accepted by `fic_get`/`fic_update`/`fic_delete`. + Worded that way in server instructions and the affected tool descriptions. +- **R9 (M4).** Split account info from reference data: `fic_info(kind)` covers the 5 reference + lookups (`vat_types|payment_accounts|payment_methods|units|currencies`); a separate + `fic_account(kind)` covers `whoami|companies|company_info`. Each tool embeds its exact enum + list and notes the CLI equivalent (hyphen↔underscore) so the model copies rather than guesses. +- **R10 (M5).** For pre-parse usage errors (`ctx.obj` is None), emit the JSON envelope + **unconditionally** (default output is json), special-casing plaintext only when a literal + `--output table` / `--output=table` token is in `sys.argv` — a membership check, not a parser. +- **R11 (N1/N7/N8).** Final tool set (**13**): reads `fic_list`, `fic_get`, `fic_info`, + `fic_account`, `fic_einvoice_status`, `fic_einvoice_rejection`, `fic_docs_pdf_url`; writes + (confirm) `fic_create`, `fic_update`, `fic_delete`, `fic_docs_convert`, `fic_docs_pay`, + `fic_einvoice_send`. `fic_list` accepts `docs`/`received` as aliases for + `issued_documents`/`received_documents` (CLI parity) and an optional `year` sugar for issued + docs. (`fic_einvoice_status` kept despite being derivable from `fic_get` — "is this invoice + accepted by SDI" is the most common e-invoice question and a focused `{id, ei_status}` beats + making the agent dig through a full document.) +- **R12 (N4/N5/N6).** agent-docs skips the per-command `--help` flag (no `help` *command* + exists); `version` is included. `ConfigError` gets no hint (message already carries the fix); + the `FIC_ACCESS_TOKEN` hint is `AuthError`-only. Golden anchor = the agent-docs JSON; the MCP + surface is asserted **structurally** (tool-name set + every write tool's schema has a required + boolean `confirm`), not via byte-for-byte prose goldens. diff --git a/skills/fic/SKILL.md b/skills/fic/SKILL.md new file mode 100644 index 0000000..8cb4294 --- /dev/null +++ b/skills/fic/SKILL.md @@ -0,0 +1,107 @@ +--- +name: fic +description: >- + Drive the Fatture in Cloud (TeamSystem) API v2 — Italian invoicing, + e-invoicing and SDI — through the `fic` CLI or its embedded MCP server. + Use when asked to read or manage clients, suppliers, products, issued or + received documents (invoices, quotes, receipts, expenses), VAT/payment + reference data, mark documents paid, convert documents, or send/check + e-invoices for an Italian Fatture in Cloud account, or whenever the `fic` + CLI or fic MCP tools are available. +--- + +# Using fic + +`fic` is an unofficial, JSON-first client for the **Fatture in Cloud** (FIC) API v2. +You reach it two ways — prefer the MCP tools when connected, else the CLI: + +- **MCP server** (`fic mcp serve`) — ~13 tools (below). Reads are free; writes need + `confirm=true`. +- **CLI** (`fic`) — the same data plus auth. Run `fic agent-docs` for the full, + self-describing manual (every command, flag, exit code, and the error envelope) as + JSON (default) or markdown (`fic --output table agent-docs`). + +Single FIC account, one token. Every call needs credentials (see Auth). + +## The shape of the data + +FIC is **type-scoped** for documents and uses pagination: + +- **Issued document types:** `invoice`, `quote`, `proforma`, `receipt`, `delivery_note`, + `credit_note`, `order`, `work_report`, `supplier_order`, `self_own_invoice`, + `self_supplier_invoice`. (There is no `self_invoice` here.) +- **Received document types:** `expense`, `passive_credit_note`, `passive_delivery_note`, + `self_invoice`. (There is no `passive_invoice`.) +- **E-invoice `ei_status`** (read-only): `attempt, missing, not_sent, sent, pending, + processing, error, discarded, not_delivered, accepted, rejected, no_response, + manual_accepted, manual_rejected`. +- Lists return one page unless you ask for all of them (`--all` / `all=true`, + auto-paginates). `--query`/`query` maps to the API `q` filter. + +## MCP tools + +Reads (call freely): + +- `fic_list(resource, type?, query?, year?, page?, per_page?, all?, sort?, fieldset?)` — + `resource` ∈ `clients|suppliers|products|issued_documents|received_documents` + (aliases `docs`, `received`). `type` is **required** for `fic_list` and `fic_create` + on `issued_documents`/`received_documents`; it is **not** used by get/update/delete. +- `fic_get(resource, id)` — one record (issued docs include `ei_status`). +- `fic_info(kind)` — `vat_types|payment_accounts|payment_methods|units|currencies`. +- `fic_account(kind)` — `whoami|companies|company_info`. +- `fic_einvoice_status(id)` → `{id, ei_status}`; `fic_einvoice_rejection(id)`; + `fic_docs_pdf_url(id)` → temporary PDF URL (fetch promptly). + +Writes (**require `confirm=true`** — without it they refuse and do nothing; re-call with +`confirm=true` once the user agrees): + +- `fic_create(resource, data, type?, confirm)`, `fic_update(resource, id, data, confirm)`, + `fic_delete(resource, id, confirm)`. +- `fic_docs_convert(id, to, from_type?, e_invoice?, keep_copy?, confirm)`. +- `fic_docs_pay(id, payment_account_id, date?, amount?, confirm)` — composite read+update; + omitting `amount` marks the **first** unpaid `payments_list` line, so pass `amount` when + several are unpaid. Get a `payment_account_id` from `fic_info("payment_accounts")`. +- `fic_einvoice_send(id, dry_run?, confirm)` — **always** needs `confirm=true` (it calls the + SDI send endpoint even when validating). `dry_run=true` validates without transmitting to + SDI; `dry_run=false` really sends. + +## Driving the CLI + +- `--output json` (default) → one JSON object on stdout; `--output table` → human Rich table. + Diagnostics/errors go to stderr. Global options (`--output`, `--company-id`, `-v`) go + **before** the command: `fic --output table docs list --type invoice`. +- Destructive CLI commands (`delete`, `pay`, `convert`, `einvoice send`) require `--yes`. +- Build payloads from a file or stdin: `fic docs create --type invoice -f invoice.json`. + +## Auth + +Credentials resolve as: env `FIC_ACCESS_TOKEN` (+ `FIC_COMPANY_ID`) > `~/.config/fic/config.toml` +> `fic auth device` (OAuth device flow; FIC enables it per-app on request). Check with +`fic auth status` (no network) or `fic auth whoami` (network). The token is never a CLI flag. + +## Exit codes and recovering from errors + +Branch on the exit code: `0` ok, `1` error, `2` usage, `3` auth/config, `4` validation, +`5` rate-limit, `6` not found, `7` forbidden. On failure in JSON mode, stderr carries one +envelope line: + +```json +{"error":{"code":"validation","message":"...","hint":"...","fields":{...},"totals":{...}}} +``` + +`code` ∈ `error, usage, config, auth, validation, rate_limited, not_found, forbidden`. +Recovery: `validation` (code 4) means fixable input — read `message`/`fields` (e.g. the +allowed `type` values) and retry. `auth`/`config` (3) → fix credentials. `not_found` (6) → +check the id and company. Over MCP these arrive as tool errors whose message is prefixed +with the bracketed code, e.g. `[not_found] Resource not found (404).` — branch on the prefix. + +## Known traps + +- **Totals-mismatch 422:** amounts with >2 decimals get rejected; the computed totals come back + in the envelope's `totals`. Round money to 2 decimals. +- **Create a client before invoicing it:** a document for a non-saved client stores only partial + entity data. Create the client (or inline the full entity object). +- **No PDF or e-invoice-status endpoint:** PDF is the document's temporary `url`; + `ei_status` is read off the document, not a live SDI poll. +- **403 is overloaded:** real permission denial vs long-term quota; the short-term limit + (300 req / 5 min) is shared company-wide across all apps.