From d2355b8ac2faa957697f854de2ef923e0ecbe642 Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 28 Jul 2026 13:09:36 +0200 Subject: [PATCH 1/6] Add full memory support to the Python SDK --- AGENTS.md | 2 +- CHANGELOG.md | 12 +- README.md | 34 +++- examples/09_memory.py | 66 +++++++ examples/README.md | 3 +- src/cominty_sdk/__init__.py | 10 ++ src/cominty_sdk/client.py | 4 +- src/cominty_sdk/models/__init__.py | 7 +- src/cominty_sdk/models/memory.py | 71 ++++++++ src/cominty_sdk/resources/__init__.py | 3 +- src/cominty_sdk/resources/memory.py | 116 ++++++++++++ tests/integration/test_smoke.py | 37 +++- tests/unit/test_memory.py | 250 ++++++++++++++++++++++++++ 13 files changed, 607 insertions(+), 8 deletions(-) create mode 100644 examples/09_memory.py create mode 100644 src/cominty_sdk/models/memory.py create mode 100644 src/cominty_sdk/resources/memory.py create mode 100644 tests/unit/test_memory.py diff --git a/AGENTS.md b/AGENTS.md index 9f630f5..bd27ec3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -448,4 +448,4 @@ One-time PyPI setup (must match the workflow exactly, or PyPI rejects the token) Note: `release.yml` builds + `twine check`s but does **not** run the test suite — tests run in `ci.yml` on push/PR to `main`/`dev`. Only merge to `main` through green CI so a Release -never ships untested code. +never ships untested code. \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 25725c2..9e15737 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `client.memory` — full async CRUD for per-user memory files: `list()`, + `create()`, `get()`, `update()`, `delete()` (`GET/POST /memory`, + `GET/PUT/DELETE /memory/file`). New models `MemoryFileCreate`, + `MemoryFileUpdate`, `MemoryFileOut`, `MemoryFileSummaryOut`. `update()` is a + partial update — pass only the fields you want to change, distinguishing an + omitted field (left untouched) from an explicit `None` (cleared) — and + guards against concurrent writes via an opaque `version` token, raising + `ConflictError` (409) on a stale value. See `examples/09_memory.py`. + ### Changed - `__version__` is now resolved at runtime from installed package metadata (`importlib.metadata.version("cominty-sdk")`) instead of the removed @@ -43,4 +53,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/cominty/python-sdk/compare/v0.1.1...HEAD [0.1.1]: https://github.com/cominty/python-sdk/compare/v0.1.0...v0.1.1 -[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0 +[0.1.0]: https://github.com/cominty/python-sdk/releases/tag/v0.1.0 \ No newline at end of file diff --git a/README.md b/README.md index 3a249ce..201cff1 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,38 @@ await client.threads.update(thread_id, name="Renamed", starred=True) await client.threads.archive(thread_id) ``` +### Memory files + +`client.memory` stores per-user files an agent can read back later — scoped to +the client's `user_id` automatically. + +```python +# Create a file +file = await client.memory.create( + path="preferences/tone.md", purpose="writing style", content="Keep it casual." +) + +# List files (summaries — no content) +for f in await client.memory.list(): + print(f.path, f.purpose, f.version) + +# Read one file's content +file = await client.memory.get("preferences/tone.md") + +# Partial update — only the fields you pass change. `version` guards against +# overwriting a concurrent change: pass back the value from your last read, +# and a stale one raises ConflictError (409). +file = await client.memory.update( + "preferences/tone.md", version=file.version, content="Keep it upbeat." +) + +# Delete +await client.memory.delete("preferences/tone.md") +``` + +`version` is an opaque token — never parse or compare it, just round-trip +whatever the API last gave you. + ## Examples Runnable scripts for each scenario live in [`examples/`](examples/): @@ -294,4 +326,4 @@ A local rehearsal to TestPyPI is available via `uv run invoke publish-test`. ## License -MIT +MIT \ No newline at end of file diff --git a/examples/09_memory.py b/examples/09_memory.py new file mode 100644 index 0000000..8cb3754 --- /dev/null +++ b/examples/09_memory.py @@ -0,0 +1,66 @@ +"""Create, list, read, update, and delete a memory file. + + python examples/09_memory.py + +Demonstrates the full memory resource lifecycle. ``update`` is partial: only +the fields you pass are changed, and ``version`` (an opaque token from the +previous read) guards against overwriting a concurrent change — a stale +``version`` raises ``ConflictError``. The file created here is always deleted +before the script exits, even on error. +""" + +from __future__ import annotations + +import asyncio +from uuid import uuid4 + +import _pretty as pretty +from _shared import make_client + +from cominty_sdk import ConflictError + + +async def main() -> None: + async with make_client() as client: + path = f"sdk-examples/{uuid4()}.md" + + # create() -> the new file, with its initial version token. + created = await client.memory.create( + path=path, + purpose="scratch note for the memory example", + content="Remember to buy milk.", + ) + pretty.console.print(f" [bold]create[/] path={created.path!r}") + + try: + # list() -> lightweight summaries (no content) for every file. + summaries = await client.memory.list() + pretty.console.print(f" [bold]list[/] {len(summaries)} file(s)") + + # get() -> the full file, including content. + fetched = await client.memory.get(path) + pretty.console.print(f" [bold]get[/] content={fetched.content!r}") + + # update() is partial: only content changes here, purpose is untouched. + # version must match the file's current version or this raises + # ConflictError (409) — the API's optimistic-concurrency guard. + updated = await client.memory.update( + path, version=fetched.version, content="Buy oat milk instead." + ) + pretty.console.print(f" [bold]update[/] content={updated.content!r}") + + # Reusing the now-stale version demonstrates the 409 guard. + try: + await client.memory.update( + path, version=fetched.version, content="stale write" + ) + except ConflictError: + pretty.console.print(" [bold]conflict[/] [yellow]stale version rejected[/]") + finally: + # Always clean up the file this example created. + await client.memory.delete(path) + pretty.console.print(" [bold]delete[/] [green]done[/]") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index 9219da5..e2955b3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,5 +34,6 @@ python examples/01_stream_events.py | [`06_manage_thread.py`](06_manage_thread.py) | Get, rename/star, and archive a thread | | [`07_custom_agent.py`](07_custom_agent.py) | Call a custom managed agent (needs `COMINTY_CUSTOM_AGENT_ID`) | | [`08_mcp_linear.py`](08_mcp_linear.py) | Custom agent pulls live context from the Linear MCP server | +| [`09_memory.py`](09_memory.py) | Create, list, read, update, and delete a memory file | -> Shared client setup lives in [`_shared.py`](_shared.py). +> Shared client setup lives in [`_shared.py`](_shared.py). \ No newline at end of file diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index 313c369..ac3c328 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -38,6 +38,12 @@ ThreadSummary, UpdateThreadParams, ) +from .models.memory import ( + MemoryFileCreate, + MemoryFileOut, + MemoryFileSummaryOut, + MemoryFileUpdate, +) from .streaming import AssistantRun, StartedChat try: @@ -81,4 +87,8 @@ "Thread", "ThreadSummary", "UpdateThreadParams", + "MemoryFileCreate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryFileUpdate", ] diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index 566e5dd..07329c1 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -10,6 +10,7 @@ from ._transport import AsyncTransport from .models.chat import validate_user_id from .resources.chat import ChatResource +from .resources.memory import MemoryResource from .resources.threads import ThreadsResource __all__ = ["AsyncCominty"] @@ -53,6 +54,7 @@ def __init__( self._transport = AsyncTransport(self._config) self.chat = ChatResource(self._transport, user_id=self._config.user_id) self.threads = ThreadsResource(self._transport, user_id=self._config.user_id) + self.memory = MemoryResource(self._transport, user_id=self._config.user_id) @property def user_id(self) -> str: @@ -76,4 +78,4 @@ async def __aexit__( await self.close() async def close(self) -> None: - await self._transport.aclose() + await self._transport.aclose() \ No newline at end of file diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 805426a..273efb1 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -18,6 +18,7 @@ Thread, ThreadSummary, ) +from .memory import MemoryFileCreate, MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate __all__ = [ "Agent", @@ -28,10 +29,14 @@ "Message", "MessageRole", "MessageStatus", + "MemoryFileCreate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryFileUpdate", "Question", "ShareLink", "StartChatOptions", "StartChatParams", "Thread", "ThreadSummary", -] +] \ No newline at end of file diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py new file mode 100644 index 0000000..1e7344b --- /dev/null +++ b/src/cominty_sdk/models/memory.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, model_validator + +from .chat import UserId + +__all__ = [ + "MemoryFileCreate", + "MemoryFileUpdate", + "MemoryFileOut", + "MemoryFileSummaryOut", +] + + +class MemoryFileCreate(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + path: str + purpose: str + content: str + user_id: UserId + """Unlike the other memory endpoints, ``POST /memory`` takes ``user_id`` in + the request body rather than as a query parameter — confirmed empirically, + the OpenAPI contract doesn't declare it as a parameter here at all.""" + + +class MemoryFileUpdate(BaseModel): + """Partial update body for ``PUT /memory/file``. + + Built by the resource from only the arguments the caller actually passed, + then dumped with ``exclude_unset=True`` — this is what lets an explicit + ``None`` (clear the field) round-trip differently from an omitted argument + (leave the field untouched), which plain ``exclude_none`` cannot do. + """ + + model_config = ConfigDict(strict=True, extra="forbid") + + content: str | None = None + purpose: str | None = None + + @model_validator(mode="after") + def _require_at_least_one_field(self) -> MemoryFileUpdate: + if not self.model_fields_set: + raise ValueError("at least one of `content` or `purpose` must be provided") + return self + + +class MemoryFileOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + path: str + purpose: str + content: str + created_at: datetime + updated_at: datetime + version: str + """Opaque concurrency token (currently identical to ``updated_at``) — pass + it back unchanged to :meth:`~.resources.memory.MemoryResource.update`. + Never parse, compare, or otherwise interpret its contents.""" + + +class MemoryFileSummaryOut(BaseModel): + model_config = ConfigDict(extra="ignore") + + path: str + purpose: str + created_at: datetime + updated_at: datetime + version: str \ No newline at end of file diff --git a/src/cominty_sdk/resources/__init__.py b/src/cominty_sdk/resources/__init__.py index ab7f6b8..5a473ec 100644 --- a/src/cominty_sdk/resources/__init__.py +++ b/src/cominty_sdk/resources/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .chat import ChatResource +from .memory import MemoryResource from .threads import ThreadsResource -__all__ = ["ChatResource", "ThreadsResource"] +__all__ = ["ChatResource", "ThreadsResource", "MemoryResource"] \ No newline at end of file diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py new file mode 100644 index 0000000..8fa2f02 --- /dev/null +++ b/src/cominty_sdk/resources/memory.py @@ -0,0 +1,116 @@ +"""The memory resource: list, create, read, update, and delete memory files.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Union + +from pydantic import ValidationError + +from ..exceptions import InvalidParams +from ..models.memory import ( + MemoryFileCreate, + MemoryFileOut, + MemoryFileSummaryOut, + MemoryFileUpdate, +) + +if TYPE_CHECKING: + from .._transport import AsyncTransport + +__all__ = ["MemoryResource"] + + +class _Unset: + """Sentinel default for :meth:`MemoryResource.update`'s optional fields. + + Lets the method tell "argument not passed" (leave untouched) apart from + "argument passed as ``None``" (clear the field) — a plain ``None`` default + can't make that distinction. + """ + + def __repr__(self) -> str: + return "UNSET" + + +_UNSET = _Unset() +_OptionalField = Union[str, None, _Unset] + + +class MemoryResource: + def __init__(self, transport: AsyncTransport, *, user_id: str) -> None: + self._transport = transport + self._user_id = user_id + + async def list(self) -> list[MemoryFileSummaryOut]: + """List the current user's memory files (``GET /memory``).""" + raw = await self._transport.request( + "GET", "/memory", params={"user_id": self._user_id} + ) + return [MemoryFileSummaryOut.model_validate(item) for item in raw] + + async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOut: + """Create a memory file (``POST /memory``, 201 Created). + + Unlike every other memory endpoint, ``user_id`` is injected into the + request body here rather than sent as a query param. + """ + try: + params = MemoryFileCreate( + path=path, purpose=purpose, content=content, user_id=self._user_id + ) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="memory.create") from None + raw = await self._transport.request( + "POST", "/memory", json_body=params.model_dump(mode="json") + ) + return MemoryFileOut.model_validate(raw) + + async def get(self, path: str) -> MemoryFileOut: + """Fetch a single memory file (``GET /memory/file``).""" + raw = await self._transport.request( + "GET", "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/memory/file", params={"path": path, "user_id": self._user_id} + ) + return MemoryFileOut.model_validate(raw) + + async def update( + self, + path: str, + *, + version: str, + content: _OptionalField = _UNSET, + purpose: _OptionalField = _UNSET, + ) -> MemoryFileOut: + """Update a memory file's content and/or purpose (``PUT /memory/file``). + + ``version`` is the opaque token from a previously fetched + :class:`~.models.memory.MemoryFileOut` — round-tripped unchanged as a + query param. Raises :class:`~.exceptions.ConflictError` (409) if it no + longer matches the file's current version. + + Only the fields you pass are sent: an omitted ``content``/``purpose`` + leaves that field untouched server-side, while an explicit ``None`` + clears it — the two are not equivalent. Omitting both raises + :class:`~.exceptions.InvalidParams` before any request is sent, since + that call would be a no-op. + """ + fields: dict[str, object] = {} + if not isinstance(content, _Unset): + fields["content"] = content + if not isinstance(purpose, _Unset): + fields["purpose"] = purpose + try: + body_model = MemoryFileUpdate.model_validate(fields) + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="memory.update") from None + body = body_model.model_dump(mode="json", exclude_unset=True) + params = {"path": path, "version": version, "user_id": self._user_id} + raw = await self._transport.request( + "PUT", "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/memory/file", params=params, json_body=body + ) + return MemoryFileOut.model_validate(raw) + + async def delete(self, path: str) -> None: + """Delete a memory file (``DELETE /memory/file``, 204 No Content).""" + await self._transport.request( + "DELETE", "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/memory/file", params={"path": path, "user_id": self._user_id} + ) \ No newline at end of file diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index b9f4b52..7b27e73 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -9,10 +9,11 @@ from __future__ import annotations import os +from uuid import uuid4 import pytest -from cominty_sdk import AsyncCominty +from cominty_sdk import AsyncCominty, ConflictError, NotFoundError pytestmark = pytest.mark.integration @@ -52,3 +53,37 @@ async def test_start_and_get_reply(creds: tuple[str, str], agent_id: str) -> Non reply = await run.result() assert reply.content assert str(reply.thread_id) == str(run.thread.id) + + +@pytest.mark.asyncio +async def test_memory_lifecycle(creds: tuple[str, str]) -> None: + api_key, user_id = creds + async with AsyncCominty(api_token=api_key, user_id=user_id) as client: + path = f"sdk-integration-tests/{uuid4()}.md" + created = await client.memory.create( + path=path, purpose="integration test", content="buy milk" + ) + try: + assert created.path == path + assert created.content == "buy milk" + + summaries = await client.memory.list() + assert any(f.path == path for f in summaries) + + fetched = await client.memory.get(path) + assert fetched.content == "buy milk" + + updated = await client.memory.update( + path, version=fetched.version, content="buy oat milk" + ) + assert updated.content == "buy oat milk" + + with pytest.raises(ConflictError): + await client.memory.update( + path, version=fetched.version, content="stale write" + ) + finally: + await client.memory.delete(path) + + with pytest.raises(NotFoundError): + await client.memory.get(path) diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py new file mode 100644 index 0000000..0d7c445 --- /dev/null +++ b/tests/unit/test_memory.py @@ -0,0 +1,250 @@ +"""Unit tests for the memory resource: list, create, get, update, delete. + +user_id is sourced from the client (set once at construction). Every memory +endpoint takes it as a query param except POST /memory, which takes it in the +request body instead — confirmed against the validated OpenAPI contract. +""" + +from __future__ import annotations + +import json + +import httpx +import pytest +import respx + +from cominty_sdk import ( + AsyncCominty, + ConflictError, + InvalidParams, + MemoryFileOut, + MemoryFileSummaryOut, +) + +USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" + + +def _file( + path: str = "notes/todo.md", + *, + purpose: str = "scratch notes", + content: str = "buy milk", + version: str = "v1", +) -> dict[str, object]: + return { + "path": path, + "purpose": purpose, + "content": content, + "created_at": "2026-06-28T10:00:00Z", + "updated_at": "2026-06-28T10:00:00Z", + "version": version, + } + + +def _summary( + path: str = "notes/todo.md", *, purpose: str = "scratch notes", version: str = "v1" +) -> dict[str, object]: + return { + "path": path, + "purpose": purpose, + "created_at": "2026-06-28T10:00:00Z", + "updated_at": "2026-06-28T10:00:00Z", + "version": version, + } + + +# --------------------------------------------------------------------------- # +# list +# --------------------------------------------------------------------------- # +async def test_list_scopes_to_client_user_id( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("/memory").mock( + return_value=httpx.Response(200, json=[_summary("a"), _summary("b")]) + ) + + files = await client.memory.list() + + assert [f.path for f in files] == ["a", "b"] + assert all(isinstance(f, MemoryFileSummaryOut) for f in files) + params = route.calls.last.request.url.params + assert params["user_id"] == USER_ID + assert route.calls.last.request.method == "GET" + + +# --------------------------------------------------------------------------- # +# create +# --------------------------------------------------------------------------- # +async def test_create_sends_user_id_in_body_not_query( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/memory").mock( + return_value=httpx.Response(201, json=_file()) + ) + + result = await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + + request = route.calls.last.request + assert request.method == "POST" + # user_id belongs in the body here — every other memory endpoint puts it + # in the query string instead. + assert "user_id" not in request.url.params + body = json.loads(request.content) + assert body == { + "path": "notes/todo.md", + "purpose": "scratch notes", + "content": "buy milk", + "user_id": USER_ID, + } + assert isinstance(result, MemoryFileOut) + assert result.path == "notes/todo.md" + + +async def test_create_conflict_raises_conflict_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.post("/memory").mock( + return_value=httpx.Response( + 409, json={"detail": "A memory file already exists at 'notes/todo.md'."} + ) + ) + + with pytest.raises(ConflictError): + await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + + +# --------------------------------------------------------------------------- # +# get +# --------------------------------------------------------------------------- # +async def test_get_sends_path_and_user_id_as_query( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("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/memory/file").mock( + return_value=httpx.Response(200, json=_file()) + ) + + result = await client.memory.get("notes/todo.md") + + params = route.calls.last.request.url.params + assert params["path"] == "notes/todo.md" + assert params["user_id"] == USER_ID + assert isinstance(result, MemoryFileOut) + assert result.content == "buy milk" + + +# --------------------------------------------------------------------------- # +# update +# --------------------------------------------------------------------------- # +async def test_update_omitted_field_is_excluded_from_body( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("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/memory/file").mock( + return_value=httpx.Response(200, json=_file(content="new content", version="v2")) + ) + + result = await client.memory.update("notes/todo.md", version="v1", content="new content") + + request = route.calls.last.request + # purpose was never passed -> excluded entirely, not sent as null. + assert json.loads(request.content) == {"content": "new content"} + params = request.url.params + assert params["path"] == "notes/todo.md" + assert params["version"] == "v1" + assert params["user_id"] == USER_ID + assert isinstance(result, MemoryFileOut) + assert result.version == "v2" + + +async def test_update_explicit_none_clears_field( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("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/memory/file").mock(return_value=httpx.Response(200, json=_file())) + + await client.memory.update("notes/todo.md", version="v1", purpose=None) + + # An explicit None round-trips as a JSON null, distinct from being omitted. + assert json.loads(route.calls.last.request.content) == {"purpose": None} + + +async def test_update_no_fields_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.update("notes/todo.md", version="v1") + + # Rejected client-side before any request is sent — a no-op PUT would just + # waste a round trip and silently mask a caller bug. + assert mock_api.calls.call_count == 0 + + +async def test_update_version_round_trips_unchanged( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("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/memory/file").mock(return_value=httpx.Response(200, json=_file())) + + opaque_version = "W/\"2026-06-28T10:00:00Z-xyz\"" + await client.memory.update("notes/todo.md", version=opaque_version, content="x") + + assert route.calls.last.request.url.params["version"] == opaque_version + + +async def test_update_conflict_raises_conflict_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.put("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/memory/file").mock( + return_value=httpx.Response(409, json={"detail": "version mismatch"}) + ) + + with pytest.raises(ConflictError): + await client.memory.update("notes/todo.md", version="stale", content="x") + + +# --------------------------------------------------------------------------- # +# delete +# --------------------------------------------------------------------------- # +async def test_delete_sends_query_and_returns_none( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.delete("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/memory/file").mock(return_value=httpx.Response(204)) + + result = await client.memory.delete("notes/todo.md") + + assert result is None + request = route.calls.last.request + assert request.method == "DELETE" + assert request.url.params["path"] == "notes/todo.md" + assert request.url.params["user_id"] == USER_ID + + +# --------------------------------------------------------------------------- # +# lifecycle +# --------------------------------------------------------------------------- # +async def test_full_lifecycle(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + mock_api.post("/memory").mock(return_value=httpx.Response(201, json=_file(version="v1"))) + mock_api.get("/memory").mock(return_value=httpx.Response(200, json=[_summary()])) + mock_api.get("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/memory/file").mock(return_value=httpx.Response(200, json=_file(version="v1"))) + mock_api.put("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/memory/file").mock( + return_value=httpx.Response(200, json=_file(content="updated", version="v2")) + ) + mock_api.delete("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/memory/file").mock(return_value=httpx.Response(204)) + + created = await client.memory.create( + path="notes/todo.md", purpose="scratch notes", content="buy milk" + ) + listed = await client.memory.list() + fetched = await client.memory.get(created.path) + updated = await client.memory.update( + fetched.path, version=fetched.version, content="updated" + ) + deleted = await client.memory.delete(updated.path) + + assert created.path == "notes/todo.md" + assert listed[0].path == "notes/todo.md" + assert fetched.version == "v1" + assert updated.content == "updated" + assert updated.version == "v2" + assert deleted is None \ No newline at end of file From 7499616f1cf6c60e36aa574eaef3d667eac89e31 Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 28 Jul 2026 14:17:16 +0200 Subject: [PATCH 2/6] docs: trim redundant docstring commentary in memory module --- src/cominty_sdk/models/memory.py | 14 +++++--------- src/cominty_sdk/resources/memory.py | 22 ++++++---------------- tests/unit/test_memory.py | 5 ++--- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py index 1e7344b..ea40ac7 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -22,17 +22,14 @@ class MemoryFileCreate(BaseModel): content: str user_id: UserId """Unlike the other memory endpoints, ``POST /memory`` takes ``user_id`` in - the request body rather than as a query parameter — confirmed empirically, - the OpenAPI contract doesn't declare it as a parameter here at all.""" + the request body rather than as a query parameter.""" class MemoryFileUpdate(BaseModel): """Partial update body for ``PUT /memory/file``. - Built by the resource from only the arguments the caller actually passed, - then dumped with ``exclude_unset=True`` — this is what lets an explicit - ``None`` (clear the field) round-trip differently from an omitted argument - (leave the field untouched), which plain ``exclude_none`` cannot do. + Dumped with ``exclude_unset=True`` so an explicit ``None`` (clear the + field) round-trips differently from an omitted argument (leave untouched). """ model_config = ConfigDict(strict=True, extra="forbid") @@ -56,9 +53,8 @@ class MemoryFileOut(BaseModel): created_at: datetime updated_at: datetime version: str - """Opaque concurrency token (currently identical to ``updated_at``) — pass - it back unchanged to :meth:`~.resources.memory.MemoryResource.update`. - Never parse, compare, or otherwise interpret its contents.""" + """Opaque concurrency token — pass it back unchanged to + :meth:`~.resources.memory.MemoryResource.update`.""" class MemoryFileSummaryOut(BaseModel): diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index 8fa2f02..97b86b8 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -21,12 +21,8 @@ class _Unset: - """Sentinel default for :meth:`MemoryResource.update`'s optional fields. - - Lets the method tell "argument not passed" (leave untouched) apart from - "argument passed as ``None``" (clear the field) — a plain ``None`` default - can't make that distinction. - """ + """Sentinel default distinguishing "not passed" from "passed as ``None``" + for :meth:`MemoryResource.update`'s optional fields.""" def __repr__(self) -> str: return "UNSET" @@ -82,16 +78,10 @@ async def update( ) -> MemoryFileOut: """Update a memory file's content and/or purpose (``PUT /memory/file``). - ``version`` is the opaque token from a previously fetched - :class:`~.models.memory.MemoryFileOut` — round-tripped unchanged as a - query param. Raises :class:`~.exceptions.ConflictError` (409) if it no - longer matches the file's current version. - - Only the fields you pass are sent: an omitted ``content``/``purpose`` - leaves that field untouched server-side, while an explicit ``None`` - clears it — the two are not equivalent. Omitting both raises - :class:`~.exceptions.InvalidParams` before any request is sent, since - that call would be a no-op. + Partial: only the fields you pass are sent, and an explicit ``None`` + clears a field rather than leaving it untouched. ``version`` is the + opaque token from a previous read; a stale one raises + :class:`~.exceptions.ConflictError` (409). """ fields: dict[str, object] = {} if not isinstance(content, _Unset): diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index 0d7c445..c5c1314 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -2,7 +2,7 @@ user_id is sourced from the client (set once at construction). Every memory endpoint takes it as a query param except POST /memory, which takes it in the -request body instead — confirmed against the validated OpenAPI contract. +request body instead. """ from __future__ import annotations @@ -176,8 +176,7 @@ async def test_update_no_fields_raises_invalid_params( with pytest.raises(InvalidParams): await client.memory.update("notes/todo.md", version="v1") - # Rejected client-side before any request is sent — a no-op PUT would just - # waste a round trip and silently mask a caller bug. + # Rejected client-side before any request is sent. assert mock_api.calls.call_count == 0 From 67c42ebfb8142844145a5eda9a2e96364c9acc8d Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Fri, 31 Jul 2026 16:12:13 +0200 Subject: [PATCH 3/6] fix: reject null updates and over-deep memory paths locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both looked like they'd work but the live API silently no-ops on them (200, nothing actually changes) instead of erroring — now caught client-side with InvalidParams so callers don't get a false success. --- CHANGELOG.md | 16 ++++-- README.md | 12 +++++ src/cominty_sdk/models/memory.py | 59 +++++++++++++++++++-- src/cominty_sdk/resources/memory.py | 42 +++++++++++++-- tests/unit/test_memory.py | 79 ++++++++++++++++++++++++++--- 5 files changed, 188 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e15737..1f85d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `create()`, `get()`, `update()`, `delete()` (`GET/POST /memory`, `GET/PUT/DELETE /memory/file`). New models `MemoryFileCreate`, `MemoryFileUpdate`, `MemoryFileOut`, `MemoryFileSummaryOut`. `update()` is a - partial update — pass only the fields you want to change, distinguishing an - omitted field (left untouched) from an explicit `None` (cleared) — and - guards against concurrent writes via an opaque `version` token, raising - `ConflictError` (409) on a stale value. See `examples/09_memory.py`. + partial update — pass only the fields you want to change; the API does not + support clearing `content`/`purpose` once set (a `null` is silently ignored + server-side), so passing `content=None`/`purpose=None` raises + `InvalidParams` locally instead of sending a request that looks like it + succeeded but did nothing. `path` may have at most one folder segment + (`"folder/file.md"`, not `"a/b/file.md"`) — checked locally, also raising + `InvalidParams`, since the API only enforces this after a round trip. + `content` may be an empty string (no minimum length). Guards against + concurrent writes via an opaque `version` token, raising `ConflictError` + (409) on a stale value — a malformed `version` raises `APIError` (422) + instead. `delete()` is not idempotent: deleting an already-deleted path + raises `NotFoundError` (404). See `examples/09_memory.py`. ### Changed - `__version__` is now resolved at runtime from installed package metadata diff --git a/README.md b/README.md index 201cff1..eabeb98 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,18 @@ await client.memory.delete("preferences/tone.md") `version` is an opaque token — never parse or compare it, just round-trip whatever the API last gave you. +There's currently no way to clear `content` or `purpose` once set — the API +ignores an explicit `null` (leaves the existing value untouched), so +`memory.update(..., content=None)` raises `InvalidParams` locally rather than +sending a request that looks like it succeeded but did nothing. + +A few other things worth knowing: +- `path` may have at most one folder segment — `"preferences/tone.md"` is + fine, `"a/b/tone.md"` isn't (raises `InvalidParams` locally). +- `content` may be an empty string; there's no minimum length. +- `memory.delete()` is not idempotent — deleting an already-deleted path + raises `NotFoundError`, not a repeated success. + ## Examples Runnable scripts for each scenario live in [`examples/`](examples/): diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py index ea40ac7..007962e 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -1,8 +1,10 @@ from __future__ import annotations from datetime import datetime +from typing import Annotated -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator +from typing_extensions import TypeAlias from .chat import UserId @@ -11,13 +13,46 @@ "MemoryFileUpdate", "MemoryFileOut", "MemoryFileSummaryOut", + "MemoryPath", + "MemoryPathParam", + "validate_memory_path", ] +# Not in the OpenAPI spec — found by manually exercising the live API: a path +# with more than one folder segment (e.g. "a/b/file.md") is rejected with a +# 422 "Maximum folder depth is 1". Checked locally so a too-deep path fails +# before a request, not after a round trip. +_MAX_PATH_DEPTH = 1 + + +def validate_memory_path(value: str) -> str: + """Return ``value`` if it's within the API's folder-depth limit, else raise.""" + depth = value.count("/") + if depth > _MAX_PATH_DEPTH: + raise ValueError( + f"path {value!r} has {depth} folder levels; the API allows at most " + f"{_MAX_PATH_DEPTH} (e.g. 'folder/file.md' is fine, 'a/b/file.md' isn't)" + ) + return value + + +MemoryPath: TypeAlias = Annotated[str, AfterValidator(validate_memory_path)] +"""A memory file path, folder-depth-checked before any request is sent.""" + + +class MemoryPathParam(BaseModel): + """Validates a bare ``path`` argument (``get``/``update``/``delete``, + which don't otherwise go through a request-body model).""" + + model_config = ConfigDict(strict=True) + + path: MemoryPath + class MemoryFileCreate(BaseModel): model_config = ConfigDict(strict=True, extra="forbid") - path: str + path: MemoryPath purpose: str content: str user_id: UserId @@ -28,8 +63,12 @@ class MemoryFileCreate(BaseModel): class MemoryFileUpdate(BaseModel): """Partial update body for ``PUT /memory/file``. - Dumped with ``exclude_unset=True`` so an explicit ``None`` (clear the - field) round-trips differently from an omitted argument (leave untouched). + Dumped with ``exclude_unset=True`` so only explicitly-passed fields are + sent. The API does not currently support clearing ``content``/``purpose`` + once set — a ``null`` is silently ignored server-side (200, value + unchanged) rather than clearing the field. To avoid that confusing + silent-no-op, this model rejects an explicit ``None`` locally instead of + forwarding it. """ model_config = ConfigDict(strict=True, extra="forbid") @@ -43,6 +82,18 @@ def _require_at_least_one_field(self) -> MemoryFileUpdate: raise ValueError("at least one of `content` or `purpose` must be provided") return self + @model_validator(mode="after") + def _reject_explicit_none(self) -> MemoryFileUpdate: + nulled = sorted(name for name in self.model_fields_set if getattr(self, name) is None) + if nulled: + fields = " and ".join(nulled) + raise ValueError( + f"{fields} cannot be set to None: the API does not support " + "clearing a field once set (it's currently a silent no-op) — " + "omit the argument instead of passing None" + ) + return self + class MemoryFileOut(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index 97b86b8..a997a4a 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -12,6 +12,7 @@ MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate, + MemoryPathParam, ) if TYPE_CHECKING: @@ -37,6 +38,13 @@ def __init__(self, transport: AsyncTransport, *, user_id: str) -> None: self._transport = transport self._user_id = user_id + @staticmethod + def _validate_path(path: str, *, context: str) -> str: + try: + return MemoryPathParam(path=path).path + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context=context) from None + async def list(self) -> list[MemoryFileSummaryOut]: """List the current user's memory files (``GET /memory``).""" raw = await self._transport.request( @@ -48,7 +56,13 @@ async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOu """Create a memory file (``POST /memory``, 201 Created). Unlike every other memory endpoint, ``user_id`` is injected into the - request body here rather than sent as a query param. + request body here rather than sent as a query param. ``path`` may have + at most one folder segment (``"folder/file.md"``, not + ``"a/b/file.md"``); a deeper path raises + :class:`~.exceptions.InvalidParams` locally. ``content`` may be an + empty string — the API doesn't enforce a minimum length. Creating at a + ``path`` that already exists raises + :class:`~.exceptions.ConflictError` (409). """ try: params = MemoryFileCreate( @@ -63,6 +77,7 @@ async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOu async def get(self, path: str) -> MemoryFileOut: """Fetch a single memory file (``GET /memory/file``).""" + path = self._validate_path(path, context="memory.get") raw = await self._transport.request( "GET", "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/memory/file", params={"path": path, "user_id": self._user_id} ) @@ -78,11 +93,22 @@ async def update( ) -> MemoryFileOut: """Update a memory file's content and/or purpose (``PUT /memory/file``). - Partial: only the fields you pass are sent, and an explicit ``None`` - clears a field rather than leaving it untouched. ``version`` is the - opaque token from a previous read; a stale one raises + Partial: only the fields you pass are sent. ``version`` is the opaque + token from a previous read; a stale one raises :class:`~.exceptions.ConflictError` (409). + + The API does not currently support clearing ``content``/``purpose`` + once set — passing ``content=None`` or ``purpose=None`` raises + :class:`~.exceptions.InvalidParams` locally rather than silently + sending a ``null`` the server would ignore. Omit the argument to + leave a field untouched. + + ``version`` must be a real version token from a previous read, not an + arbitrary string — a well-formed but stale one raises + :class:`~.exceptions.ConflictError` (409), a malformed one raises + :class:`~.exceptions.APIError` (422). """ + path = self._validate_path(path, context="memory.update") fields: dict[str, object] = {} if not isinstance(content, _Unset): fields["content"] = content @@ -100,7 +126,13 @@ async def update( return MemoryFileOut.model_validate(raw) async def delete(self, path: str) -> None: - """Delete a memory file (``DELETE /memory/file``, 204 No Content).""" + """Delete a memory file (``DELETE /memory/file``, 204 No Content). + + Not idempotent: deleting an already-deleted (or never-existing) path + raises :class:`~.exceptions.NotFoundError` (404) rather than + succeeding again. + """ + path = self._validate_path(path, context="memory.delete") await self._transport.request( "DELETE", "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/memory/file", params={"path": path, "user_id": self._user_id} ) \ No newline at end of file diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index c5c1314..74a38be 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -117,6 +117,62 @@ async def test_create_conflict_raises_conflict_error( ) +async def test_create_empty_content_is_allowed( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + # The API has no minimum-length constraint on content. + mock_api.post("/memory").mock(return_value=httpx.Response(201, json=_file(content=""))) + + result = await client.memory.create(path="notes/todo.md", purpose="scratch notes", content="") + + assert result.content == "" + + +# --------------------------------------------------------------------------- # +# path folder-depth limit (create/get/update/delete) +# --------------------------------------------------------------------------- # +# Not in the OpenAPI spec — the live API rejects more than one folder segment +# with a 422 ("Maximum folder depth is 1"). Checked locally in all 4 methods +# that take a path, so it fails before a request, not after a round trip. +TOO_DEEP_PATH = "a/b/c.md" + + +async def test_create_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.create(path=TOO_DEEP_PATH, purpose="x", content="y") + + assert mock_api.calls.call_count == 0 + + +async def test_get_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.get(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + +async def test_update_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.update(TOO_DEEP_PATH, version="v1", content="x") + + assert mock_api.calls.call_count == 0 + + +async def test_delete_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.delete(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + # --------------------------------------------------------------------------- # # get # --------------------------------------------------------------------------- # @@ -159,15 +215,24 @@ async def test_update_omitted_field_is_excluded_from_body( assert result.version == "v2" -async def test_update_explicit_none_clears_field( - client: AsyncCominty, mock_api: respx.MockRouter +@pytest.mark.parametrize( + "kwargs", + [ + {"purpose": None}, + {"content": None}, + {"content": "new content", "purpose": None}, + ], +) +async def test_update_explicit_none_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter, kwargs: dict[str, object] ) -> None: - route = mock_api.put("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/memory/file").mock(return_value=httpx.Response(200, json=_file())) - - await client.memory.update("notes/todo.md", version="v1", purpose=None) + # The API silently ignores an explicit null (200, value unchanged) instead + # of clearing the field, so the SDK rejects it client-side rather than + # sending a request that looks like it succeeded but did nothing. + with pytest.raises(InvalidParams): + await client.memory.update("notes/todo.md", version="v1", **kwargs) - # An explicit None round-trips as a JSON null, distinct from being omitted. - assert json.loads(route.calls.last.request.content) == {"purpose": None} + assert mock_api.calls.call_count == 0 async def test_update_no_fields_raises_invalid_params( From c644aeafbe1eeda311bffd9ba025cd61d3b9c80c Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Mon, 3 Aug 2026 15:46:35 +0200 Subject: [PATCH 4/6] docs: reorganize memory tests and drop remaining redundant comments --- src/cominty_sdk/models/memory.py | 7 ++-- tests/unit/test_memory.py | 71 ++++++++++++++------------------ 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py index 007962e..1f90006 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -18,10 +18,9 @@ "validate_memory_path", ] -# Not in the OpenAPI spec — found by manually exercising the live API: a path -# with more than one folder segment (e.g. "a/b/file.md") is rejected with a -# 422 "Maximum folder depth is 1". Checked locally so a too-deep path fails -# before a request, not after a round trip. + +# A path with more than one folder segment (e.g. "a/b/file.md") is rejected with a +# 422 "Maximum folder depth is 1". _MAX_PATH_DEPTH = 1 diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index 74a38be..da351fc 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -23,6 +23,10 @@ USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" +# The live API rejects a path with more than one folder segment (422 "Maximum +# folder depth is 1"). +TOO_DEEP_PATH = "a/b/c.md" + def _file( path: str = "notes/todo.md", @@ -88,8 +92,6 @@ async def test_create_sends_user_id_in_body_not_query( request = route.calls.last.request assert request.method == "POST" - # user_id belongs in the body here — every other memory endpoint puts it - # in the query string instead. assert "user_id" not in request.url.params body = json.loads(request.content) assert body == { @@ -128,15 +130,6 @@ async def test_create_empty_content_is_allowed( assert result.content == "" -# --------------------------------------------------------------------------- # -# path folder-depth limit (create/get/update/delete) -# --------------------------------------------------------------------------- # -# Not in the OpenAPI spec — the live API rejects more than one folder segment -# with a 422 ("Maximum folder depth is 1"). Checked locally in all 4 methods -# that take a path, so it fails before a request, not after a round trip. -TOO_DEEP_PATH = "a/b/c.md" - - async def test_create_path_too_deep_raises_invalid_params( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: @@ -146,33 +139,6 @@ async def test_create_path_too_deep_raises_invalid_params( assert mock_api.calls.call_count == 0 -async def test_get_path_too_deep_raises_invalid_params( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - with pytest.raises(InvalidParams): - await client.memory.get(TOO_DEEP_PATH) - - assert mock_api.calls.call_count == 0 - - -async def test_update_path_too_deep_raises_invalid_params( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - with pytest.raises(InvalidParams): - await client.memory.update(TOO_DEEP_PATH, version="v1", content="x") - - assert mock_api.calls.call_count == 0 - - -async def test_delete_path_too_deep_raises_invalid_params( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - with pytest.raises(InvalidParams): - await client.memory.delete(TOO_DEEP_PATH) - - assert mock_api.calls.call_count == 0 - - # --------------------------------------------------------------------------- # # get # --------------------------------------------------------------------------- # @@ -192,6 +158,15 @@ async def test_get_sends_path_and_user_id_as_query( assert result.content == "buy milk" +async def test_get_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.get(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + # --------------------------------------------------------------------------- # # update # --------------------------------------------------------------------------- # @@ -205,7 +180,6 @@ async def test_update_omitted_field_is_excluded_from_body( result = await client.memory.update("notes/todo.md", version="v1", content="new content") request = route.calls.last.request - # purpose was never passed -> excluded entirely, not sent as null. assert json.loads(request.content) == {"content": "new content"} params = request.url.params assert params["path"] == "notes/todo.md" @@ -241,7 +215,6 @@ async def test_update_no_fields_raises_invalid_params( with pytest.raises(InvalidParams): await client.memory.update("notes/todo.md", version="v1") - # Rejected client-side before any request is sent. assert mock_api.calls.call_count == 0 @@ -267,6 +240,15 @@ async def test_update_conflict_raises_conflict_error( await client.memory.update("notes/todo.md", version="stale", content="x") +async def test_update_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.update(TOO_DEEP_PATH, version="v1", content="x") + + assert mock_api.calls.call_count == 0 + + # --------------------------------------------------------------------------- # # delete # --------------------------------------------------------------------------- # @@ -284,6 +266,15 @@ async def test_delete_sends_query_and_returns_none( assert request.url.params["user_id"] == USER_ID +async def test_delete_path_too_deep_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.delete(TOO_DEEP_PATH) + + assert mock_api.calls.call_count == 0 + + # --------------------------------------------------------------------------- # # lifecycle # --------------------------------------------------------------------------- # From 09b6ce98616c26bb370c0ed19fbfc729ebee733d Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Wed, 5 Aug 2026 09:44:53 +0200 Subject: [PATCH 5/6] feat: add task to bump, validate, commit, and tag releases --- AGENTS.md | 36 +++++++--- CHANGELOG.md | 5 ++ README.md | 26 +++++-- pyproject.toml | 2 + tasks.py | 153 ++++++++++++++++++++++++++++++++++++++-- tests/test_release.py | 159 ++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 + 7 files changed, 365 insertions(+), 18 deletions(-) create mode 100644 tests/test_release.py diff --git a/AGENTS.md b/AGENTS.md index bd27ec3..9527002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -400,6 +400,7 @@ uv run invoke clean # remove dist/ build/ *.egg-info (no stale artifa uv run invoke build # clean, then `uv build` -> sdist + wheel in dist/ uv run invoke check # build, `twine check dist/*`, and print sdist + wheel contents uv run invoke publish-test # check, then upload to TestPyPI (rehearsal — never PyPI) +uv run invoke release --patch # bump + validate + commit + tag (see §12.3) — modifies Git state ``` `check` is the gate to run before cutting a release. It confirms: @@ -414,14 +415,33 @@ uv run invoke publish-test # check, then upload to TestPyPI (rehearsal — n ### 12.3 Cutting a release (steps) -1. **Bump the version** in `pyproject.toml`'s `[project] version` (the only place — see §12.1). -2. **Verify locally**: `uv run invoke check` (and `uv run invoke publish-test` for a dry run). -3. **Commit + tag**: `git commit -am "Release X.Y.Z"` then `git tag vX.Y.Z`; push both. - The tag `vX.Y.Z` must equal `pyproject.toml`'s version — the build derives the version from - that file, **not** the tag. -4. **Publish via a GitHub Release** — create/publish a Release for tag `vX.Y.Z` - (`gh release create vX.Y.Z` or the GitHub UI). Publishing the Release is what triggers - the `release` workflow; a plain tag push does not. +> **`uv run invoke release` modifies repository files and CREATES A GIT COMMIT AND TAG.** +> It never pushes and never creates the GitHub Release — that's step 2 below, always manual. + +1. **Bump, validate, commit, and tag** — pick exactly one mode: + + ```bash + uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1) + uv run invoke release --minor # X.Y.Z -> X.(Y+1).0 + uv run invoke release --major # X.Y.Z -> (X+1).0.0 + uv run invoke release --version X.Y.Z # explicit target instead of incrementing + ``` + + The task (`tasks.py`): rejects zero or more than one of the four flags; requires a clean + working tree, a target version strictly greater than the current one, and no pre-existing + `vX.Y.Z` tag — all checked **before** touching any file. It then bumps `pyproject.toml`'s + `[project] version` (the only place — see §12.1), regenerates `uv.lock` via `uv lock`, and + runs the same lint/type-check/test/build gate as `uv run invoke check`. If any of that + fails, the file changes are rolled back and nothing is committed. On success it creates one + commit (`chore(release): version X.Y.Z`) and one **annotated** tag (`vX.Y.Z`), and prints + the exact next commands — it does not run them for you. +2. **Push, then publish via a GitHub Release** — `git push origin HEAD && git push origin + vX.Y.Z`, then create/publish a Release for that tag (`gh release create vX.Y.Z + --generate-notes` or the GitHub UI). Publishing the Release is what triggers the `release` + workflow; a plain tag push does not. + +`tests/test_release.py` covers the task itself (patch/minor/major/explicit-version success, +plus every validation failure) against disposable temp Git repos — never this repository. `uv run invoke publish` (direct upload to real PyPI) exists as a manual fallback only. The **preferred** path is the GitHub Release → CI flow below, so no PyPI token lives on a laptop. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f85d4f..8f93dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (409) on a stale value — a malformed `version` raises `APIError` (422) instead. `delete()` is not idempotent: deleting an already-deleted path raises `NotFoundError` (404). See `examples/09_memory.py`. +- `uv run invoke release --patch|--minor|--major|--version X.Y.Z` — a dev-only + task that bumps `pyproject.toml`, regenerates `uv.lock`, runs the lint/ + type-check/test/build gate, then creates one release commit and one + annotated `vX.Y.Z` tag. Never pushes or creates the GitHub Release; prints + the exact next commands instead. See `AGENTS.md` §12.3. ### Changed - `__version__` is now resolved at runtime from installed package metadata diff --git a/README.md b/README.md index eabeb98..31aaeee 100644 --- a/README.md +++ b/README.md @@ -324,14 +324,28 @@ See [AGENTS.md](AGENTS.md) for coding conventions (typing, versioning, models). Publishing to PyPI uses **Trusted Publishing (OIDC)** — no tokens stored in GitHub — and is triggered by publishing a **GitHub Release** (`.github/workflows/release.yml`). The published version comes from -`pyproject.toml`, so the tag is cosmetic; keep them in sync. +`pyproject.toml`. + +**`uv run invoke release` modifies repository files and CREATES A GIT COMMIT +AND TAG.** It bumps `pyproject.toml`, regenerates `uv.lock`, runs the lint / +type-check / test / build gate, then commits and tags — it never pushes and +never creates the GitHub Release itself. ```bash -# 1. bump the version in pyproject.toml -# 2. commit on main and push -# 3. create the release — this tags and triggers the publish -gh release create v0.4.0 --title "v0.4.0" --generate-notes -# pre-release rehearsal: gh release create v0.4.0rc1 --prerelease --generate-notes +# 1. bump, validate, commit, and tag locally — pick exactly one +uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1) +uv run invoke release --minor # X.Y.Z -> X.(Y+1).0 +uv run invoke release --major # X.Y.Z -> (X+1).0.0 +uv run invoke release --version X.Y.Z # set an explicit version + +# 2. push the commit and the tag it just created +git push origin HEAD +git push origin vX.Y.Z + +# 3. create the release — this triggers the publish workflow +gh release create vX.Y.Z --title vX.Y.Z --generate-notes +# pre-release rehearsal (skips `invoke release`): tag and push manually, +# then gh release create vX.Y.Zrc1 --prerelease --generate-notes ``` A local rehearsal to TestPyPI is available via `uv run invoke publish-test`. diff --git a/pyproject.toml b/pyproject.toml index 9b18562..bcf2c92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ dev = [ "invoke>=2.2", "twine>=5", "rich>=13", # examples/ pretty terminal output (not a runtime dependency) + "packaging>=23", # tasks.py release: PEP 440 / SemVer version parsing ] [build-system] @@ -64,6 +65,7 @@ only-include = ["src/cominty_sdk", "README.md", "pyproject.toml"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] +pythonpath = ["."] # so tests/test_release.py can `import tasks` (repo-root script) markers = [ "integration: opt-in tests requiring COMINTY_API_KEY", ] diff --git a/tasks.py b/tasks.py index 9ffc1bf..0ff5e32 100644 --- a/tasks.py +++ b/tasks.py @@ -1,14 +1,19 @@ """Developer tasks. Run with `uv run invoke ` (e.g. `uv run invoke publish-test`). -List tasks: uv run invoke --list -Build + validate: uv run invoke check +List tasks: uv run invoke --list +Build + validate: uv run invoke check Dry-run to TestPyPI: uv run invoke publish-test -Real release: done via CI on GitHub Release (see .github/workflows/release.yml) +Bump + tag release: uv run invoke release --patch|--minor|--major|--version X.Y.Z +Real release: done via CI on GitHub Release (see .github/workflows/release.yml) """ from __future__ import annotations -from invoke import task +import re +from pathlib import Path + +from invoke import Exit, task +from packaging.version import InvalidVersion, Version TESTPYPI_URL = "https://test.pypi.org/legacy/" @@ -64,3 +69,143 @@ def publish(c, token=None): if token: cmd += f" --token {token}" c.run(cmd, echo=True) + + +# --------------------------------------------------------------------------- # +# release +# --------------------------------------------------------------------------- # +PYPROJECT_PATH = Path("pyproject.toml") +UV_LOCK_PATH = Path("uv.lock") + +_VERSION_LINE_RE = re.compile(r'(?m)^(version = ")([^"]+)(")') +_XYZ_RE = re.compile(r"\d+\.\d+\.\d+") + + +def _read_version(text: str) -> str: + match = _VERSION_LINE_RE.search(text) + if match is None: + raise Exit('could not find `version = "..."` in pyproject.toml') + current = match.group(2) + if not _XYZ_RE.fullmatch(current): + raise Exit( + f"pyproject.toml's version {current!r} isn't X.Y.Z — fix it manually first" + ) + return current + + +def _write_version(text: str, new_version: str) -> str: + return _VERSION_LINE_RE.sub(rf"\g<1>{new_version}\g<3>", text, count=1) + + +def _bump(current: str, part: str) -> str: + major, minor, patch = (int(x) for x in current.split(".")) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + return f"{major}.{minor}.{patch + 1}" + + +def _validate_explicit_version(value: str) -> None: + if not _XYZ_RE.fullmatch(value): + raise Exit(f"--version must be X.Y.Z (three dot-separated integers), got {value!r}") + try: + Version(value) + except InvalidVersion as exc: + raise Exit(f"{value!r} is not a valid PEP 440 version: {exc}") from exc + + +def _git_is_dirty(c) -> bool: + result = c.run("git status --porcelain", hide=True, warn=True, in_stream=False) + return bool(result.stdout.strip()) + + +def _tag_exists(c, tag: str) -> bool: + result = c.run( + f"git rev-parse -q --verify refs/tags/{tag}", hide=True, warn=True, in_stream=False + ) + return result.ok + + +def _run_validation(c) -> None: + """The pre-commit gate: lint, type-check, tests, and a packaging dry run.""" + c.run("uv run ruff check .", echo=True, in_stream=False) + c.run("uv run pyright", echo=True, in_stream=False) + c.run("uv run pytest", echo=True, in_stream=False) + c.run("uv run invoke check", echo=True, in_stream=False) + + +@task( + help={ + "patch": "Bump the patch version: X.Y.Z -> X.Y.(Z+1)", + "minor": "Bump the minor version: X.Y.Z -> X.(Y+1).0", + "major": "Bump the major version: X.Y.Z -> (X+1).0.0", + "version": "Set an explicit target version X.Y.Z instead of incrementing", + } +) +def release(c, patch=False, minor=False, major=False, version=None): + """Bump the version and cut a release commit + tag. + + WARNING: this modifies repository files and CREATES A GIT COMMIT AND TAG. + It never pushes and never creates a GitHub Release — run the commands it + prints at the end to do that yourself. + + Exactly one of --patch/--minor/--major/--version is required. + """ + modes = {"patch": patch, "minor": minor, "major": major, "version": version is not None} + selected = [name for name, on in modes.items() if on] + if len(selected) != 1: + raise Exit( + "exactly one of --patch/--minor/--major/--version is required " + f"(got {selected or 'none'})" + ) + + if not PYPROJECT_PATH.exists(): + raise Exit("pyproject.toml not found — run this from the repo root") + + original_pyproject = PYPROJECT_PATH.read_text() + current = _read_version(original_pyproject) + + if version is not None: + _validate_explicit_version(version) + target = version + else: + target = _bump(current, selected[0]) + + if Version(target) <= Version(current): + raise Exit(f"target version {target} is not greater than the current version {current}") + + tag = f"v{target}" + + if _git_is_dirty(c): + raise Exit("git working tree is dirty — commit or stash changes before releasing") + if _tag_exists(c, tag): + raise Exit(f"tag {tag} already exists") + + original_uv_lock = UV_LOCK_PATH.read_text() if UV_LOCK_PATH.exists() else None + + def _rollback() -> None: + PYPROJECT_PATH.write_text(original_pyproject) + if original_uv_lock is not None: + UV_LOCK_PATH.write_text(original_uv_lock) + elif UV_LOCK_PATH.exists(): + UV_LOCK_PATH.unlink() + + PYPROJECT_PATH.write_text(_write_version(original_pyproject, target)) + try: + c.run("uv lock", echo=True, in_stream=False) + _run_validation(c) + except Exception: + _rollback() + raise + + c.run(f"git add {PYPROJECT_PATH} {UV_LOCK_PATH}", echo=True, in_stream=False) + c.run(f'git commit -m "chore(release): version {target}"', echo=True, in_stream=False) + c.run(f'git tag -a {tag} -m "{tag}"', echo=True, in_stream=False) + + print( + f"\nCreated commit and tag {tag} locally. Nothing was pushed. Next steps:\n\n" + f" git push origin HEAD\n" + f" git push origin {tag}\n" + f" gh release create {tag} --title {tag} --generate-notes\n" + ) diff --git a/tests/test_release.py b/tests/test_release.py new file mode 100644 index 0000000..3b9e436 --- /dev/null +++ b/tests/test_release.py @@ -0,0 +1,159 @@ +"""Tests for the `release` invoke task in tasks.py. + +Everything runs inside a disposable temp Git repo (see the `repo` fixture) — +never against this actual repository. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest +import tasks +from invoke import Context, Exit + +_MINIMAL_PYPROJECT = """\ +[project] +name = "scratch-pkg" +version = "{version}" +requires-python = ">=3.9" +dependencies = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +""" + + +def _git(*args: str) -> None: + subprocess.run(["git", *args], check=True, capture_output=True) + + +def _git_out(*args: str) -> str: + result = subprocess.run(["git", *args], check=True, capture_output=True, text=True) + return result.stdout.strip() + + +def _is_clean() -> bool: + return _git_out("status", "--porcelain") == "" + + +def _pyproject_version() -> str: + return tasks._read_version(Path("pyproject.toml").read_text()) + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.chdir(tmp_path) + _git("init", "-q") + _git("config", "user.email", "test@example.com") + _git("config", "user.name", "Test") + (tmp_path / "pyproject.toml").write_text(_MINIMAL_PYPROJECT.format(version="0.1.0")) + subprocess.run(["uv", "lock"], check=True, capture_output=True) + _git("add", "-A") + _git("commit", "-q", "-m", "initial") + # The SDK's own lint/type-check/test/build gate has nothing to check + # against a scratch project — stub it out so tests only exercise the + # version/Git logic. + monkeypatch.setattr(tasks, "_run_validation", lambda c: None) + return tmp_path + + +@pytest.fixture +def c() -> Context: + return Context() + + +# --------------------------------------------------------------------------- # +# success +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "kwargs, expected", + [ + ({"patch": True}, "0.1.1"), + ({"minor": True}, "0.2.0"), + ({"major": True}, "1.0.0"), + ({"version": "5.2.1"}, "5.2.1"), + ], +) +def test_release_success( + repo: Path, c: Context, capsys: pytest.CaptureFixture[str], kwargs: dict, expected: str +) -> None: + tasks.release(c, **kwargs) + + assert _pyproject_version() == expected + assert f'version = "{expected}"' in (repo / "uv.lock").read_text() + assert _git_out("log", "-1", "--format=%s") == f"chore(release): version {expected}" + assert _git_out("cat-file", "-t", f"v{expected}") == "tag" # annotated, not lightweight + assert _is_clean() + + out = capsys.readouterr().out + assert f"v{expected}" in out + assert "git push" in out + assert "gh release create" in out + + +# --------------------------------------------------------------------------- # +# failures — each must leave the repo untouched +# --------------------------------------------------------------------------- # +def test_release_no_mode_fails(repo: Path, c: Context) -> None: + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c) + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + assert _pyproject_version() == "0.1.0" + + +@pytest.mark.parametrize( + "kwargs", + [ + {"patch": True, "minor": True}, + {"patch": True, "version": "9.9.9"}, + ], + ids=["two-flags", "flag-and-version"], +) +def test_release_multiple_modes_fails(repo: Path, c: Context, kwargs: dict) -> None: + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c, **kwargs) + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + + +@pytest.mark.parametrize("bad", ["not-a-version", "1.2", "1.2.3.4", "v1.2.3"]) +def test_release_invalid_version_format_fails(repo: Path, c: Context, bad: str) -> None: + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c, version=bad) + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + assert _pyproject_version() == "0.1.0" + + +def test_release_dirty_tree_fails(repo: Path, c: Context) -> None: + (repo / "README.md").write_text("uncommitted change") + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c, patch=True) + assert _git_out("rev-parse", "HEAD") == before + assert _pyproject_version() == "0.1.0" + + +@pytest.mark.parametrize("target", ["0.1.0", "0.0.9"], ids=["equal", "lower"]) +def test_release_non_increasing_version_fails(repo: Path, c: Context, target: str) -> None: + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c, version=target) + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + + +def test_release_existing_tag_fails(repo: Path, c: Context) -> None: + _git("tag", "v0.1.1") + before = _git_out("rev-parse", "HEAD") + with pytest.raises(Exit): + tasks.release(c, patch=True) + assert _git_out("rev-parse", "HEAD") == before + assert _pyproject_version() == "0.1.0" diff --git a/uv.lock b/uv.lock index f42e5b0..8fd3f73 100644 --- a/uv.lock +++ b/uv.lock @@ -511,6 +511,7 @@ dev = [ { name = "invoke" }, { name = "jupyter" }, { name = "nbformat" }, + { name = "packaging" }, { name = "pyright" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -536,6 +537,7 @@ dev = [ { name = "invoke", specifier = ">=2.2" }, { name = "jupyter", specifier = ">=1.0" }, { name = "nbformat", specifier = ">=5" }, + { name = "packaging", specifier = ">=23" }, { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, From b1e3c376c9d18d69082df37cb6204e79163606d8 Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 11 Aug 2026 13:53:15 +0200 Subject: [PATCH 6/6] test: expand SDK unit test coverage to 100% and enforce it in CI --- .github/workflows/ci.yml | 9 +- .gitignore | 3 + AGENTS.md | 16 ++ README.md | 7 + pyproject.toml | 12 ++ src/cominty_sdk/resources/memory.py | 2 +- src/cominty_sdk/streaming.py | 4 +- tests/unit/test_chat_stream.py | 60 ++++++ tests/unit/test_client.py | 15 ++ tests/unit/test_exceptions.py | 279 ++++++++++++++++++++++++++++ tests/unit/test_transport.py | 117 ++++++++++++ uv.lock | 276 +++++++++++++++++++++++++++ 12 files changed, 796 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_exceptions.py create mode 100644 tests/unit/test_transport.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ccc40..7c067bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,5 +33,10 @@ jobs: - name: Pyright run: uv run pyright - - name: Pytest - run: uv run pytest + - name: Pytest with coverage + run: > + uv run pytest --cov --cov-report=term-missing --cov-fail-under=100 + + - name: Coverage summary + if: always() + run: uv run coverage report --format=markdown >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 80fb040..8c6d95d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ build/ .ruff_cache/ .pytest_cache/ .env +.coverage +.coverage.* +htmlcov/ diff --git a/AGENTS.md b/AGENTS.md index 9527002..bb2636d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -350,6 +350,22 @@ No `MagicMock` on the resource classes themselves — test the real resource, fa @pytest.mark.integration # skipped in CI unless COMINTY_API_KEY is set ``` +### 9.5 Coverage + +`pytest-cov` measures statement and branch coverage of `src/cominty_sdk`, configured in +`pyproject.toml`'s `[tool.coverage.*]` tables. + +```bash +uv run pytest --cov --cov-report=term-missing # coverage summary + missing lines +uv run coverage html && open htmlcov/index.html # annotated per-line HTML report +``` + +CI runs the same suite with `--cov-fail-under=100` and fails the build on any regression. An +excluded line or branch must carry a `# pragma: no cover` **and** a one-line comment saying +why it can't be reached through the public API — see `streaming.py`'s `_stream()` guard or +`resources/memory.py`'s `_Unset.__repr__` for examples. Don't add tests that only exist to +nudge the percentage; prefer a justified exclusion for genuinely unreachable code. + --- ## 10. Commit & PR Discipline diff --git a/README.md b/README.md index 31aaeee..f722f18 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,13 @@ uv run ruff check . # lint uv run pyright # type-check (strict) ``` +Coverage (statements + branches) is measured with `pytest-cov`; CI fails under +100%: + +```bash +uv run pytest --cov --cov-report=term-missing +``` + Integration tests are opt-in (they hit the real API): ```bash diff --git a/pyproject.toml b/pyproject.toml index bcf2c92..6682337 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dev = [ "pyright>=1.1", "pytest>=8", "pytest-asyncio>=0.24", + "pytest-cov>=5", "respx>=0.21", "ruff>=0.6", "jupyter>=1.0", @@ -89,3 +90,14 @@ select = ["E", "F", "I", "UP", "B", "SIM", "ANN"] pythonVersion = "3.9" typeCheckingMode = "strict" include = ["src/cominty_sdk"] + +[tool.coverage.run] +source = ["src/cominty_sdk"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_also = [ + # Type-only imports; never executed at runtime. + "if TYPE_CHECKING:", +] diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index a997a4a..a44759f 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -25,7 +25,7 @@ class _Unset: """Sentinel default distinguishing "not passed" from "passed as ``None``" for :meth:`MemoryResource.update`'s optional fields.""" - def __repr__(self) -> str: + def __repr__(self) -> str: # pragma: no cover - debug display only, never returned to callers return "UNSET" diff --git a/src/cominty_sdk/streaming.py b/src/cominty_sdk/streaming.py index b0aa2e4..e51b96f 100644 --- a/src/cominty_sdk/streaming.py +++ b/src/cominty_sdk/streaming.py @@ -69,7 +69,9 @@ def __aiter__(self) -> AsyncIterator[AnyEvent]: async def _stream(self) -> AsyncGenerator[AnyEvent]: if self._consumed: - raise SDKError("this AssistantRun stream has already been consumed") + # __aiter__ memoizes self._gen, so this never re-fires through the + # public API — a defensive guard against calling this method directly. + raise SDKError("this AssistantRun stream has already been consumed") # pragma: no cover self._consumed = True headers: dict[str, str] = {} diff --git a/tests/unit/test_chat_stream.py b/tests/unit/test_chat_stream.py index a020f65..6f71435 100644 --- a/tests/unit/test_chat_stream.py +++ b/tests/unit/test_chat_stream.py @@ -18,6 +18,7 @@ import respx from cominty_sdk import ( + AssistantRun, AsyncCominty, NotFoundError, SDKError, @@ -392,6 +393,65 @@ def test_bare_stream_primitive_has_no_thread( assert run.message_id == UUID(ids.assistant_msg) +async def test_aiter_called_twice_reuses_same_generator( + client: AsyncCominty, + mock_api: respx.MockRouter, + jsonl: Jsonl, + make_message: MakeMessage, + ids: SimpleNamespace, +) -> None: + body = jsonl(make_message(id=ids.assistant_msg, role="assistant", content="final")) + mock_api.get(_stream_path(ids.assistant_msg)).mock( + return_value=httpx.Response(200, text=body) + ) + + run = client.chat.stream(UUID(ids.assistant_msg)) + first = run.__aiter__() + second = run.__aiter__() + + assert first is second + + +async def test_resume_sends_last_event_id_header( + client: AsyncCominty, + mock_api: respx.MockRouter, + jsonl: Jsonl, + make_message: MakeMessage, + ids: SimpleNamespace, +) -> None: + body = jsonl(make_message(id=ids.assistant_msg, role="assistant", content="final")) + route = mock_api.get(_stream_path(ids.assistant_msg)).mock( + return_value=httpx.Response(200, text=body) + ) + + run = AssistantRun( + client._transport, # noqa: SLF001 + UUID(ids.assistant_msg), + last_event_id="1782676050530-0", + ) + async for _ in run: + pass + + assert route.calls.last.request.headers["last-event-id"] == "1782676050530-0" + + +async def test_empty_stream_result_raises_sdk_error( + client: AsyncCominty, mock_api: respx.MockRouter, ids: SimpleNamespace +) -> None: + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text="")) + + run = client.chat.stream(UUID(ids.assistant_msg)) + with pytest.raises(SDKError): + await run.result() + + +async def test_aclose_without_iteration_is_a_noop( + client: AsyncCominty, ids: SimpleNamespace +) -> None: + run = client.chat.stream(UUID(ids.assistant_msg)) + await run.aclose() # never iterated — nothing to close + + async def test_context_manager_closes_cleanly( client: AsyncCominty, mock_api: respx.MockRouter, diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index dfc2dc3..4880611 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -34,3 +34,18 @@ def test_malformed_user_id_rejected_locally( monkeypatch.delenv("COMINTY_USER_ID", raising=False) with pytest.raises(ValueError): AsyncCominty(api_token="t", user_id=bad, base_url="https://x.test") + + +def test_api_token_required(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("COMINTY_API_KEY", raising=False) + with pytest.raises(ValueError, match="api_token is required"): + AsyncCominty(user_id=VALID_USER_ID) + + +def test_base_url_exposed_and_defaulted(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("COMINTY_BASE_URL", raising=False) + explicit = AsyncCominty(api_token="t", user_id=VALID_USER_ID, base_url="https://x.test/") + assert explicit.base_url == "https://x.test" # trailing slash stripped + + default = AsyncCominty(api_token="t", user_id=VALID_USER_ID) + assert default.base_url == "https://ds.cominty.com" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..92f0888 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,279 @@ +"""Unit tests for the exception hierarchy: status-code mapping, the 429 +scope/retry_after/reset_at properties, rate-limit message composition, and +InvalidParams.from_validation_error's grouping of pydantic errors. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from cominty_sdk import ( + APIError, + AuthError, + ConflictError, + InvalidParams, + NotFoundError, + PermissionError, + RateLimitError, + ServerError, +) +from cominty_sdk.exceptions import error_from_response +from cominty_sdk.models.chat import HumanMessage + + +# --------------------------------------------------------------------------- # +# error_from_response — status-code mapping +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "status_code, expected_cls", + [ + (401, AuthError), + (403, PermissionError), + (404, NotFoundError), + (409, ConflictError), + (500, ServerError), + (503, ServerError), + (418, APIError), # unmapped 4xx falls back to the base class + ], +) +def test_error_from_response_maps_status_code(status_code: int, expected_cls: type) -> None: + err = error_from_response(status_code, {"detail": "boom"}) + + assert isinstance(err, expected_cls) + assert err.status_code == status_code + assert err.detail == "boom" + assert str(err) == "boom" + + +def test_error_from_response_maps_429_to_rate_limit_error() -> None: + # 429's message is composed separately (see the dedicated tests below), so + # it's excluded from the generic mapping test above. + err = error_from_response(429, {"detail": "boom"}) + assert isinstance(err, RateLimitError) + assert err.status_code == 429 + + +def test_error_from_response_falls_back_to_generic_message_without_detail() -> None: + err = error_from_response(404, {"other_field": "x"}) + + assert err.detail is None + assert str(err) == "HTTP 404" + + +def test_error_from_response_handles_non_dict_body() -> None: + err = error_from_response(500, "plain text error page") + + assert err.detail is None + assert err.body == "plain text error page" + assert str(err) == "HTTP 500" + + +def test_error_from_response_handles_missing_body() -> None: + err = error_from_response(500, None) + + assert err.detail is None + assert err.body is None + + +# --------------------------------------------------------------------------- # +# RateLimitError.scope +# --------------------------------------------------------------------------- # +def test_scope_reads_quota_reached_from_detail() -> None: + err = RateLimitError("x", status_code=429, detail={"quota_reached": "organization"}) + assert err.scope == "organization" + + +def test_scope_detects_concurrency_string() -> None: + err = RateLimitError("x", status_code=429, detail="Too many concurrent requests") + assert err.scope == "concurrency" + + +def test_scope_none_when_undeterminable() -> None: + assert RateLimitError("x", status_code=429, detail=None).scope is None + assert RateLimitError("x", status_code=429, detail={"other": 1}).scope is None + assert RateLimitError("x", status_code=429, detail="unrelated message").scope is None + + +# --------------------------------------------------------------------------- # +# RateLimitError.retry_after +# --------------------------------------------------------------------------- # +def test_retry_after_reads_header() -> None: + err = RateLimitError("x", status_code=429, headers={"Retry-After": "12.5"}) + assert err.retry_after == 12.5 + + +def test_retry_after_accepts_lowercase_header() -> None: + err = RateLimitError("x", status_code=429, headers={"retry-after": "3"}) + assert err.retry_after == 3.0 + + +def test_retry_after_none_without_headers() -> None: + assert RateLimitError("x", status_code=429, headers=None).retry_after is None + + +def test_retry_after_none_on_malformed_header() -> None: + err = RateLimitError("x", status_code=429, headers={"Retry-After": "soon"}) + assert err.retry_after is None + + +def test_retry_after_none_when_header_absent_from_present_headers() -> None: + err = RateLimitError("x", status_code=429, headers={"Content-Type": "application/json"}) + assert err.retry_after is None + + +# --------------------------------------------------------------------------- # +# RateLimitError.reset_at +# --------------------------------------------------------------------------- # +def test_reset_at_reads_detail_field() -> None: + err = RateLimitError( + "x", status_code=429, detail={"reset_at": "2026-06-28T10:00:00+00:00"} + ) + assert err.reset_at == datetime(2026, 6, 28, 10, 0, tzinfo=timezone.utc) + + +def test_reset_at_falls_back_to_locked_until() -> None: + err = RateLimitError( + "x", status_code=429, detail={"locked_until": "2026-06-28T10:00:00+00:00"} + ) + assert err.reset_at is not None + + +def test_reset_at_falls_back_to_header_without_detail() -> None: + err = RateLimitError( + "x", + status_code=429, + detail="Too many concurrent requests", + headers={"X-RateLimit-Reset": "2026-06-28T10:00:00+00:00"}, + ) + assert err.reset_at is not None + + +def test_reset_at_falls_back_to_header_when_detail_dates_unparseable() -> None: + err = RateLimitError( + "x", + status_code=429, + detail={"quota_reached": "user"}, # no reset_at/locked_until + headers={"X-RateLimit-Reset": "2026-06-28T10:00:00+00:00"}, + ) + assert err.reset_at is not None + + +def test_reset_at_none_when_date_is_unparseable() -> None: + err = RateLimitError("x", status_code=429, detail={"reset_at": "not-a-date"}) + assert err.reset_at is None + + +def test_reset_at_none_when_nothing_available() -> None: + assert RateLimitError("x", status_code=429).reset_at is None + + +# --------------------------------------------------------------------------- # +# 429 message composition (via error_from_response) +# --------------------------------------------------------------------------- # +def test_rate_limit_message_for_known_quota_scope() -> None: + err = error_from_response(429, {"detail": {"quota_reached": "user"}}) + assert "User rate limit reached" in str(err) + assert "organization admin" in str(err) + + +def test_rate_limit_message_for_forward_compatible_quota_scope() -> None: + err = error_from_response(429, {"detail": {"quota_reached": "team"}}) + assert "Team rate limit reached" in str(err) + + +def test_rate_limit_message_for_concurrency() -> None: + err = error_from_response(429, {"detail": "Too many concurrent requests"}) + assert "Too many concurrent requests" in str(err) + + +def test_rate_limit_message_falls_back_to_raw_detail_text() -> None: + err = error_from_response(429, {"detail": "slow down"}) + assert str(err).startswith("slow down.") + + +def test_rate_limit_message_with_no_detail_at_all() -> None: + err = error_from_response(429, {}) + assert str(err).startswith("Rate limit reached.") + + +def test_rate_limit_message_includes_retry_after_when_present() -> None: + err = error_from_response( + 429, {"detail": {"quota_reached": "user"}}, headers={"Retry-After": "5"} + ) + assert "retry in 5s" in str(err) + + +def test_rate_limit_message_includes_reset_at_when_no_retry_after() -> None: + err = error_from_response( + 429, {"detail": {"quota_reached": "user", "reset_at": "2026-06-28T10:00:00+00:00"}} + ) + assert "Quota resets at" in str(err) + + +def test_rate_limit_message_ignores_malformed_retry_after_header() -> None: + err = error_from_response( + 429, + {"detail": {"quota_reached": "user", "reset_at": "2026-06-28T10:00:00+00:00"}}, + headers={"Retry-After": "not-a-number"}, + ) + # Falls through to the reset_at phrasing instead of a "retry in ...s" one. + assert "retry in" not in str(err) + assert "Quota resets at" in str(err) + + +# --------------------------------------------------------------------------- # +# InvalidParams.from_validation_error +# --------------------------------------------------------------------------- # +def _validation_error() -> ValidationError: + try: + HumanMessage.model_validate({"content": 123, "file_ids": "not-a-list"}) + except ValidationError as exc: + return exc + raise AssertionError("expected a ValidationError") + + +def test_from_validation_error_groups_one_entry_per_field() -> None: + err = InvalidParams.from_validation_error(_validation_error(), context="chat.start") + + params = {e["param"] for e in err.errors} + assert params == {"content", "file_ids"} + assert "chat.start" in str(err) + + +def test_from_validation_error_deduplicates_repeated_messages_on_same_field() -> None: + # Two errors at the same location with the identical message collapse into + # one entry instead of repeating it. + exc = ValidationError.from_exception_data( + "Test", + [ + {"type": "string_type", "loc": ("content",), "input": None}, + {"type": "string_type", "loc": ("content",), "input": None}, + ], + ) + + err = InvalidParams.from_validation_error(exc, context="ctx") + + assert len(err.errors) == 1 + assert err.errors[0]["message"].count("valid string") == 1 + + +def test_from_validation_error_suppresses_input_for_missing_and_extra_forbidden() -> None: + # "missing"/"extra_forbidden" carry the parent container as `input`, which + # is noise — it's dropped rather than surfaced as the offending value. + exc = ValidationError.from_exception_data( + "Test", + [ + {"type": "missing", "loc": ("purpose",), "input": {"content": "x"}}, + {"type": "extra_forbidden", "loc": ("bogus",), "input": {"content": "x"}}, + ], + ) + + err = InvalidParams.from_validation_error(exc, context="ctx") + + by_param = {e["param"]: e for e in err.errors} + assert by_param["purpose"]["input"] is None + assert by_param["bogus"]["input"] is None + assert "(got" not in str(err) diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py new file mode 100644 index 0000000..534ab04 --- /dev/null +++ b/tests/unit/test_transport.py @@ -0,0 +1,117 @@ +"""Unit tests for the low-level transport: timeout/network error mapping on +both the plain-request and streaming paths, and JSONL line tolerance. +""" + +from __future__ import annotations + +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any +from uuid import UUID + +import httpx +import pytest +import respx + +from cominty_sdk import APIConnectionError, AsyncCominty + +MakeEvent = Callable[..., dict[str, Any]] +MakeMessage = Callable[..., dict[str, Any]] +Jsonl = Callable[..., str] + + +# --------------------------------------------------------------------------- # +# request() — timeout / network errors +# --------------------------------------------------------------------------- # +async def test_request_timeout_raises_connection_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.get("/chat").mock(side_effect=httpx.TimeoutException("boom")) + + with pytest.raises(APIConnectionError, match="timed out"): + await client.threads.list() + + +async def test_request_network_error_raises_connection_error( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + mock_api.get("/chat").mock(side_effect=httpx.ConnectError("no route")) + + with pytest.raises(APIConnectionError, match="failed"): + await client.threads.list() + + +# --------------------------------------------------------------------------- # +# stream_lines() — timeout / network errors +# --------------------------------------------------------------------------- # +async def test_stream_timeout_raises_connection_error( + client: AsyncCominty, mock_api: respx.MockRouter, ids: SimpleNamespace +) -> None: + path = f"/chat/messages/{ids.assistant_msg}/stream" + mock_api.get(path).mock(side_effect=httpx.TimeoutException("boom")) + + run = client.chat.stream(UUID(ids.assistant_msg)) + with pytest.raises(APIConnectionError, match="timed out"): + async for _ in run: + pass + + +async def test_stream_network_error_raises_connection_error( + client: AsyncCominty, mock_api: respx.MockRouter, ids: SimpleNamespace +) -> None: + path = f"/chat/messages/{ids.assistant_msg}/stream" + mock_api.get(path).mock(side_effect=httpx.ConnectError("no route")) + + run = client.chat.stream(UUID(ids.assistant_msg)) + with pytest.raises(APIConnectionError, match="failed"): + async for _ in run: + pass + + +# --------------------------------------------------------------------------- # +# JSONL line tolerance +# --------------------------------------------------------------------------- # +async def test_non_json_line_is_skipped( + client: AsyncCominty, + mock_api: respx.MockRouter, + jsonl: Jsonl, + make_event: MakeEvent, + make_message: MakeMessage, + ids: SimpleNamespace, +) -> None: + body = jsonl( + make_event("waiting_for_start", id="0-0"), + "not valid json at all", + make_message(id=ids.assistant_msg, role="assistant", content="final"), + ) + mock_api.get(f"/chat/messages/{ids.assistant_msg}/stream").mock( + return_value=httpx.Response(200, text=body) + ) + + run = client.chat.stream(UUID(ids.assistant_msg)) + names = [e.name async for e in run] + + assert names == ["waiting_for_start"] + + +async def test_non_dict_json_line_is_skipped( + client: AsyncCominty, + mock_api: respx.MockRouter, + jsonl: Jsonl, + make_event: MakeEvent, + make_message: MakeMessage, + ids: SimpleNamespace, +) -> None: + body = jsonl( + make_event("waiting_for_start", id="0-0"), + "[1, 2, 3]", # valid JSON, but not an object + make_message(id=ids.assistant_msg, role="assistant", content="final"), + ) + mock_api.get(f"/chat/messages/{ids.assistant_msg}/stream").mock( + return_value=httpx.Response(200, text=body) + ) + + run = client.chat.stream(UUID(ids.assistant_msg)) + names = [e.name async for e in run] + + assert names == ["waiting_for_start"] diff --git a/uv.lock b/uv.lock index 8fd3f73..fed00e3 100644 --- a/uv.lock +++ b/uv.lock @@ -517,6 +517,7 @@ dev = [ { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest-asyncio", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pytest-cov" }, { name = "respx" }, { name = "rich" }, { name = "ruff" }, @@ -541,6 +542,7 @@ dev = [ { name = "pyright", specifier = ">=1.1" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=5" }, { name = "respx", specifier = ">=0.21" }, { name = "rich", specifier = ">=13" }, { name = "ruff", specifier = ">=0.6" }, @@ -556,6 +558,264 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, ] +[[package]] +name = "coverage" +version = "7.10.7" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] +sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987, upload-time = "2025-09-21T20:00:57.218Z" }, + { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388, upload-time = "2025-09-21T20:01:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148, upload-time = "2025-09-21T20:01:01.768Z" }, + { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958, upload-time = "2025-09-21T20:01:03.355Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819, upload-time = "2025-09-21T20:01:04.968Z" }, + { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754, upload-time = "2025-09-21T20:01:06.321Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860, upload-time = "2025-09-21T20:01:07.605Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877, upload-time = "2025-09-21T20:01:08.829Z" }, + { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108, upload-time = "2025-09-21T20:01:10.527Z" }, + { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752, upload-time = "2025-09-21T20:01:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497, upload-time = "2025-09-21T20:01:13.459Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392, upload-time = "2025-09-21T20:01:14.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102, upload-time = "2025-09-21T20:01:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505, upload-time = "2025-09-21T20:01:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898, upload-time = "2025-09-21T20:01:19.488Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831, upload-time = "2025-09-21T20:01:20.817Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937, upload-time = "2025-09-21T20:01:22.171Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021, upload-time = "2025-09-21T20:01:23.907Z" }, + { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626, upload-time = "2025-09-21T20:01:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682, upload-time = "2025-09-21T20:01:27.105Z" }, + { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402, upload-time = "2025-09-21T20:01:28.629Z" }, + { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320, upload-time = "2025-09-21T20:01:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536, upload-time = "2025-09-21T20:01:32.184Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425, upload-time = "2025-09-21T20:01:33.557Z" }, + { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103, upload-time = "2025-09-21T20:01:34.929Z" }, + { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290, upload-time = "2025-09-21T20:01:36.455Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515, upload-time = "2025-09-21T20:01:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020, upload-time = "2025-09-21T20:01:39.617Z" }, + { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769, upload-time = "2025-09-21T20:01:41.341Z" }, + { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901, upload-time = "2025-09-21T20:01:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413, upload-time = "2025-09-21T20:01:44.469Z" }, + { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820, upload-time = "2025-09-21T20:01:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941, upload-time = "2025-09-21T20:01:47.296Z" }, + { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519, upload-time = "2025-09-21T20:01:48.73Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375, upload-time = "2025-09-21T20:01:50.529Z" }, + { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699, upload-time = "2025-09-21T20:01:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512, upload-time = "2025-09-21T20:01:53.481Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147, upload-time = "2025-09-21T20:01:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" }, + { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" }, + { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" }, + { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" }, + { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" }, + { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" }, + { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" }, + { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" }, + { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" }, + { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" }, + { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" }, + { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" }, + { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, + { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, + { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625, upload-time = "2025-09-21T20:03:36.09Z" }, + { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399, upload-time = "2025-09-21T20:03:38.342Z" }, + { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142, upload-time = "2025-09-21T20:03:40.591Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284, upload-time = "2025-09-21T20:03:42.355Z" }, + { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353, upload-time = "2025-09-21T20:03:44.218Z" }, + { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430, upload-time = "2025-09-21T20:03:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311, upload-time = "2025-09-21T20:03:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500, upload-time = "2025-09-21T20:03:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408, upload-time = "2025-09-21T20:03:51.803Z" }, + { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version < '3.10'" }, +] + +[[package]] +name = "coverage" +version = "7.15.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version >= '3.11' and python_full_version < '3.14'", + "python_full_version == '3.10.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/be/c3/4f2195f512fb172aa425a8803a874b2baa9ba7f80ff7b6080998761fc701/coverage-7.15.4.tar.gz", hash = "sha256:0548198fff07ccf4faf469520bce1c2eceb1ce3e62891921138dec10907f9d00", size = 936952, upload-time = "2026-08-06T13:50:24.442Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/70/b052a519a584663a7bd052841a2debe11c8309ec49a7786340003f9c0a02/coverage-7.15.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d0be6daac4cce6b8c8dc65886bae1b082ddbca4da8e5cbb5e15166acf253e264", size = 222245, upload-time = "2026-08-06T13:46:55.253Z" }, + { url = "https://files.pythonhosted.org/packages/67/39/892fa511aba3d1c3c8f49509a0ff5c71eab9f9f88d08e1a38da395821660/coverage-7.15.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b24e078eabcd6a9caa8b0713f9bc1eeb310bcc960a29d45a3b4fcd4b16d5b11d", size = 222762, upload-time = "2026-08-06T13:46:57.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/95/b2c724ce1e64bc23cb5b1d7eeffa9548dc3d811f7a6297b2d01607f4e062/coverage-7.15.4-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe20cc8cf8821d4fe54f89106cbf06aa27f37b5bbe3535568065a81539b4150", size = 249498, upload-time = "2026-08-06T13:46:59.012Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4f/b1973f67a1382af65b572a31ed692f8e490a6ad707191eab59148376832a/coverage-7.15.4-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:83cf06cdd687677742caff1a9134833b7a8b75f111519d2cb0e0ba1b9a851e15", size = 251328, upload-time = "2026-08-06T13:47:00.764Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/03efa6722a132abcac91b32a60b64b240dd707c189c64eee697e48992c96/coverage-7.15.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8fa4de68e2a752468ff14b4e15db7def689a71be759e826a31ccecbef69c5fd0", size = 253194, upload-time = "2026-08-06T13:47:01.976Z" }, + { url = "https://files.pythonhosted.org/packages/45/63/8299201d9c80fb65551ce99c966cab83d706ec4066ac999bef08201346de/coverage-7.15.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4dff9daa47d83120c3ec38ce921214242944a832aa04e903e50b5b7ebac8972d", size = 255106, upload-time = "2026-08-06T13:47:03.281Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/26fd8a691eb8d9a230128685f6d23309d7402cb030aa553001788c8c50fc/coverage-7.15.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a093fd37229918976f602aa07aa59e0973cde82186f220c8e197f721f5be0ce4", size = 250177, upload-time = "2026-08-06T13:47:04.713Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/3c7556f33783a0a566e01443ca62bd8eb2cdfe22d271efdc02e08beb5654/coverage-7.15.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:317db01a2cb02552fd67e2b1cca77a4b528a2a277176c5e0bf2cecbb639d3f54", size = 251234, upload-time = "2026-08-06T13:47:06.104Z" }, + { url = "https://files.pythonhosted.org/packages/29/49/640a34043edac950738f36a3567832db5731d4cb2ed84b59cdb89c6bccbf/coverage-7.15.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:8ee3838dcb656602c3b51e16aed9bfb0822f8d8d6d1c5966d32ec8c104be8e20", size = 249237, upload-time = "2026-08-06T13:47:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/48/f5/e80f212669dd1be954ff844f883ef11a437ef4fd0089c6e0effc7b66b15d/coverage-7.15.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:425920379052ff1fe465268f3361d35804a241bbdd5a1b592c8cb60df4c52325", size = 253050, upload-time = "2026-08-06T13:47:08.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e9/e5da0fe39f7fde1bca9edc09c60921bb5fdba4cec7db5bbad41ddfd8c230/coverage-7.15.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:69bb2400abef928e365ea7d4d9925169ada78ed2295546780002d4b65de3df88", size = 249508, upload-time = "2026-08-06T13:47:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/7d/38/41bf25774a0c8bba6b467f917cb1c9a0a2605e02dc93aad489fc7050ed59/coverage-7.15.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:81661f82d302484e3119e7c80c519c02fa9bcc2a6b339baf67d67bc89c580f04", size = 250110, upload-time = "2026-08-06T13:47:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/89/6e/26f2e54b79acc29d179ee4272922625aedb69198c4eb61f7ff4f098f3c78/coverage-7.15.4-cp310-cp310-win32.whl", hash = "sha256:cb476b2e828ecb71cb6b6a928d23fd20a7ddb501188022dae1c37499149cc338", size = 224294, upload-time = "2026-08-06T13:47:12.753Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/9a318fc3ae040d4d6cb2d86101c6aa963fab20899a5c58666adf52cde0ca/coverage-7.15.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fc2130bf37df31852a8384f12601563a45a0024bccc6624f38355cba7a8d360", size = 224919, upload-time = "2026-08-06T13:47:14.17Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/edcec7d7a0b524aa8923e22925fde6fe50ce005a113dca13ae1581455c4c/coverage-7.15.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bbac5abad70df71019988f83f26ac7092ff2642975def4429e98dc7585ef3490", size = 222367, upload-time = "2026-08-06T13:47:15.578Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c6/ab8de429e2e8548faf58ec7e1674a4ce00414b4113942d3fe87109cf0f68/coverage-7.15.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:357a173465c7ce028d07a95cc2b63b5bf59f50ecdd5ad75c5cbb78ada984048e", size = 222874, upload-time = "2026-08-06T13:47:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/3b7b49587e8a6b9af79b3eb468d443d6042b6d65b47aa26586846a0d6566/coverage-7.15.4-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21b803935e2efc3acebe9697197a294fccf5dc4e5382bd6369542ff7a7d2a1d7", size = 253287, upload-time = "2026-08-06T13:47:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/fb/65/ec03b743a2a229c72cc1eff3e57be9d3564e9c6b4d5aba2d70744a3fc0d8/coverage-7.15.4-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a2b580774a4786c1053157c0165e04476e03ff293993d7c148eee784a94bae6", size = 255199, upload-time = "2026-08-06T13:47:19.765Z" }, + { url = "https://files.pythonhosted.org/packages/41/4b/5163729e4b6582d61975cfd3ccab45b4ec53e21cf156d9941cb025188468/coverage-7.15.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9464451c4efffe8d47ace5a540b10b0dc10e879066290f8600872b7f54a419d", size = 257308, upload-time = "2026-08-06T13:47:21.206Z" }, + { url = "https://files.pythonhosted.org/packages/86/08/2167a0f08fb87d702fa423a48578a32865464b7c9e1db3911ad7812ab414/coverage-7.15.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de602f34123c2f4af1c1869c6dbbbd60da6d5983bf01937367295d135cccbfce", size = 259268, upload-time = "2026-08-06T13:47:22.503Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e5/68eebae3053dbd48508edea559c21b23fbdf3460784f91370c83a86a6acd/coverage-7.15.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6879ded16a27f3eeca19b900c147e81616e7054db451471a611b2755ee5249f7", size = 253392, upload-time = "2026-08-06T13:47:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/fd4ced40a2b691c774e515c9b69500bfa64c7960b67fcee4b2f6fad97fc3/coverage-7.15.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:986be58c3ab54aae8d3496a6225eea74f760fdbe739b38bd442c7e8d133aa53b", size = 255001, upload-time = "2026-08-06T13:47:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/53/25/ae2e5fa710bb6957a9aadeb9e3598d3b3e4af6587ce857ad42e8639a3f30/coverage-7.15.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6103639613fe6c1e989082948419bc77a2d26b6c825c99d7fad25f7d3d87afc", size = 253061, upload-time = "2026-08-06T13:47:26.845Z" }, + { url = "https://files.pythonhosted.org/packages/d7/31/67ddc0365db2c6e93ac8580bc4bbc50f65273262f973f63ebcdbc15c0495/coverage-7.15.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d3af93dddb5659276c63bc16ac6466ac2033a70ca816097bbc06345b8ccdf571", size = 256831, upload-time = "2026-08-06T13:47:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/f6/78/82b8fd18f57fb13f12d98fe874995bb2c4f9f17be8aff762c426323fdb96/coverage-7.15.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b10075e5421d04265766a6d1dac809bbeb8a946fbb23c8f82c227409b2190719", size = 252781, upload-time = "2026-08-06T13:47:29.712Z" }, + { url = "https://files.pythonhosted.org/packages/0a/eb/6c74ef4dd12b252e573c49bdef9e2ac265bf3dbb79b8d7feb3266e084e9e/coverage-7.15.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a67a9f78b2942d87ba8ce3059c642164d2aedd65337377fb52fe9803656bc5c7", size = 253692, upload-time = "2026-08-06T13:47:31.192Z" }, + { url = "https://files.pythonhosted.org/packages/5a/66/eb9aed1c3fd2d36ee00eb173f434b14fa607fc056739c9a89ff4244010ea/coverage-7.15.4-cp311-cp311-win32.whl", hash = "sha256:69484d1aca26e322e1c3ce03f09341e84524ababad2d7202161738d83cc9f82e", size = 224461, upload-time = "2026-08-06T13:47:32.572Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6d/81fa4161dfb3ed9d74e40d58647eff83a56b7612e78352581280fce2f477/coverage-7.15.4-cp311-cp311-win_amd64.whl", hash = "sha256:63fd6fcd1dd6e158f7eb78606e72933b3f6d01e7b747f99c6c12d764307a0fdc", size = 224937, upload-time = "2026-08-06T13:47:34.205Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c1/d8dacf683c6cad3cf85ce68fd3774a6774ec402128822fdfaed920f11e6a/coverage-7.15.4-cp311-cp311-win_arm64.whl", hash = "sha256:ea82116c9893fa89e929b7f197ee5a1950a76e91cc5c85ba503fc02379d04890", size = 224479, upload-time = "2026-08-06T13:47:36.118Z" }, + { url = "https://files.pythonhosted.org/packages/1d/48/bc8d4ba7b37551a767bd863f15b3f80182b271c2f55975356f5f7dbe94c2/coverage-7.15.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d4fedd1f7f428f9fe83b1ead5e7cc87a43427be31aadafbac3ac0636dc7abb22", size = 222543, upload-time = "2026-08-06T13:47:37.562Z" }, + { url = "https://files.pythonhosted.org/packages/20/dd/88d6f83f1fffc974a3691a34a97951c5b12df7512a6782c5963883cbc058/coverage-7.15.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:37e2f0cdf58e2e1fed4e4d5a8f8786ae2f7eb80b478016876667dc4a01d60a97", size = 222905, upload-time = "2026-08-06T13:47:38.927Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/54ee0d4748585bb0acab9891cd8d92f2d3593165b4e59fc9de113bfb3140/coverage-7.15.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fb55d0e70bb15f2e81477613627286581414693d74ac7963c93a790dd453ca9d", size = 254407, upload-time = "2026-08-06T13:47:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3f/f0642a372f494bd0d7dad3b497083b910194a5f1c88be2c94fef707c3b59/coverage-7.15.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:899b9da30f3c6c336566e3707495bb23e8302d39d862f01fa78c48b99b9437e2", size = 257145, upload-time = "2026-08-06T13:47:41.931Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8b46d0ed68251016002ec972c8fc0119961a765d0984cafb8bf317c43758/coverage-7.15.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d15715e8c46552827e5e4f30a35575a2dbcad14454cf3284c54483946bd16931", size = 258257, upload-time = "2026-08-06T13:47:43.527Z" }, + { url = "https://files.pythonhosted.org/packages/30/b8/8498a0e72d0adbe15477dd07463d2b3bb2c9f6a4815e8589e50939e2c3ae/coverage-7.15.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:002a438859f7b430bc99afeaf01a6d187dad1d0dc907b64cdeffc632a5db8fd8", size = 260517, upload-time = "2026-08-06T13:47:45.121Z" }, + { url = "https://files.pythonhosted.org/packages/41/e1/7dce19c3bdb1e3dd63e769508216500edad81bd5f69a26d724e32aceaf78/coverage-7.15.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4193a04b518f7968f3099755f5509ee7cccc6dc2b92a6b14841934d22e222c9", size = 254785, upload-time = "2026-08-06T13:47:46.541Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b1/e1494703c675a2561723cd9b89f45c9168782c31280c611b1f767851e57c/coverage-7.15.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e98dcc55d572b38e69d117da7e8e8efb8500f1f5eaf81ecd460a63220790b839", size = 256176, upload-time = "2026-08-06T13:47:48.155Z" }, + { url = "https://files.pythonhosted.org/packages/73/76/a5629d270fb638a43a4b10466f51e2f49d532c1aa4da2913cbbb150bbe0a/coverage-7.15.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af6c538498ce66c10d3fd541c2a8d5b03da5850355add34e6cba564210cb9e72", size = 254321, upload-time = "2026-08-06T13:47:49.757Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/9c44447218435d5766b911534f9d798144a5560f85e9a54ebe5f3f5d19f9/coverage-7.15.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1d10025d96ea89fc2f73714dbc4cbd433fe012c1ac9e23f895d7728b238b6e52", size = 258390, upload-time = "2026-08-06T13:47:51.248Z" }, + { url = "https://files.pythonhosted.org/packages/de/36/c1e127616fb3fa18a9ff71e76c417f2fd7424332a4870015ac224ef4c039/coverage-7.15.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d802e1947603162ded419bff83ac7489820355d2b856dfb09206574e3a37ac0c", size = 253894, upload-time = "2026-08-06T13:47:52.816Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b9/fdb92c8ae7a8bb9b850cc253b7b3b9c8526f68130002048b5671cd510d09/coverage-7.15.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c2de40895718f91951b86712b4c5b694acaf9a0a49be13874896f599a1eed3f4", size = 255763, upload-time = "2026-08-06T13:47:54.296Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/a7d51b2587c7bdb76e71b0896d2565bf7d60436b5122fc83e511adb1f7cd/coverage-7.15.4-cp312-cp312-win32.whl", hash = "sha256:5c3431b2161279b7db5c2a1aa58ae02e5cb8c3c42d93a5094be3f5537bd5b11b", size = 224597, upload-time = "2026-08-06T13:47:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/49/b9/5c5f80cc55f5acaaca6dee677626bfcec8c87204a7809b438b08e84f4571/coverage-7.15.4-cp312-cp312-win_amd64.whl", hash = "sha256:6befeab5fb2b51c958ca4ac6c5d141a1e8240f4f76e46350f1911963deda49cd", size = 225135, upload-time = "2026-08-06T13:47:57.52Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/2a4561f89ff6bf7c925c287d0f2cce8bdf139c3a33735c87e3203401cf94/coverage-7.15.4-cp312-cp312-win_arm64.whl", hash = "sha256:67bc345491ab55b837277d76f5775d057e8c7f1ac44d890d8c2c82adde258c6f", size = 224515, upload-time = "2026-08-06T13:47:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/f1/84/651a9310859673aaa3b3203f1aa1641ca60fcf2494683e1c9474c7172780/coverage-7.15.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c705b28feb2775dc82a25f1d473a370bc37ff93f5177f4e29ce2425f560f6921", size = 222565, upload-time = "2026-08-06T13:48:00.796Z" }, + { url = "https://files.pythonhosted.org/packages/82/f9/4dcf700137e8af550670f4d74d1b63828ce93e1e2b05e5f10710eb2ea987/coverage-7.15.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3ff205ab5e3ecc670f6a4dd19d9cbf12ede53dd41cfc1e15716ec961ea6d314e", size = 222936, upload-time = "2026-08-06T13:48:02.391Z" }, + { url = "https://files.pythonhosted.org/packages/07/4a/612ff1e780b3fbfd637486f542f84adc5503873d8b5d279dec1ffeef9414/coverage-7.15.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5172326e861a38b48b48befca15e0f477a26b283337a33a739c8fed229934e36", size = 253926, upload-time = "2026-08-06T13:48:04.382Z" }, + { url = "https://files.pythonhosted.org/packages/b0/04/d1cff1c2ead4708a6a79c01d3736b6a25bd38a36678398f72a8dd33dfad9/coverage-7.15.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:12b59c90084e3234fb11184886bf4a40f4f16a8c8f867be2e087b81f8e8868d4", size = 256523, upload-time = "2026-08-06T13:48:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/b9/80/d34e13fb4b293cbdb9665838cf5522077b8ad14ef947550631a4bced36a5/coverage-7.15.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349062d66f00b40fa2c1c222438bad25fabf755631b5d82937fe985c8008615c", size = 257759, upload-time = "2026-08-06T13:48:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e7/2c5fe7636fdb0732fe0f09f308a5b066864078b7fc61f6678e8478554f2e/coverage-7.15.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4256ced708e598e05209bc1a8ab4074e04a51dba4c62fb45926a229af675ace7", size = 259890, upload-time = "2026-08-06T13:48:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/92/28/9689f0858dfff59c2ea688938ab9fa2925631235df67126a42b6c5c70ae1/coverage-7.15.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d80f974b20782d9612c8b4c9beeca867074c7cf4079d1419843fa25a26428b25", size = 254121, upload-time = "2026-08-06T13:48:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/785077c230c157243eb5aa9a26c3be260ecd02001bead54a3cada3df8e03/coverage-7.15.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e179f19bfe1d31f8eeeaa12990194d761c4f62f0759661000bca6cd8729f40b", size = 255891, upload-time = "2026-08-06T13:48:13.209Z" }, + { url = "https://files.pythonhosted.org/packages/d4/90/e20371b17b40f912f21305c2db2f30efa3de306f7320fc916804872c85a4/coverage-7.15.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8bc16bb47b7679670eceff71d78bfb7d6e5b143f6c2cd117487ec7c75e0d4b78", size = 253859, upload-time = "2026-08-06T13:48:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/05/49/25371987ee459a5f67c0427fb75c74f9358e65f2c71fe75bf41c1b6c5fcb/coverage-7.15.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd685005cd2c4200adfc14cf39a603b9320efab3f18a8f7f156d20c9cc3345f", size = 258011, upload-time = "2026-08-06T13:48:16.464Z" }, + { url = "https://files.pythonhosted.org/packages/30/6e/32e67467f6154bf4f1c4f63b05acc5097cba4237d45bbeeea446b52e8ac1/coverage-7.15.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:337399ad2c93b3acd2a937627dae8b3e86b66707cd3d3e856347999aadf1ef8d", size = 253676, upload-time = "2026-08-06T13:48:18.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/c1/8b24192e89286399765155251f99ee9f070a9d637109018ac23d99b99f6f/coverage-7.15.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:96e257121228ec5cd2bb919276e94ac11074471bc37d68dbae0e8308cce15fff", size = 255453, upload-time = "2026-08-06T13:48:20.057Z" }, + { url = "https://files.pythonhosted.org/packages/16/6f/8b41ebdf67c87854e17c035336a90f1cfbad0c14c2a584301be6ff148718/coverage-7.15.4-cp313-cp313-win32.whl", hash = "sha256:c65a9e0dfc6143491879da4e13b5e30f8be192055de508d737fb14601edbd22c", size = 224605, upload-time = "2026-08-06T13:48:21.655Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e2/2946c7f0b42b152ecb21ff1bdad72e3d301e790c0c487e4a86e8c9f69347/coverage-7.15.4-cp313-cp313-win_amd64.whl", hash = "sha256:2ff8f5e9b8f7a94f0c11c45631eee103dbcb7d63274edd12c56efe1be690b3b4", size = 225148, upload-time = "2026-08-06T13:48:23.376Z" }, + { url = "https://files.pythonhosted.org/packages/9e/83/3f4a69957f48ae7a0aba76c34743f88963d607b19e03f3f8e66f91cae0f9/coverage-7.15.4-cp313-cp313-win_arm64.whl", hash = "sha256:6e0a8a5083b096487d6cfced94cdd514d8f5db6f113610fb36c0620edb1028cf", size = 224536, upload-time = "2026-08-06T13:48:25.117Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ac/748cf29eeb2d6be34a3176ce26a4f49e38085ee08e8935f05f6f26ed7e0f/coverage-7.15.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:770e9325ab5ea6d56f77e59b29ecfe0ac20b57a82a601876f90494a4dda0386f", size = 222608, upload-time = "2026-08-06T13:48:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/0b/02/1abbf5c984677b0aa439cdacaccbf38d248939d8ef8fe1cc7a50d73edb77/coverage-7.15.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d12b33a3a50a1676b7784dc8d00a0c6d66a9f2add4b85a041c19b6a7e53ef23c", size = 222940, upload-time = "2026-08-06T13:48:28.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e1/ff8f9f53d9fcf586125b55d0b1f04ec1c14955fee41e83d5814bee141bb5/coverage-7.15.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5669c8378ebde86f5def7a25d29586631b58acc27ffde04399f678f3dfc6e082", size = 253985, upload-time = "2026-08-06T13:48:29.995Z" }, + { url = "https://files.pythonhosted.org/packages/a1/26/595759762e514e81be1d7d01ed03444303bcd152226a6529998d253f9201/coverage-7.15.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff97a14362eef486483ed44042ca2027ea257df6ff768e62358ee0c9776925ac", size = 256492, upload-time = "2026-08-06T13:48:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/b79aabac54d482be23b5fcdd4f4662bff24a78edc4ee29201726929936d5/coverage-7.15.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a325e815318638aed1655d9c06e6d7c2d3d46c09231ce988070428a8762d734", size = 257837, upload-time = "2026-08-06T13:48:33.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/0f/bf7f297885a5bf6fd71e5782404e0ff059ca09e8711ceb3a08544abde45a/coverage-7.15.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:474223409d88eb20d2d6a0d37ea60e8647a65a90cc008dc1f0410af5f64f1e0d", size = 260152, upload-time = "2026-08-06T13:48:34.75Z" }, + { url = "https://files.pythonhosted.org/packages/fd/f1/296744e854ff8368542343457414380465e9ceefb9192342feb9d3bc461d/coverage-7.15.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f2f62ae3cd189dd2e13aece758c57b3eecbd27be070dbd4cbd10936049e5dbf", size = 253978, upload-time = "2026-08-06T13:48:36.434Z" }, + { url = "https://files.pythonhosted.org/packages/55/b0/bbdb2e9057493e66220a2e149ca2d301ba0e3a58a83bd6b90de9826d16f3/coverage-7.15.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:39ece820e29e0a2ba34b3ecb3be83c27e997eed8926f2ba6fe7ce7a0bda5843b", size = 255846, upload-time = "2026-08-06T13:48:38.317Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/38015b2b6d21258713bd17e76b59d033b191efb5703589cffd037dfbca20/coverage-7.15.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f21b56dcace11dfe013014201f577dcd592b2a9b72182d930361b47cf6f73f25", size = 253808, upload-time = "2026-08-06T13:48:39.993Z" }, + { url = "https://files.pythonhosted.org/packages/0b/64/0d515c1e60ee6fbfd1a0e79c07cd87d388a233b7adc37758735677203808/coverage-7.15.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:93a3a0b662abcc10c73a47cbc72cd60f63618d6989fb2d1286e50eacd974f303", size = 258081, upload-time = "2026-08-06T13:48:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/91/71/04d9e7a3642146c6351338aef4ef85ab11dbbb54744c13245caba1aad1c0/coverage-7.15.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:141fae2cabf5569b782c10afc4c850ce10f618c13f8db54765cba99cc839da1f", size = 253624, upload-time = "2026-08-06T13:48:43.731Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a7/6c28b74c81ebff66987b0e2522ba5cffa3e90b0c33cb6a2eb264d4ee8cf1/coverage-7.15.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:81294c7e6ab30c5f74c0353b11b2fd6320e72d9bee6ac73b357caa8b916323a5", size = 255280, upload-time = "2026-08-06T13:48:45.58Z" }, + { url = "https://files.pythonhosted.org/packages/52/af/bc19996a7014b98d7bbb0f0939453c67074af65784a3aa16a789a07381fa/coverage-7.15.4-cp314-cp314-win32.whl", hash = "sha256:7bbd7d6418e0dab31a206af5203bd43ae36edb8e7fba1940b055d3e9249290d7", size = 224768, upload-time = "2026-08-06T13:48:47.525Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/219484e476d6e101ba0a444852579e05f5b75c37c611a42ed1190f73ef62/coverage-7.15.4-cp314-cp314-win_amd64.whl", hash = "sha256:f0204ed122758782970526057093f448051a39db9d810d4e344bb87a3546f425", size = 225259, upload-time = "2026-08-06T13:48:49.513Z" }, + { url = "https://files.pythonhosted.org/packages/b7/66/fa77daf4e383e5f776dac62c2409b6af81910ae6fe326bd5170dba74cc63/coverage-7.15.4-cp314-cp314-win_arm64.whl", hash = "sha256:9e71e7bc71c686a123347ae47a0de33a175e797a85bb57b791492adf4eec8ed8", size = 224684, upload-time = "2026-08-06T13:48:51.235Z" }, + { url = "https://files.pythonhosted.org/packages/58/5b/f03bf0ce362bbf3f785fa5219620d00778d4ac6fc9e407734828e9c672f6/coverage-7.15.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7c922735321eef3f87c280a3d39afff6b646723a2880b862cda4ac7a093b8aa8", size = 223338, upload-time = "2026-08-06T13:48:52.896Z" }, + { url = "https://files.pythonhosted.org/packages/0f/76/e77d0ae22501831cc9f92193e8a957a5caa1dd177f90a6d1d9b106242d92/coverage-7.15.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f41c17c4668a655ce96d090d8d5ffdc24ef64b5a02f9753884d08483e8a4a41a", size = 223609, upload-time = "2026-08-06T13:48:54.688Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/b1f089da8d38ac612fa2dd6dc7f4a1a7657d12f3e261d2996edd3a838d0b/coverage-7.15.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46822e9b6ff1c6a72b518c162c44a8f45a61a1d609c51084bf5b16c023c5037b", size = 264970, upload-time = "2026-08-06T13:48:56.403Z" }, + { url = "https://files.pythonhosted.org/packages/bf/31/e66d98d6e9c7fcc88470f1e234eaf6b1950dc0dfbf797f7282c1c861da24/coverage-7.15.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d6f4955b73b5445271379a59e3792b0d978f42d4a01e0cf7a67d9c33a3bb0a5", size = 267088, upload-time = "2026-08-06T13:48:58.41Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/ae94eb2c541add426378408379f233591e069040b1e2cdb33df9498a0682/coverage-7.15.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3fc9e047706fb4a9abb54f719d3aa643e80e5bb3818182c40aee01ac0f0247ba", size = 269508, upload-time = "2026-08-06T13:49:00.42Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c7/88a10694a1c6a213569766aba9f25847b28155d4ac731b13226db216356d/coverage-7.15.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05e491d4f3165d62d4f5c8fd48dfeabf2ae8f42cbbd484319af33ea851b78982", size = 270629, upload-time = "2026-08-06T13:49:02.234Z" }, + { url = "https://files.pythonhosted.org/packages/b3/34/d8b8232e5e55169933b59aabcef2fedfa4b9d8897361bb80fcbda146505f/coverage-7.15.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:226c66e80ec0598d3b9b4874123df167ccca342aca8714f77cac6829688ee09c", size = 264043, upload-time = "2026-08-06T13:49:04.102Z" }, + { url = "https://files.pythonhosted.org/packages/7e/35/58b009dbf8c471c7224716478b9fed4a7e1af15320e1ed41660978504663/coverage-7.15.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac41cc14bebda0dbfb0628036b7f75706935c95bcc07fefe9a0f93614aa60a57", size = 266963, upload-time = "2026-08-06T13:49:05.821Z" }, + { url = "https://files.pythonhosted.org/packages/62/aa/57fbda1b42c892968273c56b6ee9dc0f1310850859230a507bc7873b1f65/coverage-7.15.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8af623e5cd92080acddd02b38f2f406a2c3a0893c38950b211890361448fbf26", size = 264569, upload-time = "2026-08-06T13:49:07.706Z" }, + { url = "https://files.pythonhosted.org/packages/98/8a/360e6e7f24d477b7e889703af0afa878d15b6d4d8d2a822b2835c169a879/coverage-7.15.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07545711d4f0f32852a18f18ad11f76f0109909d09e78b9008b4cfc67e829429", size = 268299, upload-time = "2026-08-06T13:49:09.587Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/6f701261aee21b6b5fa8f7872229406dc917e125069448292223bf213606/coverage-7.15.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a0865421cfdc53654b342d515e5a233187590882d20b95752150e53f65460017", size = 263413, upload-time = "2026-08-06T13:49:11.604Z" }, + { url = "https://files.pythonhosted.org/packages/3f/0f/6f04036edc260ed425af83e834f627fad48941ce97b50bfe6edd8b6fa623/coverage-7.15.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:460115e32ee40566476db5048f9bec1e842c127ad8e6f8be745aad3ac9cbc839", size = 265725, upload-time = "2026-08-06T13:49:13.38Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ce/d19b5d4d5c49a7bfb925fd74310fee7d28bc99520ac3367ccbc54e662518/coverage-7.15.4-cp314-cp314t-win32.whl", hash = "sha256:cbde877ef9dd7baf272b9bfef2b8a25edd45d9170fc326951dd20eb480335e85", size = 225079, upload-time = "2026-08-06T13:49:15.265Z" }, + { url = "https://files.pythonhosted.org/packages/26/bb/7aa1b3b173faee0679037ca950bbbe1247273656697994d8d13f80f8d4b4/coverage-7.15.4-cp314-cp314t-win_amd64.whl", hash = "sha256:3da9e92d1c551fd7563833e9ade686efb0c4b7363ab7681a94283958c950bf5e", size = 225911, upload-time = "2026-08-06T13:49:17.279Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/4ea9e47426d80038d9222db3c4534cb6021a74b237d3ff97ffd33b6600dd/coverage-7.15.4-cp314-cp314t-win_arm64.whl", hash = "sha256:3a54f5a0d85050c73a38f6793090ee83974531e67fe5e57a1da9bee11398aa5e", size = 225219, upload-time = "2026-08-06T13:49:19.293Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/dc5d2ac8f9142e7ec7de66e7bf0591db29d78955a040bd915870d9c0e657/coverage-7.15.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2c9872e4d9dc5d3cf616bf4b382f5a00359305a5be666a3dd0b5cdb4e49597f9", size = 222604, upload-time = "2026-08-06T13:49:21.279Z" }, + { url = "https://files.pythonhosted.org/packages/70/39/33e63df81fe2ee100897451841c821467635923e58e37c6bd4b46dd8106c/coverage-7.15.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:e101dbb4b9b72f0cddd8cdc8c9c5b47f456766f5e0ac82dbfb75e5c55409b78a", size = 222944, upload-time = "2026-08-06T13:49:23.187Z" }, + { url = "https://files.pythonhosted.org/packages/99/1f/ef3ffb5557febc75a0d97aa459d0266d7d741110265121cc6d8539343d44/coverage-7.15.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7d1abebdb047729e852b9c77a00497dfbeb11eb3a117e037d7dbc3ac8e5f5c54", size = 254050, upload-time = "2026-08-06T13:49:25.008Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f5/1f0f6f77698c3601ca0ae7431e34b24c62ca2f06fecb23b73ed1f651d2be/coverage-7.15.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d28a4a899354d0ea6214cc59b4fa19eefbce1b9ff1688ab579acf49e894bd3fb", size = 256967, upload-time = "2026-08-06T13:49:26.896Z" }, + { url = "https://files.pythonhosted.org/packages/03/7a/2ed9bed79925f4367c83c77f66a89e5ca7229c288d2d19ad5f36d1ca0070/coverage-7.15.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffb3c2aacea411cc7e1d27712490c11108e2de1d39019ae32915493a59a8b9ed", size = 258587, upload-time = "2026-08-06T13:49:28.692Z" }, + { url = "https://files.pythonhosted.org/packages/45/8c/fa34044f71b7cc4ecb6da9c2408770959b0591fa9b5fb6fb6bca38f94298/coverage-7.15.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9447978a92f405d301123cfd39ff49895490efb769a758fe2734c7f631bf8ce", size = 260785, upload-time = "2026-08-06T13:49:30.472Z" }, + { url = "https://files.pythonhosted.org/packages/4f/54/d5727ce36b4524a7394ab9f5f1df378e1f23affcdab01037dc8655185cc7/coverage-7.15.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050467a7983b8e2fe7dd41a78bb30c3e7f8c0b8cafda14b1c46f8b5e3cf2dd3c", size = 254545, upload-time = "2026-08-06T13:49:32.271Z" }, + { url = "https://files.pythonhosted.org/packages/dc/e6/6e3783e576719590194bdffb6dd6d85490801785b7c331e35a245d8cb8b5/coverage-7.15.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d003b7a5708ddad5c206c79607a6b92abb6fc13c57d99d8a4468cc03a2941ced", size = 256682, upload-time = "2026-08-06T13:49:34.089Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/bacdbde18b69ed2de424fcf64d9fb0a4913753d4f0eca8bae9daad69f4bd/coverage-7.15.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c38efe30fd74e5c19e9433f11fb1f5dc9c6522770971b7c6145bbaa413dc8800", size = 254560, upload-time = "2026-08-06T13:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a3/1fb927196e3477c1b48831169ab58ba08f451ba87ae311ff1de68b26a616/coverage-7.15.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1f4f826d70f772ab8b0c052329580d7fe8b8abd191e4ce0c8f81aec6614665d3", size = 258792, upload-time = "2026-08-06T13:49:38.01Z" }, + { url = "https://files.pythonhosted.org/packages/41/58/30d4c149c69053de0edfe325614c1d28d508f62b1783e0e4a234d2e49136/coverage-7.15.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4a4bf917c9953f57c957be31c1cd504e3bd2f34d4a352b9d391a3025336f6768", size = 253968, upload-time = "2026-08-06T13:49:39.934Z" }, + { url = "https://files.pythonhosted.org/packages/89/e4/77f639371b918aad30dda4051f95404b43578f7f2e2f87ba73e02ed1ff37/coverage-7.15.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1c9bf40ebef178a45192c75c4964760bb261b0e6ad725da5fc4c93f674f19753", size = 255893, upload-time = "2026-08-06T13:49:41.825Z" }, + { url = "https://files.pythonhosted.org/packages/5c/62/13be29b3ddab35f14c87967a4820a05106d2a3eccb4fa4ff550bf30b75e0/coverage-7.15.4-cp315-cp315-win32.whl", hash = "sha256:43619d04c3671792d2c4706ae8bf45e265dc87bbd4078189ef8b847ea1e74be2", size = 224768, upload-time = "2026-08-06T13:49:44.08Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/af0c6be0f964af6954f6b74bc109b0dbca02824696d2520fb17fe1ab06e3/coverage-7.15.4-cp315-cp315-win_amd64.whl", hash = "sha256:be619439dbcd31a2eab10b32de9fff62c26ed4bab69dc32b8363fdaaa0882809", size = 225242, upload-time = "2026-08-06T13:49:45.899Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2d/f3bd3aab899fc9efc18b53133ee68f5f98574ef480649b23e12962226387/coverage-7.15.4-cp315-cp315-win_arm64.whl", hash = "sha256:def597967dafc2e8d97c9097ea453c464e0bb8ed38f193a43070f10dc623bb6d", size = 224674, upload-time = "2026-08-06T13:49:48.322Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ca/f69251cd63eabc6438321aea22148754cce758a26bde07dd490e3fe7cfc5/coverage-7.15.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c7dbc748ac8a1e3e59a2b28bea47675e6e778081dbbf081bde0d75def2fcbe1d", size = 223333, upload-time = "2026-08-06T13:49:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/037b53b2885b0d8447064432491a4d5a1014cd9f97a594d53acd0c04541a/coverage-7.15.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:2413074a5ecbb61a01a7888fc72db0ca324d13588c5b38bc0dd8564cdcdfea26", size = 223630, upload-time = "2026-08-06T13:49:52.637Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/152b8a4779ae90da11bb24f7467df8a59f0be48a5c52acb856325ca48289/coverage-7.15.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4e6f6f632b7b2f714bf7a1346e8f97b650ee71f3c298aaad42a2ab60f0f07645", size = 264489, upload-time = "2026-08-06T13:49:54.52Z" }, + { url = "https://files.pythonhosted.org/packages/10/2d/84b4b9e0e1dd6528a51920ff7031f35b789382e467a28ec6a5a578cb8812/coverage-7.15.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8df457da2249d3c75ca2e5e835d59c725abfe92d27fdff6cd99eed85b51d5e9a", size = 267567, upload-time = "2026-08-06T13:49:56.721Z" }, + { url = "https://files.pythonhosted.org/packages/53/fc/ba01cc25299f9f8a2c8b02d3b28c53f3543d9fbfbe4e74fa2760b48f163e/coverage-7.15.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:050f66a08805acb5b8a23c6d4a517b1ecf82c08e81ed0e4bd727df065e5c6624", size = 270123, upload-time = "2026-08-06T13:49:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d0/db2647cbf40b14f8c308f94ff7bf89c06d564e59f396906edf50086ec788/coverage-7.15.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1587fb771d1ccceef708fdde1e5af8c7ed24b486b61d13a321acb7d8145390aa", size = 271107, upload-time = "2026-08-06T13:50:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/4d2d17924552c458bb4f77dd631f0e3bc92fbbdf2d2d916cd4b33bbfd5b1/coverage-7.15.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b4f1c3a69ca580f3fbd6b2046915f536d7f586874f25c1bb23add2a3c88d50f", size = 264955, upload-time = "2026-08-06T13:50:03.023Z" }, + { url = "https://files.pythonhosted.org/packages/ee/de/dc010c7a3691f396d93bbc26bfcafa1c2a3a351cd520470f15faf5795bd5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ffb58d7eff5b7f6ecc6fa21d6288ab7f968a212cb67d682c269c09b9eba3b66f", size = 267949, upload-time = "2026-08-06T13:50:05.557Z" }, + { url = "https://files.pythonhosted.org/packages/78/ea/dc96a11375e83c045c2f7c61fb6918277cfe9401db7c0f7b1d111a84b2e5/coverage-7.15.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:d9df165544774574ee004b953023d1bebada1894a80b1052a43d798b0f676e67", size = 264421, upload-time = "2026-08-06T13:50:07.612Z" }, + { url = "https://files.pythonhosted.org/packages/c8/86/b77131a0f9503ce461cd577076147d7a9040f0c5dda772686f729e2cc9cb/coverage-7.15.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f9de0a24a4079b53e523b5c5e2c5945ec251ab486652659955187cf255a259bc", size = 269121, upload-time = "2026-08-06T13:50:09.58Z" }, + { url = "https://files.pythonhosted.org/packages/24/24/944bc35007862955e7ebf05754e645419dcf5d7526c52735cfa2715e8ebf/coverage-7.15.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:150089274bdc9f940628552cb92844e0223c987f1902ab8efe9f45a2ec758d88", size = 264565, upload-time = "2026-08-06T13:50:11.722Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/a3bb9f93e7e740659163e2ea584f8196ddcd2c456a5dbe15f6c50105fec1/coverage-7.15.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:a58a94fed5da6997d258e8f7668c1e195fbd04a691d781b7558f1e468f9e68bc", size = 266522, upload-time = "2026-08-06T13:50:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/e0e40f3560d878d888c580698ff5ad1179f5e1c3ac949684ef66b41a3817/coverage-7.15.4-cp315-cp315t-win32.whl", hash = "sha256:ebd5a6d8466ff30836572f3ba2cae8a5e8f85029b1c6d5e2ed338dc472a5166a", size = 225068, upload-time = "2026-08-06T13:50:15.825Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/37732ea80eebc30e976e4cdab15c190bc42d96959a42e38ddf6f8c60468f/coverage-7.15.4-cp315-cp315t-win_amd64.whl", hash = "sha256:288bde2a2d7ab6b6c2d7252fcde8b524387f2d970bdba9658fc6f8bbcaef0f9b", size = 225895, upload-time = "2026-08-06T13:50:17.928Z" }, + { url = "https://files.pythonhosted.org/packages/c6/08/1e00f7923eaaba45fb3d51dd794125fc766304b1df264f3a9c6557bfb30e/coverage-7.15.4-cp315-cp315t-win_arm64.whl", hash = "sha256:68be5e1de60ff13c9095bbec0e5a7fa45b33b101752215b91345ea1f61c4a278", size = 225213, upload-time = "2026-08-06T13:50:19.981Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d9/e70c286c979378f061d8266e279b686ab0b0b688e1fe0af864684f23a77d/coverage-7.15.4-py3-none-any.whl", hash = "sha256:964730a1e9de9c0cf11be6a1a3c79ce419c34882842abd256086ba4698705e84", size = 214332, upload-time = "2026-08-06T13:50:22.192Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, +] + [[package]] name = "cryptography" version = "47.0.0" @@ -2554,6 +2814,22 @@ 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", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.10'" }, + { name = "coverage", version = "7.15.4", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, + { name = "pluggy" }, + { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "pytest", version = "9.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +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"