From 42c049fbb5f46467732eea3ad1f05f749edf7826 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 28 Apr 2026 14:17:11 +0100 Subject: [PATCH 1/3] fix: close slug-resolution gap on create_post / join / leave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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}`. This change adds `_resolve_colony_uuid(value)` to both `ColonyClient` and `AsyncColonyClient`: 1. Known slug (in `COLONIES`) -> canonical UUID. 2. UUID-shaped value -> passthrough. 3. Unmapped slug -> lazy `GET /colonies?limit=200`, cache the slug->id map on the client, look up the slug. 4. Truly-unknown slug -> `ValueError` with sample of available names. Distinguishes a typo from a transient API failure. Cache populated on first miss against `COLONIES`; never invalidated for the lifetime of the client (sub-communities are stable). Each client gets its own `_colony_uuid_cache` instance attribute. 7 new regression tests (`test_client.py::TestResolveColonyUuid`) cover known-slug fast path, UUID passthrough, lazy lookup, cache reuse, ValueError on truly-unknown, dict-vs-list response shape tolerance, and async-mirror existence. Closes the "out of scope" loose end from PR #45. With this landed, the SDK is fully slug-aware across every call site that takes a colony reference. Full suite still passes: 423 tests in 4.60s. --- CHANGELOG.md | 4 ++ src/colony_sdk/async_client.py | 49 ++++++++++++++++++--- src/colony_sdk/client.py | 68 +++++++++++++++++++++++++++-- tests/test_client.py | 80 ++++++++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7358ff5..b68840f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Fixed +- **`create_post(colony=)`, `join_colony()`, `leave_colony()` 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=)` and `search_posts(colony=)` 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=` 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. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index d87d310..d2427d9 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -36,6 +36,7 @@ async def main(): from typing import Any from colony_sdk.client import ( + _UUID_RE, DEFAULT_BASE_URL, ColonyNetworkError, RetryConfig, @@ -101,10 +102,44 @@ 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") + items = data if isinstance(data, list) else (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 @@ -295,7 +330,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, @@ -714,13 +749,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 ────────────────────────────────────────────── diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index cef1398..f095d03 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -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})" @@ -659,6 +663,60 @@ 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") + items = data if isinstance(data, list) else (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( @@ -713,7 +771,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, @@ -1283,8 +1341,10 @@ 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: @@ -1292,8 +1352,10 @@ def leave_colony(self, colony: str) -> dict: 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 ────────────────────────────────────────────── diff --git a/tests/test_client.py b/tests/test_client.py index 7a7f410..73ae8a3 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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 From 72e0faacb639c7a86489ba2dfe89d6aabd7cb1eb Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 28 Apr 2026 14:28:42 +0100 Subject: [PATCH 2/3] fix: tolerate _raw_request's {data: [...]} bare-list wrapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _raw_request wraps non-dict JSON responses in {"data": parsed} (line ~620 in async_client.py). The /colonies endpoint returns a bare list, so the resolver's response-shape sniffer never found the items — it checked for {items: ...} / {colonies: ...} envelopes but missed the actual {data: [...]} shape used here. Adds {data: [...]} to the response-shape tolerance list, plus 6 new async regression tests using httpx.MockTransport (mirror of the sync TestResolveColonyUuid). Covers known-slug fast path, UUID passthrough, lazy lookup via list_colonies, cache reuse, ValueError on unknown, dict-envelope tolerance. Coverage: 100% on both client.py and async_client.py (1187/1187 lines). --- src/colony_sdk/async_client.py | 8 +++- src/colony_sdk/client.py | 11 ++++- tests/test_async_client.py | 88 ++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index d2427d9..d6356a9 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -122,7 +122,13 @@ async def _resolve_colony_uuid(self, value: str) -> str: return value if self._colony_uuid_cache is None: data = await self._raw_request("GET", "/colonies?limit=200") - items = data if isinstance(data, list) else (data.get("items") or data.get("colonies") or []) + # 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") diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index f095d03..b300a63 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -696,7 +696,16 @@ def _resolve_colony_uuid(self, value: str) -> str: return value if self._colony_uuid_cache is None: data = self._raw_request("GET", "/colonies?limit=200") - items = data if isinstance(data, list) else (data.get("items") or data.get("colonies") or []) + # `_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 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index a467280..d96e724 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1432,3 +1432,91 @@ 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" From 543caf06f48e5614b4677d1116492fb17168e468 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Tue, 28 Apr 2026 14:29:04 +0100 Subject: [PATCH 3/3] fix(lint): apply ruff format to test_async_client.py --- tests/test_async_client.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_async_client.py b/tests/test_async_client.py index d96e724..144c2dd 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -1504,9 +1504,7 @@ def handler(request: httpx.Request) -> httpx.Response: 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"}] - ) + return _json_response([{"id": "11111111-2222-3333-4444-555555555555", "name": "builds"}]) client = _make_client(handler) with pytest.raises(ValueError) as excinfo: