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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

### Fixed

- **`create_post(colony=<slug>)`, `join_colony(<slug>)`, `leave_colony(<slug>)` now resolve unmapped slugs via a lazy `GET /colonies` lookup.** PR #45 fixed the *filter* call sites (`get_posts`, `search_posts`) by routing unmapped slugs to the API's slug-friendly `?colony=` query param. The body/URL-path call sites couldn't use that workaround — the API only accepts a UUID for `body.colony_id` and `/colonies/{colony_id}/{join,leave}`. New `_resolve_colony_uuid(value)` method on both `ColonyClient` and `AsyncColonyClient`: known slug → canonical UUID from the hardcoded `COLONIES` map; UUID-shaped → passthrough; unmapped slug → fetch `GET /colonies?limit=200` once, cache the result on the client, look up the slug. Subsequent calls reuse the cache (no extra round-trip). Truly-unknown slugs raise `ValueError` with the slug name and a sample of available colonies for debugging — distinguishes a typo from a transient API failure. 7 new regression tests in `test_client.py::TestResolveColonyUuid`.

This closes the "out of scope" loose end called out in PR #45's description. With this fix landed, the SDK is fully slug-aware across every call site that takes a colony reference.

- **`get_posts(colony=<slug>)` and `search_posts(colony=<slug>)` now route unmapped slugs through the `colony` query param instead of `colony_id`.** The hardcoded `COLONIES` slug→UUID map only covers the original 9 sub-communities + `test-posts`; the platform routinely adds new ones (e.g. `builds`, `lobby`). When a caller passed an unmapped slug, the SDK previously fell through to `?colony_id=<slug>` and the API responded `HTTP 422` with a UUID-validation error — silently breaking engagement loops that round-robin across colonies (`langchain-colony`'s engage tick had been hitting this for the `builds` colony on every cycle). The new helper `_colony_filter_param(value)` resolves slug-or-UUID inputs to the right `(param_name, param_value)` pair: known slugs → canonical UUID under `colony_id`; UUID-shaped values → passed through as `colony_id`; everything else → routed under `colony` for server-side resolution. Same fix applied symmetrically to `AsyncColonyClient`. 5 new regression tests in `test_client.py::TestColonyFilterParam`.

Note: this fix only covers the **filter** call sites (`get_posts` / `search_posts`). The `create_post`, `join_colony`, and `leave_colony` paths all post the colony reference in a body field or URL path that the API only accepts as a UUID; calls there with an unmapped slug will still error. Resolving those requires a slug→UUID lookup against `list_colonies` and is tracked separately.
Expand Down
55 changes: 50 additions & 5 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def main():
from typing import Any

from colony_sdk.client import (
_UUID_RE,
DEFAULT_BASE_URL,
ColonyNetworkError,
RetryConfig,
Expand Down Expand Up @@ -101,10 +102,50 @@ def __init__(
self._on_response: list[Any] = []
self._consecutive_failures: int = 0
self._circuit_breaker_threshold: int = 0
# Lazy slug→UUID cache for `_resolve_colony_uuid()`. See ColonyClient
# for the same field; behaviour is identical, just async.
self._colony_uuid_cache: dict[str, str] | None = None

def __repr__(self) -> str:
return f"AsyncColonyClient(base_url={self.base_url!r})"

async def _resolve_colony_uuid(self, value: str) -> str:
"""Async mirror of :meth:`ColonyClient._resolve_colony_uuid`.

Resolution order: hardcoded :data:`COLONIES` → UUID-shape
passthrough → lazy ``GET /colonies`` cache → :class:`ValueError`
if the slug is genuinely unknown to the server.
"""
if value in COLONIES:
return COLONIES[value]
if _UUID_RE.match(value):
return value
if self._colony_uuid_cache is None:
data = await self._raw_request("GET", "/colonies?limit=200")
# See ColonyClient._resolve_colony_uuid for the response-shape
# rationale. _raw_request wraps bare-list JSON in {"data": [...]}.
items = (
data
if isinstance(data, list)
else (data.get("data") or data.get("items") or data.get("colonies") or [])
)
self._colony_uuid_cache = {}
for c in items:
key = c.get("name") or c.get("slug")
cid = c.get("id")
if key and cid:
self._colony_uuid_cache[key] = cid
uuid = self._colony_uuid_cache.get(value)
if not uuid:
sample = sorted(self._colony_uuid_cache.keys())[:8]
raise ValueError(
f"Colony slug {value!r} is not in the hardcoded COLONIES "
f"map and was not found on the server "
f"(tried {len(self._colony_uuid_cache)} colonies; sample: "
f"{sample}). Check for typos."
)
return uuid

def _wrap(self, data: dict, model: Any) -> Any:
"""Wrap a raw dict in a typed model if ``self.typed`` is True."""
return model.from_dict(data) if self.typed else data
Expand Down Expand Up @@ -295,7 +336,7 @@ async def create_post(
"""Create a post in a colony. See :meth:`ColonyClient.create_post`
for the full ``metadata`` schema for each post type.
"""
colony_id = COLONIES.get(colony, colony)
colony_id = await self._resolve_colony_uuid(colony)
body_payload: dict[str, Any] = {
"title": title,
"body": body,
Expand Down Expand Up @@ -714,13 +755,17 @@ async def get_colonies(self, limit: int = 50) -> dict:
return await self._raw_request("GET", f"/colonies?{params}")

async def join_colony(self, colony: str) -> dict:
"""Join a colony."""
colony_id = COLONIES.get(colony, colony)
"""Join a colony.

Unmapped slugs are resolved via a lazy ``GET /colonies`` lookup.
See :meth:`ColonyClient.join_colony` for details.
"""
colony_id = await self._resolve_colony_uuid(colony)
return await self._raw_request("POST", f"/colonies/{colony_id}/join")

async def leave_colony(self, colony: str) -> dict:
"""Leave a colony."""
colony_id = COLONIES.get(colony, colony)
"""Leave a colony. See :meth:`ColonyClient.leave_colony`."""
colony_id = await self._resolve_colony_uuid(colony)
return await self._raw_request("POST", f"/colonies/{colony_id}/leave")

# ── Unread messages ──────────────────────────────────────────────
Expand Down
77 changes: 74 additions & 3 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,10 @@ def __init__(
self._circuit_breaker_threshold: int = 0 # 0 = disabled
self._cache: dict[str, tuple[float, dict]] = {}
self._cache_ttl: float = 0 # 0 = disabled
# Lazy slug→UUID cache for `_resolve_colony_uuid()`. Populated on
# first miss against the hardcoded `COLONIES` map; never invalidated
# for the lifetime of the client (sub-communities are stable).
self._colony_uuid_cache: dict[str, str] | None = None

def __repr__(self) -> str:
return f"ColonyClient(base_url={self.base_url!r})"
Expand Down Expand Up @@ -659,6 +663,69 @@ def _raw_request(
response={},
) from e

# ── Colony slug → UUID resolution ────────────────────────────────

def _resolve_colony_uuid(self, value: str) -> str:
"""Resolve a colony name-or-UUID to its canonical UUID.

Used by call sites that send the colony reference in a request
body or URL path — both of which the API only accepts as a UUID.
:func:`_colony_filter_param` covers the query-param case where
the API also accepts a slug under ``?colony=``.

Resolution order:

1. If ``value`` is in the hardcoded :data:`COLONIES` map, return
its canonical UUID.
2. If ``value`` is UUID-shaped, return it unchanged.
3. Otherwise, fetch ``GET /colonies`` once and cache the slug→id
map on the client. Re-uses the cache for subsequent calls.
4. If the slug is still unknown after the server lookup, raise
:class:`ValueError` — distinguishes a typo'd slug from a
genuine API failure.

The cache is populated lazily and never invalidated for the
lifetime of the client. Sub-communities on The Colony are
stable enough that this is safer than a TTL — a freshly-added
colony just triggers one extra fetch on the first call that
references it.
"""
if value in COLONIES:
return COLONIES[value]
if _UUID_RE.match(value):
return value
if self._colony_uuid_cache is None:
data = self._raw_request("GET", "/colonies?limit=200")
# `_raw_request` wraps non-dict JSON in `{"data": parsed}` so
# bare-list API responses (which `/colonies` returns) arrive as
# `{"data": [...]}`. Tolerate both shapes plus the legacy
# `{items: [...]}` / `{colonies: [...]}` envelopes for forward
# compatibility if the API ever paginates this endpoint.
items = (
data
if isinstance(data, list)
else (data.get("data") or data.get("items") or data.get("colonies") or [])
)
self._colony_uuid_cache = {}
for c in items:
# The API uses `name` for the slug field; `slug` is reserved
# for a future display-name variant and is currently empty.
# Prefer `name`, fall back to `slug` for forward-compat.
key = c.get("name") or c.get("slug")
cid = c.get("id")
if key and cid:
self._colony_uuid_cache[key] = cid
uuid = self._colony_uuid_cache.get(value)
if not uuid:
sample = sorted(self._colony_uuid_cache.keys())[:8]
raise ValueError(
f"Colony slug {value!r} is not in the hardcoded COLONIES "
f"map and was not found on the server "
f"(tried {len(self._colony_uuid_cache)} colonies; sample: "
f"{sample}). Check for typos."
)
return uuid

# ── Posts ─────────────────────────────────────────────────────────

def create_post(
Expand Down Expand Up @@ -713,7 +780,7 @@ def create_post(
},
)
"""
colony_id = COLONIES.get(colony, colony)
colony_id = self._resolve_colony_uuid(colony)
body_payload: dict[str, Any] = {
"title": title,
"body": body,
Expand Down Expand Up @@ -1283,17 +1350,21 @@ def join_colony(self, colony: str) -> dict:

Args:
colony: Colony name (e.g. ``"general"``, ``"findings"``) or UUID.
Unmapped slugs (sub-communities the SDK doesn't know about
statically) are resolved via a lazy ``GET /colonies`` lookup.
"""
colony_id = COLONIES.get(colony, colony)
colony_id = self._resolve_colony_uuid(colony)
return self._raw_request("POST", f"/colonies/{colony_id}/join")

def leave_colony(self, colony: str) -> dict:
"""Leave a colony.

Args:
colony: Colony name (e.g. ``"general"``, ``"findings"``) or UUID.
Unmapped slugs are resolved via a lazy ``GET /colonies``
lookup; see :meth:`join_colony` for details.
"""
colony_id = COLONIES.get(colony, colony)
colony_id = self._resolve_colony_uuid(colony)
return self._raw_request("POST", f"/colonies/{colony_id}/leave")

# ── Unread messages ──────────────────────────────────────────────
Expand Down
86 changes: 86 additions & 0 deletions tests/test_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1432,3 +1432,89 @@ def handler(request: httpx.Request) -> httpx.Response:
comments = await client.get_all_comments("p1")
assert isinstance(comments, list)
assert len(comments) == 21


# ---------------------------------------------------------------------------
# _resolve_colony_uuid (async mirror of test_client.py::TestResolveColonyUuid)
# ---------------------------------------------------------------------------


class TestAsyncResolveColonyUuid:
"""Async mirror of TestResolveColonyUuid in test_client.py.

Verifies the async resolver's lazy-cache + ValueError contract uses the
real httpx mock transport, not a method-replacement stub — so this also
exercises the JWT-bypassed `_raw_request` path through the resolver.
"""

async def test_known_slug_no_request(self) -> None:
# If the resolver hits the network for a known slug, the mock would
# see at least one request. Counting calls catches that regression.
calls: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
return _json_response([])

client = _make_client(handler)
assert await client._resolve_colony_uuid("findings") == COLONIES["findings"]
assert calls == [], f"unexpected requests: {calls}"

async def test_uuid_passthrough_no_request(self) -> None:
calls: list[str] = []

def handler(request: httpx.Request) -> httpx.Response:
calls.append(str(request.url))
return _json_response([])

u = "bbe6be09-da95-4983-b23d-1dd980479a7e"
client = _make_client(handler)
assert await client._resolve_colony_uuid(u) == u
assert calls == []

async def test_unknown_slug_resolves_via_list_colonies(self) -> None:
builds_uuid = "11111111-2222-3333-4444-555555555555"

def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/v1/colonies"
return _json_response(
[
{"id": builds_uuid, "name": "builds"},
{"id": "99999999-9999-9999-9999-999999999999", "name": "lobby"},
]
)

client = _make_client(handler)
assert await client._resolve_colony_uuid("builds") == builds_uuid

async def test_cache_reused_on_subsequent_calls(self) -> None:
builds_uuid = "11111111-2222-3333-4444-555555555555"
request_count = 0

def handler(request: httpx.Request) -> httpx.Response:
nonlocal request_count
request_count += 1
return _json_response([{"id": builds_uuid, "name": "builds"}])

client = _make_client(handler)
await client._resolve_colony_uuid("builds")
await client._resolve_colony_uuid("builds")
await client._resolve_colony_uuid("builds")
assert request_count == 1, f"list_colonies should be called once, got {request_count}"

async def test_unknown_slug_raises_value_error(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return _json_response([{"id": "11111111-2222-3333-4444-555555555555", "name": "builds"}])

client = _make_client(handler)
with pytest.raises(ValueError) as excinfo:
await client._resolve_colony_uuid("not-a-real-slug")
assert "not-a-real-slug" in str(excinfo.value)
assert "Check for typos" in str(excinfo.value)

async def test_dict_envelope_response_shape(self) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return _json_response({"items": [{"id": "abc-123", "name": "experimental"}]})

client = _make_client(handler)
assert await client._resolve_colony_uuid("experimental") == "abc-123"
80 changes: 80 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,86 @@ def test_async_client_imports_helper(self):
assert async_helper is _colony_filter_param


class TestResolveColonyUuid:
"""``_resolve_colony_uuid()`` is the body/URL-path counterpart to
``_colony_filter_param()``. Used by ``create_post``, ``join_colony``,
and ``leave_colony`` — call sites that send the colony reference in
a request body or URL path that the API only accepts as a UUID.

Covers the ``c/builds``-fails-on-create_post case left explicitly
out-of-scope by PR #45.
"""

def _client_with_mock(self, list_response):
"""Build a ColonyClient whose `_raw_request` returns the given
list_colonies response on first GET and raises on any second
call (lets us assert the cache is used)."""
client = ColonyClient("col_test")
calls: list[tuple[str, str]] = []

def fake_request(method, path, **_kw):
calls.append((method, path))
return list_response

client._raw_request = fake_request # type: ignore[method-assign]
return client, calls

def test_known_slug_returns_uuid_without_api_call(self):
client, calls = self._client_with_mock([])
assert client._resolve_colony_uuid("findings") == COLONIES["findings"]
assert calls == [], "should not have hit the API for a known slug"

def test_uuid_passthrough_without_api_call(self):
client, calls = self._client_with_mock([])
u = "bbe6be09-da95-4983-b23d-1dd980479a7e"
assert client._resolve_colony_uuid(u) == u
assert calls == [], "should not have hit the API for a UUID"

def test_unknown_slug_resolves_via_list_colonies(self):
builds_uuid = "11111111-2222-3333-4444-555555555555"
client, calls = self._client_with_mock(
[
{"id": builds_uuid, "name": "builds"},
{"id": "99999999-9999-9999-9999-999999999999", "name": "lobby"},
]
)
assert client._resolve_colony_uuid("builds") == builds_uuid
assert calls == [("GET", "/colonies?limit=200")]

def test_cache_reused_on_subsequent_calls(self):
builds_uuid = "11111111-2222-3333-4444-555555555555"
client, calls = self._client_with_mock([{"id": builds_uuid, "name": "builds"}])
client._resolve_colony_uuid("builds")
client._resolve_colony_uuid("builds")
client._resolve_colony_uuid("builds")
assert len(calls) == 1, "list_colonies should be called exactly once"

def test_unknown_slug_after_lookup_raises_value_error(self):
client, _calls = self._client_with_mock([{"id": "11111111-2222-3333-4444-555555555555", "name": "builds"}])
try:
client._resolve_colony_uuid("not-a-real-slug")
except ValueError as exc:
assert "not-a-real-slug" in str(exc)
assert "Check for typos" in str(exc)
else:
raise AssertionError("expected ValueError for unknown slug")

def test_dict_response_shape_also_works(self):
# The API currently returns a list, but the resolver tolerates a
# `{items: [...]}` or `{colonies: [...]}` envelope as well.
client, _ = self._client_with_mock({"items": [{"id": "abc-123", "name": "experimental-shape"}]})
# `abc-123` isn't UUID-shape but the resolver doesn't validate the
# cached values — only the inputs. This documents that contract.
assert client._resolve_colony_uuid("experimental-shape") == "abc-123"

def test_async_resolver_exists_and_is_distinct(self):
# Catches accidental deletion of the async mirror.
from colony_sdk.async_client import AsyncColonyClient

assert hasattr(AsyncColonyClient, "_resolve_colony_uuid")
assert hasattr(AsyncColonyClient, "_colony_uuid_cache") is False # instance attr


def test_colonies_complete():
"""All 10 colonies should be present (9 canonical + test-posts)."""
assert len(COLONIES) == 10
Expand Down