diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 5fc50596f6..5050f9740c 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -18,16 +18,16 @@ Driving a running server by hand, with the Inspector or an LLM client, is ⚠️ **The TypeScript and Python harnesses differ.** All four TypeScript servers are tested in-process, a real `Client` linked to the server over an in-memory -transport (#4854). The Python servers' tests call their functions directly, -and those servers have no coverage gate yet (#4855). +transport (#4854). Of the Python servers, `time` drives `serve()` in-process; +`fetch` and `git` call their functions directly. No Python server has a coverage +gate yet (#4855). ## Where a test goes | Server | Directory | File name | Runner | | --- | --- | --- | --- | | `everything`, `filesystem`, `memory`, `sequentialthinking` | `src//__tests__/` | `.test.ts` | vitest, `globals: true` | -| `fetch`, `git` | `src//tests/` | `test_.py` | pytest | -| `time` | `src/time/test/` | `_test.py` | pytest | +| `fetch`, `git`, `time` | `src//tests/` | `test_.py` | pytest | | Root tooling | `scripts/`, beside the script | `.test.mjs` | `node --test` | Each vitest config includes only `**/__tests__/**/*.test.ts`, so a test file @@ -41,9 +41,10 @@ connection harness does (`__tests__/harness.ts` in `everything`, | Server | How its tests reach the code | | --- | --- | | `everything`, `filesystem`, `memory`, `sequentialthinking` | An SDK `Client` connected to the server's `createServer()` over `InMemoryTransport`, in the vitest process, through the server's own harness, plus unit tests of helper modules such as `lib.ts`. `everything` drives its stdio, SSE and Streamable HTTP transports in-process too; the other three have one thin spawn smoke of the built `dist/index.js` | -| `fetch`, `git`, `time` | Direct calls on the functions in `server.py`, with `unittest.mock` | +| `fetch`, `git` | Direct calls on the functions in `server.py`, with `unittest.mock` | +| `time` | A `ClientSession` against `serve()` in-process, over in-memory streams (`tests/test_protocol.py`); direct calls on the helpers in `server.py` (`tests/test_server.py`) | -No Python test opens a `ClientSession` yet. +Of the Python servers, only `time` opens a `ClientSession` in its tests. ## Choosing a harness for a new test @@ -165,8 +166,11 @@ like). ### Python: `ClientSession` over stdio Each Python server constructs its `Server` inside `serve()`, so it cannot be -handed to an in-memory session. A protocol-level test spawns the module and -talks to it through the SDK's stdio client: +handed to an in-memory session directly. `time` drives it in-process anyway, by +patching `stdio_server` to yield the server side of +`create_client_server_memory_streams()`; see the `serving()` helper in +`src/time/tests/test_protocol.py`. The other route spawns the module and talks +to it through the SDK's stdio client: ```python import sys @@ -196,8 +200,8 @@ def test_get_current_time_over_stdio() -> None: and the install there is editable, so this runs your edit with no build step. How an async test is written differs by server, because the dev dependencies -do: `fetch` has `pytest-asyncio` with `asyncio_mode = "auto"`, so an -`async def test_…` just works. `git` and `time` do not have it; wrap the +do: `fetch` and `time` have `pytest-asyncio` with `asyncio_mode = "auto"`, +so an `async def test_…` just works. `git` does not have it; wrap the coroutine in `anyio.run(...)` as above (`git`'s `serve()` test does), rather than adding a dependency for one test. @@ -236,7 +240,7 @@ In Python the same text follows `#`. A bug with no issue yet reads | One TypeScript server's coverage gate | `npm run coverage -w src/` (see below) | | One TypeScript server's whole chain | `npm run validate -w src/` (format check, lint, typecheck, build, test) | | One Python server | `uv run pytest` in `src/` | -| One Python test | `uv run pytest ::` in `src/` (for example `tests/test_server.py::test_git_checkout_existing_branch`, or `test/time_server_test.py::…` in `time`) | +| One Python test | `uv run pytest ::` in `src/` (for example `tests/test_server.py::test_git_checkout_existing_branch`) | | One Python server's whole chain | `npm run validate:py -- ` | | Root tooling | `npm run test:scripts` | diff --git a/src/time/.gitignore b/src/time/.gitignore new file mode 100644 index 0000000000..316f0c5879 --- /dev/null +++ b/src/time/.gitignore @@ -0,0 +1,5 @@ +# Coverage output from the per-server coverage command (see README, +# "Development"); generated locally and never committed. +coverage.json +.coverage +.coverage.* diff --git a/src/time/README.md b/src/time/README.md index b3e49e05ba..43775f71fa 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -274,6 +274,31 @@ npx @modelcontextprotocol/inspector uv run mcp-server-time 3. "When it's 4 PM in New York, what time is it in London?" 4. "Convert 9:30 AM Tokyo time to New York time" +## Development + +Run the tests from `src/time`. They live in `tests/`: `test_protocol.py` drives +the server through an MCP `ClientSession` in-process, `test_entrypoints.py` +covers `main()`, `python -m mcp_server_time` and the console script, and +`test_server.py` unit-tests the time helpers. + +```bash +cd src/time +uv run pytest +``` + +Coverage is a separate command. It measures line and branch coverage of +`src/mcp_server_time`, prints the missing lines, and writes `coverage.json` +(ignored by git): + +```bash +cd src/time +uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json +``` + +Every file must reach 90% of its lines and 90% of its branches. Code that +cannot be reached is marked `# pragma: no cover # `, always with the +reason, rather than lowering the bar. + ## Build Docker build: diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index e6400d5f12..44d24a99a0 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -39,5 +39,18 @@ dev-dependencies = [ "freezegun>=1.5.1", "pyright>=1.1.389", "pytest>=8.3.3", + "pytest-asyncio>=1.4.0", + "pytest-cov>=7.1.0", "ruff>=0.8.1", ] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.coverage.run] +branch = true +source = ["src/mcp_server_time"] + +[tool.coverage.report] +show_missing = true diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 2fdee5fe47..1db604de79 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -208,7 +208,9 @@ async def call_tool( k in arguments for k in ["source_timezone", "time", "target_timezone"] ): - raise ValueError("Missing required arguments") + raise ValueError( # pragma: no cover # unreachable: the SDK rejects a missing key against inputSchema first + "Missing required arguments" + ) result = time_server.convert_time( arguments["source_timezone"], diff --git a/src/time/tests/test_entrypoints.py b/src/time/tests/test_entrypoints.py new file mode 100644 index 0000000000..2b9dc750d9 --- /dev/null +++ b/src/time/tests/test_entrypoints.py @@ -0,0 +1,116 @@ +# Tests for mcp-server-time's entry points (#4855): `main()` in `__init__.py`, +# `python -m mcp_server_time` (`__main__.py`), and the `mcp-server-time` +# console script. +# +# `main()` and both `__main__` paths run in-process with `serve` patched out, +# so coverage measures them and nothing reads real stdin. The console script +# gets the one thin subprocess smoke test: it is the only way to prove the +# installed entry point boots and speaks MCP over real stdio. + +import os +import runpy +import shutil +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +import mcp_server_time + + +@pytest.mark.parametrize( + "argv,expected_local_timezone", + [ + ([], None), + (["--local-timezone", "Europe/Paris"], "Europe/Paris"), + (["--local-timezone=America/Chicago"], "America/Chicago"), + ], +) +def test_main_passes_local_timezone_to_serve( + argv: list[str], expected_local_timezone: str | None +) -> None: + serve = AsyncMock() + with ( + patch.object(mcp_server_time, "serve", serve), + patch.object(sys, "argv", ["mcp-server-time", *argv]), + ): + mcp_server_time.main() + serve.assert_awaited_once_with(expected_local_timezone) + + +def test_main_help_describes_the_server(capsys: pytest.CaptureFixture[str]) -> None: + serve = AsyncMock() + with ( + patch.object(mcp_server_time, "serve", serve), + patch.object(sys, "argv", ["mcp-server-time", "--help"]), + pytest.raises(SystemExit) as exit_info, + ): + mcp_server_time.main() + assert exit_info.value.code == 0 + out = capsys.readouterr().out + assert ( + "give a model the ability to handle time queries and timezone conversions" + in out + ) + assert "--local-timezone" in out + serve.assert_not_awaited() + + +def test_main_rejects_unknown_arguments(capsys: pytest.CaptureFixture[str]) -> None: + serve = AsyncMock() + with ( + patch.object(mcp_server_time, "serve", serve), + patch.object(sys, "argv", ["mcp-server-time", "--bogus"]), + pytest.raises(SystemExit) as exit_info, + ): + mcp_server_time.main() + assert exit_info.value.code == 2 + assert "unrecognized arguments: --bogus" in capsys.readouterr().err + serve.assert_not_awaited() + + +def test_python_dash_m_calls_main() -> None: + # What `python -m mcp_server_time` executes. + main = MagicMock() + with patch.object(mcp_server_time, "main", main): + runpy.run_module("mcp_server_time", run_name="__main__") + main.assert_called_once_with() + + +def test_init_module_run_as_main_calls_main() -> None: + # `__init__.py` carries its own `if __name__ == "__main__"` guard. Running + # it as a module re-executes it in a fresh namespace, which imports + # `serve` from `.server` again, so the patch goes there. + serve = AsyncMock() + with ( + patch("mcp_server_time.server.serve", serve), + patch.object(sys, "argv", ["mcp-server-time", "--local-timezone", "UTC"]), + ): + runpy.run_module("mcp_server_time.__init__", run_name="__main__") + serve.assert_awaited_once_with("UTC") + + +async def test_console_script_boots_over_stdio() -> None: + script = shutil.which("mcp-server-time", path=str(Path(sys.executable).parent)) + assert script is not None, "the console script is not installed in this venv" + params = StdioServerParameters( + command=script, + args=["--local-timezone", "UTC"], + env={**os.environ}, + ) + with open(os.devnull, "w") as errlog: + async with stdio_client(params, errlog=errlog) as (read, write): + async with ClientSession(read, write) as session: + init = await session.initialize() + tools = await session.list_tools() + result = await session.call_tool( + "get_current_time", {"timezone": "UTC"} + ) + assert init.model_dump(by_alias=True, mode="json")["serverInfo"]["name"] == ( + "mcp-time" + ) + assert [tool.name for tool in tools.tools] == ["get_current_time", "convert_time"] + assert result.model_dump(by_alias=True, mode="json")["isError"] is False diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py new file mode 100644 index 0000000000..74ec06d1a5 --- /dev/null +++ b/src/time/tests/test_protocol.py @@ -0,0 +1,911 @@ +# Characterization tests for mcp-server-time, driven through the MCP protocol +# in-process (#4855). +# +# Each test runs the real `serve()` coroutine with `stdio_server` swapped for +# an in-memory stream pair, and talks to it with a real `ClientSession`. That +# exercises what a client actually sees: the SDK's input validation, its +# folding of handler exceptions into `isError` results, the advertised tool +# schemas and the initialize handshake. The pure helpers in `server.py` are +# covered directly in `test_server.py`. +# +# These tests pin CURRENT behavior on `mcp` 1.x, including behavior open issues +# want changed, so that the SDK v2 port (#4851) has a regression net and any +# intended change shows up as a failing assertion. Assertions compare wire JSON +# (`model_dump(by_alias=True, mode="json")`) rather than SDK attribute names +# wherever practical, so the snake_case renames in SDK v2 do not force a +# rewrite. +# +# Time is frozen with `frozen_at()`, which swaps the server module's +# `datetime` for a subclass whose `now()` returns a fixed instant. freezegun is +# not used here: its fake `now(tz)` drops `fold`, so during a fall-back hour it +# reports the first occurrence's offset for an instant in the second (it would +# claim 01:30 UTC on 2024-10-27 is 01:30+01:00 in London). + +import json +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager +from datetime import datetime, tzinfo +from importlib.metadata import version +from typing import Any +from unittest.mock import patch + +import anyio +import pytest +from mcp import ClientSession +from mcp.shared.memory import create_client_server_memory_streams +from pydantic import BaseModel +from zoneinfo import ZoneInfoNotFoundError + +from mcp_server_time.server import serve + + +@contextmanager +def frozen_at(instant: str) -> Iterator[None]: + """Make `datetime.now()` inside the server return `instant`.""" + timestamp = datetime.fromisoformat(instant).timestamp() + + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz: tzinfo | None = None): + return cls.fromtimestamp(timestamp, tz) + + with patch("mcp_server_time.server.datetime", FrozenDatetime): + yield + + +def wire(model: BaseModel) -> Any: + """Serialize an SDK model the way it travels over the wire.""" + return model.model_dump(by_alias=True, mode="json", exclude_none=True) + + +@asynccontextmanager +async def serving( + local_timezone: str | None = "Europe/Warsaw", +) -> AsyncIterator[ClientSession]: + """Run `serve()` over in-memory streams and yield a not-yet-initialized client.""" + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server(): + yield server_streams + + with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, local_timezone) + async with ClientSession(*client_streams) as session: + yield session + tg.cancel_scope.cancel() + + +@asynccontextmanager +async def connected( + local_timezone: str | None = "Europe/Warsaw", +) -> AsyncIterator[ClientSession]: + """Run `serve()` in-process and yield an initialized client.""" + async with serving(local_timezone) as session: + await session.initialize() + yield session + + +async def call(session: ClientSession, name: str, arguments: dict[str, Any]) -> Any: + """Call a tool and return the wire form of its result.""" + return wire(await session.call_tool(name, arguments)) + + +async def call_json( + session: ClientSession, name: str, arguments: dict[str, Any] +) -> Any: + """Call a tool that succeeds and return the JSON object in its text block.""" + result = await call(session, name, arguments) + assert result["isError"] is False, result + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + return json.loads(result["content"][0]["text"]) + + +def error_result(text: str) -> dict[str, Any]: + return {"content": [{"type": "text", "text": text}], "isError": True} + + +def handler_error(message: str) -> dict[str, Any]: + """An exception raised in the server's handler, as the client receives it.""" + return error_result(f"Error processing mcp-server-time query: {message}") + + +# --------------------------------------------------------------------------- +# Initialize handshake +# --------------------------------------------------------------------------- + + +# KNOWN BUG #360: pins current (wrong) behavior; the fix changes this assertion. +async def test_initialize_reports_server_info_and_capabilities() -> None: + async with serving("UTC") as session: + init = wire(await session.initialize()) + + # Characterizes #360: `Server("mcp-time")` is constructed without a + # version, so the SDK reports its OWN package version as the server's + # version, not mcp-server-time's. A fix will change this assertion. + assert init["serverInfo"] == {"name": "mcp-time", "version": version("mcp")} + assert init["serverInfo"]["version"] != version("mcp-server-time") + # Only tools are advertised; no resources, prompts or logging. + assert init["capabilities"] == {"experimental": {}, "tools": {"listChanged": False}} + assert "instructions" not in init + + +# --------------------------------------------------------------------------- +# tools/list +# --------------------------------------------------------------------------- + +READ_ONLY_ANNOTATIONS = { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": False, +} + + +def expected_tools(local_tz: str) -> list[dict[str, Any]]: + # Names and descriptions are pinned as they are today. #3201 and #2853 + # request changes to them; a fix will change this fixture. + return [ + { + "name": "get_current_time", + "description": "Get current time in a specific timezone", + "inputSchema": { + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": ( + "IANA timezone name (e.g., 'America/New_York', " + f"'Europe/London'). Use '{local_tz}' as local timezone " + "if no timezone provided by the user." + ), + } + }, + "required": ["timezone"], + }, + "annotations": READ_ONLY_ANNOTATIONS, + }, + { + "name": "convert_time", + "description": "Convert time between timezones", + "inputSchema": { + "type": "object", + "properties": { + "source_timezone": { + "type": "string", + "description": ( + "Source IANA timezone name (e.g., 'America/New_York', " + f"'Europe/London'). Use '{local_tz}' as local timezone " + "if no source timezone provided by the user." + ), + }, + "time": { + "type": "string", + "description": "Time to convert in 24-hour format (HH:MM)", + }, + "target_timezone": { + "type": "string", + "description": ( + "Target IANA timezone name (e.g., 'Asia/Tokyo', " + f"'America/San_Francisco'). Use '{local_tz}' as local " + "timezone if no target timezone provided by the user." + ), + }, + }, + "required": ["source_timezone", "time", "target_timezone"], + }, + "annotations": READ_ONLY_ANNOTATIONS, + }, + ] + + +async def test_list_tools_with_local_timezone_override() -> None: + async with connected("Europe/Warsaw") as session: + result = wire(await session.list_tools()) + # No title, outputSchema, icons or pagination cursor. + assert result == {"tools": expected_tools("Europe/Warsaw")} + + +async def test_list_tools_uses_detected_local_timezone() -> None: + with patch("mcp_server_time.server.get_localzone_name", return_value="Asia/Tokyo"): + async with connected(None) as session: + result = wire(await session.list_tools()) + assert result == {"tools": expected_tools("Asia/Tokyo")} + + +async def test_list_tools_falls_back_to_utc_when_local_timezone_unknown() -> None: + with patch("mcp_server_time.server.get_localzone_name", return_value=None): + async with connected(None) as session: + result = wire(await session.list_tools()) + assert result == {"tools": expected_tools("UTC")} + + +async def test_empty_local_timezone_override_is_treated_as_absent() -> None: + # `--local-timezone ""` is falsy, so detection runs instead. + with patch( + "mcp_server_time.server.get_localzone_name", return_value="Africa/Cairo" + ): + async with connected("") as session: + result = wire(await session.list_tools()) + assert result == {"tools": expected_tools("Africa/Cairo")} + + +# KNOWN BUG #5001: an invalid --local-timezone crashes serve() with a traceback instead of a clean error; the fix changes this assertion. +async def test_invalid_local_timezone_fails_before_the_transport_opens() -> None: + # An unknown `--local-timezone` raises out of `serve()` itself, before + # stdio is opened: the process dies with a traceback instead of starting. + def unexpected_stdio_server(): + raise AssertionError("stdio_server must not be reached") + + with patch("mcp_server_time.server.stdio_server", unexpected_stdio_server): + with pytest.raises(ZoneInfoNotFoundError, match="Not/AZone"): + await serve("Not/AZone") + + +# --------------------------------------------------------------------------- +# tools/call get_current_time +# --------------------------------------------------------------------------- + + +async def test_get_current_time_wire_result() -> None: + async with connected() as session: + with frozen_at("2024-01-01 12:00:00+00:00"): + result = await call(session, "get_current_time", {"timezone": "UTC"}) + # Exactly one text block holding indented JSON; no structuredContent. + assert result == { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "timezone": "UTC", + "datetime": "2024-01-01T12:00:00+00:00", + "day_of_week": "Monday", + "is_dst": False, + }, + indent=2, + ), + } + ], + "isError": False, + } + + +@pytest.mark.parametrize( + "instant,timezone,expected_datetime,day_of_week,is_dst", + [ + # Northern hemisphere, winter and summer. + ( + "2024-01-01 12:00:00+00:00", + "Europe/Warsaw", + "2024-01-01T13:00:00+01:00", + "Monday", + False, + ), + ( + "2024-07-01 12:00:00+00:00", + "Europe/Warsaw", + "2024-07-01T14:00:00+02:00", + "Monday", + True, + ), + ( + "2024-01-01 12:00:00+00:00", + "America/New_York", + "2024-01-01T07:00:00-05:00", + "Monday", + False, + ), + # Southern hemisphere: DST in January. + ( + "2024-01-01 12:00:00+00:00", + "Australia/Sydney", + "2024-01-01T23:00:00+11:00", + "Monday", + True, + ), + ( + "2024-07-01 12:00:00+00:00", + "Australia/Sydney", + "2024-07-01T22:00:00+10:00", + "Monday", + False, + ), + # US spring-forward: the last second of EST, then the first of EDT. + ( + "2024-03-10 06:59:59+00:00", + "America/New_York", + "2024-03-10T01:59:59-05:00", + "Sunday", + False, + ), + ( + "2024-03-10 07:00:00+00:00", + "America/New_York", + "2024-03-10T03:00:00-04:00", + "Sunday", + True, + ), + # UK fall-back: 01:00 local happens twice, once in BST then in GMT. + ( + "2024-10-27 00:30:00+00:00", + "Europe/London", + "2024-10-27T01:30:00+01:00", + "Sunday", + True, + ), + ( + "2024-10-27 01:30:00+00:00", + "Europe/London", + "2024-10-27T01:30:00+00:00", + "Sunday", + False, + ), + # Half-hour DST shift and a 45-minute standard offset. + ( + "2024-01-01 12:00:00+00:00", + "Australia/Lord_Howe", + "2024-01-01T23:00:00+11:00", + "Monday", + True, + ), + ( + "2024-01-01 12:00:00+00:00", + "Asia/Kathmandu", + "2024-01-01T17:45:00+05:45", + "Monday", + False, + ), + # Date line: already the next day. + ( + "2024-01-01 12:00:00+00:00", + "Pacific/Kiritimati", + "2024-01-02T02:00:00+14:00", + "Tuesday", + False, + ), + # Behind UTC: still the previous day. + ( + "2024-01-01 05:00:00+00:00", + "Pacific/Pago_Pago", + "2023-12-31T18:00:00-11:00", + "Sunday", + False, + ), + # Non-region keys that ZoneInfo also accepts. + ( + "2024-07-01 12:00:00+00:00", + "UTC", + "2024-07-01T12:00:00+00:00", + "Monday", + False, + ), + ( + "2024-07-01 12:00:00+00:00", + "Etc/GMT+5", + "2024-07-01T07:00:00-05:00", + "Monday", + False, + ), + ( + "2024-07-01 12:00:00+00:00", + "EST5EDT", + "2024-07-01T08:00:00-04:00", + "Monday", + True, + ), + ], +) +async def test_get_current_time_across_zones_and_dst( + instant: str, + timezone: str, + expected_datetime: str, + day_of_week: str, + is_dst: bool, +) -> None: + async with connected() as session: + with frozen_at(instant): + result = await call_json( + session, "get_current_time", {"timezone": timezone} + ) + assert result == { + "timezone": timezone, + "datetime": expected_datetime, + "day_of_week": day_of_week, + "is_dst": is_dst, + } + + +async def test_get_current_time_echoes_the_requested_name_not_a_canonical_one() -> None: + # Aliases resolve, but the reply echoes what the client sent. + async with connected() as session: + with frozen_at("2024-01-01 12:00:00+00:00"): + result = await call_json( + session, "get_current_time", {"timezone": "US/Eastern"} + ) + assert result["timezone"] == "US/Eastern" + assert result["datetime"] == "2024-01-01T07:00:00-05:00" + + +async def test_get_current_time_ignores_extra_arguments() -> None: + async with connected() as session: + with frozen_at("2024-01-01 12:00:00+00:00"): + result = await call_json( + session, "get_current_time", {"timezone": "UTC", "extra": 1} + ) + assert result["datetime"] == "2024-01-01T12:00:00+00:00" + + +async def test_get_current_time_does_not_default_to_the_local_timezone() -> None: + # The local timezone only appears in the schema descriptions; the server + # never substitutes it for an empty argument. + async with connected("Europe/Warsaw") as session: + result = await call(session, "get_current_time", {"timezone": ""}) + assert result == handler_error("Missing required argument: timezone") + + +@pytest.mark.parametrize( + "arguments,expected", + [ + # Rejected by the SDK's input validation against inputSchema, before + # the handler runs. + ({}, error_result("Input validation error: 'timezone' is a required property")), + ( + {"timezone": 5}, + error_result("Input validation error: 5 is not of type 'string'"), + ), + ( + {"timezone": None}, + error_result("Input validation error: None is not of type 'string'"), + ), + # Raised by the handler and folded into an error result by the SDK. + ({"timezone": ""}, handler_error("Missing required argument: timezone")), + ( + {"timezone": "Invalid/Timezone"}, + handler_error( + "Invalid timezone: 'No time zone found with key Invalid/Timezone'" + ), + ), + ( + {"timezone": "europe/warsaw"}, + handler_error( + "Invalid timezone: 'No time zone found with key europe/warsaw'" + ), + ), + ( + {"timezone": "../etc/passwd"}, + handler_error( + "Invalid timezone: ZoneInfo keys must refer to subdirectories of TZPATH, got: ../etc/passwd" + ), + ), + ( + {"timezone": "/etc/localtime"}, + handler_error( + "Invalid timezone: ZoneInfo keys may not be absolute paths, got: /etc/localtime" + ), + ), + ], +) +async def test_get_current_time_errors( + arguments: dict[str, Any], expected: dict[str, Any] +) -> None: + async with connected() as session: + result = await call(session, "get_current_time", arguments) + assert result == expected + + +# --------------------------------------------------------------------------- +# tools/call convert_time +# --------------------------------------------------------------------------- + + +async def test_convert_time_wire_result() -> None: + async with connected() as session: + with frozen_at("2024-01-01 00:00:00+00:00"): + result = await call( + session, + "convert_time", + { + "source_timezone": "Europe/Warsaw", + "time": "12:00", + "target_timezone": "Asia/Tokyo", + }, + ) + assert result == { + "content": [ + { + "type": "text", + "text": json.dumps( + { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "day_of_week": "Monday", + "is_dst": False, + }, + "target": { + "timezone": "Asia/Tokyo", + "datetime": "2024-01-01T20:00:00+09:00", + "day_of_week": "Monday", + "is_dst": False, + }, + "time_difference": "+8.0h", + }, + indent=2, + ), + } + ], + "isError": False, + } + + +@pytest.mark.parametrize( + "instant,source,time,target,source_dt,target_dt,target_dst,difference", + [ + # Whole-hour differences carry one decimal place. + ( + "2024-01-01 00:00:00+00:00", + "Europe/London", + "12:00", + "Europe/Warsaw", + "2024-01-01T12:00:00+00:00", + "2024-01-01T13:00:00+01:00", + False, + "+1.0h", + ), + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "12:00", + "UTC", + "2024-01-01T12:00:00+00:00", + "2024-01-01T12:00:00+00:00", + False, + "+0.0h", + ), + # Fractional differences are trimmed: +5.75h, +5.5h, -4.75h. + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "12:00", + "Asia/Kathmandu", + "2024-01-01T12:00:00+00:00", + "2024-01-01T17:45:00+05:45", + False, + "+5.75h", + ), + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "12:00", + "Asia/Kolkata", + "2024-01-01T12:00:00+00:00", + "2024-01-01T17:30:00+05:30", + False, + "+5.5h", + ), + ( + "2024-01-01 00:00:00+00:00", + "Asia/Kathmandu", + "12:00", + "Europe/Warsaw", + "2024-01-01T12:00:00+05:45", + "2024-01-01T07:15:00+01:00", + False, + "-4.75h", + ), + # The week Europe has left DST and the US has not. + ( + "2024-10-28 00:00:00+00:00", + "Europe/Warsaw", + "12:00", + "America/New_York", + "2024-10-28T12:00:00+01:00", + "2024-10-28T07:00:00-04:00", + True, + "-5.0h", + ), + # Crossing the date line moves the day forward. + ( + "2024-01-01 00:00:00+00:00", + "Europe/Warsaw", + "23:00", + "Pacific/Kiritimati", + "2024-01-01T23:00:00+01:00", + "2024-01-02T12:00:00+14:00", + False, + "+13.0h", + ), + # And backwards. + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "05:00", + "Pacific/Pago_Pago", + "2024-01-01T05:00:00+00:00", + "2023-12-31T18:00:00-11:00", + False, + "-11.0h", + ), + # Single-digit hour and minute are accepted by strptime's %H:%M. + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "9:5", + "UTC", + "2024-01-01T09:05:00+00:00", + "2024-01-01T09:05:00+00:00", + False, + "+0.0h", + ), + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "00:00", + "UTC", + "2024-01-01T00:00:00+00:00", + "2024-01-01T00:00:00+00:00", + False, + "+0.0h", + ), + ( + "2024-01-01 00:00:00+00:00", + "UTC", + "23:59", + "UTC", + "2024-01-01T23:59:00+00:00", + "2024-01-01T23:59:00+00:00", + False, + "+0.0h", + ), + ], +) +async def test_convert_time_offsets( + instant: str, + source: str, + time: str, + target: str, + source_dt: str, + target_dt: str, + target_dst: bool, + difference: str, +) -> None: + async with connected() as session: + with frozen_at(instant): + result = await call_json( + session, + "convert_time", + {"source_timezone": source, "time": time, "target_timezone": target}, + ) + assert result["source"]["timezone"] == source + assert result["source"]["datetime"] == source_dt + assert result["target"]["timezone"] == target + assert result["target"]["datetime"] == target_dt + assert result["target"]["is_dst"] is target_dst + assert result["time_difference"] == difference + + +async def test_convert_time_anchors_to_today_in_the_source_timezone() -> None: + # The date is "now" in the SOURCE timezone, not in UTC or the local zone. + # At 2024-01-01 20:00 UTC it is already Jan 2 in Tokyo. + async with connected() as session: + with frozen_at("2024-01-01 20:00:00+00:00"): + result = await call_json( + session, + "convert_time", + { + "source_timezone": "Asia/Tokyo", + "time": "09:00", + "target_timezone": "UTC", + }, + ) + assert result["source"]["datetime"] == "2024-01-02T09:00:00+09:00" + assert result["source"]["day_of_week"] == "Tuesday" + assert result["target"]["datetime"] == "2024-01-02T00:00:00+00:00" + + +# KNOWN BUG #5002: a nonexistent (spring-forward) local time is converted as if valid, with no warning; the fix changes this assertion. +async def test_convert_time_into_a_nonexistent_local_time() -> None: + # 02:30 on 2024-03-10 does not exist in New York (clocks jump 02:00 -> + # 03:00). With fold=0 zoneinfo uses the pre-transition offset (EST), so + # the server reports a wall-clock time that never happened, unflagged. + async with connected() as session: + with frozen_at("2024-03-10 12:00:00+00:00"): + result = await call_json( + session, + "convert_time", + { + "source_timezone": "America/New_York", + "time": "02:30", + "target_timezone": "UTC", + }, + ) + assert result == { + "source": { + "timezone": "America/New_York", + "datetime": "2024-03-10T02:30:00-05:00", + "day_of_week": "Sunday", + "is_dst": False, + }, + "target": { + "timezone": "UTC", + "datetime": "2024-03-10T07:30:00+00:00", + "day_of_week": "Sunday", + "is_dst": False, + }, + "time_difference": "+5.0h", + } + + +async def test_convert_time_at_an_ambiguous_local_time() -> None: + # 01:30 on 2024-11-03 happens twice in New York. fold=0 picks the first + # occurrence, still on EDT. + async with connected() as session: + with frozen_at("2024-11-03 12:00:00+00:00"): + result = await call_json( + session, + "convert_time", + { + "source_timezone": "America/New_York", + "time": "01:30", + "target_timezone": "UTC", + }, + ) + assert result["source"] == { + "timezone": "America/New_York", + "datetime": "2024-11-03T01:30:00-04:00", + "day_of_week": "Sunday", + "is_dst": True, + } + assert result["target"]["datetime"] == "2024-11-03T05:30:00+00:00" + assert result["time_difference"] == "+4.0h" + + +async def test_convert_time_difference_uses_offsets_at_the_converted_instant() -> None: + # On Europe's fall-back day, 00:30 London is still BST and 12:00 is GMT, + # so the same pair of zones reports different differences. + async with connected() as session: + with frozen_at("2024-10-27 12:00:00+00:00"): + early = await call_json( + session, + "convert_time", + { + "source_timezone": "Europe/London", + "time": "00:30", + "target_timezone": "America/New_York", + }, + ) + late = await call_json( + session, + "convert_time", + { + "source_timezone": "Europe/London", + "time": "12:00", + "target_timezone": "America/New_York", + }, + ) + assert early["source"]["is_dst"] is True + assert early["time_difference"] == "-5.0h" + assert late["source"]["is_dst"] is False + assert late["time_difference"] == "-4.0h" + + +async def test_convert_time_ignores_extra_arguments() -> None: + async with connected() as session: + with frozen_at("2024-01-01 00:00:00+00:00"): + result = await call_json( + session, + "convert_time", + { + "source_timezone": "UTC", + "time": "12:00", + "target_timezone": "UTC", + "target_tz_list": ["Asia/Tokyo"], + }, + ) + assert result["target"]["timezone"] == "UTC" + + +# KNOWN BUG #5003: an empty source_timezone surfaces zoneinfo's raw message, unlike get_current_time's "Missing required argument"; the fix changes this assertion. +@pytest.mark.parametrize( + "arguments,expected", + [ + # Rejected by the SDK's input validation, before the handler runs. A + # missing key never reaches the handler's own "Missing required + # arguments" check, which is why that branch is marked unreachable. + ( + {"source_timezone": "UTC", "time": "12:00"}, + error_result( + "Input validation error: 'target_timezone' is a required property" + ), + ), + ( + {"time": "12:00", "target_timezone": "UTC"}, + error_result( + "Input validation error: 'source_timezone' is a required property" + ), + ), + ( + {"source_timezone": "UTC", "time": 1200, "target_timezone": "UTC"}, + error_result("Input validation error: 1200 is not of type 'string'"), + ), + # Raised by the handler. + ( + {"source_timezone": "Bad/Zone", "time": "12:00", "target_timezone": "UTC"}, + handler_error("Invalid timezone: 'No time zone found with key Bad/Zone'"), + ), + ( + {"source_timezone": "UTC", "time": "12:00", "target_timezone": "Bad/Zone"}, + handler_error("Invalid timezone: 'No time zone found with key Bad/Zone'"), + ), + # Unlike get_current_time, an empty timezone is not special-cased and + # surfaces zoneinfo's own message. + ( + {"source_timezone": "", "time": "12:00", "target_timezone": "UTC"}, + handler_error( + "Invalid timezone: ZoneInfo keys must be normalized relative paths, got: " + ), + ), + ], +) +async def test_convert_time_argument_errors( + arguments: dict[str, Any], expected: dict[str, Any] +) -> None: + async with connected() as session: + result = await call(session, "convert_time", arguments) + assert result == expected + + +@pytest.mark.parametrize( + "time", + ["25:00", "12:60", "24:00", "", "noon", "12", "12:00:00", "12:00 PM", "-1:00"], +) +async def test_convert_time_rejects_bad_time_strings(time: str) -> None: + async with connected() as session: + result = await call( + session, + "convert_time", + {"source_timezone": "UTC", "time": time, "target_timezone": "UTC"}, + ) + assert result == handler_error( + "Invalid time format. Expected HH:MM [24-hour format]" + ) + + +async def test_convert_time_checks_timezones_before_the_time_string() -> None: + async with connected() as session: + result = await call( + session, + "convert_time", + {"source_timezone": "UTC", "time": "nope", "target_timezone": "Bad/Zone"}, + ) + assert result == handler_error( + "Invalid timezone: 'No time zone found with key Bad/Zone'" + ) + + +# --------------------------------------------------------------------------- +# Unknown tools +# --------------------------------------------------------------------------- + + +async def test_unknown_tool_is_an_error_result_not_a_protocol_error() -> None: + # The SDK skips validation for an unlisted tool (and logs a warning), then + # the handler's fallthrough raises, which the SDK folds into `isError`. + async with connected() as session: + result = await call(session, "no_such_tool", {"timezone": "UTC"}) + assert result == handler_error("Unknown tool: no_such_tool") + + +async def test_session_survives_errors() -> None: + # A failed call does not take the server down. + async with connected() as session: + await call(session, "no_such_tool", {}) + await call(session, "get_current_time", {"timezone": "Bad/Zone"}) + result = await call(session, "get_current_time", {"timezone": "UTC"}) + assert result["isError"] is False diff --git a/src/time/test/time_server_test.py b/src/time/tests/test_server.py similarity index 100% rename from src/time/test/time_server_test.py rename to src/time/tests/test_server.py diff --git a/src/time/uv.lock b/src/time/uv.lock index 542b9969f6..a54c221cad 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -41,6 +41,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "certifi" version = "2024.8.30" @@ -153,6 +162,139 @@ 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.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/72/db17bf5f87568ab524413693385be2eeb03e652ab56c54ea05fa85515675/coverage-7.16.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:23219888477edd736b6fcaec1272d47d93b926e999641ffea7e53a1738e70b2b", size = 223670, upload-time = "2026-09-27T12:25:34.675Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e1/285727a8a74d48de256e8605413ee4cf82301228253ea58e5ab4270138b0/coverage-7.16.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:40c0f00899fe6181ae7f434ceb200e51f5ee4b8ed10e3b5f0b605f0cae15da87", size = 224191, upload-time = "2026-09-27T12:25:36.476Z" }, + { url = "https://files.pythonhosted.org/packages/50/c7/c737b73bac9bf5034f5ff45a4237b7a189d417a4e36751faf5d10c082cbe/coverage-7.16.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a4624f80732f6b427ac58f1f59c577a0994a12e8174b5af6a027b4b58795d4c3", size = 250921, upload-time = "2026-09-27T12:25:37.857Z" }, + { url = "https://files.pythonhosted.org/packages/29/4e/e1d38d27817d91776ca543d01276a67eace3df675a673cac32b12c167d58/coverage-7.16.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:191803c4996b499fcd78c2ad5e5f767dcc53cb4dc6de6d6a741b443a1821ef02", size = 252751, upload-time = "2026-09-27T12:25:39.291Z" }, + { url = "https://files.pythonhosted.org/packages/0e/19/14a8e44cbb2ad36ae62aaa03c5eec4a06a7f7df220ea3776af26927acbe2/coverage-7.16.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fd670ac43b709c575aefc25bf52d8a598a3bc5017bddfd0a179152ab06a2deb", size = 254616, upload-time = "2026-09-27T12:25:40.506Z" }, + { url = "https://files.pythonhosted.org/packages/f9/72/f5bcad0d9a9b450080032344fbff7ec60c1b0e3d38019f7735dee2b69645/coverage-7.16.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:705e5af11d34647efdc170c7840b6857c81cf74be96419a553f237e68e62cb72", size = 256541, upload-time = "2026-09-27T12:25:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9a/8c735234e8abb52bf5d063f98c780fa942e77c4c0225f9fcb9b33effc346/coverage-7.16.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8afd9bf35cc6a1f22eb3634808fa8e0b91902459c5721ef2e4461dfe771d7f08", size = 251589, upload-time = "2026-09-27T12:25:43.269Z" }, + { url = "https://files.pythonhosted.org/packages/35/6a/1bf6d32e55642d6972aa842640e0a8850e612d19ac7d05e2c32fa59dfd34/coverage-7.16.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3f43bac1856ba269b905302778d4df433d6006489a192174ad77ac528e395032", size = 252660, upload-time = "2026-09-27T12:25:45.051Z" }, + { url = "https://files.pythonhosted.org/packages/53/5b/05b1c0d0e9495cb056155a16066259adc1e935e7411a82a62779bf729975/coverage-7.16.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f8475460aa33ee28ac896ab1156d0bb3b6c639f7f8383c2677d3359eb35f8205", size = 250660, upload-time = "2026-09-27T12:25:46.51Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/5bc3db57fc9c56b4ef055725c38c34faa818a27d17d65e251371f7f0e3a0/coverage-7.16.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d6276d78f6fca7d0ac066d5da4165c5acd07829e8305c2cb900b738fb3a75a72", size = 254479, upload-time = "2026-09-27T12:25:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0f/4a5de66daef26eb919213f63b84f45d5065ab2df94cd767fc7c1174ac5e2/coverage-7.16.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:736fde09ea39646d11f8e3b76bd3425c075aa4dd45f24891970bb77c14ff20f5", size = 250940, upload-time = "2026-09-27T12:25:49.227Z" }, + { url = "https://files.pythonhosted.org/packages/57/d3/84cd6a11e707e422194739e9734d947746efa5e5b3358ff2d601f823ee45/coverage-7.16.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c85d54e7e8a2ca932fe8399301af9b8d5907ea2a455ffaff6e7d1208db83b943", size = 251543, upload-time = "2026-09-27T12:25:50.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/bc/3d84c2e2a95f38e346ce3730d9f0e53114f4e8aace72e6e51e014afb9992/coverage-7.16.2-cp310-cp310-win32.whl", hash = "sha256:5139009b5efd2194fc168ee9362f0e191ba612ef5d29242f9269c22f9b8f80c7", size = 225732, upload-time = "2026-09-27T12:25:51.945Z" }, + { url = "https://files.pythonhosted.org/packages/07/86/31f1f3170571a345ab9d8361a7b2f8e0c5698173fbecd1f7149b6e2089ee/coverage-7.16.2-cp310-cp310-win_amd64.whl", hash = "sha256:c3305c38a2fa21a4254f2ace7dd9ef5fc569c9a558b66e7017650b3d637fb95e", size = 226357, upload-time = "2026-09-27T12:25:53.272Z" }, + { url = "https://files.pythonhosted.org/packages/58/fa/ce3baf63d85b730398d92a7162f486f3a5e4e2cc3382a02488b3943725ba/coverage-7.16.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:732d950e51f3ba4fb6209c73250f3e8924fefca42953ee04a9e65d8c02414d7d", size = 223797, upload-time = "2026-09-27T12:25:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/9ba29c2aac7f756d479f03d45762120060f0f988788001001bf36e0e6fca/coverage-7.16.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5dca0bb66b4c3d624ba047887bf70270030c150692d543cb501293dc38a9f4b5", size = 224303, upload-time = "2026-09-27T12:25:56.214Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7b/0d6d60906dca7d28cc1e3fce12a9861801c4fbb6cbf220ad78cd059c9467/coverage-7.16.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:af2a2a8c7c74de0559e0c368d94c8def9e16c58faaee33a0bf081057c4227e3b", size = 254715, upload-time = "2026-09-27T12:25:57.755Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b8/9198b865679379fb165c689c64f6e11105ef380f6bd1c7673e83f73d9f5c/coverage-7.16.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db5f8394e17f877a625b257f2ba0ce8e728a499c2c1579ad66220272cd3df510", size = 256627, upload-time = "2026-09-27T12:25:59.131Z" }, + { url = "https://files.pythonhosted.org/packages/98/79/9521462cb6072fe394701bc8974b74afd576c9c9355156c7844e1a86a42b/coverage-7.16.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3146d2317c75f70df2509066d979dadd941f7021cdf9b5db4bcd8568258e25", size = 258724, upload-time = "2026-09-27T12:26:00.691Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/8d7d5d633db9fe0f3182fedc731bf09f9bcf2366055735152504ad614677/coverage-7.16.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e1d0ced76318bab499693ff25f64faa343415187cb2e4d7befdfdd391a1cf6a", size = 260692, upload-time = "2026-09-27T12:26:02.083Z" }, + { url = "https://files.pythonhosted.org/packages/4e/a7/76cb09c89ba46d74d37428bf93251fc14fb0bbe9e05cc2a5ef61773d318a/coverage-7.16.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:af98ad5ed9d6daaca956201e00bb429a7eb2b080426686f70a20353e0f9839f5", size = 254800, upload-time = "2026-09-27T12:26:03.369Z" }, + { url = "https://files.pythonhosted.org/packages/72/b6/2351c1979aaeb5b4a8091a75b90ca997ad60de36e181ddba267cf61dac97/coverage-7.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d56e4d21c56d2046447733f8b118409597db48c01efe898ee9ac24e858ec2d6", size = 256423, upload-time = "2026-09-27T12:26:04.751Z" }, + { url = "https://files.pythonhosted.org/packages/0f/f4/ad9a4f8b5cb2d494fa9452b546fe742ed2f9d3847cc14c05e36279a3e649/coverage-7.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1d5d0e3b660506fb84f995814e3118a21efdc0c8eb80127da1be627d90093c17", size = 254487, upload-time = "2026-09-27T12:26:06.082Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1f/a520470472f3e8b01169bf42162b1470c9ba992230432f62ca36269bf3a0/coverage-7.16.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:17228fbca0f22976f797be94e975dcd237799c657d49551c7de1e0654d1202e9", size = 258259, upload-time = "2026-09-27T12:26:07.513Z" }, + { url = "https://files.pythonhosted.org/packages/09/d2/ff26d5938274745855fa61cfcba0245c88ccc10d98d2cbd96064f16cd5a7/coverage-7.16.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:bc0b0ac781d489304b741269857f1f8338b7a26b1b89c06c0344658001ec0035", size = 254216, upload-time = "2026-09-27T12:26:08.982Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1d/5d832d3b06785d9f53267e4f2724a9f60c312eee6ebed9063a461d0d3b45/coverage-7.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bf1bd822ec4e387ed245bed0d71151582cf7be9e5309bc4145eefe36083d5878", size = 255125, upload-time = "2026-09-27T12:26:10.35Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/1d33edbc2fcf7d99e384e393e712aa5a2ebbbd8409825357815982207976/coverage-7.16.2-cp311-cp311-win32.whl", hash = "sha256:7ed238d227e23cc300c3d464babdaf9f6ddc740aa1b15a77ae96136e6a7c4516", size = 225898, upload-time = "2026-09-27T12:26:11.7Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7c/676df4882118756c4f8f560c954eddb93e166d84dda8c5f0b6a829689bde/coverage-7.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:a90700f743e29aa3d75a6ff5f01953176a889c00e526194bc4d281731b88d99d", size = 226369, upload-time = "2026-09-27T12:26:13.375Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0e/a457f4a461b3c5610d845137fdd45fa465e011a64c25af440518ab1f4e41/coverage-7.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:a336eec40e3520d369b8a6cdabb4f596e69a8b42927ca074aa1452fed943238a", size = 226344, upload-time = "2026-09-27T12:26:15.127Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" }, + { url = "https://files.pythonhosted.org/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" }, + { url = "https://files.pythonhosted.org/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" }, + { url = "https://files.pythonhosted.org/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" }, + { url = "https://files.pythonhosted.org/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" }, + { url = "https://files.pythonhosted.org/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" }, + { url = "https://files.pythonhosted.org/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/8eb4f220ef24f84fb27d852d4f9bf83e0c73ec1a4a08dd9a87e3f4529739/coverage-7.16.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1a37c6e478cf687e1aa30a593d19c92c02fad9d122b51ab73f51b8dc7a0c0fc9", size = 223993, upload-time = "2026-09-27T12:26:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/40/23/d4bbaf0c154e0b0c2b5264890dbf6ef098dcb50ec8f2469be9490d191660/coverage-7.16.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0993d0e90858c03943d3cb152e068a20dd4707924deec84dd2230261baae3b1b", size = 224368, upload-time = "2026-09-27T12:26:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/7f/48/fc1e88fd571ec5cb38150b7f89f7696ca1bdf9920e01432febb69774cc85/coverage-7.16.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bb2fc905bbf4e6b7f40806ea79e31515abf6349594cdf0adf27c4215f0463204", size = 255358, upload-time = "2026-09-27T12:26:43.442Z" }, + { url = "https://files.pythonhosted.org/packages/1d/56/6785397d07c29c8e70fbb9a07e97d062b43c21ffc5f12385917847f09f63/coverage-7.16.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4358b9c8c0125b460407f3017c6cce8156e904b32772c5630d27112f52bdbfe5", size = 257955, upload-time = "2026-09-27T12:26:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/27/3b/c8cdd07721e5f99abd81cea970d971997f99bf158c0b85f51bd284179c8b/coverage-7.16.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f15254427c9b33eedac4f198eaf9e356eb4f6214551afb43da6194a2c088ad7", size = 259183, upload-time = "2026-09-27T12:26:47.208Z" }, + { url = "https://files.pythonhosted.org/packages/9b/11/606b192fe43d32574ec6238549d48de588fdcc18485682a5ec0a8ac357f2/coverage-7.16.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9a75a4704ff640e46170042eec1f984385a121227c505d5a16ad8e495f452541", size = 261325, upload-time = "2026-09-27T12:26:49.084Z" }, + { url = "https://files.pythonhosted.org/packages/67/90/eea481f8b0305ceeb33f081a5f47e298391dbd1b589de0c4b3b3aa50d3f2/coverage-7.16.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:14253fc7bb15749b849795a06f5d3b6d8bc3fb8a4b5ddc341faf7a89dce205fc", size = 255531, upload-time = "2026-09-27T12:26:50.509Z" }, + { url = "https://files.pythonhosted.org/packages/6b/be/dedbf9aea1457b120c27ac10b8fc2a357f37fa2b54c3e7286d42980a0a2a/coverage-7.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:921415102a90637fcc2e3f169f61dad7699ecf690e8639fc21b813acbedc0967", size = 257329, upload-time = "2026-09-27T12:26:52.005Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cb/b25c19d5bb2bd0f2e4e27fe8e2ffcae80c7a91ae181c0dc749ed60e9b1a4/coverage-7.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cce2bc991293f15cc4084ca116827b5900c5f34e1a54dfe83f10ab5c43162eb7", size = 255294, upload-time = "2026-09-27T12:26:53.634Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a2/892c5c5f4ad44b7b2ca009aee705191f3f268f15052244f2f9e3539b2e35/coverage-7.16.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1fa594c887365b69745f25a416806e61085dd07b94c9eae68a6e20730629b23", size = 259444, upload-time = "2026-09-27T12:26:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/ed/99/a562537deba0a3e370182ae71c149be796c39d8087365f17a09188f27145/coverage-7.16.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:11e597173af1dc33d5f8a7332ada544199269a223af1ee1770ddd5e245ad0fe8", size = 255111, upload-time = "2026-09-27T12:26:56.851Z" }, + { url = "https://files.pythonhosted.org/packages/2d/20/854ec68641a9b3362ff068a32dfa41637299761617ef253791dbade6fc76/coverage-7.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e7f99698ba3a7d13988bdd984b7ebf13af4dbe2166dc8502eef90d77603b0a4", size = 256882, upload-time = "2026-09-27T12:26:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/db/0d/748e4518b0ac0f9ff2687c248a6e5f8c0737306e709372632a2556f84443/coverage-7.16.2-cp313-cp313-win32.whl", hash = "sha256:f80bd9f9633eafc73d0a913ba2645c96ba58bba1befc30590f7c0fbfde59d865", size = 226045, upload-time = "2026-09-27T12:26:59.983Z" }, + { url = "https://files.pythonhosted.org/packages/31/fa/6e46edba66a183fe4d99d4bb52c173287e9b8dddabe0888d24cb8210e580/coverage-7.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:8be099e979fc42559328a21828281b4578304191ae46ed4e80a407048a82eee6", size = 226581, upload-time = "2026-09-27T12:27:01.494Z" }, + { url = "https://files.pythonhosted.org/packages/1b/d9/9ef6845367600b336ff75d000444a0d32497d6972c833141bd39356abf68/coverage-7.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:28ff850182a67d117990fa2ce5ea1032836d8c9630dae867e8bdd3bff4533b79", size = 226409, upload-time = "2026-09-27T12:27:03.116Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/577fc0803dab4155dcf808faffbdd7b159256781c0874a8586e17b81b149/coverage-7.16.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4ee546b9e4872ffa194bf07ac87bfa1202ebb824d0795dc1ef22f175545ca90a", size = 224138, upload-time = "2026-09-27T12:27:05.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/9e/e3785ba3ecba2bd11efc74bfe2801ca4b78c4480b15a375648d809a59da3/coverage-7.16.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a2fac6895eb299a2e52d7bbb8fb3903502b9da8d3f5309ceb16ec40c646b58ee", size = 224445, upload-time = "2026-09-27T12:27:06.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d0/963ff22d3fd27117da3b8cc442f5bdc91196f783321e1a8ff0ec43476772/coverage-7.16.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:57ff3783f99d75a1e81dd56a9737eb5665e6736a5d93258ba596b6dcad8fd05b", size = 256112, upload-time = "2026-09-27T12:27:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d4/a306940c81c6ae759e82fff27d20b7fdc6896e422b821f51313cce212b6c/coverage-7.16.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f37886699cb9abd29958247d718628d5bc6f39e623dff66a09e546c42a7e03", size = 258727, upload-time = "2026-09-27T12:27:09.927Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a3/d3d99d93b02517087aa05bc0cf2d04d372956b849e5443e059079901429b/coverage-7.16.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fd7a86fdda7cb6d616d178654bd0ad6bc0f3f33c2e478aa598500a1a9e34eda", size = 259909, upload-time = "2026-09-27T12:27:11.55Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/39dd599181726758dd185ae4dc0c0ab3aeabf7ca70e68e145060feeaaa16/coverage-7.16.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac0f3b379c94acc2f7dce5f5f0b24d44fa1cc6a509717ef83dfee07450c2117c", size = 262481, upload-time = "2026-09-27T12:27:13.17Z" }, + { url = "https://files.pythonhosted.org/packages/99/e8/91ee43f6ded411460c359d7e1aebde4d6fd8f00a2e5394182d9d212eb23c/coverage-7.16.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d0732c83746bc24123c581a85d9dd96b70ddb538c9076020aa1a041790361e9", size = 256012, upload-time = "2026-09-27T12:27:14.91Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/e9499ddc33197bd7eabcb1118ca81756fc874457b324e2b479a4804b2ad2/coverage-7.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b451c68218c150f616bc9649783ec8de76a59792c759b43aa0c9c0466a465e4", size = 257898, upload-time = "2026-09-27T12:27:16.588Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6e/c081cb5991a0afba99f9c4ad6c74a5fce9513a38ddc64e3e6680c6fed9af/coverage-7.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a56ac4fa5a75c7e182e8f62600cfb4aff43c5ed7356a034f3557659c3bec1d90", size = 255938, upload-time = "2026-09-27T12:27:18.19Z" }, + { url = "https://files.pythonhosted.org/packages/b2/42/1c3d819e8f9b6eb01c2fe90874d67a8882adb9507e0bbb09361ed131ea89/coverage-7.16.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc4f73aa3fabc36e32046d6cd2971405948d8a903636508a3d3b2f9128b3a95", size = 260368, upload-time = "2026-09-27T12:27:19.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/4f/d70eac07901fd587b6ab05e659b52afe13959992aa5113bf6cce059cc572/coverage-7.16.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:723dcdab91357159b722935b500ee8abc0a66c8c432e1e9fabf4cc7598952de8", size = 255620, upload-time = "2026-09-27T12:27:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/6d87af88317d3d9a9b18a9ca1bc1673eb516917f296e579d0d4a55cb3490/coverage-7.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5397e21a90dde0e9c6896b77ded8f0be26b66f8b22b33aed41f6043ed95d55e6", size = 257521, upload-time = "2026-09-27T12:27:23.358Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/90c2641170d2fa1a6757b3f8450ba2740197317b0ddd749e9604b914e886/coverage-7.16.2-cp314-cp314-win32.whl", hash = "sha256:848893e1d361448c113dc2f0913503522a6f7be231d0e38333d2a22d9698a011", size = 226248, upload-time = "2026-09-27T12:27:25.153Z" }, + { url = "https://files.pythonhosted.org/packages/30/08/d8d0478bb02c8eb0ae20a496fc80c40fcf4d3450bd184300d682ba2d28a6/coverage-7.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a27b731c171e43dc8b5f32b76a5051dde2ec9b9366c87028f08a7088ebc2c7b", size = 226732, upload-time = "2026-09-27T12:27:26.907Z" }, + { url = "https://files.pythonhosted.org/packages/32/3f/0001da22155b0a8ce063ec0f7e64ecbe17b373f306e7a74435f6d6accb72/coverage-7.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:1c569a9fd25505f1cd6bea90588818f90373ce90e2632e2cacf19ddbd6e14fdb", size = 226645, upload-time = "2026-09-27T12:27:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/d7/85/6d8813aff9b8b8586691a9d33c43c5604f7227622574da7cdc3d91a86861/coverage-7.16.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d93db87adb6b1c1b408dce4763314b55d76a9f589e96783a84ac9e7689e48bdf", size = 224871, upload-time = "2026-09-27T12:27:30.32Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/444f3a4981ac2cda40fdcf4cc9b56a4e1a33c222abeb33e51ed3e3eb2a6b/coverage-7.16.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aa62c85046473959c13ba9edca9dc90a77d5c1095b1ba313556314d77fe5b036", size = 225123, upload-time = "2026-09-27T12:27:32.33Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c1/980681cd7b33eb66ac835044116ef0a92e11fcc7bdd866cc89d10b1130b9/coverage-7.16.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db76506aa5416081f3e8974ae0f7965c58ada0bb0ef7339ac86099588dbb20d3", size = 265049, upload-time = "2026-09-27T12:27:34.085Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e3/87679875c33bb2191f0f05544a1cc9adcc940fe0c35443a10f2df753dde5/coverage-7.16.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a0f2285329dac10ab08f79cb11f5692c497018e6c7c511f95e6fd63a70b8f831", size = 267717, upload-time = "2026-09-27T12:27:36.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/64/5d372776d6eb523d4e93bafba2253f96984e3b18261c4cc56a50863c6d0d/coverage-7.16.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:382d3346d56b0eec1b793d53a4c88799c8053f516aa3a8d7c44315696954bacf", size = 270047, upload-time = "2026-09-27T12:27:37.96Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/44082ff0cbf9f97d0043f57970a71204097ec7ba606361a9fd2065393669/coverage-7.16.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:648352b94507179d82637292e7ae8802508d95f78e2f00a705a50b6c48011681", size = 271360, upload-time = "2026-09-27T12:27:39.766Z" }, + { url = "https://files.pythonhosted.org/packages/b8/17/9a215efe25b5e0ecc87c89dbe525c4a87d14d87c8c0c7316ef140a5f6f3e/coverage-7.16.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb2bde05838fffae1a1bf75e5d411a6cac3e4e9bb97e6640fed8cd47888b33f0", size = 264736, upload-time = "2026-09-27T12:27:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/a2/da/7f0a31af8e448107d4d32844bd684757f51ea907bc0c68c8fd537b2123ff/coverage-7.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6a75180829efb8ae62b4aded25be6ddca1c888d138d2d82e21d93bfbd88f41cb", size = 267600, upload-time = "2026-09-27T12:27:43.85Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/3bfecbd3366b775bacdcb3330394d356cf384b5d8f5b2146ac4b14b252b5/coverage-7.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99704f73721e23859112072d522076e11c31744fc96b5652e5dd2018aa4359f7", size = 264767, upload-time = "2026-09-27T12:27:45.768Z" }, + { url = "https://files.pythonhosted.org/packages/b8/3f/5d62163732d87e4a0c4710a0eab30f0fd6a2d480112abe2029f014fe8c9d/coverage-7.16.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:29309ccc86b7f33df7db12813c299f215bbbc470ed6292d0bedd63ffae1ebf64", size = 269026, upload-time = "2026-09-27T12:27:47.787Z" }, + { url = "https://files.pythonhosted.org/packages/49/4d/8e4579f225426535085a9be371cc75e3b026d058d679b80affbdfb4c3ef0/coverage-7.16.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:30c1b65d529e46569899fadca59e4a87c1faf2886923f1307ba61e654d4f3c20", size = 264147, upload-time = "2026-09-27T12:27:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/d1/36/ef1f77e2c3f7bb03c2b13b9a2006f88700fdd75535ef158d70049f425c1c/coverage-7.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcf4bc2aab4e16b1c4c0c2005918f23a7dd5d7821ddae82caed9e3342dc2fcce", size = 266376, upload-time = "2026-09-27T12:27:51.551Z" }, + { url = "https://files.pythonhosted.org/packages/be/79/0cb2bf4428830dec971c718c2c841a039c084415c99e67281f5a72841aab/coverage-7.16.2-cp314-cp314t-win32.whl", hash = "sha256:a9cd3de0a5bfe7b0e21ee10e1a14e3d61bf52efc88217ab1d95d6ace6970bd46", size = 226585, upload-time = "2026-09-27T12:27:53.945Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f9/da17121c16667fd84998e972200ae226a41540f6ea4795776c6d99e8976f/coverage-7.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:611a44e5229a59d7483ce830160e1a0e85f700562c7a5651c7c63fb8f4eb528c", size = 227378, upload-time = "2026-09-27T12:27:55.778Z" }, + { url = "https://files.pythonhosted.org/packages/74/89/01179c62d1b7e6e33bd5001566b02d7f778cf33d3ec1e81e94ca170c517f/coverage-7.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:22957cef43ce038641de78ba995de7568d2d6a37c6ddbf7fa0fd7d1ae2344d91", size = 227064, upload-time = "2026-09-27T12:27:57.496Z" }, + { url = "https://files.pythonhosted.org/packages/4c/57/52935003c3f627ba6e5203d7179aad32448c10899663a30336aba8e81a2c/coverage-7.16.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:414c26dfdb96aac2d570a54e03008f001e32eb2d413705365503648c6bd361d8", size = 224140, upload-time = "2026-09-27T12:27:59.343Z" }, + { url = "https://files.pythonhosted.org/packages/31/38/df472520f3e626524d7e2fc9d6da0afe7895a2f1489d36b48af8ca40bb41/coverage-7.16.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00d3eb96e9988c45f50cccd1f1496571ac5c1f91386ac02c4d55516eeda19a24", size = 224449, upload-time = "2026-09-27T12:28:01.299Z" }, + { url = "https://files.pythonhosted.org/packages/0c/aa/3be084d5b82e63ccdad4ed751e4acbae294673573e30481d29f8b7402eec/coverage-7.16.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4dbbd1155ca46e6e0b6b89d204428c56ef6a459af21333f365d135a2820e5a09", size = 256060, upload-time = "2026-09-27T12:28:03.185Z" }, + { url = "https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8fc15cc8d0d06e873c00ef18e1372d605f9aaf3de27d8c24e50782e75bc8b843", size = 259106, upload-time = "2026-09-27T12:28:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/06/3d/b2d5986f2dd53fe201aa1be2e4ab204fa1aed5101e67c0dbbb419b850aee/coverage-7.16.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c6afdd69218202bc1758c9a14b86b8cf1084f37ed2ca143e567a103772b16d1", size = 260674, upload-time = "2026-09-27T12:28:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7e/b50160be3506ead12e6480d14279af7f0f17627694300a2d1fd2c42d2ff5/coverage-7.16.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aba5c63b7afdc749cc9eae943d5b868cba2b261a176378fa1c5a30bc8bc89982", size = 263139, upload-time = "2026-09-27T12:28:08.771Z" }, + { url = "https://files.pythonhosted.org/packages/14/5e/7c805ac9a32606de1399bd7e9bd375aa2f973dc61b12680d9e6403c2e891/coverage-7.16.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9174f0af24e5eff248b9dbfe76ec5275a3d19d37edbc2810543f12cf97347a34", size = 256591, upload-time = "2026-09-27T12:28:10.842Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9e/76f1ed129a2daf658a3ea17122824cf2e3b91fea0460d8d3664fc5a61018/coverage-7.16.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80e9fdb4c3d926b6ba721d4bf7435bdb869c3527ae7803290361d0ab73db13b6", size = 258708, upload-time = "2026-09-27T12:28:12.962Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b7/8d62e75f48b527619239a65294f842d4b7fd02a0839d43ae1de80184e2df/coverage-7.16.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:7b3bce4a0d05401d70b7d0d5ca783e686bc9d30e81dbd7d980d532609bf809e4", size = 256562, upload-time = "2026-09-27T12:28:14.934Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8d/0a15f95c3afb78e947c52644786ba4bc9de259905687dd720d5e6fae2e76/coverage-7.16.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:44f21e407b278efdfc1ee5e481e00518bd1d500310a30a5fbf2bcbedfef4aaf0", size = 261077, upload-time = "2026-09-27T12:28:17.215Z" }, + { url = "https://files.pythonhosted.org/packages/25/00/88389987305a47d732866c07c8a500000ab574df9505e3114ac69c8d027f/coverage-7.16.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:59c3926585e1cd1f2190f4b2ac9014de1bbeaf0d5d0587b0dc6b0aa90d17896a", size = 256034, upload-time = "2026-09-27T12:28:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/02/34d079d4952ad461bde037d353f9a6e037a7edc45fe0f9ee8781ff73f028/coverage-7.16.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:066429634299e14dd2d511e1e85f8f9cecc500781f6b41907c0dd6f1baea7e63", size = 258034, upload-time = "2026-09-27T12:28:21.242Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d8/3e59a62879285b464ec1b10fd824fbc1af9ce66e842cd39974f80a0becc4/coverage-7.16.2-cp315-cp315-win32.whl", hash = "sha256:893ea9cf86cb8d2546812ac93d973aaf2ee1fb45110a873b014214fd23e3725e", size = 226252, upload-time = "2026-09-27T12:28:23.102Z" }, + { url = "https://files.pythonhosted.org/packages/f4/e1/128026e1b2836e9ad6b219207ba9edf1c5e0088a7869e23088aee7fbbe7a/coverage-7.16.2-cp315-cp315-win_amd64.whl", hash = "sha256:01c6908bc613b420c26c818fe948e1b97dfd041a53c98b01c63bd8321f5c9aae", size = 226728, upload-time = "2026-09-27T12:28:25.21Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/c9fa8e7cf525ca7748ac52b0ee89331d13fe09808e45c679830708782e90/coverage-7.16.2-cp315-cp315-win_arm64.whl", hash = "sha256:967d72c835d7a8cf0af99ec813a2d06e3db6df706402f1fe85b31b437645f495", size = 226648, upload-time = "2026-09-27T12:28:27.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/13/e96b045447a856666f36f9c653e2a80bdaa732aaaf72412b19aa2c26a473/coverage-7.16.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:98d9c97f51b334b0adce7b964442a9af33c1a00c6ac856984cc5dc8d18f81c75", size = 224858, upload-time = "2026-09-27T12:28:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/23/90/087f6ad1bd3df059632ca3407a4e6552ed1053ee35354de0a771acf35423/coverage-7.16.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:3e861f1071dcc2fec1e88bef0920f6b1eaa66a143555b4f8ab79ba2b0f30ef55", size = 225139, upload-time = "2026-09-27T12:28:31.131Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8e/285dcef0184358044e7cbcd810a1bdc9566bc620f54702d605477155df4a/coverage-7.16.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb9d92ecfe2d5b494367c67f7446f8b75b68d8d0c8cf3bc3e6997478be25d9e2", size = 265090, upload-time = "2026-09-27T12:28:33.04Z" }, + { url = "https://files.pythonhosted.org/packages/06/b2/cc83f3a6e5789a4e89059c69555bc641c2efcde568405a1c06fc702951ab/coverage-7.16.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb57acff4a74246ae513c142d4b36e18c389c3aed8661914a53f7cd0071031b2", size = 268254, upload-time = "2026-09-27T12:28:35.135Z" }, + { url = "https://files.pythonhosted.org/packages/ac/41/f548c19530f5d66ac6e3c92bbcbc49da7261de3a458b9f3e54a3efb1a0b2/coverage-7.16.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:444889f7f66b74e4455c0a97e0e166dd41177f1dca8c0239a47cff25e05ba7e1", size = 270695, upload-time = "2026-09-27T12:28:36.959Z" }, + { url = "https://files.pythonhosted.org/packages/94/61/4dc27cf82ef96434d2874110ad0cc10ea4621025705dc5049862bd3bd181/coverage-7.16.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a740ea6f083c6db7b926534d159508f80ba275ab35e722522de0d18d0f56e55f", size = 271853, upload-time = "2026-09-27T12:28:38.821Z" }, + { url = "https://files.pythonhosted.org/packages/38/29/bf8072b1b8bd5f2de8b21460a404460b1a2b97e80a9464c78ec0271f6199/coverage-7.16.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e209591f7c41ae4a9171335cf6156afda0b21de73b02f73f5aa95b2d5fbb08d", size = 265644, upload-time = "2026-09-27T12:28:40.815Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2f/0aecb8721be5cdeb8afd9d6d9f6b463f074e4d8d37f00f4c42442522709f/coverage-7.16.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:396bb16e04ce04efbb3df91456ae4e3da918e69ecdf67fb711b0a0fdf35ccce0", size = 268530, upload-time = "2026-09-27T12:28:42.725Z" }, + { url = "https://files.pythonhosted.org/packages/ab/0b/92b4b7628268ee711249958e68fc0328779bd3d9a7ab4715379465aedb84/coverage-7.16.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9cdf19874e0d247f32f03609200370343c3c7aa260b191d8c2bb251d36198283", size = 265154, upload-time = "2026-09-27T12:28:44.684Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/41c95c1ab29b3dcd357cd1227181d1c98185632aca41ce670ce671b23a43/coverage-7.16.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:fd3d72233eb8b48acc94fa57d44e2d32ce8e7abed02882ccb6d855ccc4ed33ec", size = 269799, upload-time = "2026-09-27T12:28:46.672Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ebeb259aa5362b033a137b86d7274ff4b109d59be8cc9913889b783bf75a/coverage-7.16.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:bb4ffe96aa663cee727659db5a2afeb38c95f8677b747d447b90d6d4874ea2c5", size = 265196, upload-time = "2026-09-27T12:28:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/b2/18/8437620f90d023680a072eee02f968055f3658bbfb7d386d0ea34cfb7f30/coverage-7.16.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dba2edfb054f6d4a08df9d1637c39a5aa3865bca6617c13c86be21e45658a59c", size = 267266, upload-time = "2026-09-27T12:28:51.361Z" }, + { url = "https://files.pythonhosted.org/packages/28/6c/f08e8ee4293e6434035424180bef4d45e028e8ecc006c61bf9453e74405e/coverage-7.16.2-cp315-cp315t-win32.whl", hash = "sha256:251aed777c47c77aba047096d4542889db089227655711dfc2b9c54ef0e15e35", size = 226583, upload-time = "2026-09-27T12:28:53.33Z" }, + { url = "https://files.pythonhosted.org/packages/f7/fd/3f939c2847f4a72c20cff8b1ac33da78ea91a2d38d9b43336e60db719103/coverage-7.16.2-cp315-cp315t-win_amd64.whl", hash = "sha256:2aca0bdfa9e91621d5b09d815357bf63def4fc0e9cb66da67bf2cf93f3b1a6f5", size = 227374, upload-time = "2026-09-27T12:28:55.158Z" }, + { url = "https://files.pythonhosted.org/packages/5a/35/b98cdc354c952402132e675a87f2cc3227fb68f959c84aaa491fbe15933d/coverage-7.16.2-cp315-cp315t-win_arm64.whl", hash = "sha256:b88841e654f09732804809e435b3e005a929ffd9998b872b7b213957b8759cb8", size = 227064, upload-time = "2026-09-27T12:28:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -363,6 +505,8 @@ dev = [ { name = "freezegun" }, { name = "pyright" }, { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -379,6 +523,8 @@ dev = [ { name = "freezegun", specifier = ">=1.5.1" }, { name = "pyright", specifier = ">=1.1.389" }, { name = "pytest", specifier = ">=8.3.3" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "ruff", specifier = ">=0.8.1" }, ] @@ -622,6 +768,34 @@ 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-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[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-dateutil" version = "2.9.0.post0"