From 662d46d36b38f1c2918b9765ad1f4863dec68a05 Mon Sep 17 00:00:00 2001 From: Davide Casarin Date: Thu, 6 Aug 2026 09:47:52 +0200 Subject: [PATCH 1/4] Pin mcp<2 so uvx installs stop crashing at startup mcp 2.0 removed the low-level Server decorator API (@server.list_tools / @server.call_tool), so the unbounded mcp>=1.2 range meant every fresh `uvx basecamp-cli-mcp` resolved mcp 2.0 and died with AttributeError before serving a single request. Pin to <2 and bump to 0.7.2 so the published package installs again; the 2.0 migration follows separately. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 7 +++++-- uv.lock | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2fa0666..781d95e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "basecamp-cli-mcp" -version = "0.7.1" +version = "0.7.2" 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", + # mcp 2.0 dropped the low-level Server decorator API this server is built on + # (`@server.list_tools()` / `@server.call_tool()`), so an unbounded range makes + # `uvx basecamp-cli-mcp` fail at startup. See server.py. + "mcp>=1.2,<2", ] [project.urls] diff --git a/uv.lock b/uv.lock index a3e5181..785f8a0 100644 --- a/uv.lock +++ b/uv.lock @@ -35,7 +35,7 @@ wheels = [ [[package]] name = "basecamp-cli-mcp" -version = "0.7.1" +version = "0.7.2" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -47,7 +47,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2" }] +requires-dist = [{ name = "mcp", specifier = ">=1.2,<2" }] [package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8" }] From 98c395fcccaaa2b422b340a7528c1b593111d59d Mon Sep 17 00:00:00 2001 From: Davide Casarin Date: Thu, 6 Aug 2026 09:56:13 +0200 Subject: [PATCH 2/4] Migrate to the mcp 2.0 low-level Server API mcp 2.0 removed the @server.list_tools() / @server.call_tool() decorators and replaced them with on_list_tools / on_call_tool callbacks passed to Server(), so port the two handlers over and require mcp>=2,<3. Behaviour is preserved: tools still advertise the raw JSON Schema from tools.json (types.Tool now takes input_schema rather than inputSchema), and a failing call is still reported to the client as an error result carrying the CLI's message plus stderr. That last part is now explicit - 1.x's decorator wrapper converted a raised exception into isError:true for us, and a raw on_call_tool handler would surface it as a JSON-RPC error instead, so the handler returns CallToolResult(is_error=True) itself. Same convention the SDK's own MCPServer uses. Add end-to-end protocol tests over in-memory streams. The old suite only covered filter_specs, so it passed while the server could not be constructed at all - the new tests do a real handshake, tools/list and tools/call. Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 11 +-- src/basecamp_cli_mcp/server.py | 91 ++++++++++++++-------- tests/conftest.py | 6 ++ tests/test_server_protocol.py | 136 +++++++++++++++++++++++++++++++++ uv.lock | 128 ++++++++++++++++--------------- 5 files changed, 273 insertions(+), 99 deletions(-) create mode 100644 tests/test_server_protocol.py diff --git a/pyproject.toml b/pyproject.toml index 781d95e..c436b22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "basecamp-cli-mcp" -version = "0.7.2" +version = "0.8.0" description = "MCP server that wraps the basecamp CLI." readme = "README.md" requires-python = ">=3.11" @@ -9,10 +9,10 @@ authors = [{ name = "Matt Brooke-Smith", email = "matt@futureworkshops.com" }] keywords = ["mcp", "basecamp", "model-context-protocol"] dependencies = [ - # mcp 2.0 dropped the low-level Server decorator API this server is built on - # (`@server.list_tools()` / `@server.call_tool()`), so an unbounded range makes - # `uvx basecamp-cli-mcp` fail at startup. See server.py. - "mcp>=1.2,<2", + # server.py uses the mcp 2.0 low-level Server (on_list_tools / on_call_tool + # callbacks). The 1.x decorator API it replaced is gone in 2.0, so the range + # is pinned on both sides. + "mcp>=2,<3", ] [project.urls] @@ -35,6 +35,7 @@ include = ["src/basecamp_cli_mcp", "tests", "README.md", "LICENSE"] [dependency-groups] dev = [ "pytest>=8", + "anyio>=4", ] [tool.pytest.ini_options] 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_server_protocol.py b/tests/test_server_protocol.py new file mode 100644 index 0000000..7fd810c --- /dev/null +++ b/tests/test_server_protocol.py @@ -0,0 +1,136 @@ +"""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 diff --git a/uv.lock b/uv.lock index 785f8a0..085a6d9 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.2" +version = "0.8.0" source = { editable = "." } dependencies = [ { name = "mcp" }, @@ -43,22 +47,17 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "anyio" }, { name = "pytest" }, ] [package.metadata] -requires-dist = [{ name = "mcp", specifier = ">=1.2,<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" }, ] [[package]] @@ -221,49 +220,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 +295,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 +313,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/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/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/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 +487,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" @@ -524,15 +526,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.27" @@ -709,6 +702,15 @@ 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 = "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" From 20e4b37341760c869e52d2e0c4519c33df411ada Mon Sep 17 00:00:00 2001 From: Davide Casarin Date: Thu, 6 Aug 2026 10:12:01 +0200 Subject: [PATCH 3/4] Raise test coverage from 50% to 99% The mcp 2.0 break exposed how thin the suite was: it stayed green while the server could not be constructed, because three modules had no tests at all and the two that did stubbed out their own boundaries. New coverage, roughly in order of what it protects: - Runner._invoke (tests/test_runner_invoke.py) - the seam neither existing suite touched. The runner tests overrode _invoke and the server tests faked the runner, so nothing exercised argv reaching a process or the {ok, data} / {ok: false, error} envelope becoming a value or a BasecampError. Driven by a real shell stub, so subprocess, stderr and exit codes are real. - setup_cmd (tests/test_setup_cmd.py) - the only code that writes outside the repo. Covers the config merge, the abort paths that must leave a malformed file untouched, backup creation, both tool-set choices, and the installer flow. The last three releases all edited this file untested. - generator (tests/test_generator.py) - the tree walk that produces the shipped contract, via a subclass replaying canned CLI output. Worth having before the pending regeneration against basecamp CLI 0.9.0. - tools.json (tests/test_tools_contract.py) - the committed contract now has to load, satisfy the runner's expectations, pass SEP-986 tool naming, and build a server across all 255 entries. - cli (tests/test_cli.py) - flag plumbing through to server.run. Two tests deliberately pin surprising behaviour rather than assert what it ought to be, so a future change surfaces it: alias dedupe drops sibling actions that share a short description, and the todos-update workaround cannot unassign everyone. Both are documented in place. Also adds pytest-cov and coverage config so ============================= test session starts ============================== platform darwin -- Python 3.12.13, pytest-9.0.3, pluggy-1.6.0 rootdir: /Users/davide/Developer/basecamp-cli-mcp configfile: pyproject.toml testpaths: tests plugins: cov-7.1.0, anyio-4.13.0 collected 151 items tests/test_cli.py ......... [ 5%] tests/test_generator.py .......................... [ 23%] tests/test_help_parser.py .................. [ 35%] tests/test_runner.py ............................ [ 53%] tests/test_runner_invoke.py .......... [ 60%] tests/test_server.py ..... [ 63%] tests/test_server_protocol.py ......... [ 69%] tests/test_setup_cmd.py .................................... [ 93%] tests/test_tools_contract.py .......... [100%] ================================ tests coverage ================================ ______________ coverage: platform darwin, python 3.12.13-final-0 _______________ Name Stmts Miss Branch BrPart Cover ----------------------------------------------------------------------- src/basecamp_cli_mcp/__init__.py 5 2 0 0 60% src/basecamp_cli_mcp/cli.py 35 0 6 0 100% src/basecamp_cli_mcp/generator.py 90 0 36 0 100% src/basecamp_cli_mcp/help_parser.py 115 0 54 1 99% src/basecamp_cli_mcp/runner.py 120 0 60 0 100% src/basecamp_cli_mcp/server.py 59 0 12 0 100% src/basecamp_cli_mcp/setup_cmd.py 101 0 32 0 100% ----------------------------------------------------------------------- TOTAL 525 2 200 1 99% ============================= 151 passed in 2.54s ============================== works. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 0 -> 81920 bytes CLAUDE.md | 1 + pyproject.toml | 8 + tests/test_cli.py | 110 ++++++++++ tests/test_generator.py | 337 ++++++++++++++++++++++++++++ tests/test_help_parser.py | 25 +++ tests/test_runner.py | 112 ++++++++++ tests/test_runner_invoke.py | 142 ++++++++++++ tests/test_server_protocol.py | 31 +++ tests/test_setup_cmd.py | 403 ++++++++++++++++++++++++++++++++++ tests/test_tools_contract.py | 89 ++++++++ uv.lock | 159 ++++++++++++++ 12 files changed, 1417 insertions(+) create mode 100644 .coverage create mode 100644 tests/test_cli.py create mode 100644 tests/test_generator.py create mode 100644 tests/test_runner_invoke.py create mode 100644 tests/test_setup_cmd.py create mode 100644 tests/test_tools_contract.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..a2f6d1a0797960b682ccc97a7a6393b41f8dfa4c GIT binary patch literal 81920 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 VKTlj#%%5}lIsPp1s@wSQ{}+tNY6SoQ literal 0 HcmV?d00001 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 c436b22..d76059c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,15 @@ include = ["src/basecamp_cli_mcp", "tests", "README.md", "LICENSE"] 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/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 index 7fd810c..dcdabc9 100644 --- a/tests/test_server_protocol.py +++ b/tests/test_server_protocol.py @@ -134,3 +134,34 @@ async def test_unknown_tool_becomes_error_result() -> None: 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 085a6d9..72c57e2 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ dev = [ { name = "anyio" }, { name = "pytest" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -58,6 +59,7 @@ requires-dist = [{ name = "mcp", specifier = ">=2,<3" }] dev = [ { name = "anyio", specifier = ">=4" }, { name = "pytest", specifier = ">=8" }, + { name = "pytest-cov", specifier = ">=5" }, ] [[package]] @@ -151,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" @@ -526,6 +617,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +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/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]] name = "python-multipart" version = "0.0.27" @@ -702,6 +807,60 @@ 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" From f9d248bad6f4b03774d0b11be36f42c43185389a Mon Sep 17 00:00:00 2001 From: Davide Casarin Date: Thu, 6 Aug 2026 10:12:19 +0200 Subject: [PATCH 4/4] Ignore coverage artifacts .coverage slipped into the previous commit; it's regenerated on every run. Co-Authored-By: Claude Opus 5 (1M context) --- .coverage | Bin 81920 -> 0 bytes .gitignore | 3 +++ 2 files changed, 3 insertions(+) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index a2f6d1a0797960b682ccc97a7a6393b41f8dfa4c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81920 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 VKTlj#%%5}lIsPp1s@wSQ{}+tNY6SoQ 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/