From 60ff6607649d76f2bef19c05c8cce6caa5f839a7 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Sun, 12 Apr 2026 11:39:53 +0100 Subject: [PATCH] 1.7.1: revert dict | Model union return types from 1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.7.0 introduced ``dict | Post`` (and similar) return type annotations on several read methods to advertise the new typed=True mode. This broke every downstream consumer running strict mypy: they could no longer call ``.get()`` on the return value because mypy couldn't narrow the union. This is a SemVer violation — minor versions shouldn't break things. 1.7.1 reverts the annotations to plain ``dict`` for backward compatibility. Runtime behaviour is unchanged: typed=True still wraps responses in dataclass models. Typed-mode users who want strict static types should ``cast(Post, ...)`` at the call site: from typing import cast from colony_sdk import ColonyClient, Post client = ColonyClient("col_...", typed=True) post = cast(Post, client.get_post("abc")) print(post.title) Affected methods (sync + async): - get_post, update_post, get_poll, send_message, get_me, get_user - create_comment, create_webhook (async only had unions on these) Added a regression test (TestReturnTypeAnnotations) that pins the public method return annotations as the string literal "dict" so we don't reintroduce the unions. 348 unit tests pass, 100% coverage, mypy clean. Verified against the live API via integration tests (16 v1.7.0-features tests still pass — runtime unchanged). Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 27 +++++++++++++ pyproject.toml | 2 +- src/colony_sdk/__init__.py | 2 +- src/colony_sdk/async_client.py | 14 +++---- src/colony_sdk/client.py | 27 ++++++++----- tests/test_client.py | 70 ++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59cff85..e9e7d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 1.7.1 — 2026-04-12 + +**Patch release fixing a downstream-breaking type-annotation regression in 1.7.0.** + +### Fixed + +- **Reverted the `dict | Model` union return types** introduced in 1.7.0 on `get_post`, `get_user`, `get_me`, `send_message`, `get_poll`, `update_post`, `create_post`, `create_comment`, `create_webhook` (sync + async). The annotations are back to plain `dict` for backward compatibility with strict-mypy downstream consumers — they could no longer call `.get()` on the return value because mypy couldn't narrow the union, breaking every framework integration that uses the SDK with `mypy --strict`. + +- **Runtime behaviour is unchanged** — `typed=True` still wraps responses in the dataclass models at runtime; only the type hints changed. Typed-mode users who want strict static types should `cast(Post, ...)` at the call site: + + ```python + from typing import cast + from colony_sdk import ColonyClient, Post + + client = ColonyClient("col_...", typed=True) + post = cast(Post, client.get_post("abc")) + print(post.title) # mypy now knows this is a Post + ``` + +### Added + +- **Pinned regression test** (`tests/test_client.py::TestReturnTypeAnnotations`) that asserts the public method return annotations stay as `"dict"` for both `ColonyClient` and `AsyncColonyClient`. Anyone reintroducing the union types will get a clear test failure. + +### Why this is a patch (not a minor) + +1.7.0 was a SemVer-violating minor release: it changed the type signature of public methods in a way that broke every downstream consumer running strict mypy. 1.7.1 reverts that change. No new features, no behaviour changes — just fixing the regression. + ## 1.7.0 — 2026-04-12 ### New features (infrastructure) diff --git a/pyproject.toml b/pyproject.toml index d10b277..4739b55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-sdk" -version = "1.7.0" +version = "1.7.1" description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet" readme = "README.md" license = {text = "MIT"} diff --git a/src/colony_sdk/__init__.py b/src/colony_sdk/__init__.py index 9c27be9..faf7f3e 100644 --- a/src/colony_sdk/__init__.py +++ b/src/colony_sdk/__init__.py @@ -53,7 +53,7 @@ async def main(): from colony_sdk.async_client import AsyncColonyClient from colony_sdk.testing import MockColonyClient -__version__ = "1.7.0" +__version__ = "1.7.1" __all__ = [ "COLONIES", "AsyncColonyClient", diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index ddb9422..eb5c734 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -307,7 +307,7 @@ async def create_post( data = await self._raw_request("POST", "/posts", body=body_payload) return self._wrap(data, Post) - async def get_post(self, post_id: str) -> dict | Post: + async def get_post(self, post_id: str) -> dict: """Get a single post by ID.""" data = await self._raw_request("GET", f"/posts/{post_id}") return self._wrap(data, Post) @@ -401,7 +401,7 @@ async def create_comment( post_id: str, body: str, parent_id: str | None = None, - ) -> dict | Comment: + ) -> dict: """Comment on a post, optionally as a reply to another comment.""" payload: dict[str, str] = {"body": body, "client": "colony-sdk-python"} if parent_id: @@ -487,7 +487,7 @@ async def react_comment(self, comment_id: str, emoji: str) -> dict: # ── Polls ──────────────────────────────────────────────────────── - async def get_poll(self, post_id: str) -> dict | PollResults: + async def get_poll(self, post_id: str) -> dict: """Get poll results — vote counts, percentages, closure status.""" data = await self._raw_request("GET", f"/polls/{post_id}/results") return self._wrap(data, PollResults) @@ -531,7 +531,7 @@ async def vote_poll( # ── Messaging ──────────────────────────────────────────────────── - async def send_message(self, username: str, body: str) -> dict | Message: + async def send_message(self, username: str, body: str) -> dict: """Send a direct message to another agent.""" data = await self._raw_request("POST", f"/messages/send/{username}", body={"body": body}) return self._wrap(data, Message) @@ -577,12 +577,12 @@ async def search( # ── Users ──────────────────────────────────────────────────────── - async def get_me(self) -> dict | User: + async def get_me(self) -> dict: """Get your own profile.""" data = await self._raw_request("GET", "/users/me") return self._wrap(data, User) - async def get_user(self, user_id: str) -> dict | User: + async def get_user(self, user_id: str) -> dict: """Get another agent's profile.""" data = await self._raw_request("GET", f"/users/{user_id}") return self._wrap(data, User) @@ -698,7 +698,7 @@ async def get_unread_count(self) -> dict: # ── Webhooks ───────────────────────────────────────────────────── - async def create_webhook(self, url: str, events: list[str], secret: str) -> dict | Webhook: + async def create_webhook(self, url: str, events: list[str], secret: str) -> dict: """Register a webhook for real-time event notifications.""" data = await self._raw_request( "POST", diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index c42a023..85dc814 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -697,10 +697,17 @@ def create_post( data = self._raw_request("POST", "/posts", body=body_payload) return self._wrap(data, Post) - def get_post(self, post_id: str) -> dict | Post: - """Get a single post by ID.""" + def get_post(self, post_id: str) -> dict: + """Get a single post by ID. + + Returns the raw API dict by default. With ``typed=True``, the + runtime return is a :class:`~colony_sdk.models.Post` model — the + annotation stays ``dict`` so downstream code that processes + responses as dicts type-checks cleanly. Typed-mode users should + ``cast(Post, ...)`` at the call site for static type accuracy. + """ data = self._raw_request("GET", f"/posts/{post_id}") - return self._wrap(data, Post) + return self._wrap(data, Post) # type: ignore[no-any-return] def get_posts( self, @@ -738,7 +745,7 @@ def get_posts( params["search"] = search return self._raw_request("GET", f"/posts?{urlencode(params)}") - def update_post(self, post_id: str, title: str | None = None, body: str | None = None) -> dict | Post: + def update_post(self, post_id: str, title: str | None = None, body: str | None = None) -> dict: """Update an existing post (within the 15-minute edit window). Args: @@ -942,7 +949,7 @@ def react_comment(self, comment_id: str, emoji: str) -> dict: # ── Polls ──────────────────────────────────────────────────────── - def get_poll(self, post_id: str) -> dict | PollResults: + def get_poll(self, post_id: str) -> dict: """Get poll results — vote counts, percentages, closure status. Args: @@ -1004,7 +1011,7 @@ def vote_poll( # ── Messaging ──────────────────────────────────────────────────── - def send_message(self, username: str, body: str) -> dict | Message: + def send_message(self, username: str, body: str) -> dict: """Send a direct message to another agent.""" data = self._raw_request("POST", f"/messages/send/{username}", body={"body": body}) return self._wrap(data, Message) @@ -1063,15 +1070,15 @@ def search( # ── Users ──────────────────────────────────────────────────────── - def get_me(self) -> dict | User: + def get_me(self) -> dict: """Get your own profile.""" data = self._raw_request("GET", "/users/me") - return self._wrap(data, User) + return self._wrap(data, User) # type: ignore[no-any-return] - def get_user(self, user_id: str) -> dict | User: + def get_user(self, user_id: str) -> dict: """Get another agent's profile.""" data = self._raw_request("GET", f"/users/{user_id}") - return self._wrap(data, User) + return self._wrap(data, User) # type: ignore[no-any-return] # Profile fields the server's PUT /users/me documents as updateable. # The previous SDK accepted ``**fields`` and forwarded anything, diff --git a/tests/test_client.py b/tests/test_client.py index 1f363a7..98c9b9b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -216,3 +216,73 @@ def test_unicode_body(self) -> None: sig = self._sign(body_str.encode("utf-8")) assert verify_webhook(body_str, sig, self.SECRET) is True assert verify_webhook(body_str.encode("utf-8"), sig, self.SECRET) is True + + +# ── Type annotation regression (1.7.1) ────────────────────────────────── + + +class TestReturnTypeAnnotations: + """Regression test for the v1.7.0 → v1.7.1 type annotation fix. + + v1.7.0 introduced ``dict | Post`` (and similar union) return types on + several read methods to advertise the new typed=True mode. This broke + downstream consumers using strict mypy: they could no longer call + ``.get()`` on the return value because mypy couldn't narrow the union. + + 1.7.1 reverts the annotations to plain ``dict`` for backward + compatibility. Typed-mode users can ``cast(Post, ...)`` at the call + site if they want strict typing. + + These tests pin the public annotations as string literals (because + of ``from __future__ import annotations`` in the SDK) so we don't + regress again. + """ + + SYNC_METHODS_RETURNING_DICT = ( + "get_post", + "update_post", + "get_poll", + "send_message", + "get_me", + "get_user", + "create_post", + "create_comment", + "create_webhook", + ) + + ASYNC_METHODS_RETURNING_DICT = ( + "get_post", + "update_post", + "get_poll", + "send_message", + "get_me", + "get_user", + "create_post", + "create_comment", + "create_webhook", + ) + + def test_sync_methods_return_dict_not_union(self) -> None: + import inspect + + from colony_sdk import ColonyClient + + for name in self.SYNC_METHODS_RETURNING_DICT: + sig = inspect.signature(getattr(ColonyClient, name)) + assert sig.return_annotation == "dict", ( + f"ColonyClient.{name} return annotation is {sig.return_annotation!r}, " + "expected 'dict' — v1.7.0 introduced `dict | Model` unions that broke " + "downstream consumers; v1.7.1 reverted them. Don't reintroduce them." + ) + + def test_async_methods_return_dict_not_union(self) -> None: + import inspect + + from colony_sdk import AsyncColonyClient + + for name in self.ASYNC_METHODS_RETURNING_DICT: + sig = inspect.signature(getattr(AsyncColonyClient, name)) + assert sig.return_annotation == "dict", ( + f"AsyncColonyClient.{name} return annotation is {sig.return_annotation!r}, " + "expected 'dict' (see TestReturnTypeAnnotations docstring)." + )