Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
2 changes: 1 addition & 1 deletion src/colony_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 7 additions & 7 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
27 changes: 17 additions & 10 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
)