diff --git a/.gitignore b/.gitignore index 832b3c6..e2a4071 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ __pycache__/ *.egg-info/ *.pyc .pytest_cache/ +.coverage +coverage.xml +htmlcov/ diff --git a/CLAUDE.md b/CLAUDE.md index 6199ffa..d870ceb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ uv sync # install deps into .venv uv run pytest # all tests uv run pytest tests/test_runner.py # single file uv run pytest -k pattern # filter +uv run pytest --cov --cov-report=term-missing # coverage (branch, ~98%) uv run basecamp-cli-mcp generate # regenerate src/basecamp_cli_mcp/data/tools.json uv run basecamp-cli-mcp # run the stdio MCP server uv build # wheel + sdist into dist/ diff --git a/pyproject.toml b/pyproject.toml index 4a7a3a7..d76059c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "basecamp-cli-mcp" -version = "0.7.1" +version = "0.8.0" description = "MCP server that wraps the basecamp CLI." readme = "README.md" requires-python = ">=3.11" @@ -9,10 +9,10 @@ authors = [{ name = "Matt Brooke-Smith", email = "matt@futureworkshops.com" }] keywords = ["mcp", "basecamp", "model-context-protocol"] dependencies = [ - # mcp 2.0 dropped the low-level Server decorator API this server is built on - # (`@server.list_tools()` / `@server.call_tool()`), so stay on 1.x until the - # 2.0 migration lands. 1.30 is the first release with every security fix. - "mcp>=1.30,<2", + # server.py uses the mcp 2.0 low-level Server (on_list_tools / on_call_tool + # callbacks). The 1.x decorator API it replaced is gone in 2.0, so the range + # is pinned on both sides. + "mcp>=2,<3", ] [project.urls] @@ -35,7 +35,16 @@ include = ["src/basecamp_cli_mcp", "tests", "README.md", "LICENSE"] [dependency-groups] dev = [ "pytest>=8", + "anyio>=4", + "pytest-cov>=5", ] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.coverage.run] +source = ["src/basecamp_cli_mcp"] +branch = true + +[tool.coverage.report] +exclude_also = ['if __name__ == "__main__":'] diff --git a/src/basecamp_cli_mcp/server.py b/src/basecamp_cli_mcp/server.py index 812da86..1802e1e 100644 --- a/src/basecamp_cli_mcp/server.py +++ b/src/basecamp_cli_mcp/server.py @@ -7,7 +7,7 @@ from typing import Any import mcp.types as types -from mcp.server import Server +from mcp.server import Server, ServerRequestContext from mcp.server.stdio import stdio_server from . import __version__ @@ -61,39 +61,68 @@ def build_server( runner = runner or Runner() by_name = {s["name"]: s for s in specs} - server: Server = Server(name="basecamp", version=__version__, instructions=INSTRUCTIONS) - - @server.list_tools() - async def list_tools() -> list[types.Tool]: - return [ - types.Tool( - name=s["name"], - description=s["description"], - inputSchema=s["input_schema"], - ) - for s in specs - ] - - @server.call_tool() - async def call_tool(name: str, arguments: dict[str, Any] | None) -> list[types.TextContent]: - spec = by_name.get(name) - if spec is None: - raise ValueError(f"Unknown tool: {name}") + tools = [ + types.Tool( + name=s["name"], + description=s["description"], + input_schema=s["input_schema"], + ) + for s in specs + ] + + async def on_list_tools( + ctx: ServerRequestContext[object], + params: types.PaginatedRequestParams | None, + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=tools) + + async def on_call_tool( + ctx: ServerRequestContext[object], + params: types.CallToolRequestParams, + ) -> types.CallToolResult: + # A failing tool is reported as an error *result*, not a JSON-RPC error, so + # the client can show the CLI's own message. This matches how the SDK's own + # MCPServer handles tool exceptions. try: - data = runner.call(spec, arguments or {}) + text = _call_spec(runner, by_name, params.name, params.arguments or {}) except BasecampError as e: detail = f"\n{e.stderr}" if e.stderr else "" - raise RuntimeError(f"{e}{detail}") from e - - if data is None: - text = "" - elif isinstance(data, str): - text = data - else: - text = json.dumps(data, indent=2) - return [types.TextContent(type="text", text=text)] - - return server + return _error_result(f"{e}{detail}") + except Exception as e: # noqa: BLE001 - surfaced to the client as tool output + return _error_result(str(e)) + return types.CallToolResult(content=[types.TextContent(type="text", text=text)]) + + return Server( + name="basecamp", + version=__version__, + instructions=INSTRUCTIONS, + on_list_tools=on_list_tools, + on_call_tool=on_call_tool, + ) + + +def _call_spec( + runner: Runner, + by_name: dict[str, dict[str, Any]], + name: str, + arguments: dict[str, Any], +) -> str: + spec = by_name.get(name) + if spec is None: + raise ValueError(f"Unknown tool: {name}") + data = runner.call(spec, arguments) + if data is None: + return "" + if isinstance(data, str): + return data + return json.dumps(data, indent=2) + + +def _error_result(text: str) -> types.CallToolResult: + return types.CallToolResult( + content=[types.TextContent(type="text", text=text)], + is_error=True, + ) async def run(include: list[str] | None = None, exclude: list[str] | None = None) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index f7ca176..12adcd0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,3 +8,9 @@ @pytest.fixture def fixtures() -> Path: return FIXTURES + + +@pytest.fixture +def anyio_backend() -> str: + """Run `@pytest.mark.anyio` tests on asyncio only (no trio dependency).""" + return "asyncio" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..38c018a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,110 @@ +"""Tests for the `basecamp-cli-mcp` entry point. + +Mostly about wiring: that flags reach `server.run`, that `generate` writes where +it's told, and that `setup` dispatches. The subcommand bodies are stubbed — the +real generator shells out to the basecamp CLI, and `setup` installs software. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from basecamp_cli_mcp import cli + + +@pytest.fixture +def served(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: + """Capture the kwargs the stdio server would have been started with.""" + calls: list[dict[str, Any]] = [] + + async def fake_run(**kwargs: Any) -> None: + calls.append(kwargs) + + monkeypatch.setattr("basecamp_cli_mcp.server.run", fake_run) + return calls + + +def test_version_flag_prints_version(capsys: pytest.CaptureFixture[str]) -> None: + from basecamp_cli_mcp import __version__ + + with pytest.raises(SystemExit) as excinfo: + cli.main(["--version"]) + + assert excinfo.value.code == 0 + assert __version__ in capsys.readouterr().out + + +def test_no_arguments_serves_every_tool(served: list[dict[str, Any]]) -> None: + assert cli.main([]) == 0 + assert served == [{"include": None, "exclude": None}] + + +def test_include_flags_accumulate(served: list[dict[str, Any]]) -> None: + cli.main(["--include", "cards_*", "--include", "todos_*"]) + assert served[0]["include"] == ["cards_*", "todos_*"] + + +def test_exclude_flags_accumulate(served: list[dict[str, Any]]) -> None: + cli.main(["--exclude", "api_*", "--exclude", "webhooks_*"]) + assert served[0]["exclude"] == ["api_*", "webhooks_*"] + + +def test_include_and_exclude_combine(served: list[dict[str, Any]]) -> None: + cli.main(["--include", "todos_*", "--exclude", "todos_trash"]) + assert served[0] == {"include": ["todos_*"], "exclude": ["todos_trash"]} + + +# --- generate ---------------------------------------------------------------- + + +class FakeGenerator: + TOOLS = [{"name": "todos_create", "group": "todos", "action": "create"}] + + def generate(self) -> list[dict[str, Any]]: + return self.TOOLS + + +def test_generate_writes_to_explicit_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator) + out = tmp_path / "nested" / "tools.json" + + assert cli.main(["generate", "--output", str(out)]) == 0 + assert json.loads(out.read_text(encoding="utf-8")) == FakeGenerator.TOOLS + assert "Wrote 1 tools" in capsys.readouterr().err + + +def test_generate_defaults_to_the_in_tree_data_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Without --output it targets the packaged data dir, not the cwd.""" + monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator) + monkeypatch.setattr(cli, "files", lambda package: tmp_path) + + assert cli.main(["generate"]) == 0 + assert json.loads((tmp_path / "data" / "tools.json").read_text()) == FakeGenerator.TOOLS + + +def test_generated_file_ends_with_a_newline( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Keeps the committed contract diff-clean.""" + monkeypatch.setattr("basecamp_cli_mcp.generator.Generator", FakeGenerator) + out = tmp_path / "tools.json" + + cli.main(["generate", "--output", str(out)]) + + assert out.read_text(encoding="utf-8").endswith("}\n]\n") + + +# --- setup ------------------------------------------------------------------- + + +def test_setup_dispatches_and_returns_its_exit_code(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("basecamp_cli_mcp.setup_cmd.run", lambda: 7) + assert cli.main(["setup"]) == 7 diff --git a/tests/test_generator.py b/tests/test_generator.py new file mode 100644 index 0000000..83e3192 --- /dev/null +++ b/tests/test_generator.py @@ -0,0 +1,337 @@ +"""Tests for the offline schema generator. + +The generator only touches the outside world through `_list_commands`, +`_subcommands`, and `_help_text`, so a subclass that replays canned CLI output +exercises the whole tree walk: category skipping, nested-group discovery, alias +dedupe, name derivation, and schema assembly. + +This is what should catch a structural regression after a basecamp CLI upgrade, +while the tools.json diff is still under review. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from basecamp_cli_mcp.generator import Generator, _flag_schema + + +class FakeCLI(Generator): + """Replays canned `commands --json`, `--agent --help`, and `--help` output.""" + + def __init__( + self, + categories: list[dict[str, Any]], + subcommands: dict[tuple[str, ...], list[dict[str, Any]]] | None = None, + help_text: dict[tuple[str, ...], str] | None = None, + ) -> None: + super().__init__("basecamp") + self._categories = categories + self._subs = subcommands or {} + self._help = help_text or {} + + def _list_commands(self) -> list[dict[str, Any]]: + return self._categories + + def _subcommands(self, path: list[str]) -> list[dict[str, Any]]: + return self._subs.get(tuple(path), []) + + def _help_text(self, path: list[str]) -> str: + return self._help.get(tuple(path), "") + + +def category(name: str, *commands: str) -> dict[str, Any]: + return {"name": name, "commands": [{"name": c} for c in commands]} + + +def sub(name: str, short: str = "") -> dict[str, Any]: + return {"name": name, "short": short} + + +# --- walking the command tree ------------------------------------------------ + + +def test_shortcuts_category_is_skipped() -> None: + gen = FakeCLI( + [category("Core", "projects"), category("Shortcuts", "todo")], + {("projects",): [sub("list", "List projects")], ("todo",): [sub("add", "Add")]}, + ) + assert [t["name"] for t in gen.generate()] == ["projects_list"] + + +def test_bare_top_level_group_emits_nothing() -> None: + """A group with no subcommands isn't a real action, just a namespace.""" + assert FakeCLI([category("Core", "projects")]).generate() == [] + + +def test_nested_groups_are_walked() -> None: + gen = FakeCLI( + [category("Core", "cards")], + { + ("cards",): [sub("step", "Manage steps")], + ("cards", "step"): [sub("create", "Create a step"), sub("complete", "Complete")], + }, + ) + assert [t["name"] for t in gen.generate()] == ["cards_step_complete", "cards_step_create"] + + +def test_output_is_sorted_by_name() -> None: + gen = FakeCLI( + [category("Core", "todos")], + {("todos",): [sub("update", "Update"), sub("create", "Create"), sub("list", "List")]}, + ) + names = [t["name"] for t in gen.generate()] + assert names == sorted(names) + + +def test_recursion_stops_at_max_depth() -> None: + """A CLI that reports itself as its own child must not spin forever.""" + subs = { + ("g",): [sub("a", "A")], + ("g", "a"): [sub("b", "B")], + ("g", "a", "b"): [sub("c", "C")], + ("g", "a", "b", "c"): [sub("d", "D")], + ("g", "a", "b", "c", "d"): [sub("e", "E")], + ("g", "a", "b", "c", "d", "e"): [sub("f", "F")], + } + tools = FakeCLI([category("Core", "g")], subs).generate() + assert tools == [] # every leaf sits past _MAX_DEPTH + + +# --- alias dedupe ------------------------------------------------------------ + + +def test_aliases_sharing_a_description_collapse_to_the_longer_name() -> None: + gen = FakeCLI( + [category("Core", "cards")], + {("cards",): [sub("mv", "Move a card"), sub("move", "Move a card")]}, + ) + assert [t["name"] for t in gen.generate()] == ["cards_move"] + + +def test_distinct_descriptions_are_kept_apart() -> None: + gen = FakeCLI( + [category("Core", "cards")], + {("cards",): [sub("move", "Move a card"), sub("copy", "Copy a card")]}, + ) + assert sorted(t["name"] for t in gen.generate()) == ["cards_copy", "cards_move"] + + +def test_subcommands_without_descriptions_collapse_together() -> None: + """Sharp edge, pinned so a change is visible rather than silent. + + Dedupe keys on the short description, so siblings that carry none all share + the "" key and only one survives. The survivor is the longest name, and ties + keep whichever the CLI listed first, so here three real actions collapse to + `create` (tied with `update` at 6 characters, listed earlier). + """ + gen = FakeCLI( + [category("Core", "cards")], + {("cards",): [sub("create"), sub("update"), sub("trash")]}, + ) + assert [t["name"] for t in gen.generate()] == ["cards_create"] + + +# --- per-tool fields --------------------------------------------------------- + + +def test_name_argv_prefix_group_and_action_derivation() -> None: + gen = FakeCLI( + [category("Core", "cards")], + {("cards",): [sub("step", "Steps")], ("cards", "step"): [sub("create", "Create")]}, + ) + tool = gen.generate()[0] + assert tool["name"] == "cards_step_create" + assert tool["argv_prefix"] == ["cards", "step", "create"] + assert tool["group"] == "cards" + assert tool["action"] == "step_create" + + +def test_description_falls_back_to_the_command_path() -> None: + gen = FakeCLI( + [category("Core", "cards")], + {("cards",): [sub("create", "Create")]}, + help_text={("cards", "create"): ""}, + ) + assert gen.generate()[0]["description"] == "cards create" + + +def test_project_flag_is_injected_when_the_cli_omits_it() -> None: + gen = FakeCLI([category("Core", "todos")], {("todos",): [sub("list", "List")]}) + tool = gen.generate()[0] + project = [f for f in tool["flags"] if f["name"] == "project"] + assert project == [ + {"name": "project", "short": "p", "type": "string", "description": "Project ID"} + ] + assert "project" in tool["input_schema"]["properties"] + + +def test_project_flag_is_not_duplicated(fixtures: Path) -> None: + """`todos create --help` already declares --project; keep the CLI's own text.""" + gen = FakeCLI( + [category("Core", "todos")], + {("todos",): [sub("create", "Create")]}, + help_text={ + ("todos", "create"): (fixtures / "todos_create_help.txt").read_text( + encoding="utf-8" + ) + }, + ) + tool = gen.generate()[0] + assert [f["name"] for f in tool["flags"]].count("project") == 1 + + +def test_real_help_text_produces_a_usable_spec(fixtures: Path) -> None: + gen = FakeCLI( + [category("Core", "todos")], + {("todos",): [sub("create", "Create a to-do")]}, + help_text={ + ("todos", "create"): (fixtures / "todos_create_help.txt").read_text( + encoding="utf-8" + ) + }, + ) + tool = gen.generate()[0] + schema = tool["input_schema"] + assert schema["type"] == "object" + assert tool["description"] + assert schema["properties"] + # Whatever the CLI marks as a required positional must be required here too. + for pos in tool["positional"]: + if pos["required"]: + assert pos["name"] in schema["required"] + + +# --- schema assembly --------------------------------------------------------- + + +def test_build_schema_maps_positionals_and_required() -> None: + schema = Generator._build_schema( + { + "summary": "x", + "positional": [ + {"name": "content", "required": True, "description": "Content"}, + {"name": "note", "required": False, "description": "Note"}, + ], + "flags": [], + } + ) + assert schema["properties"]["content"] == {"type": "string", "description": "Content"} + assert schema["required"] == ["content"] + + +def test_build_schema_maps_variadic_positional_to_array() -> None: + schema = Generator._build_schema( + { + "summary": "x", + "positional": [ + {"name": "id", "required": True, "description": "IDs", "variadic": True} + ], + "flags": [], + } + ) + assert schema["properties"]["id"] == { + "type": "array", + "items": {"type": "string"}, + "description": "IDs", + } + + +def test_build_schema_omits_required_when_nothing_is_required() -> None: + schema = Generator._build_schema({"summary": "x", "positional": [], "flags": []}) + assert "required" not in schema + + +@pytest.mark.parametrize( + ("flag_type", "expected"), + [ + ("array", {"type": "array", "items": {"type": "string"}, "description": "d"}), + ("integer", {"type": "integer", "description": "d"}), + ("boolean", {"type": "boolean", "description": "d"}), + ("string", {"type": "string", "description": "d"}), + (None, {"type": "string", "description": "d"}), + ], +) +def test_flag_schema_by_type(flag_type: str | None, expected: dict[str, Any]) -> None: + assert _flag_schema({"name": "f", "type": flag_type, "description": "d"}) == expected + + +# --- CLI failure handling ---------------------------------------------------- + + +def test_list_commands_raises_with_stderr_on_failure(monkeypatch: pytest.MonkeyPatch) -> None: + import subprocess + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="not logged in") + + monkeypatch.setattr(subprocess, "run", fake_run) + with pytest.raises(RuntimeError, match="not logged in"): + Generator("basecamp")._list_commands() + + +def test_list_commands_unwraps_the_data_envelope(monkeypatch: pytest.MonkeyPatch) -> None: + import subprocess + + payload = {"ok": True, "data": [{"name": "Core", "commands": []}]} + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, stdout=json.dumps(payload), stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert Generator("basecamp")._list_commands() == [{"name": "Core", "commands": []}] + + +def test_subcommands_returns_empty_on_non_json_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A group that doesn't support `--agent` is a leaf, not an error.""" + import subprocess + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, stdout="Usage: basecamp ...", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert Generator("basecamp")._subcommands(["cards"]) == [] + + +def test_subcommands_filters_help_and_self_reference( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import subprocess + + meta = { + "subcommands": [ + {"name": "help"}, + {"name": "cards"}, + {"name": ""}, + {"name": "create", "short": "Create"}, + ] + } + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(argv, 0, stdout=json.dumps(meta), stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert Generator("basecamp")._subcommands(["cards"]) == [ + {"name": "create", "short": "Create"} + ] + + +def test_help_text_shells_out_to_the_cli(monkeypatch: pytest.MonkeyPatch) -> None: + import subprocess + + seen: list[list[str]] = [] + + def fake_run(argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + seen.append(argv) + return subprocess.CompletedProcess(argv, 0, stdout="Usage: ...", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert Generator("bc")._help_text(["cards", "create"]) == "Usage: ..." + assert seen == [["bc", "cards", "create", "--help"]] diff --git a/tests/test_help_parser.py b/tests/test_help_parser.py index 380aea6..0600ffc 100644 --- a/tests/test_help_parser.py +++ b/tests/test_help_parser.py @@ -77,3 +77,28 @@ def test_summary_stops_at_first_blank_line(fixtures: Path) -> None: # shell-syntax examples. The summary should keep only the first paragraph. parsed = parse(fixtures, "todos_complete") assert parsed["summary"] == "Mark one or more todos as completed." + + +@pytest.mark.parametrize( + ("hint", "expected"), + [ + (None, "boolean"), + ("bool", "boolean"), + ("stringArray", "array"), + ("int", "integer"), + ("string", "string"), + ("duration", "string"), + ], +) +def test_type_hint_mapping(hint: str | None, expected: str) -> None: + """A bare flag has no hint and is a switch; anything unrecognised is a string.""" + assert help_parser._map_type(hint) == expected + + +def test_summary_is_empty_when_help_opens_with_usage() -> None: + """Some actions have no prose description, only a USAGE block.""" + assert help_parser.parse("USAGE\n basecamp cards move \n")["summary"] == "" + + +def test_summary_skips_leading_blank_lines() -> None: + assert help_parser.parse("\n\n Move a card.\n\nUSAGE\n")["summary"] == "Move a card." diff --git a/tests/test_runner.py b/tests/test_runner.py index e9ffdaa..92c40d5 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -196,3 +196,115 @@ def test_todos_update_propagates_get_error() -> None: runner = FakeRunner([BasecampError("not found")]) with pytest.raises(BasecampError): runner.call(UPDATE_SPEC, {"id": 1, "project": 2, "due": "2026-04-29"}) + + +# --- _merge_todo_body branches ---------------------------------------------- +# +# The workaround sends a full PUT body, so every field it does *not* override +# has to be carried over from the current todo. A wrong branch here silently +# wipes a field on the real record. + +CURRENT_FULL = { + "content": "Original title", + "description": "
Original description
", + "due_on": "2026-08-20", + "starts_on": "2026-08-01", + "assignees": [{"id": 11}, {"id": 22}], + "completion_subscribers": [{"id": 33}], +} + + +def test_merge_preserves_every_untouched_field() -> None: + body = Runner._merge_todo_body(CURRENT_FULL, {}) + assert body == { + "content": "Original title", + "description": "
Original description
", + "due_on": "2026-08-20", + "starts_on": "2026-08-01", + "assignee_ids": [11, 22], + "completion_subscriber_ids": [33], + } + + +def test_merge_sets_description() -> None: + body = Runner._merge_todo_body(CURRENT_FULL, {"description": "
New
"}) + assert body["description"] == "
New
" + + +def test_merge_clears_description_with_no_description() -> None: + body = Runner._merge_todo_body(CURRENT_FULL, {"no-description": True}) + assert body["description"] == "" + + +def test_merge_sets_and_clears_starts_on() -> None: + assert Runner._merge_todo_body(CURRENT_FULL, {"starts-on": "2026-09-01"})[ + "starts_on" + ] == "2026-09-01" + assert Runner._merge_todo_body(CURRENT_FULL, {"no-starts-on": True})["starts_on"] is None + + +def test_merge_adds_notify_only_when_asked() -> None: + assert "notify" not in Runner._merge_todo_body(CURRENT_FULL, {}) + assert Runner._merge_todo_body(CURRENT_FULL, {"notify": True})["notify"] is True + + +def test_merge_accepts_assignees_as_a_list_or_csv() -> None: + assert Runner._merge_todo_body(CURRENT_FULL, {"assignee": [1, 2]})["assignee_ids"] == [1, 2] + assert Runner._merge_todo_body(CURRENT_FULL, {"to": "3, 4"})["assignee_ids"] == [3, 4] + + +def test_merge_cannot_currently_unassign_everyone() -> None: + """Known limitation, pinned so it's visible rather than surprising. + + The lookup is `params.get("assignee") or params.get("to")`, so an empty list + is falsy and falls through to "not specified" — the current assignees are + carried over instead of being cleared. There is no way to unassign everyone + through this path today; it would need an explicit `no-assignee` flag. + """ + assert Runner._merge_todo_body(CURRENT_FULL, {"assignee": []})["assignee_ids"] == [11, 22] + + +def test_merge_falls_back_to_title_when_content_is_absent() -> None: + body = Runner._merge_todo_body({"title": "From title"}, {}) + assert body["content"] == "From title" + + +def test_merge_defaults_missing_fields() -> None: + body = Runner._merge_todo_body({}, {}) + assert body == { + "content": "", + "description": "", + "due_on": None, + "starts_on": None, + "assignee_ids": [], + "completion_subscriber_ids": [], + } + + +def test_merge_skips_assignees_without_ids() -> None: + body = Runner._merge_todo_body({"assignees": [{"id": 5}, {"name": "no id"}]}, {}) + assert body["assignee_ids"] == [5] + + +def test_todos_update_rejects_a_non_dict_get_response() -> None: + runner = FakeRunner(["not a dict"]) + with pytest.raises(BasecampError, match="Unexpected response fetching todo"): + runner.call(UPDATE_SPEC, {"id": "1", "project": "2"}) + + +def test_optional_positional_is_skipped_when_absent() -> None: + spec = { + "group": "cards", + "action": "list", + "positional": [ + {"name": "column", "required": False, "description": "Column"}, + {"name": "filter", "required": False, "description": "Filter"}, + ], + "flags": [], + } + assert Runner().build_argv(spec, {"filter": "open"}) == [ + "cards", + "list", + "open", + "--json", + ] diff --git a/tests/test_runner_invoke.py b/tests/test_runner_invoke.py new file mode 100644 index 0000000..d031ea0 --- /dev/null +++ b/tests/test_runner_invoke.py @@ -0,0 +1,142 @@ +"""Tests for `Runner._invoke`, the subprocess + envelope boundary. + +The rest of the runner suite subclasses `Runner` and overrides `_invoke`, and +the server suite fakes the whole runner, so this is the seam neither covers: +argv actually reaching a process, and the `{ok, data}` / `{ok: false, error}` +envelope being turned into a return value or a `BasecampError`. + +A stub executable stands in for the real `basecamp` CLI so the subprocess call, +stderr capture, and exit-code handling are all real. It is written once per +module and driven by environment variables: macOS scans each newly created +executable on first exec (~0.2s), so a per-test stub would cost more than the +rest of the suite combined. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Callable + +import pytest + +from basecamp_cli_mcp.runner import BasecampError, Runner + +pytestmark = pytest.mark.skipif( + sys.platform == "win32", reason="stub relies on a shebang" +) + +SPEC = { + "group": "projects", + "action": "list", + "argv_prefix": ["projects", "list"], + "positional": [], + "flags": [{"name": "project", "type": "string", "description": "Project"}], +} + + +@pytest.fixture(scope="module") +def stub(tmp_path_factory: pytest.TempPathFactory) -> Path: + """A fake `basecamp` that replays $STUB_* output and logs its argv.""" + script = tmp_path_factory.mktemp("stub") / "basecamp" + script.write_text( + "#!/bin/sh\n" + 'printf "%s\\n" "$@" > "$STUB_ARGV"\n' + 'printf %s "$STUB_STDOUT"\n' + 'printf %s "$STUB_STDERR" >&2\n' + 'exit "$STUB_CODE"\n', + encoding="utf-8", + ) + script.chmod(0o755) + return script + + +@pytest.fixture +def runner_for( + stub: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Callable[..., Runner]: + """Build a Runner whose CLI returns the given stdout/stderr/exit code.""" + argv_log = tmp_path / "argv.txt" + + def build(stdout: str = "", stderr: str = "", code: int = 0) -> Runner: + monkeypatch.setenv("STUB_STDOUT", stdout) + monkeypatch.setenv("STUB_STDERR", stderr) + monkeypatch.setenv("STUB_CODE", str(code)) + monkeypatch.setenv("STUB_ARGV", str(argv_log)) + return Runner(str(stub)) + + build.argv_log = argv_log # type: ignore[attr-defined] + return build + + +def test_invoke_returns_envelope_data(runner_for: Callable[..., Runner]) -> None: + runner = runner_for(stdout='{"ok":true,"data":[{"id":1}]}') + assert runner.call(SPEC, {}) == [{"id": 1}] + + +def test_invoke_passes_built_argv_to_the_process(runner_for: Callable[..., Runner]) -> None: + runner = runner_for(stdout='{"ok":true,"data":null}') + runner.call(SPEC, {"project": "47723798"}) + argv = runner_for.argv_log.read_text(encoding="utf-8").splitlines() # type: ignore[attr-defined] + assert argv == ["projects", "list", "--project", "47723798", "--json"] + + +def test_invoke_returns_raw_stdout_when_not_json(runner_for: Callable[..., Runner]) -> None: + """No envelope and a clean exit means the CLI printed something unstructured.""" + assert runner_for(stdout="plain text output").call(SPEC, {}) == "plain text output" + + +def test_invoke_raises_on_ok_false_despite_zero_exit( + runner_for: Callable[..., Runner] +) -> None: + """`ok: false` is an error even when the CLI exits 0.""" + runner = runner_for(stdout='{"ok":false,"error":{"message":"access denied"}}', code=0) + with pytest.raises(BasecampError) as excinfo: + runner.call(SPEC, {}) + assert str(excinfo.value) == "access denied" + + +def test_invoke_error_carries_stderr_and_payload(runner_for: Callable[..., Runner]) -> None: + runner = runner_for( + stdout='{"ok":false,"error":{"message":"nope"}}', + stderr="401 Unauthorized", + code=1, + ) + with pytest.raises(BasecampError) as excinfo: + runner.call(SPEC, {}) + assert excinfo.value.stderr == "401 Unauthorized" + assert excinfo.value.data == {"ok": False, "error": {"message": "nope"}} + + +def test_invoke_error_dict_without_message_is_serialized( + runner_for: Callable[..., Runner] +) -> None: + runner = runner_for(stdout='{"ok":false,"error":{"code":422}}', code=1) + with pytest.raises(BasecampError) as excinfo: + runner.call(SPEC, {}) + assert json.loads(str(excinfo.value)) == {"code": 422} + + +def test_invoke_error_string_is_used_verbatim(runner_for: Callable[..., Runner]) -> None: + runner = runner_for(stdout='{"ok":false,"error":"boom"}', code=1) + with pytest.raises(BasecampError, match="^boom$"): + runner.call(SPEC, {}) + + +def test_invoke_falls_back_to_exit_status_without_envelope( + runner_for: Callable[..., Runner] +) -> None: + runner = runner_for(stdout="not json", stderr="segfault", code=2) + with pytest.raises(BasecampError, match="exited with status 2"): + runner.call(SPEC, {}) + + +def test_bin_defaults_to_basecamp_bin_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BASECAMP_BIN", "/opt/custom/basecamp") + assert Runner().basecamp_bin == "/opt/custom/basecamp" + + +def test_explicit_bin_beats_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BASECAMP_BIN", "/opt/custom/basecamp") + assert Runner("/usr/local/bin/basecamp").basecamp_bin == "/usr/local/bin/basecamp" diff --git a/tests/test_server_protocol.py b/tests/test_server_protocol.py new file mode 100644 index 0000000..dcdabc9 --- /dev/null +++ b/tests/test_server_protocol.py @@ -0,0 +1,167 @@ +"""End-to-end protocol tests over in-memory streams. + +These exercise the real MCP handshake against `build_server`, which is what the +mcp 1.x -> 2.x migration broke: the old decorator API failed at server +construction, so a unit test of `filter_specs` alone would not have caught it. +""" + +from __future__ import annotations + +import json +from functools import partial +from typing import Any + +import anyio +import pytest +from mcp import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +from basecamp_cli_mcp.runner import BasecampError +from basecamp_cli_mcp.server import build_server + +pytestmark = pytest.mark.anyio + +SPECS: list[dict[str, Any]] = [ + { + "name": "projects_list", + "argv_prefix": ["projects", "list"], + "group": "projects", + "action": "list", + "description": "List all projects.", + "positional": [], + "flags": [], + "input_schema": {"type": "object", "properties": {}}, + }, + { + "name": "todos_create", + "argv_prefix": ["todos", "create"], + "group": "todos", + "action": "create", + "description": "Create a to-do.", + "positional": [{"name": "content", "required": True, "description": "Content"}], + "flags": [], + "input_schema": { + "type": "object", + "properties": {"content": {"type": "string"}}, + "required": ["content"], + }, + }, +] + + +class FakeRunner: + """Stands in for `Runner`, recording calls instead of shelling out.""" + + def __init__(self, result: Any = None, error: Exception | None = None) -> None: + self.result = result + self.error = error + self.calls: list[tuple[str, dict[str, Any]]] = [] + + def call(self, tool_spec: dict[str, Any], params: dict[str, Any] | None) -> Any: + self.calls.append((tool_spec["name"], params or {})) + if self.error is not None: + raise self.error + return self.result + + +async def _session(runner: FakeRunner, **kwargs: Any): + """Yield an initialized ClientSession wired to a server over memory streams.""" + async with create_client_server_memory_streams() as (client_streams, server_streams): + server = build_server(tool_specs=SPECS, runner=runner, **kwargs) + async with anyio.create_task_group() as tg: + tg.start_soon( + partial( + server.run, + server_streams[0], + server_streams[1], + server.create_initialization_options(), + raise_exceptions=True, + ) + ) + async with ClientSession(client_streams[0], client_streams[1]) as session: + await session.initialize() + yield session + tg.cancel_scope.cancel() + + +async def test_initialize_and_list_tools() -> None: + async for session in _session(FakeRunner()): + result = await session.list_tools() + assert [t.name for t in result.tools] == ["projects_list", "todos_create"] + todos = result.tools[1] + assert todos.description == "Create a to-do." + assert todos.input_schema["required"] == ["content"] + + +async def test_list_tools_honours_filters() -> None: + async for session in _session(FakeRunner(), include=["todos_*"]): + result = await session.list_tools() + assert [t.name for t in result.tools] == ["todos_create"] + + +async def test_call_tool_returns_json_payload() -> None: + runner = FakeRunner(result=[{"id": 1, "name": "Acme"}]) + async for session in _session(runner): + result = await session.call_tool("projects_list", {}) + assert result.is_error in (None, False) + assert json.loads(result.content[0].text) == [{"id": 1, "name": "Acme"}] + assert runner.calls == [("projects_list", {})] + + +async def test_call_tool_passes_arguments_through() -> None: + runner = FakeRunner(result={"id": 7}) + async for session in _session(runner): + await session.call_tool("todos_create", {"content": "Ship it"}) + assert runner.calls == [("todos_create", {"content": "Ship it"})] + + +async def test_call_tool_returns_empty_text_for_null_payload() -> None: + async for session in _session(FakeRunner(result=None)): + result = await session.call_tool("projects_list", {}) + assert result.content[0].text == "" + + +async def test_basecamp_error_becomes_error_result_with_stderr() -> None: + error = BasecampError("access denied", stderr="401 Unauthorized") + async for session in _session(FakeRunner(error=error)): + result = await session.call_tool("projects_list", {}) + assert result.is_error is True + assert result.content[0].text == "access denied\n401 Unauthorized" + + +async def test_unknown_tool_becomes_error_result() -> None: + async for session in _session(FakeRunner()): + result = await session.call_tool("nope_missing", {}) + assert result.is_error is True + assert "Unknown tool: nope_missing" in result.content[0].text + + +async def test_call_tool_returns_unstructured_stdout_as_is() -> None: + """The CLI sometimes prints text rather than a JSON envelope.""" + async for session in _session(FakeRunner(result="Done.")): + result = await session.call_tool("projects_list", {}) + assert result.content[0].text == "Done." + + +async def test_run_wires_stdio_to_the_server(monkeypatch: pytest.MonkeyPatch) -> None: + """Guards the three-way wiring that the mcp 2.0 upgrade could have broken: + stdio_server(), Server.run(), and create_initialization_options().""" + from contextlib import asynccontextmanager + + from basecamp_cli_mcp import server as server_module + + @asynccontextmanager + async def fake_stdio(): + yield ("read-stream", "write-stream") + + served: list[tuple[Any, ...]] = [] + + async def fake_run(self, read, write, init_options, **kwargs): + served.append((read, write, type(init_options).__name__)) + + monkeypatch.setattr(server_module, "stdio_server", fake_stdio) + monkeypatch.setattr(type(build_server(tool_specs=SPECS)), "run", fake_run) + + await server_module.run(include=["projects_*"]) + + assert served == [("read-stream", "write-stream", "InitializationOptions")] diff --git a/tests/test_setup_cmd.py b/tests/test_setup_cmd.py new file mode 100644 index 0000000..9df2007 --- /dev/null +++ b/tests/test_setup_cmd.py @@ -0,0 +1,403 @@ +"""Tests for the `setup` command. + +This is the only code in the package that writes outside the repo: it edits the +user's real `claude_desktop_config.json`. The merge and abort paths matter more +than the install plumbing, so they are what's covered here, with the config path +redirected into tmp_path. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +import pytest + +from basecamp_cli_mcp import setup_cmd + + +@pytest.fixture +def config_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Redirect the Claude Desktop config to tmp_path and pretend we're on macOS.""" + path = tmp_path / "Claude" / "claude_desktop_config.json" + monkeypatch.setattr(setup_cmd, "CLAUDE_DESKTOP_CONFIG_MACOS", path) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: False) + return path + + +def choose(monkeypatch: pytest.MonkeyPatch, choice: str) -> None: + monkeypatch.setattr(setup_cmd, "_prompt_claude_desktop_choice", lambda: choice) + + +def read(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def backups(path: Path) -> list[Path]: + return sorted(path.parent.glob(f"{path.stem}*.backup.*")) + + +# --- writing the entry ------------------------------------------------------- + + +def test_full_choice_writes_unfiltered_entry( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "full") + monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: "/opt/homebrew/bin/uvx") + + setup_cmd._maybe_configure_claude_desktop() + + assert read(config_path)["mcpServers"]["basecamp"] == { + "command": "/opt/homebrew/bin/uvx", + "args": ["basecamp-cli-mcp"], + } + + +def test_minimal_choice_writes_include_flags_in_order( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "minimal") + monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: "/usr/bin/uvx") + + setup_cmd._maybe_configure_claude_desktop() + + args = read(config_path)["mcpServers"]["basecamp"]["args"] + expected = ["basecamp-cli-mcp"] + for pattern in setup_cmd.MINIMAL_INCLUDES: + expected += ["--include", pattern] + assert args == expected + + +def test_minimal_set_covers_todos_cards_and_comments() -> None: + """The minimal set is a product decision; pin it so edits are deliberate.""" + assert setup_cmd.MINIMAL_INCLUDES == [ + "todos_*", + "cards_*", + "projects_list", + "assignments_due", + "comments_create", + ] + + +def test_falls_back_to_bare_uvx_when_not_on_path( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "full") + monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: None) + + setup_cmd._maybe_configure_claude_desktop() + + assert read(config_path)["mcpServers"]["basecamp"]["command"] == "uvx" + + +def test_preserves_other_servers_and_backs_up( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path.parent.mkdir(parents=True) + original = { + "mcpServers": {"other": {"command": "node", "args": ["x.js"]}}, + "globalShortcut": "Cmd+Space", + } + config_path.write_text(json.dumps(original), encoding="utf-8") + choose(monkeypatch, "full") + + setup_cmd._maybe_configure_claude_desktop() + + after = read(config_path) + assert after["mcpServers"]["other"] == {"command": "node", "args": ["x.js"]} + assert after["globalShortcut"] == "Cmd+Space" + assert "basecamp" in after["mcpServers"] + assert [json.loads(b.read_text()) for b in backups(config_path)] == [original] + + +def test_replaces_an_existing_basecamp_entry( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path.parent.mkdir(parents=True) + config_path.write_text( + json.dumps({"mcpServers": {"basecamp": {"command": "stale", "args": []}}}), + encoding="utf-8", + ) + choose(monkeypatch, "full") + + setup_cmd._maybe_configure_claude_desktop() + + assert read(config_path)["mcpServers"]["basecamp"]["command"] != "stale" + + +# --- paths that must not write ---------------------------------------------- + + +def test_skip_choice_writes_nothing( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "skip") + setup_cmd._maybe_configure_claude_desktop() + assert not config_path.exists() + + +def test_non_macos_is_a_noop(config_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "linux") + choose(monkeypatch, "full") + setup_cmd._maybe_configure_claude_desktop() + assert not config_path.exists() + + +def test_unparseable_config_aborts_and_leaves_file_untouched( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path.parent.mkdir(parents=True) + config_path.write_text("{ this is not json", encoding="utf-8") + choose(monkeypatch, "full") + + setup_cmd._maybe_configure_claude_desktop() + + assert config_path.read_text(encoding="utf-8") == "{ this is not json" + assert len(backups(config_path)) == 1 + + +def test_non_object_top_level_aborts( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path.parent.mkdir(parents=True) + config_path.write_text('["not", "an", "object"]', encoding="utf-8") + choose(monkeypatch, "full") + + setup_cmd._maybe_configure_claude_desktop() + + assert read(config_path) == ["not", "an", "object"] + + +def test_non_object_mcpservers_aborts( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config_path.parent.mkdir(parents=True) + config_path.write_text(json.dumps({"mcpServers": []}), encoding="utf-8") + choose(monkeypatch, "full") + + setup_cmd._maybe_configure_claude_desktop() + + assert read(config_path) == {"mcpServers": []} + + +# --- restart prompt ---------------------------------------------------------- + + +def test_declining_restart_runs_nothing( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "full") + calls: list[list[str]] = [] + monkeypatch.setattr(setup_cmd.subprocess, "run", lambda argv, **kw: calls.append(argv)) + + setup_cmd._maybe_configure_claude_desktop() + + assert calls == [] + + +def test_accepting_restart_quits_and_reopens_claude( + config_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + choose(monkeypatch, "full") + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: True) + monkeypatch.setattr(setup_cmd.time, "sleep", lambda seconds: None) + calls: list[list[str]] = [] + monkeypatch.setattr(setup_cmd.subprocess, "run", lambda argv, **kw: calls.append(argv)) + + setup_cmd._maybe_configure_claude_desktop() + + assert calls[0][0] == "osascript" + assert calls[-1] == ["open", "-a", "Claude"] + + +# --- prompt parsing ---------------------------------------------------------- + + +@pytest.mark.parametrize( + ("typed", "expected"), + [("1", "minimal"), ("2", "full"), ("3", "skip"), ("", "skip"), ("garbage", "skip")], +) +def test_choice_prompt_mapping( + monkeypatch: pytest.MonkeyPatch, typed: str, expected: str +) -> None: + monkeypatch.setattr("builtins.input", lambda prompt="": typed) + assert setup_cmd._prompt_claude_desktop_choice() == expected + + +def test_choice_prompt_defaults_to_skip_on_eof(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_eof(prompt: str = "") -> str: + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + assert setup_cmd._prompt_claude_desktop_choice() == "skip" + + +@pytest.mark.parametrize( + ("typed", "expected"), + [("y", True), ("Y", True), ("yes", True), ("n", False), ("", False), ("maybe", False)], +) +def test_yes_no_prompt(monkeypatch: pytest.MonkeyPatch, typed: str, expected: bool) -> None: + monkeypatch.setattr("builtins.input", lambda prompt="": typed) + assert setup_cmd._prompt("Proceed?") is expected + + +# --- binary resolution ------------------------------------------------------- + + +def test_resolve_binary_prefers_executable_env_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = tmp_path / "basecamp" + fake.write_text("#!/bin/sh\n", encoding="utf-8") + fake.chmod(0o755) + monkeypatch.setenv("BASECAMP_BIN", str(fake)) + + assert setup_cmd._resolve_binary() == str(fake) + + +def test_resolve_binary_ignores_non_executable_override( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + dud = tmp_path / "basecamp" + dud.write_text("not executable", encoding="utf-8") + dud.chmod(0o644) + monkeypatch.setenv("BASECAMP_BIN", str(dud)) + monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: "/usr/local/bin/basecamp") + + assert setup_cmd._resolve_binary() == "/usr/local/bin/basecamp" + + +def test_resolve_binary_falls_back_to_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BASECAMP_BIN", raising=False) + monkeypatch.setattr(setup_cmd.shutil, "which", lambda name: None) + assert setup_cmd._resolve_binary() is None + + +# --- run() control flow ------------------------------------------------------ + + +def test_run_propagates_basecamp_setup_failure_without_touching_config( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: "/usr/local/bin/basecamp") + monkeypatch.setattr( + setup_cmd.subprocess, + "run", + lambda argv, **kw: subprocess.CompletedProcess(argv, 3), + ) + configured = [] + monkeypatch.setattr( + setup_cmd, "_maybe_configure_claude_desktop", lambda: configured.append(True) + ) + + assert setup_cmd.run() == 3 + assert configured == [] + + +def test_run_configures_claude_desktop_on_success(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: "/usr/local/bin/basecamp") + monkeypatch.setattr( + setup_cmd.subprocess, + "run", + lambda argv, **kw: subprocess.CompletedProcess(argv, 0), + ) + configured = [] + monkeypatch.setattr( + setup_cmd, "_maybe_configure_claude_desktop", lambda: configured.append(True) + ) + + assert setup_cmd.run() == 0 + assert configured == [True] + + +def test_run_on_windows_without_binary_gives_manual_instructions( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: None) + + assert setup_cmd.run() == 1 + assert setup_cmd.WINDOWS_INSTALL_CMD in capsys.readouterr().err + + +def test_run_aborts_when_installer_is_declined(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: None) + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: False) + + def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("must not run the installer after a declined prompt") + + monkeypatch.setattr(setup_cmd.subprocess, "run", fail) + + assert setup_cmd.run() == 1 + + +# --- installer path ---------------------------------------------------------- + + +def test_prompt_declines_on_eof(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_eof(prompt: str = "") -> str: + raise EOFError + + monkeypatch.setattr("builtins.input", raise_eof) + assert setup_cmd._prompt("Proceed?") is False + + +def test_installer_failure_returns_its_exit_code( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: None) + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: True) + monkeypatch.setattr( + setup_cmd.subprocess, + "run", + lambda argv, **kw: subprocess.CompletedProcess(argv, 4), + ) + + assert setup_cmd.run() == 4 + assert setup_cmd.INSTALL_DOCS_URL in capsys.readouterr().err + + +def test_installer_succeeding_without_a_binary_on_path_fails_clearly( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Common case: the installer wrote to a dir the current shell hasn't picked up.""" + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: None) + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: True) + monkeypatch.setattr( + setup_cmd.subprocess, + "run", + lambda argv, **kw: subprocess.CompletedProcess(argv, 0), + ) + + assert setup_cmd.run() == 1 + assert "restart your shell" in capsys.readouterr().err + + +def test_installer_success_continues_to_basecamp_setup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "platform", "darwin") + resolved = iter([None, "/usr/local/bin/basecamp"]) + monkeypatch.setattr(setup_cmd, "_resolve_binary", lambda: next(resolved)) + monkeypatch.setattr(setup_cmd, "_prompt", lambda question: True) + ran: list[list[str]] = [] + + def fake_run(argv: list[str], **kw: object) -> subprocess.CompletedProcess[str]: + ran.append(argv) + return subprocess.CompletedProcess(argv, 0) + + monkeypatch.setattr(setup_cmd.subprocess, "run", fake_run) + monkeypatch.setattr(setup_cmd, "_maybe_configure_claude_desktop", lambda: None) + + assert setup_cmd.run() == 0 + assert ran[0] == ["bash", "-c", setup_cmd.INSTALL_CMD] + assert ran[1] == ["/usr/local/bin/basecamp", "setup"] diff --git a/tests/test_tools_contract.py b/tests/test_tools_contract.py new file mode 100644 index 0000000..dca0e9f --- /dev/null +++ b/tests/test_tools_contract.py @@ -0,0 +1,89 @@ +"""Checks on the committed `data/tools.json`. + +`tools.json` is the contract between the offline generator and the runtime, it +ships inside the wheel, and it is regenerated by hand after a basecamp CLI +upgrade. These tests are what should fail if a regeneration produces something +the runtime or the MCP spec won't accept, rather than a client discovering it. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from mcp.shared.tool_name_validation import validate_tool_name + +from basecamp_cli_mcp.server import _load_tool_specs, build_server + + +@pytest.fixture(scope="module") +def specs() -> list[dict[str, Any]]: + return _load_tool_specs() + + +def test_package_data_is_present_and_populated(specs: list[dict[str, Any]]) -> None: + """Also covers loading via importlib.resources, which packaging changes break.""" + assert len(specs) > 200 + + +def test_every_spec_has_the_fields_the_runner_needs(specs: list[dict[str, Any]]) -> None: + for spec in specs: + missing = {"name", "group", "action", "description", "input_schema"} - spec.keys() + assert not missing, f"{spec.get('name')} is missing {missing}" + assert spec["description"].strip(), f"{spec['name']} has an empty description" + + +def test_tool_names_are_unique(specs: list[dict[str, Any]]) -> None: + names = [s["name"] for s in specs] + duplicates = {n for n in names if names.count(n) > 1} + assert not duplicates + + +def test_tool_names_conform_to_the_mcp_spec(specs: list[dict[str, Any]]) -> None: + """SEP-986 naming. mcp 2.x warns per offending tool at registration.""" + invalid = [s["name"] for s in specs if not validate_tool_name(s["name"]).is_valid] + assert not invalid + + +def test_input_schemas_are_object_schemas(specs: list[dict[str, Any]]) -> None: + for spec in specs: + schema = spec["input_schema"] + assert schema.get("type") == "object", spec["name"] + assert isinstance(schema.get("properties"), dict), spec["name"] + + +def test_required_entries_reference_declared_properties(specs: list[dict[str, Any]]) -> None: + for spec in specs: + schema = spec["input_schema"] + dangling = set(schema.get("required") or []) - schema["properties"].keys() + assert not dangling, f"{spec['name']} requires undeclared {dangling}" + + +def test_required_positionals_are_required_in_the_schema( + specs: list[dict[str, Any]] +) -> None: + """Otherwise the runner raises ValueError on a call the schema said was valid.""" + for spec in specs: + required = set(spec["input_schema"].get("required") or []) + for pos in spec.get("positional") or []: + if pos.get("required"): + assert pos["name"] in required, f"{spec['name']}: {pos['name']}" + + +def test_flag_types_are_ones_the_runner_understands(specs: list[dict[str, Any]]) -> None: + known = {"boolean", "array", "integer", "string"} + for spec in specs: + for flag in spec.get("flags") or []: + assert flag.get("type") in known, f"{spec['name']}: {flag}" + + +def test_shortcut_actions_are_not_exposed(specs: list[dict[str, Any]]) -> None: + """The Shortcuts category duplicates real actions and is skipped at generation.""" + names = {s["name"] for s in specs} + assert not names & {"todo", "done", "card", "comment"} + + +def test_the_whole_shipped_set_builds_a_server() -> None: + """Every spec has to survive validation as an MCP tool, not just the sample ones.""" + server = build_server() + assert server.name == "basecamp" diff --git a/uv.lock b/uv.lock index a9c1d5d..9039d89 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,8 @@ resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -41,7 +42,7 @@ wheels = [ [[package]] name = "basecamp-cli-mcp" -version = "0.7.1" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -49,22 +50,19 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "anyio" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.30,<2" }] +requires-dist = [{ name = "mcp", specifier = ">=2,<3" }] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] - -[[package]] -name = "certifi" -version = "2026.10.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +dev = [ + { name = "anyio", specifier = ">=4" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, ] [[package]] @@ -158,6 +156,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/79/e78bd8f75a9b8e32233ce92d4607f0a21ce88a9156cace2884e7175a82f5/coverage-7.17.0.tar.gz", hash = "sha256:cc010a0a7fbdd9bb8bdfd0d3f04a5151275860213cbd7e2b876b29778489c712", size = 991021, upload-time = "2026-10-22T16:06:50.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/25/79a66272edd250031aeee6db87d500a0bf76390d0d5f8c2f431fe354715f/coverage-7.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:99a7b390cbccc6bf029ca4da3f0f607c218bb4085e9cd6b58a90d14ca12993c3", size = 225837, upload-time = "2026-10-22T16:03:55.622Z" }, + { url = "https://files.pythonhosted.org/packages/52/a6/f5f6fefb2fc1305363927edad1d0a60ce6ef80d6c1a74a79e825f0f670eb/coverage-7.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f72640e02a470b57b15cd655820133f6892760209283a363a60e2df932d5f0fe", size = 226257, upload-time = "2026-10-22T16:03:57.164Z" }, + { url = "https://files.pythonhosted.org/packages/28/61/72889bac8eb44c51f8043edb227df551dfdca1fc4819a6c6d67b3f69427b/coverage-7.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2dc9233f39149b5a4f4aba17a4b6bc5cca22e52cfc4b06fdb07d014439b8b2ea", size = 258282, upload-time = "2026-10-22T16:03:58.521Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e4/03e4a5644df9b73de0d44d1b82acd18b06c42b5abc3f1433be8363fa983d/coverage-7.17.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e8113d3852e9377421a61de1917df27b3abb25404a4ecf69f9e64612c4345b5", size = 260506, upload-time = "2026-10-22T16:04:00.116Z" }, + { url = "https://files.pythonhosted.org/packages/6f/4e/949022286df7e59e8633a3a7e6635e0e6b53af7d57074cb0bf8f500373fb/coverage-7.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c86a39539d10d666bb819c16c94d7edfc1aa2e0531b937f7c549b83d15177c6e", size = 262600, upload-time = "2026-10-22T16:04:01.49Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/7d67f60df081006315699b7edb9402c1b47142caf1383f4e56ae8ff1a501/coverage-7.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:559f748446ee799803686b5784e3deb54c70d655ac386a860e06b11672504ba7", size = 264609, upload-time = "2026-10-22T16:04:03.286Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/9293d94afbc099990a085e88229a51c79b00828aba0f2adda32dc4cc16d1/coverage-7.17.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d65c9e8e257f48a654908005046fbf1bc48d9540ae3776ad08c0acd010e10c", size = 258382, upload-time = "2026-10-22T16:04:04.804Z" }, + { url = "https://files.pythonhosted.org/packages/24/c8/bcd28efedf262c074dae76f61098739eb02b15f3f716f80cc86f9885b8a9/coverage-7.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b7fa0997b8eba7378ac66f31ec7f70164490353ab79b9667d37c0d365e50177", size = 260299, upload-time = "2026-10-22T16:04:06.291Z" }, + { url = "https://files.pythonhosted.org/packages/b8/dc/503f55e5e2200933403c90d619d9f779b5060ddd338b1bdc63cd235592cb/coverage-7.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ae0453a3390e67f7bfbe34417bbdf5b4ebfc21db23fb446a0a41115eeeca6ca", size = 258314, upload-time = "2026-10-22T16:04:07.729Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b1/f1a57c36077cc516240636f7eff0d1427939ed795f096dd2c7769213a56c/coverage-7.17.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:96151262832f3c230ebae23b2d6911ad549e1738465946c97004913ff06112ba", size = 262239, upload-time = "2026-10-22T16:04:09.116Z" }, + { url = "https://files.pythonhosted.org/packages/a8/4f/c2e59d4a132936c7c207b9b69ab99c0f27b14c3c93266e9922aa3b562b37/coverage-7.17.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:cd7397de6cc782f00fe5ac1d75e9b306bcfa49c8b6b8dd6ed1f7709393ad40b3", size = 257841, upload-time = "2026-10-22T16:04:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/05/04/50742c0ee8b6c85826ff11737c93ed957fea4c9acf4b4b99804bdac1dd84/coverage-7.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d1fdf82c6fea57b5e9dada509f9ce9638c8334664e2c98c7f6f1ccf3d143ad2", size = 259039, upload-time = "2026-10-22T16:04:11.916Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/83ad4bbbfcb6d045a33c14cdca313f6c9275737c67f3b7300222e3239a37/coverage-7.17.0-cp311-cp311-win32.whl", hash = "sha256:38c86e5d74bbfd88a393fa3f91257c1225c63aa6db6d5918de3e49ab04f91c6e", size = 227687, upload-time = "2026-10-22T16:04:13.537Z" }, + { url = "https://files.pythonhosted.org/packages/ca/c8/dcc5a7effa79707eb8a61e699b141cff7c9b46036771274cfb48ea7b765f/coverage-7.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ed3097c6817bc1a7fc8ae0a6131db74aa1150d863cccc308237ff362d3226b2", size = 228290, upload-time = "2026-10-22T16:04:14.899Z" }, + { url = "https://files.pythonhosted.org/packages/d1/58/a8553e0dcd38d8136df63964638d2b588e0a4a916bff1bca996244fda693/coverage-7.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:6052e878b628abcd38fa5f2b64abebc686eed4e2267da42434017a9c6d24f241", size = 228199, upload-time = "2026-10-22T16:04:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/0a/25/989fd3b495c4274ac0cf54cb6664326415e60183a7f8a73dd9bbb0b11e34/coverage-7.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0cfdc938ca3319d873d1f1252e60620a39aee4ebf9dbd7cc58238109ebda9c51", size = 225938, upload-time = "2026-10-22T16:04:18.113Z" }, + { url = "https://files.pythonhosted.org/packages/db/cb/14726426bf3241657facbf0f7df7babd8f7d3fcf8e4609b7929f9dceecbc/coverage-7.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d523da3c2e4bca49417768bcdb1a443c5b359dc44b8b93a3102e118fce912c40", size = 226166, upload-time = "2026-10-22T16:04:19.524Z" }, + { url = "https://files.pythonhosted.org/packages/8e/97/f95ed32cd3cfbe261a3598e694cb770cefc7b2d13ff013418d6f70a44dde/coverage-7.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:adbf3450c0f3f2b2854e282a8758f2b3eddf787eeff24ec9c9bc2cbc38328cc1", size = 258462, upload-time = "2026-10-22T16:04:21.079Z" }, + { url = "https://files.pythonhosted.org/packages/91/ac/eb4c31f741509a9ef48cf6f28385e98e9de0785599282252eee252ff8af7/coverage-7.17.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:85932227e9dd38e62cd29c17fefdfecfb919eead6af1e3259522d03cf85de983", size = 261789, upload-time = "2026-10-22T16:04:23.037Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3e/d9cd006d1a3cc5deab07beb29b86cb4a10dd84d66b330820363fb8d310d6/coverage-7.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f5477aac9c9e4a0a641c55f8f8fddd7f3bd91176c912c50f40ffced96b4034", size = 262322, upload-time = "2026-10-22T16:04:24.94Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3a/4c872c6ae0762b09cdb8e856891bb3f584848cb60b6dda2bacb85fe2f3f0/coverage-7.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b8bb651a4406fb6a983a905309fec1783443c65e1771b41e92740a0f4a1176b", size = 264600, upload-time = "2026-10-22T16:04:26.444Z" }, + { url = "https://files.pythonhosted.org/packages/df/98/1d41a2373fa98b423bcd70adb91d75e38eafb417955426dbb920b10016f3/coverage-7.17.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0edeb6594f080c8de1263cccb239cb316ef77aeb959e2e0f38f2953fcc72a49e", size = 258678, upload-time = "2026-10-22T16:04:27.832Z" }, + { url = "https://files.pythonhosted.org/packages/92/6a/fe04b5e63e76506daa63d90bdfb00a53767fe5df18546b437d6226918d6b/coverage-7.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da94ec0484e84fce5c7bf8d0f54b97767a90d992653a129b35634699d516e0a5", size = 260210, upload-time = "2026-10-22T16:04:29.275Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/5c5a78dc19dd4799be9c6ec022316fb25ababaeacfaf0f52f24c56a57b43/coverage-7.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a25cc5e8c23a590815c6a7aa43624234d25cb23b21b484adb8a0daed59860db5", size = 258375, upload-time = "2026-10-22T16:04:30.647Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e7/4c1d8eaff4d756fefd15d58c4953a78ab656782fd0e32c5a3465dba61aa0/coverage-7.17.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db645c81cf4d6bcb392ea1894aad65d55d1588a8472c834b25ca575da3029a56", size = 262422, upload-time = "2026-10-22T16:04:32.053Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2a/0b17c3e2e7b6ff1a5d92eb58915c0d1026668928f961d14dac73cf1b6096/coverage-7.17.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6a80a11b90c4a03df1502304cf24ff953255b6b5d076ef79066ecf88baaff31f", size = 257875, upload-time = "2026-10-22T16:04:33.824Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/eb0cbde0535342df0d17a667163030a3afb67e5eb9e3ad94af9540041584/coverage-7.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e4eaf11d6b0f2147f46cfbf06fe7edcca6f095d0120da00ddc2458995ee869fc", size = 260361, upload-time = "2026-10-22T16:04:35.661Z" }, + { url = "https://files.pythonhosted.org/packages/2e/47/1be2a6db7eb655d0a1d899217c3f2469f9121cbbeddac35c1c8173b1482e/coverage-7.17.0-cp312-cp312-win32.whl", hash = "sha256:912a29f338798541d795e2e25903c6fe7a99dee70a40fe4706a9a124649b523e", size = 227747, upload-time = "2026-10-22T16:04:37.102Z" }, + { url = "https://files.pythonhosted.org/packages/11/c5/201c894c57de81875d8b8cc527ba222aa350e8cfb183eb3f6df45d06ad8e/coverage-7.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:88a1a9fb0075b83389dd9b403bb3fce976031b3c2aa0e05e3e57143cf639de69", size = 228325, upload-time = "2026-10-22T16:04:38.989Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/196095339b19758cf83f507fb598bc42820ae124745feb3084662729e17a/coverage-7.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:2c680e838ff9e68d491158625ca2c1ddb17d78d791bba96a31f8b839b4990438", size = 228131, upload-time = "2026-10-22T16:04:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/e6/1f01afbc9f4fa8ca5e1fa6fdf537a5c58b5765d1b4167ee284cddb9acc72/coverage-7.17.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:84538c040f4e6214f663459d89372ce4e6b2bbdd9d397ef0dca7b3ecd56b8eb2", size = 227080, upload-time = "2026-10-22T16:04:42.325Z" }, + { url = "https://files.pythonhosted.org/packages/92/7a/bdb7e6260c4a211389aeac9b89dc31662ebe122153ad0ecfd156897912df/coverage-7.17.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:6abfb6a4cf1cb1b67bc8fccdde177e2d2b781d0d34449e7078156547025140e5", size = 227152, upload-time = "2026-10-22T16:04:43.737Z" }, + { url = "https://files.pythonhosted.org/packages/37/4e/276e7d5b3cfb124871bdb544c27b004a8b4e270e6bf8e7414149c54b5e5b/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ef65e96f0fc1a7c8b7644c7011d47a8228555e7888ac4d6246f715a7f3d7c398", size = 225397, upload-time = "2026-10-22T16:04:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7d/dac073db12f3c131598a6693253aded581e4d7d622a89864f11bfb0ca2f5/coverage-7.17.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:55939ea4c097fda2edabf80fac5e49bef15597b950a9f29fc56292a895245feb", size = 225705, upload-time = "2026-10-22T16:04:46.603Z" }, + { url = "https://files.pythonhosted.org/packages/00/45/877b0a9248e921080ae91d115c13192d17e308f5aee86bcc7f7bc2a54ed1/coverage-7.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bfb6f868af552c0ceb2d19220b8cbcc2be096ca26163ac30e682a13041433429", size = 225828, upload-time = "2026-10-22T16:04:48.065Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f5/2c48d47abec0e4590b302920334be5ee3854f96bbd22524feabb8d92b220/coverage-7.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8d823d66afd59566030539c49c7a6acaf3083996682be2dda760c63555842094", size = 226088, upload-time = "2026-10-22T16:04:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/ec0442f3f372c7fd742f49b3022edd392f79e37a0048e1fa333f56d01ec3/coverage-7.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50678474e714c684b82c108e63fbb8f1bcf1266d8293a49980f1d08526f093b5", size = 257257, upload-time = "2026-10-22T16:04:50.982Z" }, + { url = "https://files.pythonhosted.org/packages/92/e2/eb0927ba93809f76eee69fa1e47999825fc7dbbabc3e0e1a2744f2677acb/coverage-7.17.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:045c1eeb19fb4eae949850a761102fffb3a5f638b97f977912f95843e54f6669", size = 260165, upload-time = "2026-10-22T16:04:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a2/8d4c6055efb32099b15c9cc33d007b8aabebc4832f372a3274de5ba0afc5/coverage-7.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb12891a773cbc2132349c77c690a045a882fe3f98b4bda50dfabb01893f9bd", size = 261098, upload-time = "2026-10-22T16:04:53.763Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/93412288688a7b03f152a734c8ef086e395108a0a6a746985d3967e220f2/coverage-7.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3067608b4429629f640c1b23d2f69b05d2fd45e00ebb6ee9e8fad220a6c11722", size = 263164, upload-time = "2026-10-22T16:04:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/3c/20/7eb8d805c4f1a212245cef20181dfbf39535357985073d49730ee9cf08d4/coverage-7.17.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1762e85cbbe3b5ff9c6dcbcc1b89d5c72320094292b7b9572bf882c85e428b7", size = 257287, upload-time = "2026-10-22T16:04:56.976Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/1df29630b4ed17e135c76390afcc6bd273c3dc5ba2a21a76813a600fe98c/coverage-7.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62bb77987d3f72a0aaa77c567ee7f9806bc2e748c8f46fafbe1fdb90dd148398", size = 259256, upload-time = "2026-10-22T16:04:58.545Z" }, + { url = "https://files.pythonhosted.org/packages/a1/79/2365d1008d28aafe468a450a24c6c63363de794b5140223d67685cd0e205/coverage-7.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6cc31f27240d4e080437e3b8229d92f1d809c9a4f233607385373b73974e183e", size = 257178, upload-time = "2026-10-22T16:05:00.103Z" }, + { url = "https://files.pythonhosted.org/packages/c0/9f/f002952d295d772688dffce0d991e3822795b1e3aad158073712a5a5e86e/coverage-7.17.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:61cde5222f1933d476647c5a25ed1a8678b7703098cb5a15f4a0aa39967d8c28", size = 261275, upload-time = "2026-10-22T16:05:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/40/fe/f9e15fbb426be97cd679ee5d359151e046f103bcd76b5092c318c26ed596/coverage-7.17.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11f5f7fb2d97a0fe865f5e7bad4eb0619b957637f676bc088d13844adf26ba95", size = 257029, upload-time = "2026-10-22T16:05:02.8Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4a/3c398f38694387334ff4c9f71afc8e15b4fa7db7bc0bea4d5b6032eb0df0/coverage-7.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e09612b1b82c9df1d2b6c85dee88d5aad3e73d77a8ec0b16ac6a7322cbdf5d9", size = 259109, upload-time = "2026-10-22T16:05:04.241Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a9/908a3cf1a2335aec1c788682a06c2f742a62a4f987033f080de1c5fd1997/coverage-7.17.0-cp313-cp313-win32.whl", hash = "sha256:bd3e9eb493631ff1ca34bf5efac25b5c66724f837bf6182c4fc771c55946423d", size = 227691, upload-time = "2026-10-22T16:05:05.636Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/8cdd7bfd8a63b3e6b133fc395ecfc7b1cb6f561926e7e2ad050de85b83e7/coverage-7.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:f1fc9a7cea899c51d8a53e66c2f082a3d9e461ece32418d3ef0a0b90c32bfd3d", size = 228418, upload-time = "2026-10-22T16:05:07.057Z" }, + { url = "https://files.pythonhosted.org/packages/09/cf/0b3fc57f735cd3831ca13d0be5312a5477d5934066683c5d34018cab7541/coverage-7.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:e65a1e61a68493fbfbe44d8bec71b3231d81b9917f62d3dafe0e105072147e83", size = 228142, upload-time = "2026-10-22T16:05:08.435Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/722462fe79912ebebc101497bd16c42413cde2dbae771017f1ec0acb84c2/coverage-7.17.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:dc5936491e306f2f6f095bd5fba593de11eb0375cee787ca1f245c3a333a523a", size = 227197, upload-time = "2026-10-22T16:05:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/b7/74/826b9c09f2ebfea803bccb391d935688a25309bff8e819fc446bc629c108/coverage-7.17.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:968c5fb6e0c83cab752d79a70209648ce4d90cae815c60fa229b59e264601062", size = 227314, upload-time = "2026-10-22T16:05:11.422Z" }, + { url = "https://files.pythonhosted.org/packages/6d/19/c8514d7f5e0659a96ff2bc7377119dca6d420e2761c5fc69ae37618734f8/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:12cb8a20ad904a5811e6660d829a586b216e5254b9b34b76dde8a4d65d827dc6", size = 225477, upload-time = "2026-10-22T16:05:12.761Z" }, + { url = "https://files.pythonhosted.org/packages/81/74/ef9433ce599f07d8633b4ce6e6d6921505c4ef3f7f9006f8eb66e2c278cf/coverage-7.17.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bb5ca66937c55bc85d1a5e9c5900a3e4693a8edf2280a9b3672b7b2661a84b95", size = 225787, upload-time = "2026-10-22T16:05:14.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/26e58981940e8b3944c6a9941bec5c7f50840e7009a046712e3f3bcd9e6a/coverage-7.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5cc15faaf318ee5040d835a62c75bc6f122d837274f3b5efd47bf9fbd4ac5b85", size = 226019, upload-time = "2026-10-22T16:05:15.798Z" }, + { url = "https://files.pythonhosted.org/packages/e9/63/fe175310815cc8357b83b773c9b4c20943c269f217dd825a37c4b4bfe324/coverage-7.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d6d9d396c8b7bcaf76d774a10140b218111484e54c73a5f2a1b75655381748e", size = 226169, upload-time = "2026-10-22T16:05:17.188Z" }, + { url = "https://files.pythonhosted.org/packages/2c/b0/ac84761f5b4bd226696b0ec4606903d2ef7208abda0aec06c14a4218def4/coverage-7.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:42e6f170d1ac04b7eb3363310a6c41cdfd82c1fe6bf9ffbc4a86f380bdf091c7", size = 258092, upload-time = "2026-10-22T16:05:18.691Z" }, + { url = "https://files.pythonhosted.org/packages/e9/58/d8bdd65a661913bc12eaaa3e59742b2cb8b94f2e7f78e62184f0a58ca493/coverage-7.17.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:acdb5f3c96046efff9dc6c572a6acb2b8d0da09372d7b9a9ed7249cf7d17434f", size = 261096, upload-time = "2026-10-22T16:05:20.089Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/5d82e2f0a75ea149e0a6412e137e1d610538772fdfd7199737db7fe234da/coverage-7.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3393209f6894713161ee82a9b0e673e58237e0f9733c6638e7a6c0c620ef0b33", size = 262080, upload-time = "2026-10-22T16:05:21.568Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cc/e3ebfd295d1dd47c7bc84a17d73f150465a37ab388dd68fe82b495dc79a0/coverage-7.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9494d9e99389d39e6a96227749af898d8924ff591cb63944f8a98e68582bf0e", size = 264558, upload-time = "2026-10-22T16:05:22.906Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9d/e2adff76d6544bbc6df7366d74967923c2de37ba889a006bfb875bf5857b/coverage-7.17.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:032d4c2db4c677eb71d43f2f8b80bf64e9346f7a3f7b6ebb13f1782f3cc6c274", size = 257855, upload-time = "2026-10-22T16:05:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fd/ecd1e9cddf1c36b5d03c438568b1f53e63db84bb3656539d71b454484c7d/coverage-7.17.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4343a9378b964eb3cb80fa90406385de91c7e982a769ca9314d848f93bc0d3f2", size = 259933, upload-time = "2026-10-22T16:05:25.79Z" }, + { url = "https://files.pythonhosted.org/packages/40/b6/e17a19dccdbcad494c64691f71c1dba3e55c30625bc29ffbb88ac5a0aa8b/coverage-7.17.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b609df3d6d900464207532c5170a65c6ede8d2e0b91f151f90008a866395cd76", size = 257972, upload-time = "2026-10-22T16:05:27.185Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0d/37dac534709a3fa43a937c8b0604839bb44b8670b83d143a4b78fec20d1e/coverage-7.17.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a01ef92caeedfa4f2ad171a3a8e6fece4565de871d8bca428151bf9929f41048", size = 262271, upload-time = "2026-10-22T16:05:28.619Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/7c05186925b4d64a46c271dfd944e3f0c1ad89db9095c31a6df157a75df2/coverage-7.17.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a3914e2c98c34b94c26d2add3d2a61ccfd3534fc4eaa879ee290bdbb5184ba6c", size = 257604, upload-time = "2026-10-22T16:05:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/95/65/c241cdee66a0ff86c68f6d37328e735d10e3d30cd6ff66f356039eb688d2/coverage-7.17.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8a72b26dc5c9a9422e45472b1d67ba5caf273c524c8668cc04b23062663755e", size = 259714, upload-time = "2026-10-22T16:05:31.402Z" }, + { url = "https://files.pythonhosted.org/packages/db/09/e93233fe2405574ce0a31ed201c0cba3a860e085db5d2828bf12b7dc0790/coverage-7.17.0-cp314-cp314-win32.whl", hash = "sha256:03baf0688e14a5a44ffd1eefa8928dfc92b14565e521174e072b31b971aa1657", size = 227914, upload-time = "2026-10-22T16:05:32.78Z" }, + { url = "https://files.pythonhosted.org/packages/d7/77/04979eb217edb94b94afabb4bcc179f60c746f68d3fd0b12688ba9195730/coverage-7.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:3a4459b0f389dd1e51aac3dbfc4ca35b30cae7dc99caa3c0a086d1997a985709", size = 228543, upload-time = "2026-10-22T16:05:34.118Z" }, + { url = "https://files.pythonhosted.org/packages/a0/38/155f2f2c6769f8c43eca8a5e581443faa0908828650090b97103b4a41376/coverage-7.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32ee5831b15ba239bef5d272ae586ca8acea4a661b5f2941f5a0d380e518b23", size = 228396, upload-time = "2026-10-22T16:05:35.951Z" }, + { url = "https://files.pythonhosted.org/packages/52/55/d6751572d9445485c4c5a51ec6ae11caa5c11b7ef2448cdccc884f7f54d9/coverage-7.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9c083e253e96fdeeaf48133fe836cac86ace2db215d235700e7f7532420a200c", size = 226486, upload-time = "2026-10-22T16:05:37.363Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a8/82d37760e537bfdb9fdee285fa1e1b45bc153293d23e2331c7befedd1990/coverage-7.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b88fb4d4d7608cbcebdc7ff7dbffc2115e3b81d6c33a79147cd79d54fcb080f4", size = 226861, upload-time = "2026-10-22T16:05:38.777Z" }, + { url = "https://files.pythonhosted.org/packages/24/96/70ec00a9db6d7bdd3f914eba12057360769f5cb78a98456e71fe1937deb5/coverage-7.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2bf3ee38a40c0570c316b9e7507c472469586906a20f289705898528c31aaea8", size = 266849, upload-time = "2026-10-22T16:05:40.285Z" }, + { url = "https://files.pythonhosted.org/packages/77/d8/d99be3bfbb208512f039c80b31e404138a86678fc929a17acd5b7135a6ae/coverage-7.17.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7c570293f001ea91598b90e90cb9b98fddee833e90bbede432cbd218331ccd8e", size = 269402, upload-time = "2026-10-22T16:05:41.585Z" }, + { url = "https://files.pythonhosted.org/packages/82/50/6cbe8b5a5060edbd21ce8109c4e900f8b7656e8b72ede6dc4d82dc0b8bfb/coverage-7.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:791f9ee09814dd30d9562b4f4eb037a371485ec1c830b53999d2320d20c4e764", size = 272025, upload-time = "2026-10-22T16:05:42.921Z" }, + { url = "https://files.pythonhosted.org/packages/bd/05/d3633add01e5518dfdacfdbd1be41cb5c581412dd8bccd663edf6ef7fec8/coverage-7.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7c5672ecf1b497de7a0b37743bef40e3b58e3207e9fe2ed6682cb0420836a88", size = 273515, upload-time = "2026-10-22T16:05:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/57/d9/b83387780271bf9ea9f96a3aeb01c1c302c4494a927923c4fa70cd170a3f/coverage-7.17.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:337f87e62e1c5f43c8198d879f677c342d152f84d3f157add12558853cbb8eaf", size = 266725, upload-time = "2026-10-22T16:05:45.713Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/8e80b29764ae3f4d70d44952eee3f3927eb36897490fe799e400adb7f5da/coverage-7.17.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c8a58baa8eb6154c6645eadcc19b6c089deab78c24a612a83206f2a8a5992a7c", size = 269312, upload-time = "2026-10-22T16:05:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/e702f588fe8d83ca844f133270ad0a49bf650c8cf260551ff6f11a6b6bbf/coverage-7.17.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fb5745048ee20f796b0e3ab61831049e749acff188563e2af32f58ede957ac1b", size = 266740, upload-time = "2026-10-22T16:05:48.343Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/ee13ad14b714d75ef07c490b6e43969574a8b8b8c6d67bc741fcbacbf9c3/coverage-7.17.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:7b5abcae74750302ac4bc77351eaee87c450e4abd3b6225eacde3858e260f8b2", size = 271036, upload-time = "2026-10-22T16:05:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/e2/bc/854e8912c11c54aaf416e74602000589c6efbbe1f251dc90202fdabddcb6/coverage-7.17.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4e153f0bd6509dbf837fd585f489b3328bfd98f2a718a0b15ae63032148b285c", size = 266158, upload-time = "2026-10-22T16:05:51.141Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/96432830ddf63bc42654de54eee77a0cd6949cc65769bda06f353323f23a/coverage-7.17.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e42a835271922fe7bf98c7a05c78d4032f4e52bf5b07fafa1eadfdfc2f0ad22f", size = 268088, upload-time = "2026-10-22T16:05:52.92Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ee/230c6ec065a6629d41cc89323ceb54d37e2650c104f70fc108f4ee7c5051/coverage-7.17.0-cp314-cp314t-win32.whl", hash = "sha256:5900055351ec89fdffb34b27898815106529ded29a9496464c1ebd6313fd40fa", size = 228229, upload-time = "2026-10-22T16:05:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/f0/1d/23eb6aadcb3c00764ff2961c0ff55f5dfa2543856d932b949bbc04fa68e9/coverage-7.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5ee9c8bd13a59e301dc2aefe7aa8384633406808de051b1982775667094eb673", size = 229154, upload-time = "2026-10-22T16:05:55.682Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/7df117e8e016b7a3ee459e833b3456f3610505afefd071f8727702d98439/coverage-7.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a9cb9a0fb58e6fff5442969af36094ecd6976f4907f179e33b27e15355d8f74", size = 228726, upload-time = "2026-10-22T16:05:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a1/f9c24911755f91e6f451ca2e2c1804fbcbe9fecbc3310e341bbbbfa1d85b/coverage-7.17.0-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:adabc1079ba844b096799ba8638d8df434aca1613f95bb7d5791af320cf05559", size = 227196, upload-time = "2026-10-22T16:05:58.543Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/2b665b40c626e6bb40e5e8e23d91170b48b5f2f57186f46b07f2fac42386/coverage-7.17.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:6b4593d2871c4c801656dd5861e759f8f5156febd6b6061b02f6a72d64618b7b", size = 227315, upload-time = "2026-10-22T16:06:00.409Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8c/22b518d61b930b8a4d62905d046c76a03675ef19b466462011dfb16439a3/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c2c48a36bb970b7265b196032bfd4237c0b373f2b25ef7012d7e905b5b35c19c", size = 225490, upload-time = "2026-10-22T16:06:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/08/5b/d3b141455944e030cd4987f055f8c84a44a1a0b28d81ae93389f44051691/coverage-7.17.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f8bc2cd4c6aa58be1ca243fb8cddb6c3c42031fe4403991ad9cf2b3baed03d9f", size = 225797, upload-time = "2026-10-22T16:06:03.423Z" }, + { url = "https://files.pythonhosted.org/packages/03/24/aab2156983f7022361352640c583d08f0238e4cd7463eb921172001b4cfe/coverage-7.17.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:cbf0b83ce927de6053279ba61342d9f8892afde1385fcba9144cd05d4023d109", size = 226025, upload-time = "2026-10-22T16:06:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/db/94697b9ccf4ec94cdad07bf94e2a21b9702247cb212d2d5f18d2b9c3d0de/coverage-7.17.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:66efc0fb99cee34626fc17d3adac9c3cd816bfdad563bb1e983c3ef432f566d4", size = 226193, upload-time = "2026-10-22T16:06:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/a0/fd/db1c4ade4219831d590fff5aa5b7d9ceaf84586e8e60da6fff71b6182251/coverage-7.17.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:58a304efdf2253fb2ea03d45d0a04cc174a92a0cd5279c28c5bb893ce7410968", size = 258046, upload-time = "2026-10-22T16:06:07.51Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/84ed138990ed752abd73db4adffc1842d6152017d8e21809cc109a413fda/coverage-7.17.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:aed06129a9f530a948928260d7d2d81c47261355b0127e3c782251b38958463c", size = 261735, upload-time = "2026-10-22T16:06:08.861Z" }, + { url = "https://files.pythonhosted.org/packages/64/ad/c1e4367bbd1e7d562d6bc979c82b542f2040544e95a8cd995b694caf0e1a/coverage-7.17.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6cdb47a9be2d3313829bed1d296086287682a3ee901ff987b3432b45705b771", size = 262819, upload-time = "2026-10-22T16:06:10.49Z" }, + { url = "https://files.pythonhosted.org/packages/b5/3a/e8e8509b0940403eab15d86f70022790a01fce3e971a161919c544953f7a/coverage-7.17.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:804fbaf81d09b34e2f8b1159d01e211a50713d743d32fd9b2ca6cf0ae507a66f", size = 265194, upload-time = "2026-10-22T16:06:11.847Z" }, + { url = "https://files.pythonhosted.org/packages/1f/77/d64bbdbb734b1059ebcde2b5df567f142a9948079e51af9b0abf8c9ec17b/coverage-7.17.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2afebb96da9a1bbca142f8cc12320c14e5c215e0a49cded4f191f5a31506a28", size = 258410, upload-time = "2026-10-22T16:06:13.234Z" }, + { url = "https://files.pythonhosted.org/packages/e2/29/7cf81f08a254532c074eb52cfe6b12bb753d08f2997269c9fac8de359541/coverage-7.17.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd04fd23206be9ad3de13b0b39313094fafa66a1f6baf70f5471c004a9fac47d", size = 260697, upload-time = "2026-10-22T16:06:14.595Z" }, + { url = "https://files.pythonhosted.org/packages/7f/56/bacea6dee2d5ccb31ab0cfffcee87ca7efa80ff048840209c8013aeaf02e/coverage-7.17.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:5ebfbc87836529a31d25c2dec1262c38eea852ec1c46c0f690a515a7339d9c5b", size = 258649, upload-time = "2026-10-22T16:06:16.265Z" }, + { url = "https://files.pythonhosted.org/packages/a4/97/fd8a6a63740f9ee177c53b59e2f4d0752369bb0fd154086a1e69aa1fb255/coverage-7.17.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a59c3b470ac3b2432af3598a996affbeb10236cd899a005c1c4e517fcff8b592", size = 262989, upload-time = "2026-10-22T16:06:17.85Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/7cd3d3520d45db3788456452b65446182c08f2a7c276f40c9dd11966be02/coverage-7.17.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:7fef12cec4b96f59e09761cbd4fc9bdf4788062d1ae435b920241fc16c8a9549", size = 257955, upload-time = "2026-10-22T16:06:19.563Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b9/80343a55e07d854927a915cc21a52e350ae1db9ead33f0248507b960785f/coverage-7.17.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a14e8d69464da9cbe1dc29e7116dbd37296d1d51a695327c478e250349085089", size = 260512, upload-time = "2026-10-22T16:06:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/e4c816f0cb3efdc6392bb276ff88359729f9dec605bdd3c1c7a44776f759/coverage-7.17.0-cp315-cp315-win32.whl", hash = "sha256:9a66074231799d8884d267234d49f60c90b43a945dfe069646b72f3058cd4e67", size = 227913, upload-time = "2026-10-22T16:06:22.407Z" }, + { url = "https://files.pythonhosted.org/packages/43/14/283120142ac281834808584249b89957de997c28573ddd65ae106b622121/coverage-7.17.0-cp315-cp315-win_amd64.whl", hash = "sha256:79cf71e5f9a48877ccfed5baa2c72f45af4feeaa596249e23eb15b473134830e", size = 228532, upload-time = "2026-10-22T16:06:23.756Z" }, + { url = "https://files.pythonhosted.org/packages/b9/bc/83dbc6c3bd58042afaefcb0cfd7d7a2e3076989a2f3d9bc6e6edac819784/coverage-7.17.0-cp315-cp315-win_arm64.whl", hash = "sha256:09e563b49c81cd0a33734e926d7a4a6ef1778d74304cde2dcc3aff8b30ea1036", size = 228393, upload-time = "2026-10-22T16:06:25.163Z" }, + { url = "https://files.pythonhosted.org/packages/00/ed/9463d1cb1766a2570343a372c2871c7fb4d561e5051f029399f4d57ce947/coverage-7.17.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a5e2e0625dfc789af0b2e10acfc106b352f314fffbd19e6392a7961c8cbe5d9a", size = 226481, upload-time = "2026-10-22T16:06:26.563Z" }, + { url = "https://files.pythonhosted.org/packages/ae/59/6536c7fa87be13236b4184fc395f14d18f622a5b8ef7448a6bfdf5f24ec0/coverage-7.17.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:194562ab1eff0d85aee8a17ce4c5c0bc1ebf70a629a7145257123156ad100fd6", size = 226876, upload-time = "2026-10-22T16:06:27.921Z" }, + { url = "https://files.pythonhosted.org/packages/b0/bc/4ffae6038f78382c9ab6a4969cb5fc01d50b083d119851085807629abe88/coverage-7.17.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:85682ca89bc4601125dcdff23fa70e6274e13c9ad502fba845e6c274ab2c6569", size = 267241, upload-time = "2026-10-22T16:06:29.356Z" }, + { url = "https://files.pythonhosted.org/packages/c0/60/cae4b708b4217d33ebf66b9c57f8910a257346b3eaf054a78d73605ddc4c/coverage-7.17.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0d0fac604a962ff546beb038cba9a5a09d88e6e3f01612fb46f7bd4fe5dc8dca", size = 270079, upload-time = "2026-10-22T16:06:30.736Z" }, + { url = "https://files.pythonhosted.org/packages/a9/84/1225ab04e55a6a2e483e903a5eb0f8f081cb5325df833594534aa288d91c/coverage-7.17.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72edd4f9483ae30cd1ba4f230687840ef7b58a16afb1c9e81131fd6d64655f36", size = 272775, upload-time = "2026-10-22T16:06:32.247Z" }, + { url = "https://files.pythonhosted.org/packages/94/ad/567f8dd9246a553aa8753732c41f0e0654e98e9a617d6282e64eae0e6aa8/coverage-7.17.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91e487ad07c511da77b574820141a7c2da09bc2a4b4090b5be2424295d100697", size = 274110, upload-time = "2026-10-22T16:06:33.859Z" }, + { url = "https://files.pythonhosted.org/packages/66/5f/e0ee76895df40601e4aedffb6b8e71ea48e276a629fb4488b92cffffca14/coverage-7.17.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1cabb31531002fef5197de5df3c84730aef01e750e25154b9813a2583435aba2", size = 267775, upload-time = "2026-10-22T16:06:35.193Z" }, + { url = "https://files.pythonhosted.org/packages/19/6d/d19c6e45e940e5b9ba697b7be9a8e63cef73d90bdea45b670a59df466717/coverage-7.17.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c9ac886242bbd50fb35a2242ba79555a99891e657da33f98b7ca968e3488a350", size = 270268, upload-time = "2026-10-22T16:06:36.597Z" }, + { url = "https://files.pythonhosted.org/packages/27/a3/b2731bb7aef2db800b330603675c3fa58a6497a05d5b5f8625f3b6285f68/coverage-7.17.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d45cdc495671fc99268ff3653e0da4244972e9fc0e1fc3ea9de18781ad28963a", size = 267298, upload-time = "2026-10-22T16:06:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/d0c90a81b6b6607933109914dc8b0ef0a5f4cacf9482ca868ae773ee5d0b/coverage-7.17.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:12961f738d717a046d4bf3f8d1727eb3ebe2ed5478ff96cd6233d4dfc39323d3", size = 271899, upload-time = "2026-10-22T16:06:39.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/1e/f038f3a2614e9ef839a40a184713aca40b8ed79e626ab5cdc388771045df/coverage-7.17.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1ee0a982b0e1230b3edf768a7dd022bd2377303763895327ac62deb5efacee03", size = 267380, upload-time = "2026-10-22T16:06:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/30/8d636e639b2d8b1934128573402a7caf66c8cfbc794fa02061d84f115c34/coverage-7.17.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:460679f53893081edb94eeed011d26be37228ba649b853fd2aed7e4394fb1f35", size = 268993, upload-time = "2026-10-22T16:06:42.361Z" }, + { url = "https://files.pythonhosted.org/packages/73/7a/219f6b391cb0636242d06c390a8add5af91afa3e92afd0380dbedd34f487/coverage-7.17.0-cp315-cp315t-win32.whl", hash = "sha256:600e121e412cd6bdb52ec105b95c6a04da0c9710d1534f6e64a42db667907802", size = 228216, upload-time = "2026-10-22T16:06:43.714Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3f/514d294319c5eac11494394545de209079f33993e14526c220eab29b2a96/coverage-7.17.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c3e11b81faafb1f063248735c3e2c5c4183fc1db2c7f46782170dd7760dffcbc", size = 229146, upload-time = "2026-10-22T16:06:45.262Z" }, + { url = "https://files.pythonhosted.org/packages/f6/82/c0d8bb0a67df838fc7bc6f4060ffc404fd1154fd18e624d83e6abc41aeef/coverage-7.17.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d472ee1c64868a917a47f02ffb4ac8c22b477f7589d57a3eb0c58b1ad4f0137c", size = 228716, upload-time = "2026-10-22T16:06:47.097Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/48032919343afa2906112d458aceda2043afdb24a2ee8eb4c60904d8ddf1/coverage-7.17.0-py3-none-any.whl", hash = "sha256:bac487dce0d2aff28ddf1a533a916f771416eee0e493960f611e49037b878e9d", size = 217383, upload-time = "2026-10-22T16:06:48.471Z" }, +] + [[package]] name = "cryptography" version = "50.0.2" @@ -237,40 +361,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, - { name = "h11" }, + { name = "h11", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version < '3.12' or python_full_version >= '3.14' or sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/61/afa9d6b7452f9eca73f73d45324eb0bb52f098fd5e6049bf6b8263be9b4e/httpcore2-2.16.0.tar.gz", hash = "sha256:c72cd0413563b17e1cd8354677dff887466f8ec2551679759e93126534428643", size = 69886, upload-time = "2026-10-21T07:09:45.31Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/ce417c397afa5181f0e3c6ff01a4d01639414e1adef08730d97571297151/httpcore2-2.16.0-py3-none-any.whl", hash = "sha256:5c4358319f2602d984ad93182c9fee5160f22e9da7babdda870a5e7c4e2dc34f", size = 85070, upload-time = "2026-10-21T07:09:42.802Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/73/5ca2cedf5e4e50423d1c258877865a6b62793633b561c58499f8aeb33b7e/httpx2-2.16.0.tar.gz", hash = "sha256:0d54c98128e8f2ff7fbbde0fca8c359e5f032edeaaaac443cdb7f31f685bcd7f", size = 103116, upload-time = "2026-10-21T07:09:46.348Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/7b/3d/2e4bc4fd1ef9770871669adb348d612a6e4786b303a23f9935b81c48dd60/httpx2-2.16.0-py3-none-any.whl", hash = "sha256:fd2c5d7909e8d86fe92d53020c310ca0e4e431b838c207c5754b9bbf0308d49a", size = 97880, upload-time = "2026-10-21T07:09:44.01Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.3" +name = "httpx2-jsfetch" +version = "1.0" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -320,15 +446,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.30.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -338,9 +464,34 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/93/0142dc84a666daf8ad51a34268f34c12fd6fda4f3810c4be2504eecc8212/mcp-1.30.0.tar.gz", hash = "sha256:445414625fce5c295faa505bb11bacece661ab6f4028d57c935db57820b7a3e4", size = 680511, upload-time = "2026-09-07T14:34:15.845Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f5/f4/e58bc33317c92a0203664daaf00bf6f41166cc0149e5d6870a03f7cd004a/mcp-1.30.0-py3-none-any.whl", hash = "sha256:666edb5009503e1047c9d60346a756f94b261f05cc2625f23d41c728ffc484d0", size = 234581, upload-time = "2026-09-07T14:34:14.266Z" }, + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, ] [[package]] @@ -487,21 +638,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/0f/1c34a74c8d07136f0d729ffe5e1fdab04fbdaa7684f61a92f92511a84a15/pydantic_core-2.46.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b00b76f7142fc60c762ce579bd29c8fa44aaa56592dd3c54fab3928d0d4ca6ff", size = 2184144, upload-time = "2026-04-20T14:42:57Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.16.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/ca/7817b4bf0c4b15c6a6ab8cdf4cb96c268335a8ca2f525de93b6ebe4c7d28/pydantic_settings-2.16.1.tar.gz", hash = "sha256:aff602a1886338c2df3d1e958e2193a8dc33cec75b91f202e83fd98484b4b0da", size = 303436, upload-time = "2026-10-20T11:37:19.024Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/74/fdf926afe145d8629c4b2ff57f4ea1d64ed7abc4d84d97463b2517a2547e/pydantic_settings-2.16.1-py3-none-any.whl", hash = "sha256:30f5313340da4e8f1b440d1910b92a5bc3ccbb6b4ba70ec2b8395bc921f418f5", size = 80771, upload-time = "2026-10-20T11:37:17.298Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -542,12 +678,17 @@ wheels = [ ] [[package]] -name = "python-dotenv" -version = "1.2.2" +name = "pytest-cov" +version = "7.1.0" 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" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] [[package]] @@ -726,6 +867,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, ] +[[package]] +name = "truststore" +version = "0.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"