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 2fa0666..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,7 +9,10 @@ authors = [{ name = "Matt Brooke-Smith", email = "matt@futureworkshops.com" }] keywords = ["mcp", "basecamp", "model-context-protocol"] dependencies = [ - "mcp>=1.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] @@ -32,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 a3e5181..72c57e2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "annotated-types" @@ -35,7 +39,7 @@ wheels = [ [[package]] name = "basecamp-cli-mcp" -version = "0.7.1" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -43,22 +47,19 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "anyio" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2" }] +requires-dist = [{ name = "mcp", specifier = ">=2,<3" }] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, +dev = [ + { name = "anyio", specifier = ">=4" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, ] [[package]] @@ -152,6 +153,95 @@ 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.15.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, + { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, + { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "47.0.0" @@ -221,49 +311,41 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -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/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } 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/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, + { 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" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } 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/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -304,15 +386,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.27.0" +version = "2.0.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'" }, @@ -322,9 +404,34 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" }, +] + +[[package]] +name = "mcp-types" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, ] [[package]] @@ -471,20 +578,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.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/98/c8345dccdc31de4228c039a98f6467a941e39558da41c1744fbe29fa5666/pydantic_settings-2.14.0.tar.gz", hash = "sha256:24285fd4b0e0c06507dd9fdfd331ee23794305352aaec8fc4eb92d4047aeb67d", size = 235709, upload-time = "2026-04-20T13:37:40.293Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/dd/bebff3040138f00ae8a102d426b27349b9a49acc310fcae7f92112d867e3/pydantic_settings-2.14.0-py3-none-any.whl", hash = "sha256:fc8d5d692eb7092e43c8647c1c35a3ecd00e040fcf02ed86f4cb5458ca62182e", size = 60940, upload-time = "2026-04-20T13:37:38.586Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -525,12 +618,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", extra = ["toml"] }, + { 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]] @@ -709,6 +807,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"