From 299aa87fa3d38dc80b19ba583ddc788b1e888955 Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 25 Aug 2026 13:35:00 +0200 Subject: [PATCH 01/13] feat: add full memory support to the Python SDK Fixes ENG-896 --- AGENTS.md | 2 +- CHANGELOG.md | 20 +- README.md | 46 +++- 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 | 117 ++++++++++ src/cominty_sdk/resources/__init__.py | 3 +- src/cominty_sdk/resources/memory.py | 138 ++++++++++++ tests/integration/test_smoke.py | 37 +++- tests/unit/test_memory.py | 305 ++++++++++++++++++++++++++ 13 files changed, 750 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..1f85d4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ 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; 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 (`importlib.metadata.version("cominty-sdk")`) instead of the removed @@ -43,4 +61,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..eabeb98 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,50 @@ 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. + +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/): @@ -294,4 +338,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..1f90006 --- /dev/null +++ b/src/cominty_sdk/models/memory.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator +from typing_extensions import TypeAlias + +from .chat import UserId + +__all__ = [ + "MemoryFileCreate", + "MemoryFileUpdate", + "MemoryFileOut", + "MemoryFileSummaryOut", + "MemoryPath", + "MemoryPathParam", + "validate_memory_path", +] + + +# 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 + + +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: MemoryPath + 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.""" + + +class MemoryFileUpdate(BaseModel): + """Partial update body for ``PUT /memory/file``. + + 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") + + 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 + + @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") + + path: str + purpose: str + content: str + created_at: datetime + updated_at: datetime + version: str + """Opaque concurrency token — pass it back unchanged to + :meth:`~.resources.memory.MemoryResource.update`.""" + + +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..a997a4a --- /dev/null +++ b/src/cominty_sdk/resources/memory.py @@ -0,0 +1,138 @@ +"""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, + MemoryPathParam, +) + +if TYPE_CHECKING: + from .._transport import AsyncTransport + +__all__ = ["MemoryResource"] + + +class _Unset: + """Sentinel default distinguishing "not passed" from "passed as ``None``" + for :meth:`MemoryResource.update`'s optional fields.""" + + 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 + + @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( + "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. ``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( + 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``).""" + path = self._validate_path(path, context="memory.get") + raw = await self._transport.request( + "GET", "/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``). + + 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 + 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", "/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). + + 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", "/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..da351fc --- /dev/null +++ b/tests/unit/test_memory.py @@ -0,0 +1,305 @@ +"""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. +""" + +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" + +# 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", + *, + 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" + 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" + ) + + +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 == "" + + +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 + + +# --------------------------------------------------------------------------- # +# get +# --------------------------------------------------------------------------- # +async def test_get_sends_path_and_user_id_as_query( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("/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" + + +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 +# --------------------------------------------------------------------------- # +async def test_update_omitted_field_is_excluded_from_body( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.put("/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 + 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" + + +@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: + # 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) + + assert mock_api.calls.call_count == 0 + + +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") + + 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("/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("/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") + + +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 +# --------------------------------------------------------------------------- # +async def test_delete_sends_query_and_returns_none( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.delete("/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 + + +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 +# --------------------------------------------------------------------------- # +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("/memory/file").mock(return_value=httpx.Response(200, json=_file(version="v1"))) + mock_api.put("/memory/file").mock( + return_value=httpx.Response(200, json=_file(content="updated", version="v2")) + ) + mock_api.delete("/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 78525b3cdbf3b25857274d20e5cc22f0c3517911 Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 25 Aug 2026 13:37:47 +0200 Subject: [PATCH 02/13] feat: add invoke task to bump, validate, commit, and tag releases Fixes ENG-897 --- 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 f85342828fd1669845f631f2ede07ed886e8ec0e Mon Sep 17 00:00:00 2001 From: ahmeda-cominty Date: Tue, 25 Aug 2026 13:40:30 +0200 Subject: [PATCH 03/13] test: expand SDK unit test coverage to 100% and enforce it in CI Fixes ENG-900 --- .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" From 3afdc3114a03deb84459d243b96acf51639bfa88 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:28:42 +0200 Subject: [PATCH 04/13] docs: restrict docstrings and ban em dashes --- AGENTS.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index bb2636d..f85a3af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -484,4 +484,52 @@ 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. \ No newline at end of file +never ships untested code. + +--- + +## 13. Prose and docstrings + +### 13.1 No em dashes + +Do not use the em dash character (`—`, U+2014) anywhere: source, comments, docstrings, +examples, Markdown, YAML, or TOML. Rewrite the sentence with a colon, a comma, a period, +or parentheses. Hyphens and en dashes in numeric ranges stay. + +### 13.2 Where docstrings are allowed + +- No module docstring in `src/` or `tests/`. The filename is enough. +- Module docstrings stay in `examples/`. They are the script's usage note. +- No docstring on a non-public function (leading underscore, or any helper a SDK user does + not call). If the name hides a real subtlety, rename it. If a better name is not enough, + leave a short comment that explains why. Do not write a docstring just to have one. +- Invoke tasks in `tasks.py` keep their docstrings. `invoke --list` prints them as CLI help. + +### 13.3 Public methods + +Document a method only when a SDK user calls it (`AsyncCominty`, `client.chat`, +`client.threads`, `client.memory`, and the other methods on that surface). Use Google style: + +- A short summary on the first line. +- A longer description only when the behavior is not obvious from the signature. +- `Args`: every parameter, its type, and its role. +- `Returns`: the type and what it represents. +- `Raises`: only when the failure is not obvious. +- `Examples`: only when the call shape is not obvious from the signature. + +```python +async def start(self, *, agent_id: str, message: str) -> StartedChat: + """ + Start a new thread with a first user message. + + Args: + agent_id (str): Managed agent that handles the thread. + message (str): First user message. + + Returns: + StartedChat: Handle for the in-progress assistant reply. + + Raises: + InvalidParams: ``agent_id`` or ``message`` failed local validation. + """ +``` From 5e157da2a0539c29d9cada9a2a48a856c5767da3 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:37:10 +0200 Subject: [PATCH 05/13] style: apply docstring and em dash rules --- .env.example | 6 +- .github/workflows/release.yml | 2 +- AGENTS.md | 70 ++++++------- CHANGELOG.md | 12 +-- CONTRIBUTING.md | 2 +- README.md | 68 ++++++------- examples/01_stream_events.py | 4 +- examples/03_follow_up.py | 6 +- examples/04_answer_questions.py | 2 +- examples/05_list_threads.py | 2 +- examples/06_manage_thread.py | 2 +- examples/07_custom_agent.py | 6 +- examples/08_mcp_linear.py | 4 +- examples/09_memory.py | 4 +- examples/_pretty.py | 4 +- pyproject.toml | 2 +- src/cominty_sdk/__init__.py | 2 - src/cominty_sdk/_config.py | 9 +- src/cominty_sdk/_transport.py | 26 +---- src/cominty_sdk/client.py | 54 ++++++---- src/cominty_sdk/events.py | 21 +--- src/cominty_sdk/exceptions.py | 99 ++++++++----------- src/cominty_sdk/models/__init__.py | 2 - src/cominty_sdk/models/chat.py | 18 +--- src/cominty_sdk/models/memory.py | 7 +- src/cominty_sdk/resources/__init__.py | 2 - src/cominty_sdk/resources/chat.py | 71 +++++++++---- src/cominty_sdk/resources/memory.py | 137 +++++++++++++++++--------- src/cominty_sdk/resources/threads.py | 59 ++++++++--- src/cominty_sdk/streaming.py | 62 +++++++----- tasks.py | 20 +--- tests/conftest.py | 9 +- tests/integration/test_smoke.py | 8 -- tests/test_release.py | 10 +- tests/unit/test_chat_send.py | 7 -- tests/unit/test_chat_start.py | 19 ++-- tests/unit/test_chat_stream.py | 15 +-- tests/unit/test_client.py | 2 - tests/unit/test_exceptions.py | 9 +- tests/unit/test_memory.py | 7 -- tests/unit/test_threads.py | 6 -- tests/unit/test_transport.py | 8 +- 42 files changed, 428 insertions(+), 457 deletions(-) diff --git a/.env.example b/.env.example index e55e769..1396f10 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # Example environment for the Cominty SDK. # # The SDK reads these from the real process environment. It does NOT auto-load -# .env — either export them, or load this file yourself, e.g.: +# .env: either export them, or load this file yourself, e.g.: # set -a; source .env; set +a # shell # from dotenv import load_dotenv; load_dotenv() # python-dotenv (not a dependency) # Your API key (required). Create one at https://platform.cominty.ai/api-keys COMINTY_API_KEY= -# End-user identifier (required) — the user every request acts on behalf of. +# End-user identifier (required): the user every request acts on behalf of. # Looks like user_xxxxxxxxxxxxxxxxxxxxxxxx. Find yours at # https://platform.cominty.ai -> avatar (top right) -> Profile COMINTY_USER_ID= @@ -21,5 +21,5 @@ COMINTY_USER_ID= # Used only by the example scripts (the SDK itself has no agent default). # Find agent ids at https://platform.cominty.ai/agents # COMINTY_AGENT_ID=__cominty_agents::agent.chat -# A custom agent you created on the platform — used by 07_custom_agent.py +# A custom agent you created on the platform, used by 07_custom_agent.py # COMINTY_CUSTOM_AGENT_ID= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f311044..1dc372f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ name: Release # Publishes to PyPI when a GitHub Release is published. -# Uses PyPI Trusted Publishing (OIDC) — no API token stored anywhere. +# Uses PyPI Trusted Publishing (OIDC): no API token stored anywhere. on: release: types: [published] diff --git a/AGENTS.md b/AGENTS.md index f85a3af..1d62451 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# AGENTS.md — Cominty Python SDK Coding Bible +# AGENTS.md: Cominty Python SDK Coding Bible This document is the authoritative guide for every coding agent and contributor working on `cominty-sdk`. It defines conventions, patterns, and rules that must be followed across the @@ -8,24 +8,24 @@ entire codebase. Deviations require an explicit, justified comment at the call s ## 1. Language & Runtime -- **Python 3.9 is the floor.** `requires-python = ">=3.9"` is a promise to users — the source +- **Python 3.9 is the floor.** `requires-python = ">=3.9"` is a promise to users: the source must import and run on 3.9 through 3.13. pyright and ruff are both pinned to `3.9`/`py39`, so they will flag any syntax newer than the floor. Your local interpreter (3.13) is irrelevant; the floor decides what syntax is legal, not your machine. -- Use **modern built-in generics** everywhere — `list[str]`, `dict[str, int]`, `tuple[int, ...]`, +- Use **modern built-in generics** everywhere: `list[str]`, `dict[str, int]`, `tuple[int, ...]`, `type[T]`. Never `typing.List`, `typing.Dict`, `typing.Tuple`, etc. (PEP 585 built-in generic subscription works at runtime since 3.9.) - Use **`from __future__ import annotations`** at the top of **every** module. It stringizes all annotations, so `X | Y` unions and built-in generics in *pure type-hint* positions (function signatures, variable annotations) are never evaluated at runtime and work on 3.9. -- **Unions — three cases, know which you're in:** +- **Unions: three cases, know which you're in:** - *Pure type hints* (function args, returns, plain attribute annotations): `X | Y` is fine under - the future import — never evaluated at runtime. + the future import: never evaluated at runtime. - *Pydantic model fields*: `X | Y` and `list[str]` are written as usual, **but Pydantic `eval()`s the annotation string at model-build time**, which would `TypeError` on 3.9. We depend on - **`eval-type-backport`** (`python_version < "3.10"`) so Pydantic resolves them transparently — + **`eval-type-backport`** (`python_version < "3.10"`) so Pydantic resolves them transparently: no per-field `Union[...]` needed. This is the one place the future import alone is *not* enough. - - *Runtime-evaluated values* — `TypeAlias` right-hand sides, `TypeAdapter(...)`, `cast(...)`, + - *Runtime-evaluated values*: `TypeAlias` right-hand sides, `TypeAdapter(...)`, `cast(...)`, `isinstance` targets: use **`typing.Union[X, Y]`**. These execute the `|` directly (not via Pydantic), so the backport doesn't help and `|` on typing forms `TypeError`s on 3.9. @@ -33,9 +33,9 @@ entire codebase. Deviations require an explicit, justified comment at the call s ## 2. Typing System -### 2.1 Generics — `TypeVar`, not PEP 695 brackets +### 2.1 Generics: `TypeVar`, not PEP 695 brackets -PEP 695 `[T]` syntax is **3.12+ only** — it is a hard `SyntaxError` on the 3.9 floor. Declare +PEP 695 `[T]` syntax is **3.12+ only**: it is a hard `SyntaxError` on the 3.9 floor. Declare type variables explicitly with `TypeVar`: ```python @@ -53,7 +53,7 @@ class Stack(Generic[T]): Import `TypeVar`/`Generic`/`ParamSpec` from `typing_extensions` (a declared dependency) when you need features newer than the 3.9 `typing` module; otherwise plain `typing` is fine. -### 2.2 Type aliases — `TypeAlias`, not the `type` statement +### 2.2 Type aliases: `TypeAlias`, not the `type` statement The `type X = ...` statement (PEP 695) is also **3.12+ only**. Use an annotated assignment with `TypeAlias` from `typing_extensions`, and `Union[...]` for the value (see §1 on runtime unions): @@ -107,9 +107,9 @@ Prefer `typing.Protocol` (with `runtime_checkable` when needed) over abstract ba Use `TypedDict` for dict-shaped data flowing across I/O boundaries (e.g. raw API payloads before parsing). Never use `dict[str, Any]` as a return type. -### 2.7 String enums — `(str, Enum)`, not `StrEnum` +### 2.7 String enums: `(str, Enum)`, not `StrEnum` -`enum.StrEnum` is **3.11+ only**. Subclass `str` and `Enum` instead — it serializes and compares +`enum.StrEnum` is **3.11+ only**. Subclass `str` and `Enum` instead: it serializes and compares identically under Pydantic and works on the floor: ```python @@ -136,8 +136,8 @@ API additions. ### 3.2 Naming -- Request models: `Params` — `CreateThreadParams`, `SendMessageParams`. -- Response models: `` or `Response` — `Thread`, `Message`, `ChatResponse`. +- Request models: `Params`: `CreateThreadParams`, `SendMessageParams`. +- Response models: `` or `Response`: `Thread`, `Message`, `ChatResponse`. - No "Model" suffix. No "Data" suffix. ### 3.3 Location @@ -183,7 +183,7 @@ class APIError(ComintyError): ### 4.3 Rules - **Raise early, never return `None` on failure.** If a method can't produce its return type, - it raises — never returns `None` as a sentinel. + it raises: never returns `None` as a sentinel. - All raises from the HTTP layer are caught in `_transport.py` and re-raised as `APIError` subclasses. Resource methods never catch raw `httpx` exceptions. - SDK-level validation errors (wrong arguments, etc.) raise `ValueError` with a message that @@ -228,8 +228,8 @@ The **only** public API is what `cominty_sdk/__init__.py` re-exports via an expl ### 6.2 Two enforcement signals (both required) -1. **`__all__`** in every module — governs `import *` and is the canonical list. -2. **Leading underscore** on every private name — visible at the call site, the load-bearing +1. **`__all__`** in every module: governs `import *` and is the canonical list. +2. **Leading underscore** on every private name: visible at the call site, the load-bearing deterrent against `from cominty_sdk.client import _retry`. Public names **never** carry an underscore. Private names **always** do. @@ -333,7 +333,7 @@ Patch the HTTP layer with `respx`. Every resource method gets a unit test that: 2. Asserts the returned object is the correct Pydantic type. 3. Asserts the request URL, method, and body were correct. -No `MagicMock` on the resource classes themselves — test the real resource, fake the transport. +No `MagicMock` on the resource classes themselves: test the real resource, fake the transport. ### 9.3 Notebook contract tests @@ -362,9 +362,9 @@ uv run coverage html && open htmlcov/index.html # annotated per-line HTML repor 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. +why it can't be reached through the public API: see `streaming.py`'s `_stream()` guard for +an example. Don't add tests that only exist to nudge the percentage; prefer a justified +exclusion for genuinely unreachable code. --- @@ -400,7 +400,7 @@ Hatchling bakes that value into the built sdist/wheel's `dist-info` metadata at time. - `cominty_sdk.__version__` (in `__init__.py` + `__all__`) resolves it back at runtime via - `importlib.metadata.version("cominty-sdk")` — it does not hard-code a version anywhere in + `importlib.metadata.version("cominty-sdk")`: it does not hard-code a version anywhere in the package source. - If the package isn't installed (e.g. a raw source checkout with no `uv sync` / `pip install -e .`), `importlib.metadata.version()` raises `PackageNotFoundError`; `__init__.py` catches @@ -415,15 +415,15 @@ uv run invoke --list # show all tasks uv run invoke clean # remove dist/ build/ *.egg-info (no stale artifacts) 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 +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: 1. the sdist/wheel **build** succeeds, 2. metadata + README **render** (`twine check`), -3. the artifacts contain **what they should** — `py.typed` is present in the wheel, and the +3. the artifacts contain **what they should**: `py.typed` is present in the wheel, and the sdist contains **no** `legacy/`, `.env`, or `test.py`. `publish-test` is a full dry run against TestPyPI; verify the upload in a clean env with the @@ -432,9 +432,9 @@ uv run invoke release --patch # bump + validate + commit + tag (see §12.3) — ### 12.3 Cutting a release (steps) > **`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. +> 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: +1. **Bump, validate, commit, and tag**: pick exactly one mode: ```bash uv run invoke release --patch # X.Y.Z -> X.Y.(Z+1) @@ -445,19 +445,19 @@ uv run invoke release --patch # bump + validate + commit + tag (see §12.3) — 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 + `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 + 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. +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. @@ -467,8 +467,8 @@ plus every validation failure) against disposable temp Git repos — never this `.github/workflows/release.yml` runs on `release: [published]` and publishes to PyPI with **no stored API token**: -1. **build** job — `uv build`, `twine check`, uploads `dist/` as an artifact. -2. **publish** job — downloads `dist/`, then `pypa/gh-action-pypi-publish` mints a +1. **build** job: `uv build`, `twine check`, uploads `dist/` as an artifact. +2. **publish** job: downloads `dist/`, then `pypa/gh-action-pypi-publish` mints a short-lived GitHub **OIDC** token (`permissions: id-token: write`, `environment: pypi`), PyPI verifies the token's claims against a registered **Trusted Publisher**, and issues a one-shot project-scoped upload token. @@ -482,7 +482,7 @@ One-time PyPI setup (must match the workflow exactly, or PyPI rejects the token) | Workflow | `release.yml` | | Environment | `pypi` | -Note: `release.yml` builds + `twine check`s but does **not** run the test suite — tests run +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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f93dd4..90f837a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,23 +8,23 @@ 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()`, +- `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; the API does not + 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 + (`"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) + (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 +- `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 @@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > **Correction (2026-07-24):** an earlier revision of this entry claimed > `__version__` was already sourced from package metadata as of 0.1.1. That -> was inaccurate — the SDK still hard-coded the version via +> was inaccurate: the SDK still hard-coded the version via > `src/cominty_sdk/_version.py` at the time. The actual switch to > `importlib.metadata` ships under [Unreleased] above. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ea9b22..80b5b2d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ uv run pytest -m integration ## Releasing (maintainers) -Releases are driven by git tags — see the "Releasing" section in the +Releases are driven by git tags: see the "Releasing" section in the [README](README.md). In short: bump `version` in `pyproject.toml`, push a pre-release tag (`vX.Y.Zrc1`) to validate on TestPyPI, then a final tag (`vX.Y.Z`) to publish to PyPI. diff --git a/README.md b/README.md index f722f18..6edfbe0 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Official async Python client for the Cominty managed agent chat API. Start a conversation with an agent, stream its progress live, and manage threads -— with a small, fully-typed surface that's the same on Python 3.9 through 3.13. +with a small, fully-typed surface that's the same on Python 3.9 through 3.13. ```python import asyncio @@ -24,11 +24,11 @@ asyncio.run(main()) ``` - **Async-first**, built on `httpx`. -- **Fully typed** — ships `py.typed`; strict-checked with pyright. Pydantic models everywhere. -- **One handle for streaming *and* awaiting** — iterate a run for live progress +- **Fully typed**: ships `py.typed`; strict-checked with pyright. Pydantic models everywhere. +- **One handle for streaming *and* awaiting**: iterate a run for live progress events, or just `await run.text()` for the final answer. -- **Fail-fast validation** — bad parameters raise locally, before any request. -- **Typed errors** — every failure is a `ComintyError` subclass. +- **Fail-fast validation**: bad parameters raise locally, before any request. +- **Typed errors**: every failure is a `ComintyError` subclass. --- @@ -49,7 +49,7 @@ uv add cominty-sdk You need two things, both from [platform.cominty.ai](https://platform.cominty.ai): -1. **API key** → [platform.cominty.ai/api-keys](https://platform.cominty.ai/api-keys) (shown once — copy it). +1. **API key** → [platform.cominty.ai/api-keys](https://platform.cominty.ai/api-keys) (shown once: copy it). 2. **Your user id** → avatar (top right) → **Profile**. It looks like `user_31HPTBuBvX20xlQNAbvxjOxPbKB`. The user id identifies the end user every request acts on behalf of. It's set @@ -78,12 +78,12 @@ A malformed `user_id` is rejected at construction, not as a server error later. ### Picking an agent Every chat call takes an `agent_id`. Browse your agents and copy an id at -[platform.cominty.ai/agents](https://platform.cominty.ai/agents) — they look +[platform.cominty.ai/agents](https://platform.cominty.ai/agents): they look like `__cominty_agents::agent.chat`. ## Quick start -Every conversation starts with `chat.start`, which returns a **run** — a handle +Every conversation starts with `chat.start`, which returns a **run**: a handle to the assistant's in-progress reply. From there, pick the style you need. ### Just get the answer @@ -98,8 +98,8 @@ questions). `text()` is shorthand for `result().content`. ### Stream progress events -Iterating a run yields **progress events only** — tool calls, LLM steps, the -result event — as they happen. The finished reply is captured for you. +Iterating a run yields **progress events only** (tool calls, LLM steps, the +result event) as they happen. The finished reply is captured for you. ```python from cominty_sdk import events @@ -117,7 +117,7 @@ async for event in run: print("FINAL:", await run.text()) # available after the stream drains ``` -> A run's stream is single-use: iterate it **or** await its result — the result +> A run's stream is single-use: iterate it **or** await its result: the result > is cached, so calling `text()`/`result()` after iterating is free. ### Continue the conversation @@ -158,7 +158,7 @@ print(await reply.text()) `client.threads` is scoped to the client's `user_id` automatically. ```python -# List & search the user's conversations (summaries — no messages) +# List & search the user's conversations (summaries: no messages) for t in await client.threads.list(limit=20): print(t.created_at, t.name, t.id) @@ -169,7 +169,7 @@ await client.threads.list(limit=10, page=1) # paginate (zero-based) thread = await client.threads.get(thread_id) print(len(thread.messages)) -# Partial update — only the fields you pass change (returns a ThreadSummary) +# Partial update: only the fields you pass change (returns a ThreadSummary) await client.threads.update(thread_id, name="Renamed", starred=True) # Archive (soft-delete) @@ -178,7 +178,7 @@ await client.threads.archive(thread_id) ### Memory files -`client.memory` stores per-user files an agent can read back later — scoped to +`client.memory` stores per-user files an agent can read back later: scoped to the client's `user_id` automatically. ```python @@ -187,14 +187,14 @@ file = await client.memory.create( path="preferences/tone.md", purpose="writing style", content="Keep it casual." ) -# List files (summaries — no content) +# 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 +# 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( @@ -205,19 +205,19 @@ file = await client.memory.update( await client.memory.delete("preferences/tone.md") ``` -`version` is an opaque token — never parse or compare it, just round-trip +`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 +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 +- `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 +- `memory.delete()` is not idempotent: deleting an already-deleted path raises `NotFoundError`, not a repeated success. ## Examples @@ -252,7 +252,7 @@ Both `chat.start` and `chat.send` accept: |----------|------|-------| | `agent_id` | `str` | **Required.** The agent to run. | | `message` | `str` | **Required.** The user's message (max 30,000 chars). | -| `name` | `str` | `start` only — names the new thread. | +| `name` | `str` | `start` only: names the new thread. | | `file_ids` | `list[str]` | Attach previously-uploaded files (max 5). | | `source_ids` | `list[int]` | Restrict retrieval to specific knowledge sources. | | `document_ids` | `list[str]` | Restrict retrieval to specific documents. | @@ -264,10 +264,10 @@ Invalid values raise `InvalidParams` **before** any request is sent. | Argument | Env var | Default | |----------|---------|---------| -| `api_token` | `COMINTY_API_KEY` | — (required) | -| `user_id` | `COMINTY_USER_ID` | — (required) | +| `api_token` | `COMINTY_API_KEY` | (required) | +| `user_id` | `COMINTY_USER_ID` | (required) | | `base_url` | `COMINTY_BASE_URL` | `https://ds.cominty.com` | -| `timeout` | — | `60` (seconds) | +| `timeout` | none | `60` (seconds) | Resolution order for each option: **explicit argument → environment variable → default**. The SDK does **not** auto-load `.env`; export the vars or load the @@ -279,17 +279,17 @@ Every error is a subclass of `ComintyError`: ```python from cominty_sdk import ( - ComintyError, # base — catch-all + ComintyError, # base: catch-all APIError, # any 4xx/5xx; carries .status_code and a typed .error body AuthError, # 401 PermissionError, # 403 NotFoundError, # 404 ConflictError, # 409 - RateLimitError, # 429 — exposes .reset_at + RateLimitError, # 429: exposes .reset_at ServerError, # 5xx APIConnectionError, # network failure / timeout, no response - StreamInterrupted, # server shut down mid-stream — carries the .partial Message - InvalidParams, # client-side validation failed — .errors lists each problem + StreamInterrupted, # server shut down mid-stream: carries the .partial Message + InvalidParams, # client-side validation failed: .errors lists each problem SDKError, # unexpected SDK-internal condition ) @@ -297,7 +297,7 @@ try: run = await client.chat.start(agent_id=AGENT_ID, message="hi") print(await run.text()) except RateLimitError as e: - print(f"slow down — retry after {e.reset_at}") + print(f"slow down: retry after {e.reset_at}") except APIError as e: print(f"API error {e.status_code}: {e.error}") ``` @@ -328,18 +328,18 @@ See [AGENTS.md](AGENTS.md) for coding conventions (typing, versioning, models). ## Releasing -Publishing to PyPI uses **Trusted Publishing (OIDC)** — no tokens stored in -GitHub — and is triggered by publishing a **GitHub Release** +Publishing to PyPI uses **Trusted Publishing (OIDC)**: no tokens stored in +GitHub, triggered by publishing a **GitHub Release** (`.github/workflows/release.yml`). The published version comes from `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 +type-check / test / build gate, then commits and tags: it never pushes and never creates the GitHub Release itself. ```bash -# 1. bump, validate, commit, and tag locally — pick exactly one +# 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 @@ -349,7 +349,7 @@ uv run invoke release --version X.Y.Z # set an explicit version git push origin HEAD git push origin vX.Y.Z -# 3. create the release — this triggers the publish workflow +# 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 diff --git a/examples/01_stream_events.py b/examples/01_stream_events.py index c474b82..6614956 100644 --- a/examples/01_stream_events.py +++ b/examples/01_stream_events.py @@ -2,8 +2,8 @@ python examples/01_stream_events.py -Start a thread, then iterate the run to watch the agent work — tool calls, LLM -steps, and the final result event — as they arrive. Iterating yields *progress +Start a thread, then iterate the run to watch the agent work (tool calls, LLM +steps, and the final result event) as they arrive. Iterating yields *progress events only*; the finished reply is captured for you and returned by ``text()``. """ diff --git a/examples/03_follow_up.py b/examples/03_follow_up.py index dcc19b4..90c6f7f 100644 --- a/examples/03_follow_up.py +++ b/examples/03_follow_up.py @@ -1,4 +1,4 @@ -"""Continue a conversation — send a follow-up in the same thread. +"""Continue a conversation: send a follow-up in the same thread. python examples/03_follow_up.py @@ -17,7 +17,7 @@ async def main() -> None: async with make_client() as client: - # Turn 1 — start the thread. + # Turn 1: start the thread. first = await client.chat.start( agent_id=AGENT_ID, message="Pick a programming language and say why in one line.", @@ -26,7 +26,7 @@ async def main() -> None: pretty.rule("Turn 1") pretty.answer(await first.text()) - # Turn 2 — follow up in the SAME thread; the agent remembers turn 1. + # Turn 2: follow up in the SAME thread; the agent remembers turn 1. second = await client.chat.send( thread_id, agent_id=AGENT_ID, diff --git a/examples/04_answer_questions.py b/examples/04_answer_questions.py index a5c7053..a10ab62 100644 --- a/examples/04_answer_questions.py +++ b/examples/04_answer_questions.py @@ -5,7 +5,7 @@ When an agent needs more input, it ends its turn with one or more questions (``prompt`` + suggested ``options``) instead of a final answer. Read them with ``run.questions()``, then answer by sending the chosen option (or free text) as -the next message in the thread — exactly like any other follow-up. +the next message in the thread: exactly like any other follow-up. """ from __future__ import annotations diff --git a/examples/05_list_threads.py b/examples/05_list_threads.py index 943c212..c0b2407 100644 --- a/examples/05_list_threads.py +++ b/examples/05_list_threads.py @@ -21,7 +21,7 @@ async def main() -> None: threads = await client.threads.list(limit=20) if not threads: - pretty.console.print("No threads yet — run a chat example first.") + pretty.console.print("No threads yet: run a chat example first.") return pretty.thread_table(threads, title=f"Threads for {client.user_id}") diff --git a/examples/06_manage_thread.py b/examples/06_manage_thread.py index 7842bd0..990a5ec 100644 --- a/examples/06_manage_thread.py +++ b/examples/06_manage_thread.py @@ -3,7 +3,7 @@ python examples/06_manage_thread.py Demonstrates the rest of the threads resource: ``get`` (full history), -``update`` (partial — only the fields you pass are changed), and ``archive``. +``update`` (partial: only the fields you pass are changed), and ``archive``. """ from __future__ import annotations diff --git a/examples/07_custom_agent.py b/examples/07_custom_agent.py index 8d65675..10521bf 100644 --- a/examples/07_custom_agent.py +++ b/examples/07_custom_agent.py @@ -5,7 +5,7 @@ A custom agent is one you configure at platform.cominty.ai -> Agents: its own model + failover order + custom instructions. From the SDK it's just another -``agent_id`` — nothing special to call. +``agent_id``: nothing special to call. This showcases an agent instructed to turn dense engineering input into a clean, non-technical **French** briefing for a C-level decision-maker. We feed it a @@ -19,7 +19,7 @@ import _pretty as pretty from _shared import CUSTOM_AGENT_ID, make_client -# Deliberately dense and technical — the agent's job is to make this legible to +# Deliberately dense and technical: the agent's job is to make this legible to # a non-technical executive, in French. TECHNICAL_INPUT = """\ Incident RCA (recommendations service): p99 inference latency spiked from ~80ms @@ -49,7 +49,7 @@ async def main() -> None: run = await client.chat.start( agent_id=CUSTOM_AGENT_ID, message=TECHNICAL_INPUT, - # Pure transformation — no tools needed, so turn them off for speed + # Pure transformation: no tools needed, so turn them off for speed # and determinism. disabled_tools=["web", "company_documents", "mcp:*"], ) diff --git a/examples/08_mcp_linear.py b/examples/08_mcp_linear.py index 506318a..d1f8329 100644 --- a/examples/08_mcp_linear.py +++ b/examples/08_mcp_linear.py @@ -6,7 +6,7 @@ Builds on 07: the same custom French executive-briefing agent, but here it pulls live context from the **Linear MCP server** instead of being handed the data. The agent looks up Hiroshi's tasks in the current sprint, then reports them in -its own tone — a clean, non-technical French summary for leadership. +its own tone: a clean, non-technical French summary for leadership. Prerequisites (configured on the platform, not in code): - The Linear MCP server is connected to your org / available to the agent. @@ -60,7 +60,7 @@ async def main() -> None: ) # Reported in the agent's voice: French, C-level, bullet points. - pretty.answer(await run.text(), title="Synthèse sprint — Hiroshi (FR)") + pretty.answer(await run.text(), title="Synthèse sprint: Hiroshi (FR)") if __name__ == "__main__": diff --git a/examples/09_memory.py b/examples/09_memory.py index 8cb3754..6845096 100644 --- a/examples/09_memory.py +++ b/examples/09_memory.py @@ -4,7 +4,7 @@ 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 +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. """ @@ -43,7 +43,7 @@ async def main() -> None: # 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. + # ConflictError (409): the API's optimistic-concurrency guard. updated = await client.memory.update( path, version=fetched.version, content="Buy oat milk instead." ) diff --git a/examples/_pretty.py b/examples/_pretty.py index 5530faf..e38a310 100644 --- a/examples/_pretty.py +++ b/examples/_pretty.py @@ -23,7 +23,7 @@ from rich.text import Text except ModuleNotFoundError as exc: # pragma: no cover - example-only guard raise SystemExit( - "examples need 'rich' — run `uv sync --all-extras --dev` " + "examples need 'rich': run `uv sync --all-extras --dev` " "(or `pip install rich`)." ) from exc @@ -74,7 +74,7 @@ def render(event: events.AnyEvent) -> None: # One row per event: never wrap; crop with … on a too-narrow terminal. console.print(line, no_wrap=True, crop=True) - # LLM reasoning can be a long dump; we showed one truncated line above — flag + # LLM reasoning can be a long dump; we showed one truncated line above: flag # that the rest is hidden so the stream stays scannable. if isinstance(event, events.LlmStep): full = " ".join(event.data.description.split()) diff --git a/pyproject.toml b/pyproject.toml index 6682337..736aef5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ select = ["E", "F", "I", "UP", "B", "SIM", "ANN"] [tool.ruff.lint.per-file-ignores] # Unshipped dev entrypoints (excluded from the sdist): manual endpoint-poking # scripts and the invoke task runner. Real lint (unused imports, bugs) still -# applies, but full type annotations are needless ceremony here — invoke tasks +# applies, but full type annotations are needless ceremony here: invoke tasks # idiomatically take an unannotated Context. "tasks.py" = ["ANN"] diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index ac3c328..198093d 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -1,5 +1,3 @@ -"""Official async Python client for the Cominty managed agent chat API.""" - from __future__ import annotations from importlib.metadata import PackageNotFoundError, version diff --git a/src/cominty_sdk/_config.py b/src/cominty_sdk/_config.py index 26682af..48a19bd 100644 --- a/src/cominty_sdk/_config.py +++ b/src/cominty_sdk/_config.py @@ -1,5 +1,3 @@ -"""Resolved client configuration.""" - from __future__ import annotations import os @@ -16,12 +14,7 @@ @dataclass(frozen=True) class Config: - """Immutable, fully-resolved client configuration. Built once at construction. - - Internal plumbing, not an I/O boundary — a frozen dataclass, not a pydantic - model. This layer only resolves values and checks the required ones are - present; format validation belongs on the request/response models. - """ + # Frozen dataclass, not a pydantic model: this layer only resolves values. api_token: str user_id: str diff --git a/src/cominty_sdk/_transport.py b/src/cominty_sdk/_transport.py index 2310e27..4b4518e 100644 --- a/src/cominty_sdk/_transport.py +++ b/src/cominty_sdk/_transport.py @@ -1,10 +1,4 @@ -"""Low-level async HTTP transport. - -The dumb bottom layer: it knows the auth header, JSON request/response, error -mapping, and JSONL stream framing — and nothing about threads, messages, or -events. Resource code builds requests and parses responses; it never touches -``httpx`` directly. -""" +# Auth, JSON, errors, and JSONL framing. Resources own the domain objects. from __future__ import annotations @@ -40,11 +34,6 @@ async def request( json_body: Any = None, # noqa: ANN401 - arbitrary JSON request body params: Mapping[str, Any] | None = None, ) -> Any: # noqa: ANN401 - decoded JSON; resources validate into models - """Send a request and return the decoded JSON body. - - Raises an :class:`~.exceptions.APIError` subclass on 4xx/5xx and - :class:`~.exceptions.APIConnectionError` when no response arrives. - """ try: response = await self._client.request( method, path, json=json_body, params=params @@ -69,17 +58,6 @@ async def stream_lines( params: Mapping[str, Any] | None = None, headers: Mapping[str, str] | None = None, ) -> AsyncGenerator[AsyncIterator[dict[str, Any]]]: - """Open a streaming response and yield an iterator of decoded JSONL objects. - - Must be used as an async context manager so the connection is released:: - - async with transport.stream_lines("GET", path) as lines: - async for obj in lines: - ... - - Blank keep-alive lines and any non-JSON lines are skipped here; deciding - what each JSON object *means* is the caller's job. - """ try: async with self._client.stream( method, path, params=params, headers=headers @@ -103,7 +81,7 @@ async def _iter_json_lines(response: httpx.Response) -> AsyncIterator[dict[str, async for line in response.aiter_lines(): stripped = line.strip() if not stripped: - # Blank line — a candidate keep-alive shape (heartbeat format is not + # Blank line: a candidate keep-alive shape (heartbeat format is not # yet confirmed). Skipping blanks is always safe. continue try: diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index 07329c1..b1f8dd3 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -1,5 +1,3 @@ -"""The async client — the one object users construct.""" - from __future__ import annotations from types import TracebackType @@ -17,20 +15,7 @@ class AsyncCominty: - """Async client for the Cominty API. - - ``user_id`` identifies the end user every call acts on behalf of. It is set - once here (or via ``COMINTY_USER_ID``) and applied to every request, so - resource methods never take it. - - Construct once, reuse, and close when done — ideally via ``async with``:: - - async with AsyncCominty(api_token="...", user_id="user_...") as client: - run = await client.chat.start(agent_id="agt_1", message="Hello") - async for event in run: - ... - print(await run.text()) - """ + """Async client for the Cominty API.""" def __init__( self, @@ -40,6 +25,29 @@ def __init__( base_url: str | None = None, timeout: float | None = None, ) -> None: + """ + Build a client. ``user_id`` is applied to every request. + + Resource methods do not take ``user_id``. Construct once, reuse, and + close with ``async with`` or :meth:`close`. + + Args: + user_id (str | None): End user every call acts on behalf of. + Falls back to ``COMINTY_USER_ID``. + api_token (str | None): API key. Falls back to ``COMINTY_API_KEY``. + base_url (str | None): API origin. Falls back to ``COMINTY_BASE_URL``, + then ``https://ds.cominty.com``. + timeout (float | None): Request timeout in seconds. Default is 60. + + Raises: + ValueError: ``api_token`` or ``user_id`` is missing, or ``user_id`` + is not a Cominty user id. + + Examples: + async with AsyncCominty(api_token="...", user_id="user_...") as client: + run = await client.chat.start(agent_id="agt_1", message="Hello") + print(await run.text()) + """ self._config = Config.resolve( api_token=api_token, user_id=user_id, @@ -58,12 +66,22 @@ def __init__( @property def user_id(self) -> str: - """The end-user id every request is made on behalf of.""" + """ + End-user id applied to every request. + + Returns: + str: The resolved ``user_id``. + """ return self._config.user_id @property def base_url(self) -> str: - """The resolved API base URL this client talks to.""" + """ + API origin this client talks to, without a trailing slash. + + Returns: + str: The resolved base URL. + """ return self._config.base_url async def __aenter__(self) -> Self: diff --git a/src/cominty_sdk/events.py b/src/cominty_sdk/events.py index 378e60c..2693cac 100644 --- a/src/cominty_sdk/events.py +++ b/src/cominty_sdk/events.py @@ -1,13 +1,4 @@ -"""Typed events emitted by ``GET /chat/messages/{message_id}/stream``. - -The chat stream is **progress streaming, not token streaming** — there is no -text-delta event. The assistant's reply arrives whole, once, inside the -``result`` event (``data.reply``) and again in the terminal message snapshot. -These models cover the events a client actually receives; the three -server-intercepted names (``execution_failed``, ``cancelled``, -``end_of_stream``) never reach the wire as events — they are converted to a -terminal ``Message`` snapshot by the backend, handled in the stream layer. -""" +# Progress events, not tokens. Lines without a correlation_id are not events. from __future__ import annotations @@ -181,15 +172,7 @@ class UnknownEvent(_EventBase): def parse_event(obj: dict[str, Any]) -> AnyEvent: - """Parse one decoded JSONL event line into a typed event. - - Unknown event names degrade to :class:`UnknownEvent` rather than raising, so - server-side additions never break existing SDK versions. - - Note: this handles *event* lines only. Distinguishing events from terminal - ``Message`` / ``Partial`` lines (and skipping heartbeats) is the stream - layer's job — events are the lines carrying a ``correlation_id``. - """ + # Unknown names become UnknownEvent. Only correlation_id lines are events. try: return _known_adapter.validate_python(obj) except ValidationError: diff --git a/src/cominty_sdk/exceptions.py b/src/cominty_sdk/exceptions.py index 5d65ec8..919a60b 100644 --- a/src/cominty_sdk/exceptions.py +++ b/src/cominty_sdk/exceptions.py @@ -1,23 +1,3 @@ -"""Exception hierarchy for the Cominty SDK. - -:: - - ComintyError - ├── APIError # HTTP 4xx/5xx - │ ├── AuthError # 401 - │ ├── PermissionError # 403 - │ ├── NotFoundError # 404 - │ ├── ConflictError # 409 - │ ├── RateLimitError # 429 - │ └── ServerError # 5xx - ├── APIConnectionError # network / timeout - ├── StreamInterrupted # server shut down mid-stream - └── SDKError # bug in the SDK itself - -The wire error body is FastAPI's ``{"detail": str | dict | list}`` — parsed onto -``APIError.detail``, with the full body kept on ``.body``. -""" - from __future__ import annotations from collections.abc import Mapping @@ -73,30 +53,30 @@ def __init__( class AuthError(APIError): - """401 — missing or invalid ``x-cominty-token``.""" + """401: missing or invalid ``x-cominty-token``.""" class PermissionError(APIError): # noqa: A001 - intentional, namespaced under the SDK - """403 — authenticated but not allowed.""" + """403: authenticated but not allowed.""" class NotFoundError(APIError): - """404 — the resource does not exist.""" + """404: the resource does not exist.""" class ConflictError(APIError): - """409 — the request conflicts with the current state.""" + """409: the request conflicts with the current state.""" class RateLimitError(APIError): - """429 — a rate limit was hit. + """429: a rate limit was hit. The API hits this in one of three ways, surfaced via :attr:`scope`: - - ``"concurrency"`` — too many chat sessions running at once (your plan's + - ``"concurrency"``: too many chat sessions running at once (your plan's concurrent-session cap). Transient: retry once an in-flight request finishes. - - ``"organization"`` — your organization's request quota is exhausted. - - ``"user"`` — your user's request quota is exhausted. + - ``"organization"``: your organization's request quota is exhausted. + - ``"user"``: your user's request quota is exhausted. For the quota cases an organization admin must raise the limit; the error message says so. :attr:`retry_after` exposes the ``Retry-After`` header if sent. @@ -104,11 +84,15 @@ class RateLimitError(APIError): @property def scope(self) -> str | None: - """Which limit was hit — ``"organization"``, ``"user"``, or - ``"concurrency"`` (``None`` if undeterminable). + """ + Which limit was hit. - Quota 429s carry ``{"quota_reached": "organization" | "user", ...}``; - the concurrency cap is a plain ``"Too many concurrent requests"`` string. + Quota responses carry ``{"quota_reached": "organization" | "user"}``. + The concurrency cap is the string ``"Too many concurrent requests"``. + + Returns: + str | None: ``"organization"``, ``"user"``, ``"concurrency"``, or + ``None`` when the body does not say. """ if isinstance(self.detail, dict): quota = self.detail.get("quota_reached") @@ -120,7 +104,13 @@ def scope(self) -> str | None: @property def retry_after(self) -> float | None: - """Seconds to wait before retrying, from the ``Retry-After`` header if set.""" + """ + Seconds to wait before retrying. + + Returns: + float | None: The ``Retry-After`` header, or ``None`` when it is absent + or not a number. + """ if self.headers: raw = self.headers.get("Retry-After") or self.headers.get("retry-after") if raw is not None: @@ -132,8 +122,13 @@ def retry_after(self) -> float | None: @property def reset_at(self) -> datetime | None: - """When the quota clears, from the ``reset_at`` detail field (or the - ``X-RateLimit-Reset`` header).""" + """ + When the quota clears. + + Returns: + datetime | None: ``reset_at`` or ``locked_until`` from the body, else + the ``X-RateLimit-Reset`` header, else ``None``. + """ if isinstance(self.detail, dict): parsed = _parse_dt( self.detail.get("reset_at") or self.detail.get("locked_until") @@ -146,7 +141,7 @@ def reset_at(self) -> datetime | None: class ServerError(APIError): - """5xx — the server failed to handle the request.""" + """5xx: the server failed to handle the request.""" class APIConnectionError(ComintyError): @@ -156,7 +151,7 @@ class APIConnectionError(ComintyError): class StreamInterrupted(ComintyError): """The server shut down mid-stream before the message completed. - ``partial`` is the message as far as it got — its ``status`` reflects how + ``partial`` is the message as far as it got: its ``status`` reflects how much was persisted. """ @@ -184,7 +179,7 @@ class InvalidParams(ComintyError): """Arguments failed validation before any request was sent. Raised by the SDK at the call boundary so callers never see a raw pydantic - ``ValidationError``. ``errors`` is the structured, typed breakdown — one entry + ``ValidationError``. ``errors`` is the structured, typed breakdown: one entry per offending parameter. """ @@ -196,10 +191,6 @@ def __init__(self, message: str, *, errors: list[InvalidParam]) -> None: def from_validation_error( cls, exc: ValidationError, *, context: str ) -> InvalidParams: - """Translate a pydantic ``ValidationError`` into a clean SDK error. - - ``context`` names the operation for the message header, e.g. ``"chat.start"``. - """ grouped: dict[str, dict[str, Any]] = {} for err in exc.errors(include_url=False): path = _clean_param_path(err["loc"]) @@ -208,8 +199,8 @@ def from_validation_error( ) if err["msg"] not in group["msgs"]: group["msgs"].append(err["msg"]) - # "missing"/"extra_forbidden" carry the parent container as input — - # noise, so don't surface a value for those. + # "missing"/"extra_forbidden" carry the parent container as input. + # That value is noise, so don't surface it. if err["type"] not in _NO_INPUT_TYPES: group["show_input"] = True group["input"] = err.get("input") @@ -230,12 +221,8 @@ def from_validation_error( def _clean_param_path(loc: tuple[str | int, ...]) -> str: - """Turn a pydantic ``loc`` into the public argument name. - - Drops the internal ``message``/``options`` request wrappers and pydantic's - synthetic union/type tags (``"literal[...]"``, ``"constrained-str"``) — those - always contain ``[`` or ``-``, which a Python identifier never can. - """ + # Drop message/options wrappers and pydantic tags such as "literal[...]" + # or "constrained-str". Those segments contain "[" or "-". parts: list[str | int] = [ seg for seg in loc @@ -264,7 +251,6 @@ def error_from_response( body: Any, # noqa: ANN401 - raw decoded error body headers: Mapping[str, str] | None = None, ) -> APIError: - """Build the right :class:`APIError` subclass from a failed HTTP response.""" detail: str | dict[str, Any] | list[Any] | None = None if isinstance(body, dict): detail = cast("dict[str, Any]", body).get("detail") @@ -310,12 +296,6 @@ def _rate_limit_message( detail: str | dict[str, Any] | list[Any] | None, headers: Mapping[str, str] | None, ) -> str: - """Compose a clear, actionable 429 message from the server's detail. - - Two shapes from the API: - - quota: ``{"quota_reached": "organization" | "user", "reset_at": ...}`` - - concurrency: the string ``"Too many concurrent requests"`` - """ info = detail if isinstance(detail, dict) else {} text = detail.strip() if isinstance(detail, str) else "" quota = info.get("quota_reached") @@ -325,8 +305,8 @@ def _rate_limit_message( elif isinstance(quota, str) and quota: # forward-compat for a new scope name head = f"{quota.capitalize()} rate limit reached: request quota exhausted" elif "concurrent" in text.lower(): - # Concurrency cap (CHAT_MAX_CONCURRENT_SESSIONS_*): transient — the count - # frees as in-flight requests finish — but raising it needs an admin. + # Concurrency cap (CHAT_MAX_CONCURRENT_SESSIONS_*): transient, the count + # frees as in-flight requests finish, but raising it needs an admin. return ( "Too many concurrent requests: your plan's limit on simultaneous chat " "sessions is reached. Wait for an in-flight request to finish and " @@ -342,7 +322,6 @@ def _rate_limit_message( def _when_phrase(reset_at: datetime | None, headers: Mapping[str, str] | None) -> str: - """When the caller can retry: a ``Retry-After`` delay, else a reset time.""" if headers: raw = headers.get("Retry-After") or headers.get("retry-after") if raw is not None: diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 273efb1..9a0c46b 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -1,5 +1,3 @@ -"""Typed request/response models, grouped by resource.""" - from __future__ import annotations from .chat import ( diff --git a/src/cominty_sdk/models/chat.py b/src/cominty_sdk/models/chat.py index 54de99b..8bdbe25 100644 --- a/src/cominty_sdk/models/chat.py +++ b/src/cominty_sdk/models/chat.py @@ -1,10 +1,3 @@ -"""Request and response models for the chat resource. - -Request models are strict (``strict=True, extra="forbid"``) so caller mistakes -surface immediately. Response models are lenient (``extra="ignore"``) so the SDK -tolerates additive API changes without a release. -""" - from __future__ import annotations import re @@ -74,11 +67,6 @@ class ContentOrigin(str, Enum): def validate_user_id(value: str) -> str: - """Return ``value`` if it's a well-formed Cominty user id, else raise. - - Shared by the :data:`UserId` model type and the client, which validates the - client-level ``user_id`` up front so a typo fails locally, not as a 400/404. - """ if not _USER_ID_PATTERN.match(value): raise ValueError( "expected a Cominty user id like 'user_xxxxxxxxxxxxxxxxxPbKB' " @@ -110,7 +98,7 @@ def validate_user_id(value: str) -> str: # --------------------------------------------------------------------------- # -# Request models (strict — reject unknown fields, no coercion) +# Request models (strict: reject unknown fields, no coercion) # --------------------------------------------------------------------------- # class HumanMessage(BaseModel): model_config = ConfigDict(strict=True, extra="forbid") @@ -140,7 +128,7 @@ class StartChatParams(BaseModel): class UpdateThreadParams(BaseModel): """Mutable thread fields. Only the fields you pass are sent (``exclude_none``), - so updates are partial — omitted fields keep their current value.""" + so updates are partial: omitted fields keep their current value.""" model_config = ConfigDict(strict=True, extra="forbid") @@ -149,7 +137,7 @@ class UpdateThreadParams(BaseModel): # --------------------------------------------------------------------------- # -# Response models (lenient — ignore unknown fields) +# Response models (lenient: ignore unknown fields) # --------------------------------------------------------------------------- # class Question(BaseModel): model_config = ConfigDict(extra="ignore") diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py index 1f90006..f9e9100 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -25,7 +25,6 @@ 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( @@ -64,7 +63,7 @@ class MemoryFileUpdate(BaseModel): 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 + 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. @@ -88,7 +87,7 @@ def _reject_explicit_none(self) -> MemoryFileUpdate: 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) — " + "clearing a field once set (it's currently a silent no-op): " "omit the argument instead of passing None" ) return self @@ -103,7 +102,7 @@ class MemoryFileOut(BaseModel): created_at: datetime updated_at: datetime version: str - """Opaque concurrency token — pass it back unchanged to + """Opaque concurrency token: pass it back unchanged to :meth:`~.resources.memory.MemoryResource.update`.""" diff --git a/src/cominty_sdk/resources/__init__.py b/src/cominty_sdk/resources/__init__.py index 5a473ec..3d9350d 100644 --- a/src/cominty_sdk/resources/__init__.py +++ b/src/cominty_sdk/resources/__init__.py @@ -1,5 +1,3 @@ -"""Resource namespaces hung off the client.""" - from __future__ import annotations from .chat import ChatResource diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index 2f8a723..e7d3485 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -1,5 +1,3 @@ -"""The chat resource: start a thread and stream the assistant's reply.""" - from __future__ import annotations from typing import TYPE_CHECKING @@ -39,11 +37,26 @@ async def start( document_ids: list[str] | None = None, disabled_tools: list[DisablableTool] | None = None, ) -> StartedChat: - """Start a new thread with a first user message. + """ + Start a new thread and return a handle on the assistant reply. + + Sends ``POST /chat``. Iterate the handle for progress events, or await + ``run.text()`` / ``run.result()`` for the final answer. - Sends ``POST /chat``, then returns an :class:`~.streaming.AssistantRun` - bound to the in-progress assistant reply. Iterate it for progress events, - or ``await run.text()`` / ``await run.result()`` for the final answer. + Args: + agent_id (str): Managed agent that handles the thread. + message (str): First user message. + name (str | None): Thread title. Omit it to leave the thread unnamed. + file_ids (list[str] | None): Uploaded file ids attached to the message. + source_ids (list[int] | None): Connected source ids attached to the message. + document_ids (list[str] | None): Document ids attached to the message. + disabled_tools (list[DisablableTool] | None): Tools the agent must not use. + + Returns: + StartedChat: Handle for the in-progress assistant reply. ``thread`` is set. + + Raises: + InvalidParams: A parameter failed local validation. """ body = self._build_body( agent_id=agent_id, @@ -71,18 +84,28 @@ async def send( document_ids: list[str] | None = None, disabled_tools: list[DisablableTool] | None = None, ) -> AssistantRun: - """Send a follow-up message in an existing thread. - - The mirror of :meth:`start` for an ongoing conversation: sends - ``POST /chat/{thread_id}`` and returns a streamable run for the new - assistant reply. Use this to answer an agent's :class:`~.models.chat.Question` - — pass the chosen option (or free text) as ``message``. - - Unlike :meth:`start`, this endpoint returns the new assistant - :class:`~.models.chat.Message` directly (not the whole thread), so the - returned :class:`~.streaming.AssistantRun` has no ``.thread`` — you - already hold the ``thread_id``, and ``threads.get(thread_id)`` fetches the - rest if needed. + """ + Send a follow-up in an existing thread. + + Sends ``POST /chat/{thread_id}``. The response is the new assistant + message, not the whole thread, so the returned run has no ``thread``. + Pass a suggested option, or free text, as ``message`` to answer an + agent question. + + Args: + thread_id (str | UUID): Thread to continue. + message (str): Follow-up text. + agent_id (str): Managed agent that handles this turn. + file_ids (list[str] | None): Uploaded file ids attached to the message. + source_ids (list[int] | None): Connected source ids attached to the message. + document_ids (list[str] | None): Document ids attached to the message. + disabled_tools (list[DisablableTool] | None): Tools the agent must not use. + + Returns: + AssistantRun: Handle for the new assistant reply. + + Raises: + InvalidParams: A parameter failed local validation. """ body = self._build_body( agent_id=agent_id, @@ -101,7 +124,17 @@ async def send( return AssistantRun(self._transport, reply.id) def stream(self, message_id: str | UUID) -> AssistantRun: - """Stream an existing assistant message by id (no I/O until consumed).""" + """ + Attach a handle to an assistant message that already exists. + + No request is sent until the handle is iterated or awaited. + + Args: + message_id (str | UUID): Assistant message to stream. + + Returns: + AssistantRun: Handle with no ``thread`` attached. + """ return AssistantRun(self._transport, _as_uuid(message_id)) def _build_body( diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index a44759f..c39c6af 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -1,5 +1,3 @@ -"""The memory resource: list, create, read, update, and delete memory files.""" - from __future__ import annotations from typing import TYPE_CHECKING, Union @@ -16,21 +14,17 @@ ) if TYPE_CHECKING: + # types.EllipsisType is 3.10+; this import is annotation-only so the + # future import keeps it from ever running on the 3.9 floor. + from types import EllipsisType + from .._transport import AsyncTransport __all__ = ["MemoryResource"] - -class _Unset: - """Sentinel default distinguishing "not passed" from "passed as ``None``" - for :meth:`MemoryResource.update`'s optional fields.""" - - def __repr__(self) -> str: # pragma: no cover - debug display only, never returned to callers - return "UNSET" - - -_UNSET = _Unset() -_OptionalField = Union[str, None, _Unset] +# Ellipsis distinguishes "argument omitted" from "passed as None" on update(). +# typing.Sentinel would be the natural fit, but it's 3.13+, above the floor. +_OptionalField = Union[str, None, "EllipsisType"] class MemoryResource: @@ -46,23 +40,39 @@ def _validate_path(path: str, *, context: str) -> str: 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``).""" + """ + List the current user's memory files. + + Sends ``GET /memory``. Summaries do not include file content. + + Returns: + list[MemoryFileSummaryOut]: One summary per file. + """ 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. ``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). + """ + Create a memory file. + + Sends ``POST /memory`` (201). ``user_id`` is sent in the body, unlike + the other memory calls, which send it as a query parameter. ``path`` + may have at most one folder segment (``"folder/file.md"``). ``content`` + may be empty. + + Args: + path (str): File path, at most one folder deep. + purpose (str): Why the file exists. The agent reads this. + content (str): File body. An empty string is allowed. + + Returns: + MemoryFileOut: The created file, including its ``version`` token. + + Raises: + InvalidParams: ``path`` is deeper than one folder. + ConflictError: A file already exists at ``path``. """ try: params = MemoryFileCreate( @@ -76,7 +86,21 @@ async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOu return MemoryFileOut.model_validate(raw) async def get(self, path: str) -> MemoryFileOut: - """Fetch a single memory file (``GET /memory/file``).""" + """ + Fetch one memory file, including its content. + + Sends ``GET /memory/file``. + + Args: + path (str): File path, at most one folder deep. + + Returns: + MemoryFileOut: The file, including ``content`` and ``version``. + + Raises: + InvalidParams: ``path`` is deeper than one folder. + NotFoundError: No file exists at ``path``. + """ path = self._validate_path(path, context="memory.get") raw = await self._transport.request( "GET", "/memory/file", params={"path": path, "user_id": self._user_id} @@ -88,31 +112,36 @@ async def update( path: str, *, version: str, - content: _OptionalField = _UNSET, - purpose: _OptionalField = _UNSET, + content: _OptionalField = ..., + purpose: _OptionalField = ..., ) -> MemoryFileOut: - """Update a memory file's content and/or purpose (``PUT /memory/file``). - - 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). + """ + Update a memory file's content and/or purpose. + + Sends ``PUT /memory/file``. Only the fields you pass are sent. Omit a + field to leave it unchanged. Passing ``None`` is rejected locally: the + API ignores a null and would leave the stored value in place. + + Args: + path (str): File path, at most one folder deep. + version (str): Opaque token from a previous read. Pass it back unchanged. + content (str | None): New body. Omit the argument to leave it unchanged. + purpose (str | None): New purpose. Omit the argument to leave it unchanged. + + Returns: + MemoryFileOut: The updated file, including the new ``version``. + + Raises: + InvalidParams: ``path`` is too deep, neither field was passed, or a + field was passed as ``None``. + ConflictError: ``version`` is well formed but stale. + APIError: ``version`` is malformed (422). """ path = self._validate_path(path, context="memory.update") fields: dict[str, object] = {} - if not isinstance(content, _Unset): + if content is not ...: fields["content"] = content - if not isinstance(purpose, _Unset): + if purpose is not ...: fields["purpose"] = purpose try: body_model = MemoryFileUpdate.model_validate(fields) @@ -126,11 +155,21 @@ 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. + + Sends ``DELETE /memory/file`` (204). Not idempotent: a missing path + raises, it does not succeed again. + + Args: + path (str): File path, at most one folder deep. + + Returns: + None: The file is deleted. - Not idempotent: deleting an already-deleted (or never-existing) path - raises :class:`~.exceptions.NotFoundError` (404) rather than - succeeding again. + Raises: + InvalidParams: ``path`` is deeper than one folder. + NotFoundError: The path does not exist. """ path = self._validate_path(path, context="memory.delete") await self._transport.request( diff --git a/src/cominty_sdk/resources/threads.py b/src/cominty_sdk/resources/threads.py index 3290704..23d45b6 100644 --- a/src/cominty_sdk/resources/threads.py +++ b/src/cominty_sdk/resources/threads.py @@ -1,5 +1,3 @@ -"""The threads resource: list, read, rename/star, and archive conversations.""" - from __future__ import annotations from typing import TYPE_CHECKING @@ -25,12 +23,19 @@ async def list( page: int = 0, terms: list[str] | None = None, ) -> list[ThreadSummary]: - """List the current user's threads, newest first. + """ + List the current user's threads, newest first. + + Sends ``GET /chat`` scoped to the client's ``user_id``. Summaries do not + include messages. Call :meth:`get` to load a thread's contents. - Sends ``GET /chat`` scoped to the client's ``user_id``. ``terms`` filters - by free-text search; ``limit``/``page`` paginate (page is zero-based). - Returns lightweight :class:`~.models.chat.ThreadSummary` objects (no - messages) — call :meth:`get` to load a thread's contents. + Args: + limit (int): Page size. Default is 50. + page (int): Zero-based page index. Default is 0. + terms (list[str] | None): Free-text search terms. Omit to list everything. + + Returns: + list[ThreadSummary]: Matching thread summaries. """ params: dict[str, object] = { "user_id": self._user_id, @@ -43,7 +48,17 @@ async def list( return [ThreadSummary.model_validate(item) for item in raw] async def get(self, thread_id: str | UUID) -> Thread: - """Fetch a single thread with its full message history (``GET /chat/{id}``).""" + """ + Fetch one thread, including its messages. + + Sends ``GET /chat/{thread_id}``. + + Args: + thread_id (str | UUID): Thread to load. + + Returns: + Thread: The thread and its message history. + """ raw = await self._transport.request("GET", f"/chat/{thread_id}") return Thread.model_validate(raw) @@ -54,11 +69,19 @@ async def update( name: str | None = None, starred: bool | None = None, ) -> ThreadSummary: - """Rename and/or (un)star a thread (``PUT /chat/{id}``). + """ + Rename and/or star a thread. + + Sends ``PUT /chat/{thread_id}``. Only the fields you pass are sent. + The response is a summary, without messages. - Partial: only the fields you pass are sent. Returns the updated thread - as a :class:`~.models.chat.ThreadSummary` — this endpoint responds - without the message history (unlike :meth:`get`). + Args: + thread_id (str | UUID): Thread to update. + name (str | None): New title. Omit it to leave the title unchanged. + starred (bool | None): Starred flag. Omit it to leave the flag unchanged. + + Returns: + ThreadSummary: The updated thread, without messages. """ body = UpdateThreadParams(name=name, starred=starred).model_dump( mode="json", exclude_none=True @@ -67,5 +90,15 @@ async def update( return ThreadSummary.model_validate(raw) async def archive(self, thread_id: str | UUID) -> None: - """Archive (soft-delete) a thread (``DELETE /chat/{id}``).""" + """ + Archive a thread. This is a soft delete. + + Sends ``DELETE /chat/{thread_id}``. + + Args: + thread_id (str | UUID): Thread to archive. + + Returns: + None: The thread is archived. + """ await self._transport.request("DELETE", f"/chat/{thread_id}") diff --git a/src/cominty_sdk/streaming.py b/src/cominty_sdk/streaming.py index e51b96f..dbe98aa 100644 --- a/src/cominty_sdk/streaming.py +++ b/src/cominty_sdk/streaming.py @@ -1,16 +1,3 @@ -"""The streaming handle returned by the chat resource. - -``AssistantRun`` wraps a started assistant message: it carries the ``Thread`` and -``message_id`` the POST already returned, and lazily opens the JSONL stream when -you consume it. Iterating yields progress *events* only; the terminal message is -captured internally and returned by :meth:`AssistantRun.result` / -:meth:`AssistantRun.text`. - -This is where wire lines are *classified* — events vs the terminal ``Message`` -snapshot vs a server-shutdown ``Partial`` — domain knowledge the transport -deliberately does not have. -""" - from __future__ import annotations from collections.abc import AsyncGenerator, AsyncIterator @@ -58,8 +45,12 @@ def message_id(self) -> UUID: @property def thread(self) -> Thread | None: - """The thread from the originating ``start`` call, if any (``None`` for a - bare ``chat.stream(message_id)``).""" + """ + Thread from the originating ``start`` call, when there is one. + + Returns: + Thread | None: The thread, or ``None`` for ``chat.stream(message_id)``. + """ return self._thread def __aiter__(self) -> AsyncIterator[AnyEvent]: @@ -70,7 +61,7 @@ def __aiter__(self) -> AsyncIterator[AnyEvent]: async def _stream(self) -> AsyncGenerator[AnyEvent]: if self._consumed: # __aiter__ memoizes self._gen, so this never re-fires through the - # public API — a defensive guard against calling this method directly. + # 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 @@ -99,10 +90,16 @@ async def _stream(self) -> AsyncGenerator[AnyEvent]: return async def result(self) -> Message: - """Drain the stream (if needed) and return the final :class:`Message`. + """ + Drain the stream if needed and return the final message. + + Returns: + Message: The terminal assistant message. - Raises :class:`~.exceptions.StreamInterrupted` if the server shut down - mid-stream. + Raises: + StreamInterrupted: The server shut down before the message completed. + SDKError: The stream was already partially consumed, or it ended + without a terminal message. """ if self._terminal is not None: return self._terminal @@ -117,17 +114,30 @@ async def result(self) -> Message: return self._terminal async def text(self) -> str: - """The assistant's final reply text.""" + """ + The assistant's final reply text. + + Returns: + str: ``result().content``. + """ return (await self.result()).content async def questions(self) -> list[Question]: - """Clarifying questions the agent is asking, if any. + """ + Clarifying questions the agent is asking, if any. - When the agent needs more input it ends its turn with one or more - :class:`~.models.chat.Question` (each a ``prompt`` plus suggested - ``options``). Answer by sending the chosen option — or free text — as the - next message: ``await client.chat.send(run.thread.id, message=..., - agent_id=...)``. Empty list means the agent gave a final answer. + An empty list means the agent gave a final answer. Answer a question by + sending the chosen option, or free text, as the next message. + + Returns: + list[Question]: Each item has a ``prompt`` and suggested ``options``. + + Examples: + await client.chat.send( + run.thread.id, + message=picked, + agent_id="agt_1", + ) """ return (await self.result()).questions or [] diff --git a/tasks.py b/tasks.py index 0ff5e32..cd4f29b 100644 --- a/tasks.py +++ b/tasks.py @@ -1,12 +1,3 @@ -"""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 -Dry-run to TestPyPI: uv run invoke publish-test -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 import re @@ -45,7 +36,7 @@ def check(c): help={"token": "TestPyPI API token. Or set UV_PUBLISH_TOKEN in the env."}, ) def publish_test(c, token=None): - """Upload to TestPyPI (rehearsal — never burns a real PyPI version).""" + """Upload to TestPyPI (rehearsal: never burns a real PyPI version).""" cmd = f"uv publish --publish-url {TESTPYPI_URL}" if token: cmd += f" --token {token}" @@ -88,7 +79,7 @@ def _read_version(text: str) -> str: 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" + f"pyproject.toml's version {current!r} isn't X.Y.Z: fix it manually first" ) return current @@ -128,7 +119,6 @@ def _tag_exists(c, tag: str) -> bool: 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) @@ -147,7 +137,7 @@ 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 + 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. @@ -161,7 +151,7 @@ def release(c, patch=False, minor=False, major=False, version=None): ) if not PYPROJECT_PATH.exists(): - raise Exit("pyproject.toml not found — run this from the repo root") + raise Exit("pyproject.toml not found: run this from the repo root") original_pyproject = PYPROJECT_PATH.read_text() current = _read_version(original_pyproject) @@ -178,7 +168,7 @@ def release(c, patch=False, minor=False, major=False, version=None): tag = f"v{target}" if _git_is_dirty(c): - raise Exit("git working tree is dirty — commit or stash changes before releasing") + 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") diff --git a/tests/conftest.py b/tests/conftest.py index 834c0a4..1a59a7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,3 @@ -"""Shared fixtures for the v1 test suite. - -Minimalist on purpose — a base URL, a live ``AsyncCominty`` bound to a respx -mock, and small factories for the ``ThreadOut`` / ``MessageOut`` payloads the -chat endpoints return. -""" - from __future__ import annotations import json @@ -22,7 +15,7 @@ THREAD_ID = "11111111-1111-1111-1111-111111111111" USER_MSG_ID = "22222222-2222-2222-2222-222222222222" ASSISTANT_MSG_ID = "33333333-3333-3333-3333-333333333333" -# A well-formed Cominty (Clerk) user id — matches ^user_[A-Za-z0-9]{20,}$, which +# A well-formed Cominty (Clerk) user id: matches ^user_[A-Za-z0-9]{20,}$, which # the client validates at construction. Set once, applied to every call. USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index 7b27e73..732fc45 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -1,11 +1,3 @@ -"""Opt-in smoke tests against the real API. - -Run with credentials in the environment: - - COMINTY_API_KEY=... COMINTY_USER_ID=user_... COMINTY_AGENT_ID=... \\ - uv run pytest -m integration -""" - from __future__ import annotations import os diff --git a/tests/test_release.py b/tests/test_release.py index 3b9e436..698fdf8 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -1,9 +1,3 @@ -"""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 @@ -54,7 +48,7 @@ def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: _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 + # 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 @@ -95,7 +89,7 @@ def test_release_success( # --------------------------------------------------------------------------- # -# failures — each must leave the repo untouched +# failures: each must leave the repo untouched # --------------------------------------------------------------------------- # def test_release_no_mode_fails(repo: Path, c: Context) -> None: before = _git_out("rev-parse", "HEAD") diff --git a/tests/unit/test_chat_send.py b/tests/unit/test_chat_send.py index e201f43..1490055 100644 --- a/tests/unit/test_chat_send.py +++ b/tests/unit/test_chat_send.py @@ -1,10 +1,3 @@ -"""Unit tests for ``client.chat.send`` — the follow-up (continue-in-thread) call. - -Covers: endpoint + body (user_id sourced from the client, not the caller), the -streamable handle it returns, and that client-side validation still fires before -any request. -""" - from __future__ import annotations import json diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index 152c4ec..9fe6c87 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -1,10 +1,3 @@ -"""Exhaustive unit tests for ``client.chat.start``. - -Covers: the request it sends, the handle it returns, client-side validation -(which must never hit the network), HTTP error mapping, and edge cases in -locating the assistant reply. -""" - from __future__ import annotations import json @@ -32,7 +25,7 @@ MakeThread = Callable[..., dict[str, Any]] MakeMessage = Callable[..., dict[str, Any]] -# A well-formed Cominty (Clerk) user id — must match ^user_[A-Za-z0-9]{20,}$, +# A well-formed Cominty (Clerk) user id: must match ^user_[A-Za-z0-9]{20,}$, # which client-side validation enforces before any request goes out. USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" @@ -43,7 +36,7 @@ # --------------------------------------------------------------------------- # -# Happy path — the returned handle +# Happy path: the returned handle # --------------------------------------------------------------------------- # async def test_returns_started_chat_with_thread_and_reply( client: AsyncCominty, @@ -66,12 +59,12 @@ async def test_thread_is_non_optional_on_started_chat( ) -> None: mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) run = await client.chat.start(agent_id="agt_1", message="hi") - # StartedChat narrows thread to Thread (never None) — accessible without a guard. + # StartedChat narrows thread to Thread (never None): accessible without a guard. assert run.thread.messages[0].role.value == "user" # --------------------------------------------------------------------------- # -# Happy path — the request that goes out +# Happy path: the request that goes out # --------------------------------------------------------------------------- # async def test_sends_post_with_token_and_minimal_body( client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread @@ -161,7 +154,7 @@ async def test_uses_configured_base_url(make_thread: MakeThread) -> None: # --------------------------------------------------------------------------- # -# Client-side validation — must fail BEFORE any HTTP request +# Client-side validation: must fail BEFORE any HTTP request # --------------------------------------------------------------------------- # async def test_invalid_disabled_tool_raises_before_request( client: AsyncCominty, mock_api: respx.MockRouter @@ -296,7 +289,7 @@ async def test_rate_limit_concurrency_message( assert err.scope == "concurrency" text = str(err) assert "concurrent" in text.lower() - assert "Wait for an in-flight request" in text # transient — retry guidance + assert "Wait for an in-flight request" in text # transient: retry guidance assert "admin" in text.lower() diff --git a/tests/unit/test_chat_stream.py b/tests/unit/test_chat_stream.py index 6f71435..2e5e2bc 100644 --- a/tests/unit/test_chat_stream.py +++ b/tests/unit/test_chat_stream.py @@ -1,10 +1,3 @@ -"""Exhaustive unit tests for the ``AssistantRun`` streaming handle. - -Covers: events-only iteration, heartbeat/blank skipping, terminal capture via -``result``/``text``, single-use semantics, the server-shutdown ``Partial`` path, -forward-compat for unknown events, and stream-time HTTP errors. -""" - from __future__ import annotations from collections.abc import Callable @@ -31,7 +24,7 @@ MakeEvent = Callable[..., dict[str, Any]] Jsonl = Callable[..., str] -# A well-formed Cominty (Clerk) user id — see ^user_[A-Za-z0-9]{20,}$ validation. +# A well-formed Cominty (Clerk) user id: see ^user_[A-Za-z0-9]{20,}$ validation. _USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" _COST = { @@ -57,7 +50,7 @@ def _stream_path(message_id: str) -> str: # --------------------------------------------------------------------------- # -# Iteration — events only +# Iteration: events only # --------------------------------------------------------------------------- # async def test_iterates_events_only_terminal_not_yielded( client: AsyncCominty, @@ -180,7 +173,7 @@ async def test_tracks_last_event_id_for_resume( # --------------------------------------------------------------------------- # -# result() / text() — terminal capture +# result() / text(): terminal capture # --------------------------------------------------------------------------- # async def test_result_returns_terminal_without_manual_iteration( client: AsyncCominty, @@ -449,7 +442,7 @@ 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 + await run.aclose() # never iterated: nothing to close async def test_context_manager_closes_cleanly( diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 4880611..9fc3c98 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,5 +1,3 @@ -"""Client construction: user_id is mandatory and validated up front.""" - from __future__ import annotations import pytest diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index 92f0888..b834f83 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -1,8 +1,3 @@ -"""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 @@ -25,7 +20,7 @@ # --------------------------------------------------------------------------- # -# error_from_response — status-code mapping +# error_from_response: status-code mapping # --------------------------------------------------------------------------- # @pytest.mark.parametrize( "status_code, expected_cls", @@ -262,7 +257,7 @@ def test_from_validation_error_deduplicates_repeated_messages_on_same_field() -> 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. + # is noise: it's dropped rather than surfaced as the offending value. exc = ValidationError.from_exception_data( "Test", [ diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index da351fc..660dc8e 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -1,10 +1,3 @@ -"""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. -""" - from __future__ import annotations import json diff --git a/tests/unit/test_threads.py b/tests/unit/test_threads.py index e31ca66..33b0ee8 100644 --- a/tests/unit/test_threads.py +++ b/tests/unit/test_threads.py @@ -1,9 +1,3 @@ -"""Unit tests for the threads resource: list, get, update, archive. - -user_id is sourced from the client (set once at construction) and applied to -``list`` as a query param — never passed per call. -""" - from __future__ import annotations import json diff --git a/tests/unit/test_transport.py b/tests/unit/test_transport.py index 534ab04..0f77b93 100644 --- a/tests/unit/test_transport.py +++ b/tests/unit/test_transport.py @@ -1,7 +1,3 @@ -"""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 @@ -21,7 +17,7 @@ # --------------------------------------------------------------------------- # -# request() — timeout / network errors +# request(): timeout / network errors # --------------------------------------------------------------------------- # async def test_request_timeout_raises_connection_error( client: AsyncCominty, mock_api: respx.MockRouter @@ -42,7 +38,7 @@ async def test_request_network_error_raises_connection_error( # --------------------------------------------------------------------------- # -# stream_lines() — timeout / network errors +# stream_lines(): timeout / network errors # --------------------------------------------------------------------------- # async def test_stream_timeout_raises_connection_error( client: AsyncCominty, mock_api: respx.MockRouter, ids: SimpleNamespace From 12eab07e6bd50e8378ddab1ed13ded2a38f9b2cc Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:42:02 +0200 Subject: [PATCH 06/13] chore: add code tasks and confirm releases --- AGENTS.md | 51 ++++++++---- README.md | 17 +++- examples/04_answer_questions.py | 4 +- examples/06_manage_thread.py | 10 +-- examples/07_custom_agent.py | 3 +- examples/09_memory.py | 6 +- examples/_pretty.py | 13 +-- pyproject.toml | 2 +- src/cominty_sdk/_config.py | 7 +- src/cominty_sdk/_transport.py | 8 +- src/cominty_sdk/client.py | 2 +- src/cominty_sdk/exceptions.py | 26 ++---- src/cominty_sdk/models/__init__.py | 2 +- src/cominty_sdk/models/memory.py | 2 +- src/cominty_sdk/resources/__init__.py | 2 +- src/cominty_sdk/resources/chat.py | 4 +- src/cominty_sdk/resources/memory.py | 10 +-- src/cominty_sdk/streaming.py | 4 +- tasks.py | 109 +++++++++++++++++++++++--- tests/conftest.py | 8 +- tests/integration/test_smoke.py | 8 +- tests/test_release.py | 34 +++++++- tests/test_version.py | 3 +- tests/unit/test_chat_send.py | 4 +- tests/unit/test_chat_start.py | 64 +++++---------- tests/unit/test_chat_stream.py | 68 ++++------------ tests/unit/test_client.py | 4 +- tests/unit/test_exceptions.py | 8 +- tests/unit/test_memory.py | 16 ++-- tests/unit/test_threads.py | 12 +-- 30 files changed, 265 insertions(+), 246 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d62451..ca95752 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -406,17 +406,28 @@ time. -e .`), `importlib.metadata.version()` raises `PackageNotFoundError`; `__init__.py` catches this and falls back to `__version__ = "unknown"`. See `tests/test_version.py`. -### 12.2 Local build & check (before any release) +### 12.2 Day-to-day dev loop Developer tasks live in `tasks.py` (`invoke` is a dev dependency). Run them with `uv run`: ```bash -uv run invoke --list # show all tasks -uv run invoke clean # remove dist/ build/ *.egg-info (no stale artifacts) +uv run invoke --list # show all tasks, including the code.* namespace +uv run invoke code.format # ruff format, then ruff check --fix +uv run invoke code.check # ruff check, ruff format --check, pyright +uv run invoke code.test # pytest --cov, same --cov-fail-under=100 floor as CI +uv run invoke code.all # format, then check, then test +``` + +`code.all` is the one command to run before opening a PR. + +### 12.3 Local build & check (before any release) + +```bash +uv run invoke clean # remove dist/ build/ *.egg-info and tool caches 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 +uv run invoke release --patch # bump + validate + commit + tag (see §12.4): modifies Git state ``` `check` is the gate to run before cutting a release. It confirms: @@ -429,7 +440,7 @@ uv run invoke release --patch # bump + validate + commit + tag (see §12.3): mo `publish-test` is a full dry run against TestPyPI; verify the upload in a clean env with the `uv run --isolated` snippet the task prints. It never burns a real PyPI version. -### 12.3 Cutting a release (steps) +### 12.4 Cutting a release (steps) > **`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. @@ -441,28 +452,34 @@ uv run invoke release --patch # bump + validate + commit + tag (see §12.3): mo 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 + uv run invoke release --patch --yes # same, skipping both confirmation prompts ``` 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. + `vX.Y.Z` tag: all checked **before** touching any file. It then prints a plan (current + version, target version, tag, files touched) and asks for confirmation before writing + anything. Once confirmed, it 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 asks for a second confirmation before + committing and tagging; declining rolls back the file changes too. Once both prompts are + accepted (or `--yes` skipped them), 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 --follow-tags`, 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. +every validation failure, and a decline at each confirmation prompt) 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. -### 12.4 CI release pipeline & OIDC (Trusted Publishing) +### 12.5 CI release pipeline & OIDC (Trusted Publishing) `.github/workflows/release.yml` runs on `release: [published]` and publishes to PyPI with **no stored API token**: diff --git a/README.md b/README.md index 6edfbe0..1c33b47 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,16 @@ COMINTY_API_KEY=... COMINTY_USER_ID=... uv run pytest -m integration See [AGENTS.md](AGENTS.md) for coding conventions (typing, versioning, models). +## Developer tasks + +```bash +uv run invoke --list # show all tasks, including the code.* namespace +uv run invoke code.format # ruff format, then ruff check --fix +uv run invoke code.check # ruff check, ruff format --check, pyright +uv run invoke code.test # pytest --cov, same --cov-fail-under=100 floor as CI +uv run invoke code.all # format, then check, then test: run before opening a PR +``` + ## Releasing Publishing to PyPI uses **Trusted Publishing (OIDC)**: no tokens stored in @@ -336,7 +346,9 @@ GitHub, triggered by publishing a **GitHub Release** **`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. +never creates the GitHub Release itself. It asks for confirmation before +writing any file, and again before committing and tagging; pass `--yes` to +skip both prompts. ```bash # 1. bump, validate, commit, and tag locally: pick exactly one @@ -346,8 +358,7 @@ 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 +git push origin HEAD --follow-tags # 3. create the release: this triggers the publish workflow gh release create vX.Y.Z --title vX.Y.Z --generate-notes diff --git a/examples/04_answer_questions.py b/examples/04_answer_questions.py index a10ab62..490d9fa 100644 --- a/examples/04_answer_questions.py +++ b/examples/04_answer_questions.py @@ -36,9 +36,7 @@ async def main() -> None: chosen = q.options[0] if q.options else "Tomorrow at 10am" pretty.console.print(f" [green]→ answering:[/] {chosen!r}") - followup = await client.chat.send( - run.thread.id, agent_id=AGENT_ID, message=chosen - ) + followup = await client.chat.send(run.thread.id, agent_id=AGENT_ID, message=chosen) pretty.answer(await followup.text()) diff --git a/examples/06_manage_thread.py b/examples/06_manage_thread.py index 990a5ec..eb27924 100644 --- a/examples/06_manage_thread.py +++ b/examples/06_manage_thread.py @@ -24,17 +24,13 @@ async def main() -> None: # get() -> full thread with message history. thread = await client.threads.get(thread_id) pretty.console.print( - f" [bold]get[/] {len(thread.messages)} messages, " - f"name={thread.name!r}" + f" [bold]get[/] {len(thread.messages)} messages, name={thread.name!r}" ) # update() is partial: rename and star in one call, or either alone. - updated = await client.threads.update( - thread_id, name="Renamed via SDK", starred=True - ) + updated = await client.threads.update(thread_id, name="Renamed via SDK", starred=True) pretty.console.print( - f" [bold]update[/] name={updated.name!r} " - f"starred=[yellow]{updated.starred}[/]" + f" [bold]update[/] name={updated.name!r} starred=[yellow]{updated.starred}[/]" ) # archive() soft-deletes the thread. diff --git a/examples/07_custom_agent.py b/examples/07_custom_agent.py index 10521bf..600a558 100644 --- a/examples/07_custom_agent.py +++ b/examples/07_custom_agent.py @@ -43,8 +43,7 @@ async def main() -> None: return async with make_client() as client: - pretty.panel(TECHNICAL_INPUT, title="Technical input (engineering, EN)", - style="yellow") + pretty.panel(TECHNICAL_INPUT, title="Technical input (engineering, EN)", style="yellow") run = await client.chat.start( agent_id=CUSTOM_AGENT_ID, diff --git a/examples/09_memory.py b/examples/09_memory.py index 6845096..c1f5c20 100644 --- a/examples/09_memory.py +++ b/examples/09_memory.py @@ -51,9 +51,7 @@ async def main() -> None: # Reusing the now-stale version demonstrates the 409 guard. try: - await client.memory.update( - path, version=fetched.version, content="stale write" - ) + await client.memory.update(path, version=fetched.version, content="stale write") except ConflictError: pretty.console.print(" [bold]conflict[/] [yellow]stale version rejected[/]") finally: @@ -63,4 +61,4 @@ async def main() -> None: if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/examples/_pretty.py b/examples/_pretty.py index e38a310..457c6d3 100644 --- a/examples/_pretty.py +++ b/examples/_pretty.py @@ -23,8 +23,7 @@ from rich.text import Text except ModuleNotFoundError as exc: # pragma: no cover - example-only guard raise SystemExit( - "examples need 'rich': run `uv sync --all-extras --dev` " - "(or `pip install rich`)." + "examples need 'rich': run `uv sync --all-extras --dev` (or `pip install rich`)." ) from exc from cominty_sdk import ThreadSummary, events @@ -79,16 +78,13 @@ def render(event: events.AnyEvent) -> None: if isinstance(event, events.LlmStep): full = " ".join(event.data.description.split()) if len(full) > _DETAIL_W: - console.print( - Text(f" ↳ reasoning hidden ({len(full)} chars)", style="dim") - ) + console.print(Text(f" ↳ reasoning hidden ({len(full)} chars)", style="dim")) def panel(text: str, *, title: str, style: str = "cyan") -> None: """Print arbitrary text in a bordered, titled panel.""" console.print() - console.print(Panel(text.strip(), title=title, border_style=style, - padding=(1, 2))) + console.print(Panel(text.strip(), title=title, border_style=style, padding=(1, 2))) def answer(text: str, *, title: str = "Answer") -> None: @@ -102,8 +98,7 @@ def rule(title: str = "") -> None: def thread_table(threads: list[ThreadSummary], *, title: str) -> None: """Render a list of thread summaries as a table.""" - table = Table(title=title, title_justify="left", header_style="bold", - expand=False) + table = Table(title=title, title_justify="left", header_style="bold", expand=False) table.add_column("created", style="dim", no_wrap=True) table.add_column("★", justify="center", no_wrap=True) table.add_column("name") diff --git a/pyproject.toml b/pyproject.toml index 736aef5..d2e2467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ dev = [ "nbformat>=5", "invoke>=2.2", "twine>=5", - "rich>=13", # examples/ pretty terminal output (not a runtime dependency) + "rich>=13", # examples/ pretty output, tasks.py release confirmation prompts "packaging>=23", # tasks.py release: PEP 440 / SemVer version parsing ] diff --git a/src/cominty_sdk/_config.py b/src/cominty_sdk/_config.py index 48a19bd..53c2157 100644 --- a/src/cominty_sdk/_config.py +++ b/src/cominty_sdk/_config.py @@ -34,8 +34,7 @@ def resolve( token = api_token or os.getenv(_TOKEN_ENV) if not token: raise ValueError( - f"api_token is required: pass api_token=... or set {_TOKEN_ENV} " - "in the environment." + f"api_token is required: pass api_token=... or set {_TOKEN_ENV} in the environment." ) resolved_user_id = user_id or os.getenv(_USER_ID_ENV) if not resolved_user_id: @@ -47,8 +46,6 @@ def resolve( return cls( api_token=token, user_id=resolved_user_id, - base_url=(base_url or os.getenv(_BASE_URL_ENV) or _DEFAULT_BASE_URL).rstrip( - "/" - ), + base_url=(base_url or os.getenv(_BASE_URL_ENV) or _DEFAULT_BASE_URL).rstrip("/"), timeout=timeout if timeout is not None else _DEFAULT_TIMEOUT, ) diff --git a/src/cominty_sdk/_transport.py b/src/cominty_sdk/_transport.py index 4b4518e..2b39b6a 100644 --- a/src/cominty_sdk/_transport.py +++ b/src/cominty_sdk/_transport.py @@ -35,18 +35,14 @@ async def request( params: Mapping[str, Any] | None = None, ) -> Any: # noqa: ANN401 - decoded JSON; resources validate into models try: - response = await self._client.request( - method, path, json=json_body, params=params - ) + response = await self._client.request(method, path, json=json_body, params=params) except httpx.TimeoutException as exc: raise APIConnectionError(f"Request to {path} timed out") from exc except httpx.RequestError as exc: raise APIConnectionError(f"Request to {path} failed: {exc}") from exc if response.is_error: - raise error_from_response( - response.status_code, _safe_json(response), response.headers - ) + raise error_from_response(response.status_code, _safe_json(response), response.headers) return _safe_json(response) @asynccontextmanager diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index b1f8dd3..e7bbfa0 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -96,4 +96,4 @@ async def __aexit__( await self.close() async def close(self) -> None: - await self._transport.aclose() \ No newline at end of file + await self._transport.aclose() diff --git a/src/cominty_sdk/exceptions.py b/src/cominty_sdk/exceptions.py index 919a60b..bf6e09c 100644 --- a/src/cominty_sdk/exceptions.py +++ b/src/cominty_sdk/exceptions.py @@ -130,9 +130,7 @@ def reset_at(self) -> datetime | None: the ``X-RateLimit-Reset`` header, else ``None``. """ if isinstance(self.detail, dict): - parsed = _parse_dt( - self.detail.get("reset_at") or self.detail.get("locked_until") - ) + parsed = _parse_dt(self.detail.get("reset_at") or self.detail.get("locked_until")) if parsed is not None: return parsed if self.headers: @@ -188,15 +186,11 @@ def __init__(self, message: str, *, errors: list[InvalidParam]) -> None: self.errors = errors @classmethod - def from_validation_error( - cls, exc: ValidationError, *, context: str - ) -> InvalidParams: + def from_validation_error(cls, exc: ValidationError, *, context: str) -> InvalidParams: grouped: dict[str, dict[str, Any]] = {} for err in exc.errors(include_url=False): path = _clean_param_path(err["loc"]) - group = grouped.setdefault( - path, {"msgs": [], "input": None, "show_input": False} - ) + group = grouped.setdefault(path, {"msgs": [], "input": None, "show_input": False}) if err["msg"] not in group["msgs"]: group["msgs"].append(err["msg"]) # "missing"/"extra_forbidden" carry the parent container as input. @@ -224,9 +218,7 @@ def _clean_param_path(loc: tuple[str | int, ...]) -> str: # Drop message/options wrappers and pydantic tags such as "literal[...]" # or "constrained-str". Those segments contain "[" or "-". parts: list[str | int] = [ - seg - for seg in loc - if isinstance(seg, int) or ("[" not in seg and "-" not in seg) + seg for seg in loc if isinstance(seg, int) or ("[" not in seg and "-" not in seg) ] if parts and parts[0] in ("message", "options"): parts = parts[1:] @@ -255,9 +247,7 @@ def error_from_response( if isinstance(body, dict): detail = cast("dict[str, Any]", body).get("detail") message = detail if isinstance(detail, str) else f"HTTP {status_code}" - cls = _STATUS_MAP.get(status_code) or ( - ServerError if status_code >= 500 else APIError - ) + cls = _STATUS_MAP.get(status_code) or (ServerError if status_code >= 500 else APIError) # A bare "HTTP 429" is useless. Turn the server's terse detail into a clear, # actionable message (which limit was hit + what the caller can do). if cls is RateLimitError: @@ -285,8 +275,7 @@ def _parse_dt(raw: object) -> datetime | None: # Per-scope opener, made explicit so the caller knows *which* limit was hit. _QUOTA_HEAD = { "organization": ( - "Organization rate limit reached: your organization's total request " - "quota is exhausted" + "Organization rate limit reached: your organization's total request quota is exhausted" ), "user": "User rate limit reached: your user request quota is exhausted", } @@ -315,8 +304,7 @@ def _rate_limit_message( else: head = text or "Rate limit reached" - when = _when_phrase(_parse_dt(info.get("reset_at") or info.get("locked_until")), - headers) + when = _when_phrase(_parse_dt(info.get("reset_at") or info.get("locked_until")), headers) tail = f" {when}" if when else "" return f"{head}. {_ADMIN_HINT}{tail}" diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 9a0c46b..612b7ef 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -37,4 +37,4 @@ "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 index f9e9100..81f7976 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -113,4 +113,4 @@ class MemoryFileSummaryOut(BaseModel): purpose: str created_at: datetime updated_at: datetime - version: str \ No newline at end of file + version: str diff --git a/src/cominty_sdk/resources/__init__.py b/src/cominty_sdk/resources/__init__.py index 3d9350d..6b8cd6c 100644 --- a/src/cominty_sdk/resources/__init__.py +++ b/src/cominty_sdk/resources/__init__.py @@ -4,4 +4,4 @@ from .memory import MemoryResource from .threads import ThreadsResource -__all__ = ["ChatResource", "ThreadsResource", "MemoryResource"] \ No newline at end of file +__all__ = ["ChatResource", "ThreadsResource", "MemoryResource"] diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index e7d3485..1df83c4 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -117,9 +117,7 @@ async def send( disabled_tools=disabled_tools, context="chat.send", ) - raw = await self._transport.request( - "POST", f"/chat/{thread_id}", json_body=body - ) + raw = await self._transport.request("POST", f"/chat/{thread_id}", json_body=body) reply = Message.model_validate(raw) return AssistantRun(self._transport, reply.id) diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index c39c6af..23cee72 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -48,9 +48,7 @@ async def list(self) -> list[MemoryFileSummaryOut]: Returns: list[MemoryFileSummaryOut]: One summary per file. """ - raw = await self._transport.request( - "GET", "/memory", params={"user_id": self._user_id} - ) + 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: @@ -149,9 +147,7 @@ async def update( 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", "/memory/file", params=params, json_body=body - ) + raw = await self._transport.request("PUT", "/memory/file", params=params, json_body=body) return MemoryFileOut.model_validate(raw) async def delete(self, path: str) -> None: @@ -174,4 +170,4 @@ async def delete(self, path: str) -> None: path = self._validate_path(path, context="memory.delete") await self._transport.request( "DELETE", "/memory/file", params={"path": path, "user_id": self._user_id} - ) \ No newline at end of file + ) diff --git a/src/cominty_sdk/streaming.py b/src/cominty_sdk/streaming.py index dbe98aa..676978e 100644 --- a/src/cominty_sdk/streaming.py +++ b/src/cominty_sdk/streaming.py @@ -104,9 +104,7 @@ async def result(self) -> Message: if self._terminal is not None: return self._terminal if self._consumed: - raise SDKError( - "stream was partially consumed; the final message is unavailable" - ) + raise SDKError("stream was partially consumed; the final message is unavailable") async for _ in self: pass if self._terminal is None: diff --git a/tasks.py b/tasks.py index cd4f29b..97e874c 100644 --- a/tasks.py +++ b/tasks.py @@ -3,16 +3,39 @@ import re from pathlib import Path -from invoke import Exit, task +from invoke import Collection, Exit, task from packaging.version import InvalidVersion, Version +from rich.console import Console +from rich.prompt import Confirm +from rich.table import Table + +console = Console() TESTPYPI_URL = "https://test.pypi.org/legacy/" +# Directories removed wholesale, plus loose files matched by a glob. Mirrors +# the tool-cache entries in .gitignore so `clean` never leaves stale state. +_CLEAN_DIRS = ( + "dist", + "build", + ".pytest_cache", + ".ruff_cache", + ".mypy_cache", + ".pyright", + "htmlcov", +) +_CLEAN_GLOBS = ("src/*.egg-info", "*.egg-info", ".coverage", ".coverage.*") + @task def clean(c): - """Remove build artifacts so we never ship/inspect stale files.""" - c.run("rm -rf dist build src/*.egg-info *.egg-info", echo=True) + """Remove build artifacts and tool caches so we never ship/inspect stale files.""" + c.run(f"rm -rf {' '.join(_CLEAN_DIRS)} {' '.join(_CLEAN_GLOBS)}", echo=True) + c.run( + "find . -type d -name __pycache__ -not -path './.venv/*' -prune -exec rm -rf {} +", + echo=True, + ) + c.run("find . -type f -name '*.pyc' -not -path './.venv/*' -delete", echo=True) @task(pre=[clean]) @@ -62,6 +85,38 @@ def publish(c, token=None): c.run(cmd, echo=True) +# --------------------------------------------------------------------------- # +# code: format, check, test (day-to-day dev loop, no packaging involved) +# --------------------------------------------------------------------------- # +@task(name="format") +def code_format(c): + """Format the code and auto-fix lint issues (ruff format, then ruff check --fix).""" + c.run("uv run ruff format .", echo=True) + c.run("uv run ruff check --fix .", echo=True) + + +@task(name="check") +def code_check(c): + """Lint, verify formatting, and type-check (ruff check, ruff format --check, pyright).""" + c.run("uv run ruff check .", echo=True) + c.run("uv run ruff format --check .", echo=True) + c.run("uv run pyright", echo=True) + + +@task(name="test") +def code_test(c): + """Run the test suite with coverage, at the same 100% floor as CI.""" + c.run( + "uv run pytest --cov --cov-report=term-missing --cov-fail-under=100", + echo=True, + ) + + +@task(name="all", pre=[code_format, code_check, code_test]) +def code_all(c): + """Format, then check, then test: the full local dev loop.""" + + # --------------------------------------------------------------------------- # # release # --------------------------------------------------------------------------- # @@ -78,9 +133,7 @@ def _read_version(text: str) -> str: 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" - ) + raise Exit(f"pyproject.toml's version {current!r} isn't X.Y.Z: fix it manually first") return current @@ -125,22 +178,34 @@ def _run_validation(c) -> None: c.run("uv run invoke check", echo=True, in_stream=False) +def _print_release_plan(current: str, target: str, tag: str) -> None: + table = Table(title="Release plan", show_header=False) + table.add_row("Current version", current) + table.add_row("Target version", target) + table.add_row("Tag", tag) + table.add_row("Files touched", f"{PYPROJECT_PATH}, {UV_LOCK_PATH}") + console.print(table) + + @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", + "yes": "Skip both confirmation prompts.", } ) -def release(c, patch=False, minor=False, major=False, version=None): +def release(c, patch=False, minor=False, major=False, version=None, yes=False): """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. + Exactly one of --patch/--minor/--major/--version is required. Prompts for + confirmation before writing any file, and again before committing and + tagging; --yes skips both prompts. """ modes = {"patch": patch, "minor": minor, "major": major, "version": version is not None} selected = [name for name, on in modes.items() if on] @@ -172,6 +237,10 @@ def release(c, patch=False, minor=False, major=False, version=None): if _tag_exists(c, tag): raise Exit(f"tag {tag} already exists") + _print_release_plan(current, target, tag) + if not yes and not Confirm.ask(f"Bump {current} -> {target} and run the validation gate?"): + raise Exit("aborted before touching any file") + original_uv_lock = UV_LOCK_PATH.read_text() if UV_LOCK_PATH.exists() else None def _rollback() -> None: @@ -189,13 +258,33 @@ def _rollback() -> None: _rollback() raise + commit_prompt = f'Create commit "chore(release): version {target}" and tag {tag}?' + if not yes and not Confirm.ask(commit_prompt): + _rollback() + raise Exit("aborted before creating the release commit and tag") + 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" git push origin HEAD --follow-tags\n" f" gh release create {tag} --title {tag} --generate-notes\n" ) + + +code = Collection("code") +code.add_task(code_format) +code.add_task(code_check) +code.add_task(code_test) +code.add_task(code_all) + +ns = Collection() +ns.add_task(clean) +ns.add_task(build) +ns.add_task(check) +ns.add_task(publish_test) +ns.add_task(publish) +ns.add_task(release) +ns.add_collection(code) diff --git a/tests/conftest.py b/tests/conftest.py index 1a59a7e..f881c30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,16 +33,12 @@ def user_id() -> str: @pytest.fixture def ids() -> SimpleNamespace: """Canonical UUIDs used across the default thread payload.""" - return SimpleNamespace( - thread=THREAD_ID, user_msg=USER_MSG_ID, assistant_msg=ASSISTANT_MSG_ID - ) + return SimpleNamespace(thread=THREAD_ID, user_msg=USER_MSG_ID, assistant_msg=ASSISTANT_MSG_ID) @pytest_asyncio.fixture async def client(base_url: str) -> AsyncIterator[AsyncCominty]: - async with AsyncCominty( - api_token="test-token", user_id=USER_ID, base_url=base_url - ) as instance: + async with AsyncCominty(api_token="test-token", user_id=USER_ID, base_url=base_url) as instance: yield instance diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index 732fc45..a3c0fb7 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -39,9 +39,7 @@ async def test_list_threads(creds: tuple[str, str]) -> None: async def test_start_and_get_reply(creds: tuple[str, str], agent_id: str) -> None: api_key, user_id = creds async with AsyncCominty(api_token=api_key, user_id=user_id) as client: - run = await client.chat.start( - agent_id=agent_id, message="Reply with exactly: pong" - ) + run = await client.chat.start(agent_id=agent_id, message="Reply with exactly: pong") reply = await run.result() assert reply.content assert str(reply.thread_id) == str(run.thread.id) @@ -71,9 +69,7 @@ async def test_memory_lifecycle(creds: tuple[str, str]) -> None: assert updated.content == "buy oat milk" with pytest.raises(ConflictError): - await client.memory.update( - path, version=fetched.version, content="stale write" - ) + await client.memory.update(path, version=fetched.version, content="stale write") finally: await client.memory.delete(path) diff --git a/tests/test_release.py b/tests/test_release.py index 698fdf8..49e0e17 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -74,7 +74,7 @@ def c() -> Context: def test_release_success( repo: Path, c: Context, capsys: pytest.CaptureFixture[str], kwargs: dict, expected: str ) -> None: - tasks.release(c, **kwargs) + tasks.release(c, yes=True, **kwargs) assert _pyproject_version() == expected assert f'version = "{expected}"' in (repo / "uv.lock").read_text() @@ -151,3 +151,35 @@ def test_release_existing_tag_fails(repo: Path, c: Context) -> None: tasks.release(c, patch=True) assert _git_out("rev-parse", "HEAD") == before assert _pyproject_version() == "0.1.0" + + +# --------------------------------------------------------------------------- # +# confirmation prompts +# --------------------------------------------------------------------------- # +def test_release_declined_first_confirmation_leaves_repo_untouched( + repo: Path, c: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(tasks.Confirm, "ask", staticmethod(lambda *a, **k: False)) + before = _git_out("rev-parse", "HEAD") + + with pytest.raises(Exit): + tasks.release(c, patch=True) + + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + assert _pyproject_version() == "0.1.0" + + +def test_release_declined_second_confirmation_rolls_back( + repo: Path, c: Context, monkeypatch: pytest.MonkeyPatch +) -> None: + answers = iter([True, False]) + monkeypatch.setattr(tasks.Confirm, "ask", staticmethod(lambda *a, **k: next(answers))) + before = _git_out("rev-parse", "HEAD") + + with pytest.raises(Exit): + tasks.release(c, patch=True) + + assert _git_out("rev-parse", "HEAD") == before + assert _is_clean() + assert _pyproject_version() == "0.1.0" diff --git a/tests/test_version.py b/tests/test_version.py index c4a3af8..083f315 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,7 +7,7 @@ def test_version_matches_installed_metadata() -> None: - assert cominty_sdk.__version__ == importlib.metadata.version("cominty-sdk") + assert cominty_sdk.__version__ == importlib.metadata.version("cominty-sdk") def test_version_falls_back_when_metadata_missing(monkeypatch: pytest.MonkeyPatch) -> None: @@ -21,4 +21,3 @@ def raise_not_found(name: str) -> NoReturn: assert cominty_sdk.__version__ == "unknown" finally: importlib.reload(cominty_sdk) - diff --git a/tests/unit/test_chat_send.py b/tests/unit/test_chat_send.py index 1490055..dc0f283 100644 --- a/tests/unit/test_chat_send.py +++ b/tests/unit/test_chat_send.py @@ -76,9 +76,7 @@ async def test_validation_fires_before_request( route = mock_api.post(f"/chat/{THREAD_ID}") with pytest.raises(InvalidParams) as exc: - await client.chat.send( - THREAD_ID, message="hi", agent_id="a", disabled_tools=["bogus"] - ) + await client.chat.send(THREAD_ID, message="hi", agent_id="a", disabled_tools=["bogus"]) assert not route.called assert str(exc.value).startswith("Invalid parameters for chat.send:") diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index 9fe6c87..c136757 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -69,9 +69,7 @@ async def test_thread_is_non_optional_on_started_chat( async def test_sends_post_with_token_and_minimal_body( client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread ) -> None: - route = mock_api.post("/chat").mock( - return_value=httpx.Response(200, json=make_thread()) - ) + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) await client.chat.start(agent_id="agt_1", message="hi") @@ -89,9 +87,7 @@ async def test_sends_post_with_token_and_minimal_body( async def test_includes_optional_fields_when_provided( client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread ) -> None: - route = mock_api.post("/chat").mock( - return_value=httpx.Response(200, json=make_thread()) - ) + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) await client.chat.start( agent_id="agt_1", @@ -127,25 +123,17 @@ async def test_start_does_not_open_the_stream( async def test_valid_disabled_tools_accepted( client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread, tool: str ) -> None: - route = mock_api.post("/chat").mock( - return_value=httpx.Response(200, json=make_thread()) - ) + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) - await client.chat.start( - agent_id="a", message="hi", disabled_tools=[tool] - ) + await client.chat.start(agent_id="a", message="hi", disabled_tools=[tool]) assert route.called - assert json.loads(route.calls.last.request.content)["message"]["disabled_tools"] == [ - tool - ] + assert json.loads(route.calls.last.request.content)["message"]["disabled_tools"] == [tool] async def test_uses_configured_base_url(make_thread: MakeThread) -> None: with respx.mock(base_url="https://sandbox.test", assert_all_called=False) as router: - route = router.post("/chat").mock( - return_value=httpx.Response(200, json=make_thread()) - ) + route = router.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) async with AsyncCominty( api_token="t", user_id=USER_ID, base_url="https://sandbox.test" ) as c: @@ -162,9 +150,7 @@ async def test_invalid_disabled_tool_raises_before_request( route = mock_api.post("/chat") with pytest.raises(InvalidParams) as exc: - await client.chat.start( - agent_id="a", message="hi", disabled_tools=["bogus"] - ) + await client.chat.start(agent_id="a", message="hi", disabled_tools=["bogus"]) assert not route.called assert len(mock_api.calls) == 0 @@ -179,16 +165,16 @@ async def test_invalid_source_id_type_raises( with pytest.raises(InvalidParams) as exc: await client.chat.start( - agent_id="a", message="hi", source_ids=["nope"] # type: ignore[list-item] + agent_id="a", + message="hi", + source_ids=["nope"], # type: ignore[list-item] ) assert not route.called assert any(e["param"] == "source_ids[0]" for e in exc.value.errors) -async def test_content_too_long_raises( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: +async def test_content_too_long_raises(client: AsyncCominty, mock_api: respx.MockRouter) -> None: route = mock_api.post("/chat") with pytest.raises(InvalidParams) as exc: @@ -198,16 +184,14 @@ async def test_content_too_long_raises( assert any(e["param"] == "content" for e in exc.value.errors) -async def test_too_many_file_ids_raises( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: +async def test_too_many_file_ids_raises(client: AsyncCominty, mock_api: respx.MockRouter) -> None: route = mock_api.post("/chat") with pytest.raises(InvalidParams) as exc: await client.chat.start( agent_id="a", message="hi", - file_ids=[f"f{i}" for i in range(6)], + file_ids=[f"f{i}" for i in range(6)], ) assert not route.called @@ -221,7 +205,7 @@ async def test_multiple_validation_errors_collected( await client.chat.start( agent_id="a", message="hi", - disabled_tools=["bad"], + disabled_tools=["bad"], source_ids=["nope"], # type: ignore[list-item] ) @@ -232,9 +216,7 @@ async def test_multiple_validation_errors_collected( async def test_invalid_params_message_is_clean(client: AsyncCominty) -> None: with pytest.raises(InvalidParams) as exc: - await client.chat.start( - agent_id="a", message="hi", disabled_tools=["bad"] - ) + await client.chat.start(agent_id="a", message="hi", disabled_tools=["bad"]) text = str(exc.value) assert text.startswith("Invalid parameters for chat.start:") @@ -263,9 +245,7 @@ async def test_http_errors_map_to_exceptions( status: int, expected: type[Exception], ) -> None: - mock_api.post("/chat").mock( - return_value=httpx.Response(status, json={"detail": "nope"}) - ) + mock_api.post("/chat").mock(return_value=httpx.Response(status, json={"detail": "nope"})) with pytest.raises(expected): await client.chat.start(agent_id="a", message="hi") @@ -322,9 +302,7 @@ async def test_rate_limit_organization_quota( assert "Quota resets at 2026-06-30T00:00:00+00:00" in text -async def test_rate_limit_user_quota( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: +async def test_rate_limit_user_quota(client: AsyncCominty, mock_api: respx.MockRouter) -> None: mock_api.post("/chat").mock( return_value=httpx.Response( 429, @@ -358,9 +336,7 @@ async def test_thread_without_assistant_message_raises_sdk_error( make_message: MakeMessage, ids: SimpleNamespace, ) -> None: - payload = make_thread( - messages=[make_message(id=ids.user_msg, role="user", content="hi")] - ) + payload = make_thread(messages=[make_message(id=ids.user_msg, role="user", content="hi")]) mock_api.post("/chat").mock(return_value=httpx.Response(200, json=payload)) with pytest.raises(SDKError): @@ -379,9 +355,7 @@ async def test_picks_last_assistant_message( make_message(id=ids.user_msg, role="user", content="hi"), make_message(id=_ASSISTANT_1, role="assistant", content="first"), make_message(id=_USER_2, role="user", content="again"), - make_message( - id=_ASSISTANT_2, role="assistant", status="pending", live=True - ), + make_message(id=_ASSISTANT_2, role="assistant", status="pending", live=True), ] ) mock_api.post("/chat").mock(return_value=httpx.Response(200, json=payload)) diff --git a/tests/unit/test_chat_stream.py b/tests/unit/test_chat_stream.py index 2e5e2bc..009bbb3 100644 --- a/tests/unit/test_chat_stream.py +++ b/tests/unit/test_chat_stream.py @@ -79,9 +79,7 @@ async def test_iterates_events_only_terminal_not_yielded( make_event("result", id="3-0", correlation_id=3, data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) collected = [event async for event in run] @@ -114,9 +112,7 @@ async def test_blank_heartbeat_line_skipped( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) names = [e.name async for e in run] @@ -136,9 +132,7 @@ async def test_unknown_event_is_forward_compatible( make_event("brand_new_event", id="0-0", status="running", data={"x": 1}), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) collected = [event async for event in run] @@ -161,9 +155,7 @@ async def test_tracks_last_event_id_for_resume( make_event("result", id="1782676050530-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) async for _ in run: @@ -187,9 +179,7 @@ async def test_result_returns_terminal_without_manual_iteration( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) msg = await run.result() @@ -208,9 +198,7 @@ async def test_text_returns_final_reply( 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) assert await run.text() == "final" @@ -228,9 +216,7 @@ async def test_result_after_full_iteration_returns_terminal( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) async for _ in run: @@ -247,9 +233,7 @@ async def test_result_is_idempotent( 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) - ) + 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 = await run.result() @@ -266,9 +250,7 @@ async def test_non_live_message_yields_no_events( ) -> None: # A finished message streams only its terminal snapshot, no progress events. body = jsonl(make_message(id=ids.assistant_msg, role="assistant", content="cached")) - mock_api.get(_stream_path(ids.assistant_msg)).mock( - return_value=httpx.Response(200, text=body) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) collected = [event async for event in run] @@ -293,9 +275,7 @@ async def test_partial_iteration_then_result_raises( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) async for _ in run: @@ -327,9 +307,7 @@ async def test_partial_raises_stream_interrupted_on_iteration( ), } body = jsonl(make_event("llm", id="0-0", data={"description": "x", "model": "y"}), partial) - mock_api.get(_stream_path(ids.assistant_msg)).mock( - return_value=httpx.Response(200, text=body) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) with pytest.raises(StreamInterrupted) as exc: @@ -378,9 +356,7 @@ async def test_stream_http_error_raises_on_iteration( pass -def test_bare_stream_primitive_has_no_thread( - client: AsyncCominty, ids: SimpleNamespace -) -> None: +def test_bare_stream_primitive_has_no_thread(client: AsyncCominty, ids: SimpleNamespace) -> None: run = client.chat.stream(UUID(ids.assistant_msg)) assert run.thread is None assert run.message_id == UUID(ids.assistant_msg) @@ -394,9 +370,7 @@ async def test_aiter_called_twice_reuses_same_generator( 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) - ) + 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__() @@ -453,9 +427,7 @@ async def test_context_manager_closes_cleanly( 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) async with client.chat.stream(UUID(ids.assistant_msg)) as run: events_seen = [e async for e in run] @@ -479,9 +451,7 @@ async def test_start_then_stream_end_to_end( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = await client.chat.start(agent_id="agt_1", message="hi") names = [e.name async for e in run] @@ -509,9 +479,7 @@ async def test_questions_surfaced_from_terminal_message( ], } body = jsonl(make_event("result", id="1-0", data=_RESULT_DATA), terminal) - mock_api.get(_stream_path(ids.assistant_msg)).mock( - return_value=httpx.Response(200, text=body) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) questions = await run.questions() @@ -532,9 +500,7 @@ async def test_questions_empty_when_none( make_event("result", id="1-0", data=_RESULT_DATA), 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) - ) + mock_api.get(_stream_path(ids.assistant_msg)).mock(return_value=httpx.Response(200, text=body)) run = client.chat.stream(UUID(ids.assistant_msg)) assert await run.questions() == [] diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 9fc3c98..b26e800 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -26,9 +26,7 @@ def test_explicit_user_id_beats_env(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.parametrize("bad", ["", "nope", "user_short", "31HPTBuBvX20xlQNAbvx" * 2]) -def test_malformed_user_id_rejected_locally( - bad: str, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_malformed_user_id_rejected_locally(bad: str, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("COMINTY_USER_ID", raising=False) with pytest.raises(ValueError): AsyncCominty(api_token="t", user_id=bad, base_url="https://x.test") diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py index b834f83..a2f5120 100644 --- a/tests/unit/test_exceptions.py +++ b/tests/unit/test_exceptions.py @@ -123,16 +123,12 @@ def test_retry_after_none_when_header_absent_from_present_headers() -> 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"} - ) + 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"} - ) + err = RateLimitError("x", status_code=429, detail={"locked_until": "2026-06-28T10:00:00+00:00"}) assert err.reset_at is not None diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index 660dc8e..d8e6165 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -75,9 +75,7 @@ async def test_list_scopes_to_client_user_id( 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()) - ) + 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" @@ -138,9 +136,7 @@ async def test_create_path_too_deep_raises_invalid_params( async def test_get_sends_path_and_user_id_as_query( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: - route = mock_api.get("/memory/file").mock( - return_value=httpx.Response(200, json=_file()) - ) + route = mock_api.get("/memory/file").mock(return_value=httpx.Response(200, json=_file())) result = await client.memory.get("notes/todo.md") @@ -216,7 +212,7 @@ async def test_update_version_round_trips_unchanged( ) -> None: route = mock_api.put("/memory/file").mock(return_value=httpx.Response(200, json=_file())) - opaque_version = "W/\"2026-06-28T10:00:00Z-xyz\"" + 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 @@ -285,9 +281,7 @@ async def test_full_lifecycle(client: AsyncCominty, mock_api: respx.MockRouter) ) listed = await client.memory.list() fetched = await client.memory.get(created.path) - updated = await client.memory.update( - fetched.path, version=fetched.version, content="updated" - ) + 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" @@ -295,4 +289,4 @@ async def test_full_lifecycle(client: AsyncCominty, mock_api: respx.MockRouter) assert fetched.version == "v1" assert updated.content == "updated" assert updated.version == "v2" - assert deleted is None \ No newline at end of file + assert deleted is None diff --git a/tests/unit/test_threads.py b/tests/unit/test_threads.py index 33b0ee8..0500a07 100644 --- a/tests/unit/test_threads.py +++ b/tests/unit/test_threads.py @@ -100,9 +100,7 @@ async def test_update_sends_only_provided_fields( assert thread.name == "Renamed" -async def test_update_starred_only( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: +async def test_update_starred_only(client: AsyncCominty, mock_api: respx.MockRouter) -> None: route = mock_api.put(f"/chat/{THREAD_ID}").mock( return_value=httpx.Response(200, json={**_summary(), "starred": True}) ) @@ -116,12 +114,8 @@ async def test_update_starred_only( # --------------------------------------------------------------------------- # # archive # --------------------------------------------------------------------------- # -async def test_archive_sends_delete( - client: AsyncCominty, mock_api: respx.MockRouter -) -> None: - route = mock_api.delete(f"/chat/{THREAD_ID}").mock( - return_value=httpx.Response(204) - ) +async def test_archive_sends_delete(client: AsyncCominty, mock_api: respx.MockRouter) -> None: + route = mock_api.delete(f"/chat/{THREAD_ID}").mock(return_value=httpx.Response(204)) result = await client.threads.archive(THREAD_ID) From d7868f9c444aa8cdc58b1f319370fad24143df9a Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:42:23 +0200 Subject: [PATCH 07/13] ci: fast-forward pull requests on /merge --- .github/workflows/merge.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/merge.yml diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml new file mode 100644 index 0000000..a0ec4d4 --- /dev/null +++ b/.github/workflows/merge.yml @@ -0,0 +1,30 @@ +# This is to address https://github.com/orgs/community/discussions/5524 +name: Fast Forward Merge +on: + issue_comment: + types: [created] + +jobs: + merge: + name: Fast Forward + if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/merge') + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Fast Forward PR + uses: cominty/fast-forward-js-action@d2e1f31b37078f9f1be14aa4b6ca7dca1b1544c2 + with: + # We use a PAT so pushing the fast-forwarded commits to main still + # triggers ci.yml (the GITHUB_TOKEN's own pushes never re-trigger + # workflows). /merge-no-deploy fast-forwards without that PAT, so + # it does not re-trigger anything. + # Links: + # - https://github.com/endre-spotlab/fast-forward-js-action/issues/36#issuecomment-1493006429 + # - https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/triggering-a-workflow#triggering-a-workflow-from-a-workflow + GITHUB_TOKEN: ${{ contains(github.event.comment.body, '/merge-no-deploy') && secrets.GITHUB_TOKEN || secrets.FAST_FORWARD_MERGE_PAT }} + success_message: "Success! Fast forwarded ***target_base*** to ***source_head***! ```git checkout target_base && git merge source_head --ff-only```" + failure_message: "Failed! Cannot do fast forward!" + status_name: fast-forward-merge + update_status: true From 259028492fa95ccdc0d8e1f94619d572648a405f Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 15:49:13 +0200 Subject: [PATCH 08/13] feat!: scope memory files by namespace BREAKING CHANGE: client.memory methods no longer send user_id. Every call (except list()) now requires a namespace, a caller-chosen bag name (max 128 chars). create()/get()/update()/delete() require it as a keyword argument; list(namespace=None) filters when given, lists every bag when omitted. MemoryFileOut and MemoryFileSummaryOut gain a namespace field. New list_namespaces() lists every bag with at least one file. chat.start() gains an optional memory_namespace kwarg that attaches the new thread to a bag, frozen for its lifetime. chat.send() does not accept it: a follow-up cannot change a thread's namespace. --- CHANGELOG.md | 34 ++++++-- README.md | 28 ++++-- examples/09_memory.py | 41 ++++++--- src/cominty_sdk/client.py | 2 +- src/cominty_sdk/models/chat.py | 5 ++ src/cominty_sdk/models/memory.py | 42 +++++++-- src/cominty_sdk/resources/chat.py | 16 +++- src/cominty_sdk/resources/memory.py | 101 ++++++++++++++------- tests/integration/test_smoke.py | 22 +++-- tests/unit/test_chat_start.py | 32 +++++++ tests/unit/test_memory.py | 130 +++++++++++++++++++++------- 11 files changed, 340 insertions(+), 113 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f837a..72209db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,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 +- `client.memory`: full async CRUD for memory files, scoped to a + caller-chosen **namespace** (a bag name, at most 128 characters, never + trimmed): `list(namespace=None)`, `list_namespaces()`, `create()`, `get()`, + `update()`, `delete()` (`GET/POST /memory`, `GET /memory/namespaces`, + `GET/PUT/DELETE /memory/file`). `namespace` is required on `create()`, + `get()`, `update()`, and `delete()`; optional on `list()` (omit it to list + every bag). There is no call to create a namespace: the first `create()` + against a new name brings that bag into existence. New models + `MemoryFileCreate`, `MemoryFileUpdate`, `MemoryFileOut`, + `MemoryFileSummaryOut` (the last two now carry `namespace`). `update()` is a 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 @@ -24,11 +30,20 @@ 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`. +- `chat.start(..., memory_namespace=...)`: attaches the new thread to a + memory bag, frozen for the thread's lifetime. Omit it to run with no + memory tools, unless the agent has its own namespace set. Not accepted on + `chat.send`: a follow-up cannot change a thread's namespace. +- `uv run invoke code.format|code.check|code.test|code.all`: the day-to-day + dev loop (ruff format, ruff check + pyright, pytest with the same + `--cov-fail-under=100` floor as CI, or all three in order). - `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. + annotated `vX.Y.Z` tag. Prints a plan and asks for confirmation before + writing any file, and again before committing and tagging (`--yes` skips + both). Never pushes or creates the GitHub Release; prints the exact next + commands instead. See `AGENTS.md` §12.4. ### Changed - `__version__` is now resolved at runtime from installed package metadata @@ -36,6 +51,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `src/cominty_sdk/_version.py`. Falls back to `"unknown"` when the package isn't installed (e.g. a raw source checkout). The single source of truth for the version is now `pyproject.toml`'s `[project] version`. +- `uv run invoke clean` now also removes tool caches (`__pycache__`, + `.pytest_cache`, `.ruff_cache`, `.mypy_cache`, `.pyright`, `htmlcov`, + `.coverage*`), not just build artifacts. + +### Removed +- `client.memory`'s methods no longer send `user_id`: memory files are + scoped by `namespace`, not by the end user. ## [0.1.1] - 2026-06-18 diff --git a/README.md b/README.md index 1c33b47..2e67787 100644 --- a/README.md +++ b/README.md @@ -178,31 +178,36 @@ 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. +`client.memory` stores files an agent can read back later, scoped to a +**namespace**: a bag name you choose. There's no call to create a namespace; +the first `create()` against a new name brings that bag into existence. ```python -# Create a file +# Create a file. namespace is required on every call below. file = await client.memory.create( - path="preferences/tone.md", purpose="writing style", content="Keep it casual." + path="tone.md", namespace="support-bot", purpose="writing style", content="Keep it casual." ) -# List files (summaries: no content) -for f in await client.memory.list(): +# List files in one bag (summaries: no content). Omit namespace to list +# every bag visible to this API key. +for f in await client.memory.list(namespace="support-bot"): print(f.path, f.purpose, f.version) +# List every namespace that already has at least one file. +await client.memory.list_namespaces() + # Read one file's content -file = await client.memory.get("preferences/tone.md") +file = await client.memory.get("tone.md", namespace="support-bot") # 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." + "tone.md", namespace="support-bot", version=file.version, content="Keep it upbeat." ) # Delete -await client.memory.delete("preferences/tone.md") +await client.memory.delete("tone.md", namespace="support-bot") ``` `version` is an opaque token: never parse or compare it, just round-trip @@ -214,11 +219,15 @@ ignores an explicit `null` (leaves the existing value untouched), so sending a request that looks like it succeeded but did nothing. A few other things worth knowing: +- `namespace` may be at most 128 characters. It's never trimmed: an empty or + whitespace-only value is sent to the API exactly as given. - `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. +- `chat.start(..., memory_namespace=...)` attaches a thread to a bag; the + bag is frozen once the thread starts (see [Message parameters](#message-parameters)). ## Examples @@ -257,6 +266,7 @@ Both `chat.start` and `chat.send` accept: | `source_ids` | `list[int]` | Restrict retrieval to specific knowledge sources. | | `document_ids` | `list[str]` | Restrict retrieval to specific documents. | | `disabled_tools` | `list[str]` | Turn tools off: `"web"`, `"company_documents"`, `"mcp:"`, or `"mcp:*"` for all MCP. | +| `memory_namespace` | `str` | `start` only: the memory bag for this thread (max 128 chars), frozen for its lifetime. A follow-up cannot change it. | Invalid values raise `InvalidParams` **before** any request is sent. diff --git a/examples/09_memory.py b/examples/09_memory.py index c1f5c20..7b8eb41 100644 --- a/examples/09_memory.py +++ b/examples/09_memory.py @@ -2,11 +2,13 @@ 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. +Demonstrates the full memory resource lifecycle, scoped to a namespace (a +caller-chosen bag name; the first create against a new one is enough to bring +it into existence). ``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 @@ -22,41 +24,52 @@ async def main() -> None: async with make_client() as client: - path = f"sdk-examples/{uuid4()}.md" + namespace = f"sdk-examples-{uuid4()}" + path = "notes.md" - # create() -> the new file, with its initial version token. + # create() -> the new file, in a namespace chosen by the caller. The + # first create against a new namespace is enough to bring it into + # existence: there is no separate call to create one. created = await client.memory.create( path=path, + namespace=namespace, purpose="scratch note for the memory example", content="Remember to buy milk.", ) - pretty.console.print(f" [bold]create[/] path={created.path!r}") + pretty.console.print(f" [bold]create[/] path={created.path!r} namespace={namespace!r}") try: - # list() -> lightweight summaries (no content) for every file. - summaries = await client.memory.list() + # list(namespace=...) -> lightweight summaries (no content) for + # this bag only. Omit namespace to list every bag instead. + summaries = await client.memory.list(namespace=namespace) pretty.console.print(f" [bold]list[/] {len(summaries)} file(s)") + # list_namespaces() -> every distinct bag with at least one file. + namespaces = await client.memory.list_namespaces() + pretty.console.print(f" [bold]namespaces[/] {namespace in namespaces}") + # get() -> the full file, including content. - fetched = await client.memory.get(path) + fetched = await client.memory.get(path, namespace=namespace) 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." + path, namespace=namespace, 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") + await client.memory.update( + path, namespace=namespace, 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) + await client.memory.delete(path, namespace=namespace) pretty.console.print(" [bold]delete[/] [green]done[/]") diff --git a/src/cominty_sdk/client.py b/src/cominty_sdk/client.py index e7bbfa0..8621937 100644 --- a/src/cominty_sdk/client.py +++ b/src/cominty_sdk/client.py @@ -62,7 +62,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) + self.memory = MemoryResource(self._transport) @property def user_id(self) -> str: diff --git a/src/cominty_sdk/models/chat.py b/src/cominty_sdk/models/chat.py index 8bdbe25..74035a3 100644 --- a/src/cominty_sdk/models/chat.py +++ b/src/cominty_sdk/models/chat.py @@ -9,6 +9,8 @@ from pydantic import AfterValidator, BaseModel, ConfigDict, Field, StringConstraints from typing_extensions import TypeAlias +from .memory import MemoryNamespace + __all__ = [ # enums / aliases "MessageRole", @@ -116,6 +118,9 @@ class StartChatOptions(BaseModel): agent_id: str user_id: UserId """Required: the API-token endpoint rejects a missing ``user_id`` with 400.""" + memory_namespace: MemoryNamespace | None = None + """Memory bag for this thread, frozen once the thread starts. Omit to run + with no memory tools, unless the agent has its own namespace set.""" class StartChatParams(BaseModel): diff --git a/src/cominty_sdk/models/memory.py b/src/cominty_sdk/models/memory.py index 81f7976..e92bd6a 100644 --- a/src/cominty_sdk/models/memory.py +++ b/src/cominty_sdk/models/memory.py @@ -6,22 +6,24 @@ from pydantic import AfterValidator, BaseModel, ConfigDict, model_validator from typing_extensions import TypeAlias -from .chat import UserId - __all__ = [ "MemoryFileCreate", "MemoryFileUpdate", "MemoryFileOut", "MemoryFileSummaryOut", + "MemoryFileQueryParams", + "MemoryNamespaceParam", "MemoryPath", - "MemoryPathParam", + "MemoryNamespace", "validate_memory_path", + "validate_memory_namespace", ] # 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 +_MAX_NAMESPACE_LENGTH = 128 def validate_memory_path(value: str) -> str: @@ -34,28 +36,47 @@ def validate_memory_path(value: str) -> str: return value +def validate_memory_namespace(value: str) -> str: + if len(value) > _MAX_NAMESPACE_LENGTH: + raise ValueError( + f"namespace is {len(value)} characters long; the API allows at most " + f"{_MAX_NAMESPACE_LENGTH}" + ) + return value + + MemoryPath: TypeAlias = Annotated[str, AfterValidator(validate_memory_path)] """A memory file path, folder-depth-checked before any request is sent.""" +MemoryNamespace: TypeAlias = Annotated[str, AfterValidator(validate_memory_namespace)] +"""A caller-chosen bag name, at most 128 characters. Not trimmed: an empty or +whitespace-only value is sent to the API exactly as given.""" -class MemoryPathParam(BaseModel): - """Validates a bare ``path`` argument (``get``/``update``/``delete``, - which don't otherwise go through a request-body model).""" + +class MemoryFileQueryParams(BaseModel): + """Validates the ``path`` + ``namespace`` pair shared by get/update/delete.""" model_config = ConfigDict(strict=True) path: MemoryPath + namespace: MemoryNamespace + + +class MemoryNamespaceParam(BaseModel): + """Validates a bare ``namespace`` argument, e.g. :meth:`list`'s optional filter.""" + + model_config = ConfigDict(strict=True) + + namespace: MemoryNamespace class MemoryFileCreate(BaseModel): model_config = ConfigDict(strict=True, extra="forbid") path: MemoryPath + namespace: MemoryNamespace 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.""" class MemoryFileUpdate(BaseModel): @@ -97,6 +118,8 @@ class MemoryFileOut(BaseModel): model_config = ConfigDict(extra="ignore") path: str + namespace: str + """Logical bag name. Always the caller-chosen string, never prefixed.""" purpose: str content: str created_at: datetime @@ -110,6 +133,7 @@ class MemoryFileSummaryOut(BaseModel): model_config = ConfigDict(extra="ignore") path: str + namespace: str purpose: str created_at: datetime updated_at: datetime diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index 1df83c4..2489b26 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -36,6 +36,7 @@ async def start( source_ids: list[int] | None = None, document_ids: list[str] | None = None, disabled_tools: list[DisablableTool] | None = None, + memory_namespace: str | None = None, ) -> StartedChat: """ Start a new thread and return a handle on the assistant reply. @@ -51,6 +52,9 @@ async def start( source_ids (list[int] | None): Connected source ids attached to the message. document_ids (list[str] | None): Document ids attached to the message. disabled_tools (list[DisablableTool] | None): Tools the agent must not use. + memory_namespace (str | None): Memory bag for this thread, frozen for its + lifetime. Omit to run with no memory tools, unless the agent has its + own namespace set. A follow-up cannot change it. Returns: StartedChat: Handle for the in-progress assistant reply. ``thread`` is set. @@ -66,6 +70,7 @@ async def start( source_ids=source_ids, document_ids=document_ids, disabled_tools=disabled_tools, + memory_namespace=memory_namespace, context="chat.start", ) raw = await self._transport.request("POST", "/chat", json_body=body) @@ -90,7 +95,8 @@ async def send( Sends ``POST /chat/{thread_id}``. The response is the new assistant message, not the whole thread, so the returned run has no ``thread``. Pass a suggested option, or free text, as ``message`` to answer an - agent question. + agent question. The thread's memory bag, if any, was fixed at + ``start``: a follow-up cannot change it. Args: thread_id (str | UUID): Thread to continue. @@ -115,6 +121,7 @@ async def send( source_ids=source_ids, document_ids=document_ids, disabled_tools=disabled_tools, + memory_namespace=None, context="chat.send", ) raw = await self._transport.request("POST", f"/chat/{thread_id}", json_body=body) @@ -145,6 +152,7 @@ def _build_body( source_ids: list[int] | None, document_ids: list[str] | None, disabled_tools: list[DisablableTool] | None, + memory_namespace: str | None, context: str, ) -> dict[str, object]: # Validate the whole request in one pass so error locations are rooted @@ -161,7 +169,11 @@ def _build_body( "document_ids": document_ids, "disabled_tools": disabled_tools, }, - "options": {"agent_id": agent_id, "user_id": self._user_id}, + "options": { + "agent_id": agent_id, + "user_id": self._user_id, + "memory_namespace": memory_namespace, + }, } ) except ValidationError as exc: diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index 23cee72..02608ef 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -2,15 +2,16 @@ from typing import TYPE_CHECKING, Union -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from ..exceptions import InvalidParams from ..models.memory import ( MemoryFileCreate, MemoryFileOut, + MemoryFileQueryParams, MemoryFileSummaryOut, MemoryFileUpdate, - MemoryPathParam, + MemoryNamespaceParam, ) if TYPE_CHECKING: @@ -26,42 +27,72 @@ # typing.Sentinel would be the natural fit, but it's 3.13+, above the floor. _OptionalField = Union[str, None, "EllipsisType"] +_namespaces_adapter: TypeAdapter[list[str]] = TypeAdapter(list[str]) + class MemoryResource: - def __init__(self, transport: AsyncTransport, *, user_id: str) -> None: + def __init__(self, transport: AsyncTransport) -> None: self._transport = transport - self._user_id = user_id @staticmethod - def _validate_path(path: str, *, context: str) -> str: + def _validate_query(path: str, namespace: str, *, context: str) -> tuple[str, str]: try: - return MemoryPathParam(path=path).path + validated = MemoryFileQueryParams(path=path, namespace=namespace) except ValidationError as exc: raise InvalidParams.from_validation_error(exc, context=context) from None + return validated.path, validated.namespace - async def list(self) -> list[MemoryFileSummaryOut]: + async def list(self, *, namespace: str | None = None) -> list[MemoryFileSummaryOut]: """ - List the current user's memory files. + List memory files. + + Sends ``GET /memory``. Omit ``namespace`` to list every file visible + to this API key; pass one to filter to a single bag. - Sends ``GET /memory``. Summaries do not include file content. + Args: + namespace (str | None): Bag to filter to. Omit to list every bag. Returns: list[MemoryFileSummaryOut]: One summary per file. + + Raises: + InvalidParams: ``namespace`` is longer than 128 characters. """ - raw = await self._transport.request("GET", "/memory", params={"user_id": self._user_id}) + params: dict[str, object] = {} + if namespace is not None: + try: + params["namespace"] = MemoryNamespaceParam(namespace=namespace).namespace + except ValidationError as exc: + raise InvalidParams.from_validation_error(exc, context="memory.list") from None + raw = await self._transport.request("GET", "/memory", params=params) return [MemoryFileSummaryOut.model_validate(item) for item in raw] - async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOut: + async def list_namespaces(self) -> list[str]: + """ + List the distinct namespaces that already have files. + + Sends ``GET /memory/namespaces``. Read-only: there is no call to + create a namespace, since the first read or write against a name is + enough to bring that bag into existence. + + Returns: + list[str]: Logical namespace names. + """ + raw = await self._transport.request("GET", "/memory/namespaces") + return _namespaces_adapter.validate_python(raw) + + async def create( + self, *, path: str, namespace: str, purpose: str, content: str + ) -> MemoryFileOut: """ - Create a memory file. + Create a memory file in a namespace. - Sends ``POST /memory`` (201). ``user_id`` is sent in the body, unlike - the other memory calls, which send it as a query parameter. ``path`` - may have at most one folder segment (``"folder/file.md"``). ``content`` - may be empty. + Sends ``POST /memory`` (201). ``path`` may have at most one folder + segment (``"folder/file.md"``). ``content`` may be empty. Args: path (str): File path, at most one folder deep. + namespace (str): Bag this file belongs to, at most 128 characters. purpose (str): Why the file exists. The agent reads this. content (str): File body. An empty string is allowed. @@ -69,12 +100,12 @@ async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOu MemoryFileOut: The created file, including its ``version`` token. Raises: - InvalidParams: ``path`` is deeper than one folder. - ConflictError: A file already exists at ``path``. + InvalidParams: ``path`` or ``namespace`` failed local validation. + ConflictError: A file already exists at ``path`` in ``namespace``. """ try: params = MemoryFileCreate( - path=path, purpose=purpose, content=content, user_id=self._user_id + path=path, namespace=namespace, purpose=purpose, content=content ) except ValidationError as exc: raise InvalidParams.from_validation_error(exc, context="memory.create") from None @@ -83,7 +114,7 @@ async def create(self, *, path: str, purpose: str, content: str) -> MemoryFileOu ) return MemoryFileOut.model_validate(raw) - async def get(self, path: str) -> MemoryFileOut: + async def get(self, path: str, *, namespace: str) -> MemoryFileOut: """ Fetch one memory file, including its content. @@ -91,17 +122,18 @@ async def get(self, path: str) -> MemoryFileOut: Args: path (str): File path, at most one folder deep. + namespace (str): Bag this file belongs to. Returns: MemoryFileOut: The file, including ``content`` and ``version``. Raises: - InvalidParams: ``path`` is deeper than one folder. - NotFoundError: No file exists at ``path``. + InvalidParams: ``path`` or ``namespace`` failed local validation. + NotFoundError: No file exists at ``path`` in ``namespace``. """ - path = self._validate_path(path, context="memory.get") + path, namespace = self._validate_query(path, namespace, context="memory.get") raw = await self._transport.request( - "GET", "/memory/file", params={"path": path, "user_id": self._user_id} + "GET", "/memory/file", params={"path": path, "namespace": namespace} ) return MemoryFileOut.model_validate(raw) @@ -109,6 +141,7 @@ async def update( self, path: str, *, + namespace: str, version: str, content: _OptionalField = ..., purpose: _OptionalField = ..., @@ -122,6 +155,7 @@ async def update( Args: path (str): File path, at most one folder deep. + namespace (str): Bag this file belongs to. version (str): Opaque token from a previous read. Pass it back unchanged. content (str | None): New body. Omit the argument to leave it unchanged. purpose (str | None): New purpose. Omit the argument to leave it unchanged. @@ -130,12 +164,12 @@ async def update( MemoryFileOut: The updated file, including the new ``version``. Raises: - InvalidParams: ``path`` is too deep, neither field was passed, or a - field was passed as ``None``. + InvalidParams: ``path``/``namespace`` failed local validation, + neither field was passed, or a field was passed as ``None``. ConflictError: ``version`` is well formed but stale. APIError: ``version`` is malformed (422). """ - path = self._validate_path(path, context="memory.update") + path, namespace = self._validate_query(path, namespace, context="memory.update") fields: dict[str, object] = {} if content is not ...: fields["content"] = content @@ -146,11 +180,11 @@ async def update( 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} + params = {"path": path, "namespace": namespace, "version": version} raw = await self._transport.request("PUT", "/memory/file", params=params, json_body=body) return MemoryFileOut.model_validate(raw) - async def delete(self, path: str) -> None: + async def delete(self, path: str, *, namespace: str) -> None: """ Delete a memory file. @@ -159,15 +193,16 @@ async def delete(self, path: str) -> None: Args: path (str): File path, at most one folder deep. + namespace (str): Bag this file belongs to. Returns: None: The file is deleted. Raises: - InvalidParams: ``path`` is deeper than one folder. - NotFoundError: The path does not exist. + InvalidParams: ``path`` or ``namespace`` failed local validation. + NotFoundError: The path does not exist in ``namespace``. """ - path = self._validate_path(path, context="memory.delete") + path, namespace = self._validate_query(path, namespace, context="memory.delete") await self._transport.request( - "DELETE", "/memory/file", params={"path": path, "user_id": self._user_id} + "DELETE", "/memory/file", params={"path": path, "namespace": namespace} ) diff --git a/tests/integration/test_smoke.py b/tests/integration/test_smoke.py index a3c0fb7..b9ece05 100644 --- a/tests/integration/test_smoke.py +++ b/tests/integration/test_smoke.py @@ -49,29 +49,35 @@ async def test_start_and_get_reply(creds: tuple[str, str], agent_id: str) -> Non 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" + namespace = f"sdk-integration-tests-{uuid4()}" + path = "notes.md" created = await client.memory.create( - path=path, purpose="integration test", content="buy milk" + path=path, namespace=namespace, purpose="integration test", content="buy milk" ) try: assert created.path == path + assert created.namespace == namespace assert created.content == "buy milk" - summaries = await client.memory.list() + summaries = await client.memory.list(namespace=namespace) assert any(f.path == path for f in summaries) - fetched = await client.memory.get(path) + assert namespace in await client.memory.list_namespaces() + + fetched = await client.memory.get(path, namespace=namespace) assert fetched.content == "buy milk" updated = await client.memory.update( - path, version=fetched.version, content="buy oat milk" + path, namespace=namespace, 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") + await client.memory.update( + path, namespace=namespace, version=fetched.version, content="stale write" + ) finally: - await client.memory.delete(path) + await client.memory.delete(path, namespace=namespace) with pytest.raises(NotFoundError): - await client.memory.get(path) + await client.memory.get(path, namespace=namespace) diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index c136757..d65f671 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -131,6 +131,38 @@ async def test_valid_disabled_tools_accepted( assert json.loads(route.calls.last.request.content)["message"]["disabled_tools"] == [tool] +async def test_includes_memory_namespace_when_provided( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + await client.chat.start(agent_id="agt_1", message="hi", memory_namespace="support-bot") + + body = json.loads(route.calls.last.request.content) + assert body["options"]["memory_namespace"] == "support-bot" + + +async def test_omits_memory_namespace_when_not_provided( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + await client.chat.start(agent_id="agt_1", message="hi") + + assert "memory_namespace" not in json.loads(route.calls.last.request.content)["options"] + + +async def test_memory_namespace_too_long_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.post("/chat") + + with pytest.raises(InvalidParams): + await client.chat.start(agent_id="agt_1", message="hi", memory_namespace="x" * 129) + + assert not route.called + + async def test_uses_configured_base_url(make_thread: MakeThread) -> None: with respx.mock(base_url="https://sandbox.test", assert_all_called=False) as router: route = router.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) diff --git a/tests/unit/test_memory.py b/tests/unit/test_memory.py index d8e6165..5d599e9 100644 --- a/tests/unit/test_memory.py +++ b/tests/unit/test_memory.py @@ -14,22 +14,27 @@ MemoryFileSummaryOut, ) -USER_ID = "user_31HPTBuBvX20xlQNAbvxjOxPbKB" +NAMESPACE = "support-bot" # 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" +# The live API rejects a namespace longer than 128 characters. +TOO_LONG_NAMESPACE = "x" * 129 + def _file( path: str = "notes/todo.md", *, + namespace: str = NAMESPACE, purpose: str = "scratch notes", content: str = "buy milk", version: str = "v1", ) -> dict[str, object]: return { "path": path, + "namespace": namespace, "purpose": purpose, "content": content, "created_at": "2026-06-28T10:00:00Z", @@ -39,10 +44,15 @@ def _file( def _summary( - path: str = "notes/todo.md", *, purpose: str = "scratch notes", version: str = "v1" + path: str = "notes/todo.md", + *, + namespace: str = NAMESPACE, + purpose: str = "scratch notes", + version: str = "v1", ) -> dict[str, object]: return { "path": path, + "namespace": namespace, "purpose": purpose, "created_at": "2026-06-28T10:00:00Z", "updated_at": "2026-06-28T10:00:00Z", @@ -53,7 +63,7 @@ def _summary( # --------------------------------------------------------------------------- # # list # --------------------------------------------------------------------------- # -async def test_list_scopes_to_client_user_id( +async def test_list_without_namespace_omits_query_param( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: route = mock_api.get("/memory").mock( @@ -64,35 +74,69 @@ async def test_list_scopes_to_client_user_id( 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 "namespace" not in route.calls.last.request.url.params + assert route.calls.last.request.method == "GET" + + +async def test_list_with_namespace_filters( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("/memory").mock(return_value=httpx.Response(200, json=[_summary("a")])) + + await client.memory.list(namespace=NAMESPACE) + + assert route.calls.last.request.url.params["namespace"] == NAMESPACE + + +async def test_list_namespace_too_long_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.list(namespace=TOO_LONG_NAMESPACE) + + assert mock_api.calls.call_count == 0 + + +# --------------------------------------------------------------------------- # +# list_namespaces +# --------------------------------------------------------------------------- # +async def test_list_namespaces_returns_distinct_names( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + route = mock_api.get("/memory/namespaces").mock( + return_value=httpx.Response(200, json=["default", NAMESPACE]) + ) + + namespaces = await client.memory.list_namespaces() + + assert namespaces == ["default", NAMESPACE] assert route.calls.last.request.method == "GET" # --------------------------------------------------------------------------- # # create # --------------------------------------------------------------------------- # -async def test_create_sends_user_id_in_body_not_query( +async def test_create_sends_namespace_in_body( 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" + path="notes/todo.md", namespace=NAMESPACE, purpose="scratch notes", content="buy milk" ) request = route.calls.last.request assert request.method == "POST" - assert "user_id" not in request.url.params body = json.loads(request.content) assert body == { "path": "notes/todo.md", + "namespace": NAMESPACE, "purpose": "scratch notes", "content": "buy milk", - "user_id": USER_ID, } assert isinstance(result, MemoryFileOut) assert result.path == "notes/todo.md" + assert result.namespace == NAMESPACE async def test_create_conflict_raises_conflict_error( @@ -106,7 +150,7 @@ async def test_create_conflict_raises_conflict_error( with pytest.raises(ConflictError): await client.memory.create( - path="notes/todo.md", purpose="scratch notes", content="buy milk" + path="notes/todo.md", namespace=NAMESPACE, purpose="scratch notes", content="buy milk" ) @@ -116,7 +160,9 @@ async def test_create_empty_content_is_allowed( # 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="") + result = await client.memory.create( + path="notes/todo.md", namespace=NAMESPACE, purpose="scratch notes", content="" + ) assert result.content == "" @@ -125,7 +171,20 @@ 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") + await client.memory.create( + path=TOO_DEEP_PATH, namespace=NAMESPACE, purpose="x", content="y" + ) + + assert mock_api.calls.call_count == 0 + + +async def test_create_namespace_too_long_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter +) -> None: + with pytest.raises(InvalidParams): + await client.memory.create( + path="notes/todo.md", namespace=TOO_LONG_NAMESPACE, purpose="x", content="y" + ) assert mock_api.calls.call_count == 0 @@ -133,16 +192,16 @@ async def test_create_path_too_deep_raises_invalid_params( # --------------------------------------------------------------------------- # # get # --------------------------------------------------------------------------- # -async def test_get_sends_path_and_user_id_as_query( +async def test_get_sends_path_and_namespace_as_query( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: route = mock_api.get("/memory/file").mock(return_value=httpx.Response(200, json=_file())) - result = await client.memory.get("notes/todo.md") + result = await client.memory.get("notes/todo.md", namespace=NAMESPACE) params = route.calls.last.request.url.params assert params["path"] == "notes/todo.md" - assert params["user_id"] == USER_ID + assert params["namespace"] == NAMESPACE assert isinstance(result, MemoryFileOut) assert result.content == "buy milk" @@ -151,7 +210,7 @@ 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) + await client.memory.get(TOO_DEEP_PATH, namespace=NAMESPACE) assert mock_api.calls.call_count == 0 @@ -166,14 +225,16 @@ async def test_update_omitted_field_is_excluded_from_body( 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") + result = await client.memory.update( + "notes/todo.md", namespace=NAMESPACE, version="v1", content="new content" + ) request = route.calls.last.request assert json.loads(request.content) == {"content": "new content"} params = request.url.params assert params["path"] == "notes/todo.md" + assert params["namespace"] == NAMESPACE assert params["version"] == "v1" - assert params["user_id"] == USER_ID assert isinstance(result, MemoryFileOut) assert result.version == "v2" @@ -193,7 +254,7 @@ async def test_update_explicit_none_raises_invalid_params( # 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) + await client.memory.update("notes/todo.md", namespace=NAMESPACE, version="v1", **kwargs) assert mock_api.calls.call_count == 0 @@ -202,7 +263,7 @@ 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") + await client.memory.update("notes/todo.md", namespace=NAMESPACE, version="v1") assert mock_api.calls.call_count == 0 @@ -213,7 +274,9 @@ async def test_update_version_round_trips_unchanged( route = mock_api.put("/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") + await client.memory.update( + "notes/todo.md", namespace=NAMESPACE, version=opaque_version, content="x" + ) assert route.calls.last.request.url.params["version"] == opaque_version @@ -226,14 +289,16 @@ async def test_update_conflict_raises_conflict_error( ) with pytest.raises(ConflictError): - await client.memory.update("notes/todo.md", version="stale", content="x") + await client.memory.update( + "notes/todo.md", namespace=NAMESPACE, 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") + await client.memory.update(TOO_DEEP_PATH, namespace=NAMESPACE, version="v1", content="x") assert mock_api.calls.call_count == 0 @@ -246,20 +311,20 @@ async def test_delete_sends_query_and_returns_none( ) -> None: route = mock_api.delete("/memory/file").mock(return_value=httpx.Response(204)) - result = await client.memory.delete("notes/todo.md") + result = await client.memory.delete("notes/todo.md", namespace=NAMESPACE) 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 + assert request.url.params["namespace"] == NAMESPACE 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) + await client.memory.delete(TOO_DEEP_PATH, namespace=NAMESPACE) assert mock_api.calls.call_count == 0 @@ -277,14 +342,17 @@ async def test_full_lifecycle(client: AsyncCominty, mock_api: respx.MockRouter) mock_api.delete("/memory/file").mock(return_value=httpx.Response(204)) created = await client.memory.create( - path="notes/todo.md", purpose="scratch notes", content="buy milk" + path="notes/todo.md", namespace=NAMESPACE, purpose="scratch notes", content="buy milk" + ) + listed = await client.memory.list(namespace=NAMESPACE) + fetched = await client.memory.get(created.path, namespace=NAMESPACE) + updated = await client.memory.update( + fetched.path, namespace=NAMESPACE, version=fetched.version, content="updated" ) - 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) + deleted = await client.memory.delete(updated.path, namespace=NAMESPACE) assert created.path == "notes/todo.md" + assert created.namespace == NAMESPACE assert listed[0].path == "notes/todo.md" assert fetched.version == "v1" assert updated.content == "updated" From b6395da387f1b329a70886e87302ba806528fefa Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:14:31 +0200 Subject: [PATCH 09/13] style: ban relative imports from parent packages Enable ruff's TID252 with ban-relative-imports = "parents": only same-package, single-dot relative imports stay relative, imports reaching into a parent package are absolute (cominty_sdk.foo) instead. Fixes resources/*.py accordingly. Also drops the types.EllipsisType import in resources/memory.py, which pyright rejected under the 3.9 floor (it's 3.10+ even under TYPE_CHECKING). Uses typeshed's synthesized "ellipsis" forward-ref instead, which needs no import and works down to 3.9. --- AGENTS.md | 13 +++++++++++-- pyproject.toml | 8 +++++++- src/cominty_sdk/resources/chat.py | 8 ++++---- src/cominty_sdk/resources/memory.py | 14 ++++++-------- src/cominty_sdk/resources/threads.py | 4 ++-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca95752..fd95a3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -304,15 +304,24 @@ never to silence a real type error. Each ignore must have an inline comment expl ```toml [tool.ruff] -target-version = "py313" +target-version = "py39" line-length = 100 [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM", "ANN"] +select = ["E", "F", "I", "UP", "B", "SIM", "ANN", "TID252"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "parents" ``` `ANN` (annotations) enforces that every public function is fully annotated. +`TID252` with `ban-relative-imports = "parents"` allows only single-dot, +same-package relative imports (`from .memory import MemoryFileOut` inside +`models/`). Anything reaching into a parent package (`from ..exceptions +import InvalidParams` from `resources/`) must be an absolute import instead +(`from cominty_sdk.exceptions import InvalidParams`). + --- ## 9. Testing diff --git a/pyproject.toml b/pyproject.toml index d2e2467..406e8f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,13 @@ line-length = 100 src = ["src", "tests"] [tool.ruff.lint] -select = ["E", "F", "I", "UP", "B", "SIM", "ANN"] +select = ["E", "F", "I", "UP", "B", "SIM", "ANN", "TID252"] + +[tool.ruff.lint.flake8-tidy-imports] +# Only single-dot, same-package relative imports (`from .foo import Bar`) are +# allowed. Anything reaching into a parent package (`from ..foo import Bar`) +# must be an absolute import instead. +ban-relative-imports = "parents" [tool.ruff.lint.per-file-ignores] # Unshipped dev entrypoints (excluded from the sdist): manual endpoint-poking diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index 2489b26..e46410e 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -5,18 +5,18 @@ from pydantic import ValidationError -from ..exceptions import InvalidParams, SDKError -from ..models.chat import ( +from cominty_sdk.exceptions import InvalidParams, SDKError +from cominty_sdk.models.chat import ( DisablableTool, Message, MessageRole, StartChatParams, Thread, ) -from ..streaming import AssistantRun, StartedChat +from cominty_sdk.streaming import AssistantRun, StartedChat if TYPE_CHECKING: - from .._transport import AsyncTransport + from cominty_sdk._transport import AsyncTransport __all__ = ["ChatResource"] diff --git a/src/cominty_sdk/resources/memory.py b/src/cominty_sdk/resources/memory.py index 02608ef..c5e74f3 100644 --- a/src/cominty_sdk/resources/memory.py +++ b/src/cominty_sdk/resources/memory.py @@ -4,8 +4,8 @@ from pydantic import TypeAdapter, ValidationError -from ..exceptions import InvalidParams -from ..models.memory import ( +from cominty_sdk.exceptions import InvalidParams +from cominty_sdk.models.memory import ( MemoryFileCreate, MemoryFileOut, MemoryFileQueryParams, @@ -15,17 +15,15 @@ ) if TYPE_CHECKING: - # types.EllipsisType is 3.10+; this import is annotation-only so the - # future import keeps it from ever running on the 3.9 floor. - from types import EllipsisType - - from .._transport import AsyncTransport + from cominty_sdk._transport import AsyncTransport __all__ = ["MemoryResource"] # Ellipsis distinguishes "argument omitted" from "passed as None" on update(). # typing.Sentinel would be the natural fit, but it's 3.13+, above the floor. -_OptionalField = Union[str, None, "EllipsisType"] +# "ellipsis" (the type of `...`) is typeshed's synthesized name for it: no +# import needed, and unlike types.EllipsisType it's available on Python 3.9. +_OptionalField = Union[str, None, "ellipsis"] # noqa: F821 - typeshed-only, not a real name _namespaces_adapter: TypeAdapter[list[str]] = TypeAdapter(list[str]) diff --git a/src/cominty_sdk/resources/threads.py b/src/cominty_sdk/resources/threads.py index 23d45b6..54a80e5 100644 --- a/src/cominty_sdk/resources/threads.py +++ b/src/cominty_sdk/resources/threads.py @@ -3,10 +3,10 @@ from typing import TYPE_CHECKING from uuid import UUID -from ..models.chat import Thread, ThreadSummary, UpdateThreadParams +from cominty_sdk.models.chat import Thread, ThreadSummary, UpdateThreadParams if TYPE_CHECKING: - from .._transport import AsyncTransport + from cominty_sdk._transport import AsyncTransport __all__ = ["ThreadsResource"] From 9f48ab9b1f0a5cccd340ace7f9ce32cad1a8a578 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:39:00 +0200 Subject: [PATCH 10/13] feat!: move agent from thread to message, add inception and error_code BREAKING CHANGE: ThreadSummary.agent (and Thread.agent) is removed. The local API no longer returns an agent on the thread itself: verified live against a running instance. Read the agent from each Message instead. Message gains agent (Agent | Literal["ARCHIVED"] | None) and error_code (str | None, e.g. "budget_exhausted"). ThreadSummary gains inception, a new ThreadInception enum ("conversational" | "routine"). Found and confirmed via the playground scenarios against a real local backend: chat.start and threads.list both raised a pydantic ValidationError on the missing required `agent` field before this fix. --- CHANGELOG.md | 7 +++++++ src/cominty_sdk/__init__.py | 2 ++ src/cominty_sdk/models/__init__.py | 2 ++ src/cominty_sdk/models/chat.py | 15 ++++++++++++++- tests/conftest.py | 7 ++++++- tests/unit/test_chat_start.py | 5 ++++- tests/unit/test_threads.py | 2 +- 7 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72209db..421817d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `Message.agent`: the agent that handled that message (`Agent | Literal["ARCHIVED"] | None`; + `"ARCHIVED"` if the original agent has since been deleted). `Message.error_code`: set when + the message failed for a known reason (e.g. `"budget_exhausted"`), typed as a plain `str` + since the API adds new codes over time. `ThreadSummary.inception`: `"conversational"` or + `"routine"`, exposed as the new `ThreadInception` enum. - `client.memory`: full async CRUD for memory files, scoped to a caller-chosen **namespace** (a bag name, at most 128 characters, never trimmed): `list(namespace=None)`, `list_namespaces()`, `create()`, `get()`, @@ -58,6 +63,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - `client.memory`'s methods no longer send `user_id`: memory files are scoped by `namespace`, not by the end user. +- `ThreadSummary.agent` (and `Thread.agent`, since `Thread` extends it): the API no longer + returns an agent on the thread itself. Use `Message.agent` on each message instead. ## [0.1.1] - 2026-06-18 diff --git a/src/cominty_sdk/__init__.py b/src/cominty_sdk/__init__.py index 198093d..a377962 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -33,6 +33,7 @@ StartChatOptions, StartChatParams, Thread, + ThreadInception, ThreadSummary, UpdateThreadParams, ) @@ -83,6 +84,7 @@ "StartChatOptions", "StartChatParams", "Thread", + "ThreadInception", "ThreadSummary", "UpdateThreadParams", "MemoryFileCreate", diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index 612b7ef..a28370c 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -14,6 +14,7 @@ StartChatOptions, StartChatParams, Thread, + ThreadInception, ThreadSummary, ) from .memory import MemoryFileCreate, MemoryFileOut, MemoryFileSummaryOut, MemoryFileUpdate @@ -36,5 +37,6 @@ "StartChatOptions", "StartChatParams", "Thread", + "ThreadInception", "ThreadSummary", ] diff --git a/src/cominty_sdk/models/chat.py b/src/cominty_sdk/models/chat.py index 74035a3..7ce1354 100644 --- a/src/cominty_sdk/models/chat.py +++ b/src/cominty_sdk/models/chat.py @@ -16,6 +16,7 @@ "MessageRole", "MessageStatus", "ContentOrigin", + "ThreadInception", "DisablableTool", "UserId", "validate_user_id", @@ -63,6 +64,11 @@ class ContentOrigin(str, Enum): agent = "agent" +class ThreadInception(str, Enum): + conversational = "conversational" + routine = "routine" + + # Cominty user ids are Clerk-issued: "user_" + a base58-ish token, # e.g. "user_31HPTBuBvX20xlQNAbvxjOxPbKB". _USER_ID_PATTERN = re.compile(r"^user_[A-Za-z0-9]{20,}$") @@ -191,6 +197,10 @@ class Message(BaseModel): thread_id: UUID role: MessageRole content: str + error_code: str | None + """Set when the message failed for a known reason (e.g. ``"budget_exhausted"``). + Left as ``str`` rather than a closed enum since the API adds new codes + over time and this is a lenient response model.""" questions: list[Question] | None live: bool status: MessageStatus @@ -198,6 +208,9 @@ class Message(BaseModel): """Raw persisted event log (not the typed stream events).""" structured_output: dict[str, Any] | None files: list[ConversationFile] + agent: Agent | Literal["ARCHIVED"] | None + """The agent that handled this message. ``"ARCHIVED"`` if the original + agent has since been deleted; ``None`` before one is assigned.""" class ThreadSummary(BaseModel): @@ -207,8 +220,8 @@ class ThreadSummary(BaseModel): name: str created_at: datetime live: bool - agent: Agent starred: bool + inception: ThreadInception project_id: str | None = None diff --git a/tests/conftest.py b/tests/conftest.py index f881c30..fa868bb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,18 +57,23 @@ def _make( content: str = "", status: str = "success", live: bool = False, + agent: dict[str, Any] | str | None = None, ) -> dict[str, Any]: + if agent is None and role == "assistant": + agent = {"id": "agt_1", "name": "Support"} return { "id": id, "thread_id": THREAD_ID, "role": role, "content": content, + "error_code": None, "questions": None, "live": live, "status": status, "events": None, "structured_output": None, "files": [], + "agent": agent, } return _make @@ -99,8 +104,8 @@ def _make( "name": name, "created_at": "2026-06-28T10:00:00Z", "live": True, - "agent": {"id": "agt_1", "name": "Support"}, "starred": False, + "inception": "conversational", "project_id": None, "messages": messages, } diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index d65f671..7204347 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -10,6 +10,7 @@ import respx from cominty_sdk import ( + Agent, AsyncCominty, AuthError, ConflictError, @@ -51,7 +52,9 @@ async def test_returns_started_chat_with_thread_and_reply( assert isinstance(run, StartedChat) assert str(run.thread.id) == ids.thread assert str(run.message_id) == ids.assistant_msg # the live assistant reply - assert run.thread.agent.name == "Support" + agent = run.thread.messages[-1].agent + assert isinstance(agent, Agent) + assert agent.name == "Support" async def test_thread_is_non_optional_on_started_chat( diff --git a/tests/unit/test_threads.py b/tests/unit/test_threads.py index 0500a07..b18eab8 100644 --- a/tests/unit/test_threads.py +++ b/tests/unit/test_threads.py @@ -22,8 +22,8 @@ def _summary(name: str = "A thread") -> dict[str, Any]: "name": name, "created_at": "2026-06-28T10:00:00Z", "live": False, - "agent": {"id": "agt_1", "name": "Support"}, "starred": False, + "inception": "conversational", "project_id": None, } From 4e29ba9b2a734ee6425ad1dfd956c063d9900723 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:46:45 +0200 Subject: [PATCH 11/13] chore(deps): bump pydantic, typing-extensions, pyright, and ruff Bumps the python-dependencies group with 4 updates: pydantic (2.13.4 -> 2.13.5), typing-extensions (4.15.0 -> 4.16.0), pyright (1.1.411 -> 1.1.414), ruff (0.15.14 -> 0.16.8). ruff 0.16 formats fenced Python code blocks in Markdown files; AGENTS.md and README.md are reformatted accordingly (double blank line before top-level defs/classes, single space before trailing comments). No prose changed. --- AGENTS.md | 12 ++- README.md | 45 ++++---- uv.lock | 316 ++++++++++++++++++++++++++++-------------------------- 3 files changed, 198 insertions(+), 175 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd95a3f..0c3c55f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,8 +43,10 @@ from typing import TypeVar T = TypeVar("T") + def identity(x: T) -> T: ... + class Stack(Generic[T]): def push(self, item: T) -> None: ... def pop(self) -> T: ... @@ -77,9 +79,10 @@ from typing_extensions import ParamSpec P = ParamSpec("P") R = TypeVar("R") + def retry(fn: Callable[P, R]) -> Callable[P, R]: - async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - ... + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: ... + return wrapper # type: ignore[return-value] # only here, where unavoidable ``` @@ -91,6 +94,7 @@ so IDEs infer the correct type at each call site: ```python from typing import overload, Literal + @overload async def send(self, *, stream: Literal[False] = ...) -> ChatResponse: ... @overload @@ -115,6 +119,7 @@ identically under Pydantic and works on the floor: ```python from enum import Enum + class MessageRole(str, Enum): user = "user" assistant = "assistant" @@ -175,6 +180,7 @@ class APIErrorBody(BaseModel): message: str details: dict[str, object] | None = None + class APIError(ComintyError): status_code: int error: APIErrorBody @@ -205,7 +211,7 @@ Streaming methods return a `StreamHandle` object, not a raw iterator: ```python class StreamHandle[EventT]: async def events(self) -> AsyncIterator[EventT]: ... - async def text(self) -> str: ... # accumulates all text deltas + async def text(self) -> str: ... # accumulates all text deltas async def __aiter__(self) -> AsyncIterator[EventT]: ... # delegates to events() ``` diff --git a/README.md b/README.md index 2e67787..a73ffe9 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,15 @@ with a small, fully-typed surface that's the same on Python 3.9 through 3.13. import asyncio from cominty_sdk import AsyncCominty + async def main() -> None: - async with AsyncCominty() as client: # reads COMINTY_API_KEY + COMINTY_USER_ID - run = await client.chat.start(agent_id="__cominty_agents::agent.chat", - message="What is Cominty?") + async with AsyncCominty() as client: # reads COMINTY_API_KEY + COMINTY_USER_ID + run = await client.chat.start( + agent_id="__cominty_agents::agent.chat", message="What is Cominty?" + ) print(await run.text()) + asyncio.run(main()) ``` @@ -63,7 +66,7 @@ export COMINTY_USER_ID="user_..." ``` ```python -async with AsyncCominty() as client: # picks both up from the environment +async with AsyncCominty() as client: # picks both up from the environment ... ``` @@ -90,7 +93,7 @@ to the assistant's in-progress reply. From there, pick the style you need. ```python run = await client.chat.start(agent_id=AGENT_ID, message="Give me one fun fact.") -print(await run.text()) # blocks until the agent finishes +print(await run.text()) # blocks until the agent finishes ``` `await run.result()` gives the full `Message` (status, files, structured output, @@ -114,7 +117,7 @@ async for event in run: elif isinstance(event, events.Result): print(f"cost {event.data.cost.total}") -print("FINAL:", await run.text()) # available after the stream drains +print("FINAL:", await run.text()) # available after the stream drains ``` > A run's stream is single-use: iterate it **or** await its result: the result @@ -130,7 +133,9 @@ first = await client.chat.start(agent_id=AGENT_ID, message="Pick a language.") await first.text() second = await client.chat.send( - first.thread.id, agent_id=AGENT_ID, message="Now show hello-world in it.", + first.thread.id, + agent_id=AGENT_ID, + message="Now show hello-world in it.", ) print(await second.text()) ``` @@ -162,8 +167,8 @@ print(await reply.text()) for t in await client.threads.list(limit=20): print(t.created_at, t.name, t.id) -await client.threads.list(terms=["invoice"]) # free-text search -await client.threads.list(limit=10, page=1) # paginate (zero-based) +await client.threads.list(terms=["invoice"]) # free-text search +await client.threads.list(limit=10, page=1) # paginate (zero-based) # Load one thread's full history thread = await client.threads.get(thread_id) @@ -289,18 +294,18 @@ Every error is a subclass of `ComintyError`: ```python from cominty_sdk import ( - ComintyError, # base: catch-all - APIError, # any 4xx/5xx; carries .status_code and a typed .error body - AuthError, # 401 - PermissionError, # 403 - NotFoundError, # 404 - ConflictError, # 409 - RateLimitError, # 429: exposes .reset_at - ServerError, # 5xx + ComintyError, # base: catch-all + APIError, # any 4xx/5xx; carries .status_code and a typed .error body + AuthError, # 401 + PermissionError, # 403 + NotFoundError, # 404 + ConflictError, # 409 + RateLimitError, # 429: exposes .reset_at + ServerError, # 5xx APIConnectionError, # network failure / timeout, no response - StreamInterrupted, # server shut down mid-stream: carries the .partial Message - InvalidParams, # client-side validation failed: .errors lists each problem - SDKError, # unexpected SDK-internal condition + StreamInterrupted, # server shut down mid-stream: carries the .partial Message + InvalidParams, # client-side validation failed: .errors lists each problem + SDKError, # unexpected SDK-internal condition ) try: diff --git a/uv.lock b/uv.lock index fed00e3..e09e9a2 100644 --- a/uv.lock +++ b/uv.lock @@ -832,27 +832,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, { url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, { url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, { url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, { url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, { url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, { url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, { url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, { url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, { url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, { url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, { url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, { url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, { url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, { url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, { url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, { url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, { url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, { url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, { url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, { url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, { url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, { url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, { url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, { url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, { url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, { url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, @@ -881,27 +887,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, @@ -2530,7 +2542,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.4" +version = "2.13.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2538,139 +2550,139 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.4" +version = "2.46.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/5d/00/13a0c039569d1e583779ee1b8d7df6bfe275a0db83fcae14f01d6856c16e/pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae", size = 2115337, upload-time = "2026-05-06T13:38:37.741Z" }, - { url = "https://files.pythonhosted.org/packages/41/60/e70fa1ee03e243bdfd4b1fddf1e1f2a8fba681df3034b51b9376c0fb5bf5/pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201", size = 1957976, upload-time = "2026-05-06T13:37:33.478Z" }, - { url = "https://files.pythonhosted.org/packages/11/9a/78fb5f2ea849f767ea802de8b4e8f5a0c4a48ddbe4bc66bd19ac2f55a01c/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0", size = 1979390, upload-time = "2026-05-06T13:36:52.419Z" }, - { url = "https://files.pythonhosted.org/packages/f5/7d/3acfdcd000bad9735de0430a88355948469781f62cb841fd63e8a307e80e/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15", size = 2043263, upload-time = "2026-05-06T13:39:54.798Z" }, - { url = "https://files.pythonhosted.org/packages/35/60/1325e5a8d7f9697416481c7f7c1c304738d6b961a7fd1ea0f054ce0f14fb/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76", size = 2225708, upload-time = "2026-05-06T13:40:24.887Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b0/9ec8c38f33b26db0b612cb7fd165bb0a370773710432a2a74fa31287b430/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49", size = 2288494, upload-time = "2026-05-06T13:38:00.091Z" }, - { url = "https://files.pythonhosted.org/packages/65/05/497446a9586d1b2d24ee25ebe208beb15388f1875d783e1e014055d150ac/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928", size = 2095629, upload-time = "2026-05-06T13:38:23.632Z" }, - { url = "https://files.pythonhosted.org/packages/93/d9/cd5fa98f9d94f9294c15459396c8a2383c164469e679ac178d6d42cfee6b/pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066", size = 2119309, upload-time = "2026-05-06T13:39:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/20/1b/64cec655451ddbf3976df5dc9706b240df4fdaebdeebeadd4f59a8dab926/pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6", size = 2170216, upload-time = "2026-05-06T13:39:14.561Z" }, - { url = "https://files.pythonhosted.org/packages/2a/21/fe9f039138c9ea3be10ccdb6ec490acb54dcbef5a5e96dbdf1411f82b929/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9", size = 2186726, upload-time = "2026-05-06T13:37:51.597Z" }, - { url = "https://files.pythonhosted.org/packages/44/cb/19ca0da64821d1aefcef65f253aa9ecbdd0dde360f607d0f9b3d95db2b4e/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29", size = 2320400, upload-time = "2026-05-06T13:39:36.29Z" }, - { url = "https://files.pythonhosted.org/packages/cd/14/fe3fbf6e845bf2080dc2f282d75085ddf79d037b35634ecde68f33c217b4/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9", size = 2363318, upload-time = "2026-05-06T13:38:53.039Z" }, - { url = "https://files.pythonhosted.org/packages/62/88/60b110889507a426eecf626f7536566cb290ada71147eff49b6e2724ca62/pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1", size = 1988880, upload-time = "2026-05-06T13:39:16.572Z" }, - { url = "https://files.pythonhosted.org/packages/0b/d6/8ede2f98f17e1e4e127d37be0eced4eee931a511c62cd68af50e1b25bfa9/pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac", size = 2079257, upload-time = "2026-05-06T13:39:38.498Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/6b/8f79692844269427abb3e4dd9e68edfcbe65ae25527d99183214de716c59/pydantic_core-2.46.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6", size = 2076533, upload-time = "2026-08-28T09:57:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d0/c787604c71c2bdcda1a5656942fc822cd0f9cd879b9484bb84fc42172703/pydantic_core-2.46.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615", size = 1924650, upload-time = "2026-08-28T09:57:37.944Z" }, + { url = "https://files.pythonhosted.org/packages/4a/77/ca2f8e997d9bfdb32205297aff38f210f398822d895b1af1b59fd9df9c13/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb", size = 1951261, upload-time = "2026-08-28T09:57:39.339Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/bd12e1a9255df4edee00353778e2614b5346265d51e1567ab72153e803a2/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b", size = 2021808, upload-time = "2026-08-28T09:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/f7f312751ebc6d6767da91964a9c7954c18e226a1720ab234e3dfb9d6c17/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6", size = 2196184, upload-time = "2026-08-28T09:57:42.275Z" }, + { url = "https://files.pythonhosted.org/packages/3d/93/ce93aa030ab6bac4683ba8861e7baad89dd24b02e66b8801a0e4f6a00311/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793", size = 2238212, upload-time = "2026-08-28T09:57:44.122Z" }, + { url = "https://files.pythonhosted.org/packages/34/a1/c8e6b66f499f510752c07a092dfe27621f9c255635e59d38704b5681c35a/pydantic_core-2.46.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b", size = 2064073, upload-time = "2026-08-28T09:57:45.613Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/605e2b127ee30dbf4b1da9da4843587cf2b2d16486c241cc7a5be2d2c1bd/pydantic_core-2.46.5-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461", size = 2093102, upload-time = "2026-08-28T09:57:46.953Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f7/1ab28093c09032ddce7c92c7a55d503b6ecd70f42c32492946c1cb5477b1/pydantic_core-2.46.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736", size = 2133452, upload-time = "2026-08-28T09:57:48.362Z" }, + { url = "https://files.pythonhosted.org/packages/30/c8/47c79b756f12f85e8b0fbdb2b495f6b6eb32e6c98a4beae7a570a0b7c63c/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3", size = 2146477, upload-time = "2026-08-28T09:57:49.74Z" }, + { url = "https://files.pythonhosted.org/packages/13/5c/79fc00cb8f651d6061991de8d7cedf1c78c73cbd4862c42ef418f03b8bfa/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f", size = 2300832, upload-time = "2026-08-28T09:57:51.639Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/dd1a29853cf6d22a1ebd9e3baf0239cbc57d2d16caff36a89e38eb9b1db3/pydantic_core-2.46.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1", size = 2320505, upload-time = "2026-08-28T09:57:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/ba4a8e06a9ddad0b4caf69cfaeecc0fbfcec20473bd808f5127fd16491c4/pydantic_core-2.46.5-cp310-cp310-win32.whl", hash = "sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069", size = 1956853, upload-time = "2026-08-28T09:57:54.592Z" }, + { url = "https://files.pythonhosted.org/packages/f2/94/205ed9d7ddaf44acd489889708ea124a3f41bdb42c141c8684d528ad0e7a/pydantic_core-2.46.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d", size = 2042551, upload-time = "2026-08-28T09:57:56.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/96/cc/4c88abc035cc0d8b2646a715d8c4145fad7d95817eb5f18297066b21e20e/pydantic_core-2.46.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed", size = 2078970, upload-time = "2026-08-28T10:00:18.938Z" }, + { url = "https://files.pythonhosted.org/packages/b4/59/fa3ef009cc1b2ca3753fd6869ee461b0b5b67c420cf659e32a12be027a6a/pydantic_core-2.46.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0", size = 1917185, upload-time = "2026-08-28T10:00:20.891Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/b3d8901f9775ad928077c3155c36f56fc1c813285e3986ed736a5fbf538e/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655", size = 1955266, upload-time = "2026-08-28T10:00:23.135Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9e/5522b09d12e8720013f2e4ac174999f40a05501bd15ad2bfa197bc136198/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a", size = 2023466, upload-time = "2026-08-28T10:00:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/89/2a/a5267bf2c6c7ded3f282b315e5f0cf2c58008c15b917a652fc32f92d6775/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d", size = 2198448, upload-time = "2026-08-28T10:00:27.676Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/f72c192aba23924065946728e4fba96f73939b90e5aa4f7d41e728aea8d4/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8", size = 2240121, upload-time = "2026-08-28T10:00:30.168Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/0c0cc24149429c030bef1a5c1776150e7e61fcbbfc068c6d1f9de90eb259/pydantic_core-2.46.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf", size = 2067262, upload-time = "2026-08-28T10:00:32.285Z" }, + { url = "https://files.pythonhosted.org/packages/1c/00/a2b8690a11d909d9ec9c4eb4d084b4d2e1b227e9b2e74f5926cd39096245/pydantic_core-2.46.5-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464", size = 2095116, upload-time = "2026-08-28T10:00:35.556Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7e/d3088a2717b7bb316d8d0e64a4b0caf994769e88c56df79df547d75c1dc0/pydantic_core-2.46.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64", size = 2134727, upload-time = "2026-08-28T10:00:37.829Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a4/55a9e0ef61cfd1cbf4289059eb68a3eab765fca8ead6f9991d7de027d42e/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168", size = 2147932, upload-time = "2026-08-28T10:00:40.123Z" }, + { url = "https://files.pythonhosted.org/packages/2d/25/d2fbc9d59f91f6c50c0d2ec032041c5e3295d68325ade06ec93fa82da43c/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e", size = 2301528, upload-time = "2026-08-28T10:00:42.339Z" }, + { url = "https://files.pythonhosted.org/packages/9d/76/eccc0528d1421e298b42f85650cf021f0f7c42f502c7e58808db4a672bdb/pydantic_core-2.46.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13", size = 2322431, upload-time = "2026-08-28T10:00:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0c/ffab5a9a0fb82825c44f00dea8ec9d540d2e1e4ab2f1c4f0c32bb8b37fd9/pydantic_core-2.46.5-cp39-cp39-win32.whl", hash = "sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7", size = 1958681, upload-time = "2026-08-28T10:00:46.786Z" }, + { url = "https://files.pythonhosted.org/packages/86/89/8bb47660fed8c16adf1aae301ba149442e8fd220c126bbea2d24b987abb8/pydantic_core-2.46.5-cp39-cp39-win_amd64.whl", hash = "sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0", size = 2046649, upload-time = "2026-08-28T10:00:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, ] [[package]] @@ -2721,15 +2733,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.411" +version = "1.1.414" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, ] [[package]] @@ -3780,27 +3792,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.14" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" }, - { url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" }, - { url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" }, - { url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" }, - { url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" }, - { url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" }, - { url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" }, - { url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" }, - { url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" }, - { url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" }, - { url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" }, +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] [[package]] @@ -4043,11 +4055,11 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From 32239c61b1ef948dc804a345feaa079f7e22afc9 Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:25:25 +0200 Subject: [PATCH 12/13] feat: introduce max_steps parameter to limit agent's tool rounds per message ref ENG-1206 Added `max_steps` to `chat.start` and `chat.send` methods, allowing users to cap the number of tool rounds for individual messages. The parameter accepts integers `>= 1` and the sentinel `SERVER_DEFAULT` to use the server's default (currently 60). Invalid values raise `InvalidParams`. Updated documentation and examples to reflect this new feature. --- CHANGELOG.md | 11 +++++ README.md | 28 +++++++++++ examples/10_max_steps.py | 78 ++++++++++++++++++++++++++++++ examples/README.md | 1 + src/cominty_sdk/__init__.py | 4 ++ src/cominty_sdk/models/__init__.py | 4 ++ src/cominty_sdk/models/chat.py | 37 +++++++++++++- src/cominty_sdk/resources/chat.py | 34 +++++++++++-- tests/unit/test_chat_send.py | 52 +++++++++++++++++++- tests/unit/test_chat_start.py | 42 ++++++++++++++++ 10 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 examples/10_max_steps.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 421817d..98f90a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- `chat.start(..., max_steps=...)` and `chat.send(..., max_steps=...)`: cap the + agent's tool rounds for that one message (integer `>= 1`). The cap is per + message, not per thread: a follow-up that leaves it unset runs with the server + default (60 today, subject to change). Leaving it unset, or passing the new + `SERVER_DEFAULT` sentinel, omits the field from the request. There is no + "unlimited": `None`, integers below 1, floats, bools, and strings raise + `InvalidParams` locally. Reaching the cap is not an error: the reply ends with + `status="success"` and asks whether to continue, with no flag to detect it. + New exports `SERVER_DEFAULT` and the `MaxSteps` alias. See + `examples/10_max_steps.py`. Routines (which also accept `max_steps` server-side) + are not exposed by this SDK. - `Message.agent`: the agent that handled that message (`Agent | Literal["ARCHIVED"] | None`; `"ARCHIVED"` if the original agent has since been deleted). `Message.error_code`: set when the message failed for a known reason (e.g. `"budget_exhausted"`), typed as a plain `str` diff --git a/README.md b/README.md index a73ffe9..8d8c0d3 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,32 @@ reply = await client.chat.send(run.thread.id, agent_id=AGENT_ID, message="Tomorr print(await reply.text()) ``` +### Cap the agent's tool rounds + +`max_steps` limits how many tool rounds (web search, file write, ...) the agent +may run for **one message**. Reaching it is not an error: the agent stops using +tools, recaps, and asks whether to continue, and the message ends with +`status="success"`. Continue with a normal follow-up. + +```python +run = await client.chat.start( + agent_id=AGENT_ID, message="Research X, then write it up.", max_steps=5 +) +await run.text() + +# The cap is per message: pass it again to keep it. Leaving it out runs with +# the server default (60 today). +reply = await client.chat.send( + run.thread.id, agent_id=AGENT_ID, message="Yes, continue.", max_steps=10 +) +print(await reply.text()) +``` + +Leave `max_steps` unset (or pass `SERVER_DEFAULT`) to omit it from the request. +There is no "unlimited": `None` and integers below 1 raise `InvalidParams`. No +field tells you the cap was hit, and the reply wording is model-written, so don't +parse it. See [`examples/10_max_steps.py`](examples/10_max_steps.py). + ### Manage threads `client.threads` is scoped to the client's `user_id` automatically. @@ -248,6 +274,7 @@ Runnable scripts for each scenario live in [`examples/`](examples/): | [`06_manage_thread.py`](examples/06_manage_thread.py) | Get, rename/star, archive | | [`07_custom_agent.py`](examples/07_custom_agent.py) | Call a custom managed agent (your own model + instructions) | | [`08_mcp_linear.py`](examples/08_mcp_linear.py) | Custom agent pulls live context from an MCP server (Linear) | +| [`10_max_steps.py`](examples/10_max_steps.py) | Cap tool rounds per message, then continue past the cap | They render colored, aligned output with [`rich`](https://github.com/Textualize/rich), which ships in the dev extras: @@ -272,6 +299,7 @@ Both `chat.start` and `chat.send` accept: | `document_ids` | `list[str]` | Restrict retrieval to specific documents. | | `disabled_tools` | `list[str]` | Turn tools off: `"web"`, `"company_documents"`, `"mcp:"`, or `"mcp:*"` for all MCP. | | `memory_namespace` | `str` | `start` only: the memory bag for this thread (max 128 chars), frozen for its lifetime. A follow-up cannot change it. | +| `max_steps` | `int` | Cap on tool rounds for **this message** (integer `>= 1`). Per message, not per thread. Unset (or `SERVER_DEFAULT`) means the server default, 60 today. | Invalid values raise `InvalidParams` **before** any request is sent. diff --git a/examples/10_max_steps.py b/examples/10_max_steps.py new file mode 100644 index 0000000..7100051 --- /dev/null +++ b/examples/10_max_steps.py @@ -0,0 +1,78 @@ +"""Cap the agent's tool rounds with ``max_steps``, then continue past the cap. + + python examples/10_max_steps.py + +``max_steps`` limits how many tool rounds (web search, file write, ...) the agent +may run for ONE message. Reaching the cap is not an error: the agent stops using +tools, replies with a short recap, and asks whether to continue. The message ends +with ``status="success"``; no field flags that the cap was hit, and the wording is +written by the model, so never parse the reply text. + +Rules to remember: +- The cap is per message, not per thread. A follow-up that leaves ``max_steps`` + unset runs with the server default (60 today), whatever the first message used. + Pass an integer on every call to keep a custom cap. +- Leaving it unset (or passing ``SERVER_DEFAULT``) omits the field from the + request. There is no "unlimited": ``None`` and integers below 1 raise + ``InvalidParams`` locally. +- It is a rough order of magnitude, not an exact count: the agent can run up to + ``max_steps + 1`` rounds, and each sub-agent gets its own budget. +""" + +from __future__ import annotations + +import asyncio + +import _pretty as pretty +from _shared import AGENT_ID, make_client + +from cominty_sdk import SERVER_DEFAULT, InvalidParams + +TASK = ( + "1. Search the web for what is new in Python 3.13. " + "2. Read the most relevant page. " + "3. Write a short summary of it in your reply." +) + + +async def main() -> None: + async with make_client() as client: + # A deliberately tight cap: the task above needs more than one round. + first = await client.chat.start(agent_id=AGENT_ID, message=TASK, max_steps=1) + pretty.rule("Turn 1: max_steps=1") + pretty.answer(await first.text()) + reply = await first.result() + # Hitting the cap still ends in "success": this is not a failure. + pretty.console.print(f" [dim]status[/] {reply.status.value}") + + # Answer the agent's "do you want me to continue?" with a bigger budget. + # The new message gets its own cap; nothing is inherited from turn 1. + second = await client.chat.send( + first.thread.id, + agent_id=AGENT_ID, + message="Yes, continue.", + max_steps=10, + ) + pretty.rule("Turn 2: max_steps=10") + pretty.answer(await second.text()) + + # Explicit server default: same as omitting the argument. + third = await client.chat.send( + first.thread.id, + agent_id=AGENT_ID, + message="Now give it to me in one sentence.", + max_steps=SERVER_DEFAULT, + ) + pretty.rule("Turn 3: server default") + pretty.answer(await third.text()) + + # Invalid values are rejected before any request is sent. + try: + await client.chat.start(agent_id=AGENT_ID, message="hi", max_steps=0) + except InvalidParams as exc: + pretty.rule("Invalid value") + pretty.console.print(f" [red]{exc}[/]") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md index e2955b3..e69f3d7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,5 +35,6 @@ python examples/01_stream_events.py | [`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 | +| [`10_max_steps.py`](10_max_steps.py) | Cap tool rounds per message with `max_steps`, then continue past the cap | > 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 a377962..43e87e8 100644 --- a/src/cominty_sdk/__init__.py +++ b/src/cominty_sdk/__init__.py @@ -20,11 +20,13 @@ StreamInterrupted, ) from .models.chat import ( + SERVER_DEFAULT, Agent, ContentOrigin, ConversationFile, DisablableTool, HumanMessage, + MaxSteps, Message, MessageRole, MessageStatus, @@ -76,10 +78,12 @@ "ConversationFile", "DisablableTool", "HumanMessage", + "MaxSteps", "Message", "MessageRole", "MessageStatus", "Question", + "SERVER_DEFAULT", "ShareLink", "StartChatOptions", "StartChatParams", diff --git a/src/cominty_sdk/models/__init__.py b/src/cominty_sdk/models/__init__.py index a28370c..39bdb5b 100644 --- a/src/cominty_sdk/models/__init__.py +++ b/src/cominty_sdk/models/__init__.py @@ -1,11 +1,13 @@ from __future__ import annotations from .chat import ( + SERVER_DEFAULT, Agent, ContentOrigin, ConversationFile, DisablableTool, HumanMessage, + MaxSteps, Message, MessageRole, MessageStatus, @@ -25,6 +27,7 @@ "ConversationFile", "DisablableTool", "HumanMessage", + "MaxSteps", "Message", "MessageRole", "MessageStatus", @@ -33,6 +36,7 @@ "MemoryFileSummaryOut", "MemoryFileUpdate", "Question", + "SERVER_DEFAULT", "ShareLink", "StartChatOptions", "StartChatParams", diff --git a/src/cominty_sdk/models/chat.py b/src/cominty_sdk/models/chat.py index 7ce1354..b631c67 100644 --- a/src/cominty_sdk/models/chat.py +++ b/src/cominty_sdk/models/chat.py @@ -6,7 +6,14 @@ from typing import Annotated, Any, Literal, Union from uuid import UUID -from pydantic import AfterValidator, BaseModel, ConfigDict, Field, StringConstraints +from pydantic import ( + AfterValidator, + BaseModel, + ConfigDict, + Field, + StringConstraints, + field_validator, +) from typing_extensions import TypeAlias from .memory import MemoryNamespace @@ -22,6 +29,8 @@ "validate_user_id", "DISABLE_MCP_PREFIX", "DISABLE_ALL_MCP", + "SERVER_DEFAULT", + "MaxSteps", # request models "HumanMessage", "StartChatOptions", @@ -95,6 +104,20 @@ def validate_user_id(value: str) -> str: DISABLE_ALL_MCP = f"{DISABLE_MCP_PREFIX}*" """Wildcard token that disables every connected MCP server at once.""" + +# Single-member enum: the one sentinel idiom pyright narrows with `is`. +# typing_extensions.sentinel is not understood by pyright's bundled typeshed. +class _ServerDefaultType(Enum): + SERVER_DEFAULT = "SERVER_DEFAULT" + + +SERVER_DEFAULT = _ServerDefaultType.SERVER_DEFAULT +"""Default of ``max_steps``: the field is left out of the request and the server +applies its own default (60 today, subject to change).""" + +MaxSteps: TypeAlias = Union[int, Literal[_ServerDefaultType.SERVER_DEFAULT]] +"""A tool-round cap: an integer ``>= 1``, or ``SERVER_DEFAULT`` to omit it.""" + # TODO: include the wildcard * for disable all, can it be with the other literals? DisablableTool: TypeAlias = Union[ Literal["web", "company_documents"], @@ -127,6 +150,18 @@ class StartChatOptions(BaseModel): memory_namespace: MemoryNamespace | None = None """Memory bag for this thread, frozen once the thread starts. Omit to run with no memory tools, unless the agent has its own namespace set.""" + max_steps: int | None = Field(default=None, ge=1) + """Tool-round cap for this message only. Omit to let the server apply its + default. ``None`` is the "omitted" state, never a value: an explicit ``None`` + is rejected, since the API has no "unlimited" and answers ``null`` with a 422.""" + + @field_validator("max_steps", mode="before") + @classmethod + def _reject_explicit_none(cls, value: object) -> object: + # Validators skip defaults, so this only fires on an explicit None. + if value is None: + raise ValueError("expected an integer >= 1, or leave it unset for the server default") + return value class StartChatParams(BaseModel): diff --git a/src/cominty_sdk/resources/chat.py b/src/cominty_sdk/resources/chat.py index e46410e..5c5a114 100644 --- a/src/cominty_sdk/resources/chat.py +++ b/src/cominty_sdk/resources/chat.py @@ -7,7 +7,9 @@ from cominty_sdk.exceptions import InvalidParams, SDKError from cominty_sdk.models.chat import ( + SERVER_DEFAULT, DisablableTool, + MaxSteps, Message, MessageRole, StartChatParams, @@ -37,6 +39,7 @@ async def start( document_ids: list[str] | None = None, disabled_tools: list[DisablableTool] | None = None, memory_namespace: str | None = None, + max_steps: MaxSteps = SERVER_DEFAULT, ) -> StartedChat: """ Start a new thread and return a handle on the assistant reply. @@ -55,6 +58,12 @@ async def start( memory_namespace (str | None): Memory bag for this thread, frozen for its lifetime. Omit to run with no memory tools, unless the agent has its own namespace set. A follow-up cannot change it. + max_steps (MaxSteps): Cap on the agent's tool rounds for this + message, an integer ``>= 1``. Leave it at ``SERVER_DEFAULT`` to let the + server apply its own default (60 today, subject to change). Reaching the + cap is not an error: the reply ends with ``status="success"`` and asks + whether to continue. It is a rough order of magnitude, not an exact + count, and does not limit tokens, cost, or duration. Returns: StartedChat: Handle for the in-progress assistant reply. ``thread`` is set. @@ -71,6 +80,7 @@ async def start( document_ids=document_ids, disabled_tools=disabled_tools, memory_namespace=memory_namespace, + max_steps=max_steps, context="chat.start", ) raw = await self._transport.request("POST", "/chat", json_body=body) @@ -88,6 +98,7 @@ async def send( source_ids: list[int] | None = None, document_ids: list[str] | None = None, disabled_tools: list[DisablableTool] | None = None, + max_steps: MaxSteps = SERVER_DEFAULT, ) -> AssistantRun: """ Send a follow-up in an existing thread. @@ -106,6 +117,12 @@ async def send( source_ids (list[int] | None): Connected source ids attached to the message. document_ids (list[str] | None): Document ids attached to the message. disabled_tools (list[DisablableTool] | None): Tools the agent must not use. + max_steps (MaxSteps): Cap on the agent's tool rounds for this + message, an integer ``>= 1``. The cap is per message, not per thread: + a follow-up that leaves it at ``SERVER_DEFAULT`` runs with the server + default (60 today), whatever earlier messages used. Pass an integer on + every call to keep a custom cap. Typically the answer to a "do you want + me to continue?" reply. Returns: AssistantRun: Handle for the new assistant reply. @@ -122,6 +139,7 @@ async def send( document_ids=document_ids, disabled_tools=disabled_tools, memory_namespace=None, + max_steps=max_steps, context="chat.send", ) raw = await self._transport.request("POST", f"/chat/{thread_id}", json_body=body) @@ -153,11 +171,21 @@ def _build_body( document_ids: list[str] | None, disabled_tools: list[DisablableTool] | None, memory_namespace: str | None, + max_steps: MaxSteps, context: str, ) -> dict[str, object]: # Validate the whole request in one pass so error locations are rooted # consistently, then surface a clean SDK error instead of raw pydantic. # user_id is sourced from the client, not the caller. + options: dict[str, object] = { + "agent_id": agent_id, + "user_id": self._user_id, + "memory_namespace": memory_namespace, + } + # The sentinel leaves the key out so the server default applies. An + # explicit None is passed through on purpose: the model rejects it. + if max_steps is not SERVER_DEFAULT: + options["max_steps"] = max_steps try: params = StartChatParams.model_validate( { @@ -169,11 +197,7 @@ def _build_body( "document_ids": document_ids, "disabled_tools": disabled_tools, }, - "options": { - "agent_id": agent_id, - "user_id": self._user_id, - "memory_namespace": memory_namespace, - }, + "options": options, } ) except ValidationError as exc: diff --git a/tests/unit/test_chat_send.py b/tests/unit/test_chat_send.py index dc0f283..35a1585 100644 --- a/tests/unit/test_chat_send.py +++ b/tests/unit/test_chat_send.py @@ -9,7 +9,7 @@ import pytest import respx -from cominty_sdk import AssistantRun, AsyncCominty, InvalidParams +from cominty_sdk import SERVER_DEFAULT, AssistantRun, AsyncCominty, InvalidParams # Mirror the canonical ids in conftest (tests/ is not an importable package). THREAD_ID = "11111111-1111-1111-1111-111111111111" @@ -70,6 +70,56 @@ async def test_accepts_uuid_thread_id( assert route.called +async def test_sends_max_steps_in_options_when_provided( + client: AsyncCominty, mock_api: respx.MockRouter, make_message: MakeMessage +) -> None: + route = mock_api.post(f"/chat/{THREAD_ID}").mock( + return_value=httpx.Response( + 200, json=make_message(id=ASSISTANT_MSG_ID, role="assistant", live=True) + ) + ) + + await client.chat.send(THREAD_ID, message="yes, continue", agent_id="agt_1", max_steps=10) + + assert json.loads(route.calls.last.request.content)["options"]["max_steps"] == 10 + + +@pytest.mark.parametrize("explicit", [False, True]) +async def test_omits_max_steps_for_the_server_default( + client: AsyncCominty, mock_api: respx.MockRouter, make_message: MakeMessage, explicit: bool +) -> None: + route = mock_api.post(f"/chat/{THREAD_ID}").mock( + return_value=httpx.Response( + 200, json=make_message(id=ASSISTANT_MSG_ID, role="assistant", live=True) + ) + ) + + if explicit: + await client.chat.send(THREAD_ID, message="hi", agent_id="agt_1", max_steps=SERVER_DEFAULT) + else: + await client.chat.send(THREAD_ID, message="hi", agent_id="agt_1") + + assert "max_steps" not in json.loads(route.calls.last.request.content)["options"] + + +@pytest.mark.parametrize("bad", [None, 0, -1, 2.5, True, "5"]) +async def test_invalid_max_steps_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter, bad: object +) -> None: + route = mock_api.post(f"/chat/{THREAD_ID}") + + with pytest.raises(InvalidParams) as exc: + await client.chat.send( + THREAD_ID, + message="hi", + agent_id="agt_1", + max_steps=bad, # type: ignore[arg-type] + ) + + assert exc.value.errors[0]["param"] == "max_steps" + assert not route.called + + async def test_validation_fires_before_request( client: AsyncCominty, mock_api: respx.MockRouter ) -> None: diff --git a/tests/unit/test_chat_start.py b/tests/unit/test_chat_start.py index 7204347..c6f6e2d 100644 --- a/tests/unit/test_chat_start.py +++ b/tests/unit/test_chat_start.py @@ -10,6 +10,7 @@ import respx from cominty_sdk import ( + SERVER_DEFAULT, Agent, AsyncCominty, AuthError, @@ -166,6 +167,47 @@ async def test_memory_namespace_too_long_raises_invalid_params( assert not route.called +async def test_sends_max_steps_in_options_when_provided( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + await client.chat.start(agent_id="agt_1", message="hi", max_steps=5) + + assert json.loads(route.calls.last.request.content)["options"]["max_steps"] == 5 + + +@pytest.mark.parametrize("explicit", [False, True]) +async def test_omits_max_steps_for_the_server_default( + client: AsyncCominty, mock_api: respx.MockRouter, make_thread: MakeThread, explicit: bool +) -> None: + route = mock_api.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) + + if explicit: + await client.chat.start(agent_id="agt_1", message="hi", max_steps=SERVER_DEFAULT) + else: + await client.chat.start(agent_id="agt_1", message="hi") + + assert "max_steps" not in json.loads(route.calls.last.request.content)["options"] + + +@pytest.mark.parametrize("bad", [None, 0, -1, 2.5, True, "5"]) +async def test_invalid_max_steps_raises_invalid_params( + client: AsyncCominty, mock_api: respx.MockRouter, bad: object +) -> None: + route = mock_api.post("/chat") + + with pytest.raises(InvalidParams) as exc: + await client.chat.start( + agent_id="agt_1", + message="hi", + max_steps=bad, # type: ignore[arg-type] + ) + + assert exc.value.errors[0]["param"] == "max_steps" + assert not route.called + + async def test_uses_configured_base_url(make_thread: MakeThread) -> None: with respx.mock(base_url="https://sandbox.test", assert_all_called=False) as router: route = router.post("/chat").mock(return_value=httpx.Response(200, json=make_thread())) From dd1e63b00f0c8f20768c448324d53a72b7429aef Mon Sep 17 00:00:00 2001 From: Elfo <170177218+elfominty@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:54:54 +0200 Subject: [PATCH 13/13] chore(release): prepare version 0.5.0 Bump the version to 0.5.0 in pyproject.toml and uv.lock. Split the CHANGELOG per release (0.2.0 to 0.5.0) from the tag history, and point .env.example and examples/README.md at the staging base URL https://ds-staging.cominty.com. --- .env.example | 4 ++-- CHANGELOG.md | 59 ++++++++++++++++++++++++++++++++++++++++++---- examples/README.md | 2 +- pyproject.toml | 2 +- uv.lock | 55 +++++++++++++++++++++++++++++++----------- 5 files changed, 100 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 1396f10..ba4dd2e 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,8 @@ COMINTY_USER_ID= # --- Optional ------------------------------------------------------------ # Override the API base URL (default: https://ds.cominty.com). -# dev -> https://ds-dev.cominty.com -# COMINTY_BASE_URL=https://ds-dev.cominty.com +# staging -> https://ds-staging.cominty.com +# COMINTY_BASE_URL=https://ds-staging.cominty.com # Used only by the example scripts (the SDK itself has no agent default). # Find agent ids at https://platform.cominty.ai/agents diff --git a/CHANGELOG.md b/CHANGELOG.md index 98f90a2..dd9cce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-10-07 + ### Added - `chat.start(..., max_steps=...)` and `chat.send(..., max_steps=...)`: cap the agent's tool rounds for that one message (integer `>= 1`). The cap is per @@ -72,11 +74,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `.coverage*`), not just build artifacts. ### Removed -- `client.memory`'s methods no longer send `user_id`: memory files are - scoped by `namespace`, not by the end user. - `ThreadSummary.agent` (and `Thread.agent`, since `Thread` extends it): the API no longer returns an agent on the thread itself. Use `Message.agent` on each message instead. +## [0.4.0] - 2026-06-30 + +### Added +- `client.threads`: `list()`, `get()`, `update()` and `archive()` to manage + existing threads. New `UpdateThreadParams` export. +- `chat.send(thread_id, message=..., agent_id=...)`: send a follow-up message + in an existing thread, for example to answer an agent's question. +- `StartedChat.questions()` / `AssistantRun.questions()`: the clarifying + questions the agent is asking, if any. +- `AsyncCominty(user_id=...)` and `client.user_id`. The end-user id can also be + set with `COMINTY_USER_ID`. +- `RateLimitError.scope` (`"organization"`, `"user"` or `"concurrency"`) and + `RateLimitError.retry_after`, with a clearer error message saying which limit + was hit. +- Runnable scripts in `examples/` (streaming, follow-ups, threads, custom agent, + MCP). + +### Changed +- **Breaking:** `user_id` is now required on the client (argument or + `COMINTY_USER_ID`) and is no longer a parameter of `chat.start()`. +- Fixed `RateLimitError` handling of 429 responses and adjusted response models + to the shapes the API actually returns. + +## [0.3.0] - 2026-06-29 + +### Changed +- **Breaking:** v1 redesign with a new module layout. `client.chat.start()` + returns a `StartedChat` handle and `client.chat.stream()` an `AssistantRun`, + both streamable. Added the `events` module and the typed exception hierarchy + (`InvalidParams`, `StreamInterrupted`, and so on). +- **Breaking:** the minimum Python version is lowered from 3.11 to 3.9. +- Toolchain moved to pyright (instead of mypy), `invoke` tasks, and the + `AGENTS.md` coding guide. + +### Removed +- The `agents`, `files`, `messages`, `usage` and `api_tokens` resources of the + 0.1/0.2 client, as well as the retry helper. + +### CI +- PyPI release workflow (Trusted Publishing) and `invoke` build/publish tasks. + +## [0.2.0] - 2026-06-19 + +### Added +- `agents.create()` (`POST /agents`) and `agents.update()` (`PUT /agents/{id}`) + to manage org agents with an API key, no Clerk session required. + ## [0.1.1] - 2026-06-18 ### Added @@ -92,7 +139,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > `__version__` was already sourced from package metadata as of 0.1.1. That > was inaccurate: the SDK still hard-coded the version via > `src/cominty_sdk/_version.py` at the time. The actual switch to -> `importlib.metadata` ships under [Unreleased] above. +> `importlib.metadata` ships in 0.5.0 above. ### Docs - Setup now points to [platform.cominty.ai](https://platform.cominty.ai) for @@ -104,6 +151,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial async client `AsyncCominty` covering threads, chat, messages (send/stream/export/cancel), files (upload/download), usage, and API tokens. -[Unreleased]: https://github.com/cominty/python-sdk/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/cominty/python-sdk/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/cominty/python-sdk/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/cominty/python-sdk/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/cominty/python-sdk/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/cominty/python-sdk/compare/v0.1.1...v0.2.0 [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 \ No newline at end of file diff --git a/examples/README.md b/examples/README.md index e69f3d7..f13f5a1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ export COMINTY_API_KEY="" # platform.cominty.ai -> API keys export COMINTY_USER_ID="user_..." # platform.cominty.ai -> Profile # optional: export COMINTY_AGENT_ID="__cominty_agents::agent.chat" # else the default is used -export COMINTY_BASE_URL="https://ds-dev.cominty.com" # else production +export COMINTY_BASE_URL="https://ds-staging.cominty.com" # staging, else production ``` Then run any script from the repo root: diff --git a/pyproject.toml b/pyproject.toml index 406e8f1..e11727d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cominty-sdk" -version = "0.4.0" +version = "0.5.0" description = "Official async Python client for the Cominty managed agent chat API" readme = "README.md" requires-python = ">=3.9" diff --git a/uv.lock b/uv.lock index e09e9a2..f52c35a 100644 --- a/uv.lock +++ b/uv.lock @@ -495,7 +495,7 @@ wheels = [ [[package]] name = "cominty-sdk" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } dependencies = [ { name = "eval-type-backport", marker = "python_full_version < '3.10'" }, @@ -521,7 +521,8 @@ dev = [ { name = "respx" }, { name = "rich" }, { name = "ruff" }, - { name = "twine" }, + { name = "twine", version = "6.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "twine", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] [package.metadata] @@ -2373,11 +2374,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -4033,26 +4034,52 @@ wheels = [ name = "twine" version = "6.2.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version > '3.9' and python_full_version < '3.10'", + "python_full_version <= '3.9'", +] dependencies = [ - { name = "id" }, + { name = "id", marker = "python_full_version < '3.10'" }, { name = "importlib-metadata", version = "8.7.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, - { name = "packaging" }, + { name = "keyring", marker = "python_full_version < '3.10' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging", marker = "python_full_version < '3.10'" }, { name = "readme-renderer", version = "44.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "readme-renderer", version = "45.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "requests-toolbelt" }, - { name = "rfc3986" }, - { name = "rich" }, + { name = "requests-toolbelt", marker = "python_full_version < '3.10'" }, + { name = "rfc3986", marker = "python_full_version < '3.10'" }, + { name = "rich", marker = "python_full_version < '3.10'" }, { name = "urllib3", version = "2.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, ] +[[package]] +name = "twine" +version = "7.0.0" +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.*'", +] +dependencies = [ + { name = "id", marker = "python_full_version >= '3.10'" }, + { name = "keyring", marker = "python_full_version >= '3.10' and platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "readme-renderer", version = "45.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests", version = "2.34.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests-toolbelt", marker = "python_full_version >= '3.10'" }, + { name = "rfc3986", marker = "python_full_version >= '3.10'" }, + { name = "rich", marker = "python_full_version >= '3.10'" }, + { name = "urllib3", version = "2.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"