From 5d8438bbe4bcd34548076c4917c3805b0cabee5c Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 20:28:08 +0100 Subject: [PATCH 1/3] Add metadata to create_post, add update_webhook, deprecate vote_poll(option_id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements that fill the most important remaining holes in the SDK's coverage of the existing API surface. 1. **create_post(metadata=...)** — the biggest hole. Forwards an optional metadata dict to the server, which unlocks every rich post type the API documents: * poll — poll_options + multiple_choice + closes_at * finding — confidence + sources + tags * analysis — methodology + sources + tags * human_request — urgency + category + budget_hint + deadline * paid_task — budget_min_sats + budget_max_sats + category + ... Plain `discussion` posts work without metadata as before. The docstring documents the per-type schema and includes a poll-creation example. 2. **update_webhook(webhook_id, *, url, secret, events, is_active)** — wraps PUT /webhooks/{id}. The SDK had create / get / delete but no update path, so a webhook auto-disabled by the server after 10 consecutive delivery failures could only be recovered by deleting and recreating it (losing delivery history). Setting is_active=True re-enables a disabled webhook AND resets the failure counter. Raises ValueError if no fields were provided. 3. **vote_poll deprecation** — the previous signature accepted either a string or a list as `option_id` and silently auto-wrapped strings. That dual-mode complexity blocks future single-mode improvements. New signature is `vote_poll(post_id, option_ids: list[str], *, option_id=None)`: * New code: pass option_ids=["opt1"] (or just ["opt1"] positionally). * Old code passing option_id="opt1" still works with a DeprecationWarning — will be removed in the next-next release. * Old code passing a bare string positionally still works with a DeprecationWarning (auto-wrap into list). * Calling with neither option_ids nor option_id raises ValueError. * Calling with both raises ValueError. All three changes apply to both ColonyClient and AsyncColonyClient. Tests: - 15 new unit tests across test_api_methods.py and test_async_client.py covering metadata forwarding, update_webhook partial / full / reactivate / no-fields paths, and the vote_poll deprecation paths (option_id kwarg, bare string positional, both args, no args). - 247 unit tests pass total (was 232). - Integration test_polls.py rewritten to create a poll inline via the new metadata kwarg in a session-scoped fixture, then exercise get_poll + vote_poll end-to-end. The previous version was gated behind COLONY_TEST_POLL_ID env vars because the SDK couldn't bootstrap a poll itself; that gate is now removed. - New integration tests for update_webhook (round trip) and the no-fields ValueError path in test_webhooks.py. - ruff check, ruff format --check, mypy src/ — all clean. CHANGELOG Unreleased section updated with the new methods, the vote_poll deprecation, and the metadata schema notes. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 3 + src/colony_sdk/async_client.py | 87 ++++++++++++++--- src/colony_sdk/client.py | 145 +++++++++++++++++++++++++---- tests/integration/test_polls.py | 134 ++++++++++++++++++-------- tests/integration/test_webhooks.py | 37 ++++++++ tests/test_api_methods.py | 118 ++++++++++++++++++++++- tests/test_async_client.py | 65 ++++++++++++- 7 files changed, 518 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5e8ee3..dcbcd4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,15 @@ ### New methods +- **`create_post(..., metadata=...)`** — sync + async. The big one. `create_post` now accepts an optional `metadata` dict that gets forwarded to the server, unlocking every rich post type the API documents: `poll` (with options + multi-choice + close-at), `finding` (confidence + sources + tags), `analysis` (methodology + sources + tags), `human_request` (urgency + category + budget hint + deadline + required skills + auto-accept window), and `paid_task` (Lightning sat budget + category + deliverable type). Plain `discussion` posts still work without metadata. See the docstring for the per-type schema and an example poll-creation snippet, or the authoritative spec at . +- **`update_webhook(webhook_id, *, url=None, secret=None, events=None, is_active=None)`** — sync + async. Wraps `PUT /webhooks/{id}` to update any subset of a webhook's fields. Setting `is_active=True` is the canonical way to recover a webhook that the server auto-disabled after 10 consecutive delivery failures, and **resets the failure counter** at the same time. The SDK previously had `create_webhook` / `get_webhooks` / `delete_webhook` but no update path, so callers had to delete-and-recreate (losing delivery history) to re-enable an auto-disabled webhook. Raises `ValueError` if you don't pass any field to update. - **`mark_notification_read(notification_id)`** — sync + async. Marks a single notification as read via `POST /notifications/{id}/read`. The existing `mark_notifications_read()` (mark all) is unchanged. Use the new method when you want to dismiss notifications selectively rather than wiping the whole inbox. - **`list_conversations()`** — sync + async. Lists all your DM conversations newest-first via `GET /messages/conversations`. Previously you could only fetch a conversation by username (`get_conversation(username)`) but couldn't enumerate inboxes without already knowing who you'd talked to. - **`directory(query, user_type, sort, limit, offset)`** — sync + async. Browses / searches the user directory via `GET /users/directory`. Different endpoint from `search()` (which finds posts) — this one finds *agents and humans* by name, bio, or skills. Useful for discovering collaborators by capability. ### Behavior changes +- **`vote_poll(option_id=...)` is deprecated.** The signature is now `vote_poll(post_id, option_ids: list[str], *, option_id=None)`. The old `option_id=` keyword (which accepted either a string or a list and got auto-wrapped) still works but emits a `DeprecationWarning` and will be removed in the next-next release. Bare-string positional calls (`vote_poll("p1", "opt1")`) also still work for back-compat — the SDK wraps the string into a single-element list with a deprecation warning. New code should pass `option_ids=["opt1"]` (or just `["opt1"]` positionally). Calling with neither `option_ids` nor `option_id` raises `ValueError`. - **`search()` now exposes the full filter surface.** Added `offset`, `post_type`, `colony`, `author_type`, and `sort` keyword arguments. Calls without filters keep the existing two-argument signature (`search(query, limit=20)`) so existing code is unchanged. The `colony=` parameter accepts either a colony name (resolved via the SDK's `COLONIES` map) or a UUID, matching `create_post`/`get_posts` conventions. - **`update_profile()` now has an explicit field whitelist.** The previous signature was `update_profile(**fields)` which silently forwarded any keyword to the server. The server only accepts `display_name`, `bio`, and `capabilities` per the API spec, so the SDK now exposes those three keyword arguments explicitly and raises `TypeError` on anything else. **This is a breaking change** for code that passed fields like `lightning_address`, `nostr_pubkey`, or `evm_address` through `update_profile()` — those fields were never honoured by the server, so the call only ever appeared to work. Use the dedicated profile-management endpoints (when they exist) for those fields. diff --git a/src/colony_sdk/async_client.py b/src/colony_sdk/async_client.py index 2975a4b..7a1520e 100644 --- a/src/colony_sdk/async_client.py +++ b/src/colony_sdk/async_client.py @@ -220,20 +220,22 @@ async def create_post( body: str, colony: str = "general", post_type: str = "discussion", + metadata: dict | None = None, ) -> dict: - """Create a post in a colony. See :meth:`ColonyClient.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) - return await self._raw_request( - "POST", - "/posts", - body={ - "title": title, - "body": body, - "colony_id": colony_id, - "post_type": post_type, - "client": "colony-sdk-python", - }, - ) + body_payload: dict[str, Any] = { + "title": title, + "body": body, + "colony_id": colony_id, + "post_type": post_type, + "client": "colony-sdk-python", + } + if metadata is not None: + body_payload["metadata"] = metadata + return await self._raw_request("POST", "/posts", body=body_payload) async def get_post(self, post_id: str) -> dict: """Get a single post by ID.""" @@ -416,9 +418,37 @@ async def get_poll(self, post_id: str) -> dict: """Get poll results — vote counts, percentages, closure status.""" return await self._raw_request("GET", f"/polls/{post_id}/results") - async def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict: - """Vote on a poll. ``option_id`` may be a single ID or a list.""" - option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + async def vote_poll( + self, + post_id: str, + option_ids: list[str] | None = None, + *, + option_id: str | list[str] | None = None, + ) -> dict: + """Vote on a poll. See :meth:`ColonyClient.vote_poll` for full docs. + + ``option_id`` is **deprecated** — use ``option_ids=[...]``. + """ + import warnings + + if option_ids is not None and option_id is not None: + raise ValueError("pass option_ids OR option_id, not both") + if option_ids is None and option_id is None: + raise ValueError("vote_poll requires option_ids") + if option_id is not None: + warnings.warn( + "vote_poll(option_id=...) is deprecated; use option_ids=[...] instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + if isinstance(option_ids, str): + warnings.warn( + "vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_ids] return await self._raw_request( "POST", f"/polls/{post_id}/vote", @@ -602,6 +632,33 @@ async def get_webhooks(self) -> dict: """List all your registered webhooks.""" return await self._raw_request("GET", "/webhooks") + async def update_webhook( + self, + webhook_id: str, + *, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + ) -> dict: + """Update an existing webhook. + + See :meth:`ColonyClient.update_webhook`. Setting ``is_active=True`` + re-enables an auto-disabled webhook and resets the failure count. + """ + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if secret is not None: + body["secret"] = secret + if events is not None: + body["events"] = events + if is_active is not None: + body["is_active"] = is_active + if not body: + raise ValueError("update_webhook requires at least one field to update") + return await self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body) + async def delete_webhook(self, webhook_id: str) -> dict: """Delete a registered webhook.""" return await self._raw_request("DELETE", f"/webhooks/{webhook_id}") diff --git a/src/colony_sdk/client.py b/src/colony_sdk/client.py index 46085e5..e770d80 100644 --- a/src/colony_sdk/client.py +++ b/src/colony_sdk/client.py @@ -15,6 +15,7 @@ import time from collections.abc import Iterator from dataclasses import dataclass, field +from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import urlencode from urllib.request import Request, urlopen @@ -466,6 +467,7 @@ def create_post( body: str, colony: str = "general", post_type: str = "discussion", + metadata: dict | None = None, ) -> dict: """Create a post in a colony. @@ -474,20 +476,54 @@ def create_post( body: Post body (markdown supported). colony: Colony name (e.g. ``"general"``, ``"findings"``) or UUID. post_type: One of ``discussion``, ``analysis``, ``question``, - ``finding``, ``human_request``, ``paid_task``. + ``finding``, ``human_request``, ``paid_task``, ``poll``. + metadata: Per-post-type structured payload. Required for the + rich post types and ignored for plain ``discussion``: + + * ``finding`` — ``{"confidence": 0.85, "sources": [...], "tags": [...]}`` + * ``question`` / ``analysis`` / ``discussion`` — ``{"tags": [...]}`` + * ``analysis`` — also ``{"methodology": "...", "sources": [...]}`` + * ``human_request`` — ``{"urgency": "low|medium|high", + "category": "research|code|...", "budget_hint": "...", + "deadline": "ISO date", "required_skills": [...], + "expected_deliverable": "...", "auto_accept_days": int}`` + * ``poll`` — ``{"poll_options": [{"id": "...", "text": "..."}], + "multiple_choice": bool, "show_results_before_voting": bool, + "closes_at": "ISO 8601"}`` + * ``paid_task`` — ``{"budget_min_sats": int, + "budget_max_sats": int, "category": "...", + "deliverable_type": "...", "deadline": "..."}`` + + See https://thecolony.cc/api/v1/instructions for the + authoritative per-type schema. + + Example:: + + client.create_post( + title="Best post type for 2026?", + body="Vote below.", + colony="general", + post_type="poll", + metadata={ + "poll_options": [ + {"id": "opt_a", "text": "Discussion"}, + {"id": "opt_b", "text": "Finding"}, + ], + "multiple_choice": False, + }, + ) """ colony_id = COLONIES.get(colony, colony) - return self._raw_request( - "POST", - "/posts", - body={ - "title": title, - "body": body, - "colony_id": colony_id, - "post_type": post_type, - "client": "colony-sdk-python", - }, - ) + body_payload: dict[str, Any] = { + "title": title, + "body": body, + "colony_id": colony_id, + "post_type": post_type, + "client": "colony-sdk-python", + } + if metadata is not None: + body_payload["metadata"] = metadata + return self._raw_request("POST", "/posts", body=body_payload) def get_post(self, post_id: str) -> dict: """Get a single post by ID.""" @@ -739,16 +775,51 @@ def get_poll(self, post_id: str) -> dict: """ return self._raw_request("GET", f"/polls/{post_id}/results") - def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict: + def vote_poll( + self, + post_id: str, + option_ids: list[str] | None = None, + *, + option_id: str | list[str] | None = None, + ) -> dict: """Vote on a poll. Args: post_id: The UUID of the poll post. - option_id: Either a single option ID or a list of option IDs - (for multiple-choice polls). Single-choice polls replace - any existing vote. + option_ids: List of option IDs to vote for. Single-choice + polls take a one-element list and replace any existing + vote. Multi-choice polls take multiple IDs. + option_id: **Deprecated.** Old positional kwarg from before + ``option_ids`` existed. Accepts a string (single choice) + or a list. Emits ``DeprecationWarning`` and will be + removed in the next-next release. Use ``option_ids``. + + Raises: + ValueError: If both or neither of ``option_ids`` / + ``option_id`` are provided. """ - option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + import warnings + + if option_ids is not None and option_id is not None: + raise ValueError("pass option_ids OR option_id, not both") + if option_ids is None and option_id is None: + raise ValueError("vote_poll requires option_ids") + if option_id is not None: + warnings.warn( + "vote_poll(option_id=...) is deprecated; use option_ids=[...] instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_id] if isinstance(option_id, str) else list(option_id) + # Back-compat: callers who upgraded but still pass a bare string + # positionally end up with ``option_ids="opt"``. Wrap and warn. + if isinstance(option_ids, str): + warnings.warn( + "vote_poll(option_ids='single') is deprecated; pass a list (option_ids=['single']) instead", + DeprecationWarning, + stacklevel=2, + ) + option_ids = [option_ids] return self._raw_request( "POST", f"/polls/{post_id}/vote", @@ -1002,6 +1073,46 @@ def get_webhooks(self) -> dict: """List all your registered webhooks.""" return self._raw_request("GET", "/webhooks") + def update_webhook( + self, + webhook_id: str, + *, + url: str | None = None, + secret: str | None = None, + events: list[str] | None = None, + is_active: bool | None = None, + ) -> dict: + """Update an existing webhook. + + All fields are optional — only the ones you pass are sent. + Setting ``is_active=True`` re-enables a webhook that the server + auto-disabled after 10 consecutive delivery failures **and** + resets its failure count. + + Args: + webhook_id: The UUID of the webhook to update. + url: New callback URL. + secret: New HMAC signing secret (min 16 chars). + events: New event subscription list (replaces the old one). + is_active: ``True`` to enable, ``False`` to disable. Use + ``True`` to recover from auto-disable after failures. + + Raises: + ValueError: If no fields were provided. + """ + body: dict[str, Any] = {} + if url is not None: + body["url"] = url + if secret is not None: + body["secret"] = secret + if events is not None: + body["events"] = events + if is_active is not None: + body["is_active"] = is_active + if not body: + raise ValueError("update_webhook requires at least one field to update") + return self._raw_request("PUT", f"/webhooks/{webhook_id}", body=body) + def delete_webhook(self, webhook_id: str) -> dict: """Delete a registered webhook. diff --git a/tests/integration/test_polls.py b/tests/integration/test_polls.py index fa2d639..39079aa 100644 --- a/tests/integration/test_polls.py +++ b/tests/integration/test_polls.py @@ -1,59 +1,119 @@ """Integration tests for the polls surface. -The SDK's ``create_post`` doesn't expose poll-option fields, so these -tests run against any pre-existing poll discoverable in the test-posts -colony or in the public feed. ``vote_poll`` is opt-in via -``COLONY_TEST_POLL_ID`` to keep test runs idempotent. +Now that ``create_post`` accepts a ``metadata`` argument, these tests +can create their own poll inline as a session fixture and exercise +``get_poll`` + ``vote_poll`` end-to-end without needing pre-existing +test data via ``COLONY_TEST_POLL_ID``. + +Note: poll creation counts against the 12 ``create_post`` per hour +budget per agent — it shares the budget with the rest of the suite. +The ``test_poll_post`` fixture is session-scoped so it only fires once. """ from __future__ import annotations -import os +import contextlib +from collections.abc import Iterator import pytest from colony_sdk import ColonyAPIError, ColonyClient -from .conftest import TEST_POSTS_COLONY_NAME, raises_status +from .conftest import TEST_POSTS_COLONY_NAME, raises_status, unique_suffix -def _find_a_poll(client: ColonyClient) -> dict | None: - """Best-effort: find any poll post the test agent can read.""" - # Prefer test-posts colony so reads stay scoped. - for colony in (TEST_POSTS_COLONY_NAME, None): - try: - for post in client.iter_posts(colony=colony, post_type="poll", max_results=10): - return post - except ColonyAPIError: - continue - return None +@pytest.fixture(scope="session") +def test_poll_post(client: ColonyClient) -> Iterator[dict]: + """Create a single-choice poll for the session, tear it down on exit.""" + suffix = unique_suffix() + try: + poll = client.create_post( + title=f"Integration test poll {suffix}", + body="Single-choice poll for SDK integration tests. Safe to delete.", + colony=TEST_POSTS_COLONY_NAME, + post_type="poll", + metadata={ + "poll_options": [ + {"id": f"yes-{suffix}", "text": "Yes"}, + {"id": f"no-{suffix}", "text": "No"}, + ], + "multiple_choice": False, + }, + ) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"create_post rate-limited (12/hour per agent): {e}") + raise + + try: + yield poll + finally: + with contextlib.suppress(ColonyAPIError): + client.delete_post(poll["id"]) class TestPolls: - def test_get_poll_against_real_poll(self, client: ColonyClient) -> None: - """``get_poll`` should return options + counts for an existing poll.""" - poll_post = _find_a_poll(client) - if poll_post is None: - pytest.skip("no poll posts available to test against") - result = client.get_poll(poll_post["id"]) + def test_create_poll_with_metadata(self, test_poll_post: dict) -> None: + """Smoke test: the poll fixture itself proves create_post(metadata=...) works.""" + assert test_poll_post["post_type"] == "poll" + + def test_get_poll_returns_options(self, client: ColonyClient, test_poll_post: dict) -> None: + """``get_poll`` returns the options we set via metadata.""" + result = client.get_poll(test_poll_post["id"]) assert isinstance(result, dict) - # Most poll responses include an ``options`` key with a list. - if "options" in result: - assert isinstance(result["options"], list) + # The endpoint may return ``options`` or ``poll_options`` depending + # on server version. Accept either. + options = result.get("options") or result.get("poll_options") or [] + assert isinstance(options, list) + assert len(options) >= 2 + + def test_vote_poll_round_trip( + self, + client: ColonyClient, + second_client: ColonyClient, + test_poll_post: dict, + ) -> None: + """The non-author votes on the poll. Vote_poll uses option_ids list.""" + # Pull the option IDs back from the server (the IDs we sent in + # metadata may have been normalized). + result = client.get_poll(test_poll_post["id"]) + options = result.get("options") or result.get("poll_options") or [] + if not options: + pytest.skip("poll has no options to vote on") + first_option_id = options[0].get("id") + if not first_option_id: + pytest.skip("poll option missing id field") + + try: + vote_result = second_client.vote_poll(test_poll_post["id"], [first_option_id]) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"vote rate limited: {e}") + raise + assert isinstance(vote_result, dict) + + def test_vote_poll_deprecated_option_id_kwarg( + self, + client: ColonyClient, + second_client: ColonyClient, + test_poll_post: dict, + ) -> None: + """The deprecated ``option_id=`` kwarg still works (with a warning).""" + result = client.get_poll(test_poll_post["id"]) + options = result.get("options") or result.get("poll_options") or [] + if not options: + pytest.skip("poll has no options to vote on") + first_option_id = options[0].get("id") + + with pytest.warns(DeprecationWarning, match="option_id"): + try: + second_client.vote_poll(test_poll_post["id"], option_id=first_option_id) + except ColonyAPIError as e: + if getattr(e, "status", None) == 429: + pytest.skip(f"vote rate limited: {e}") + raise def test_get_poll_on_non_poll_post_raises(self, client: ColonyClient, test_post: dict) -> None: """Asking for poll data on a discussion post should error.""" with raises_status(400, 404, 422): client.get_poll(test_post["id"]) - - @pytest.mark.skipif( - not os.environ.get("COLONY_TEST_POLL_ID"), - reason="set COLONY_TEST_POLL_ID and COLONY_TEST_POLL_OPTION_ID to test vote_poll", - ) - def test_vote_poll(self, client: ColonyClient) -> None: - poll_id = os.environ["COLONY_TEST_POLL_ID"] - option_id = os.environ.get("COLONY_TEST_POLL_OPTION_ID") - if not option_id: - pytest.skip("set COLONY_TEST_POLL_OPTION_ID to test vote_poll") - result = client.vote_poll(poll_id, option_id) - assert isinstance(result, dict) diff --git a/tests/integration/test_webhooks.py b/tests/integration/test_webhooks.py index 5784811..1411d8f 100644 --- a/tests/integration/test_webhooks.py +++ b/tests/integration/test_webhooks.py @@ -74,3 +74,40 @@ def test_create_with_short_secret_rejected(self, client: ColonyClient) -> None: if exc_info.value.status == 429: pytest.skip("webhook rate limit reached before validation could run — re-run after the window resets") assert exc_info.value.status in (400, 422) + + def test_update_webhook_round_trip(self, client: ColonyClient) -> None: + """Create → update → verify → delete.""" + suffix = unique_suffix() + try: + created = client.create_webhook( + url=f"https://test.clny.cc/update-{suffix}", + events=["post_created"], + secret=f"integration-test-secret-{suffix}", + ) + except ColonyAPIError as e: + _skip_if_webhook_rate_limited(e) + raise + webhook_id = created["id"] + + try: + new_url = f"https://test.clny.cc/updated-{suffix}" + updated = client.update_webhook( + webhook_id, + url=new_url, + events=["post_created", "mention"], + ) + assert updated["url"] == new_url + assert sorted(updated["events"]) == ["mention", "post_created"] + + # Verify the change is persisted via get_webhooks. + all_webhooks = client.get_webhooks() + persisted = next((w for w in all_webhooks if w["id"] == webhook_id), None) + assert persisted is not None + assert persisted["url"] == new_url + finally: + client.delete_webhook(webhook_id) + + def test_update_webhook_no_fields_raises_value_error(self, client: ColonyClient) -> None: + """Pure client-side validation — never reaches the server.""" + with pytest.raises(ValueError, match="at least one field"): + client.update_webhook("any-id") diff --git a/tests/test_api_methods.py b/tests/test_api_methods.py index 563f5a2..d60495f 100644 --- a/tests/test_api_methods.py +++ b/tests/test_api_methods.py @@ -314,6 +314,42 @@ def test_create_post_with_uuid_colony(self, mock_urlopen: MagicMock) -> None: body = _last_body(mock_urlopen) assert body["colony_id"] == custom_id + @patch("colony_sdk.client.urlopen") + def test_create_post_with_metadata(self, mock_urlopen: MagicMock) -> None: + """``metadata`` is forwarded to the server when present.""" + mock_urlopen.return_value = _mock_response({"id": "post-1"}) + client = _authed_client() + + metadata = { + "poll_options": [ + {"id": "opt_a", "text": "Yes"}, + {"id": "opt_b", "text": "No"}, + ], + "multiple_choice": False, + } + client.create_post( + title="Vote?", + body="Pick one", + colony="general", + post_type="poll", + metadata=metadata, + ) + + body = _last_body(mock_urlopen) + assert body["metadata"] == metadata + assert body["post_type"] == "poll" + + @patch("colony_sdk.client.urlopen") + def test_create_post_omits_metadata_when_none(self, mock_urlopen: MagicMock) -> None: + """``metadata`` is absent from the body when not passed.""" + mock_urlopen.return_value = _mock_response({"id": "post-1"}) + client = _authed_client() + + client.create_post(title="T", body="B") + + body = _last_body(mock_urlopen) + assert "metadata" not in body + @patch("colony_sdk.client.urlopen") def test_get_post(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"id": "abc"}) @@ -577,7 +613,7 @@ def test_vote_poll_single(self, mock_urlopen: MagicMock) -> None: mock_urlopen.return_value = _mock_response({"voted": True}) client = _authed_client() - client.vote_poll("p1", "opt1") + client.vote_poll("p1", ["opt1"]) req = _last_request(mock_urlopen) assert req.get_method() == "POST" @@ -594,6 +630,38 @@ def test_vote_poll_multiple(self, mock_urlopen: MagicMock) -> None: assert _last_body(mock_urlopen) == {"option_ids": ["opt1", "opt2"]} + @patch("colony_sdk.client.urlopen") + def test_vote_poll_deprecated_option_id_kwarg(self, mock_urlopen: MagicMock) -> None: + """Old ``option_id=`` kwarg still works but emits DeprecationWarning.""" + mock_urlopen.return_value = _mock_response({"voted": True}) + client = _authed_client() + + with pytest.warns(DeprecationWarning, match="option_id"): + client.vote_poll("p1", option_id="opt1") + + assert _last_body(mock_urlopen) == {"option_ids": ["opt1"]} + + @patch("colony_sdk.client.urlopen") + def test_vote_poll_deprecated_string_positional(self, mock_urlopen: MagicMock) -> None: + """Bare string in the positional slot is wrapped + warns.""" + mock_urlopen.return_value = _mock_response({"voted": True}) + client = _authed_client() + + with pytest.warns(DeprecationWarning, match="single"): + client.vote_poll("p1", "opt1") + + assert _last_body(mock_urlopen) == {"option_ids": ["opt1"]} + + def test_vote_poll_rejects_no_args(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="requires option_ids"): + client.vote_poll("p1") + + def test_vote_poll_rejects_both_args(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="not both"): + client.vote_poll("p1", option_ids=["a"], option_id="b") + # --------------------------------------------------------------------------- # Messaging @@ -1015,6 +1083,54 @@ def test_delete_webhook(self, mock_urlopen: MagicMock) -> None: assert req.get_method() == "DELETE" assert req.full_url == f"{BASE}/webhooks/wh-1" + @patch("colony_sdk.client.urlopen") + def test_update_webhook_partial(self, mock_urlopen: MagicMock) -> None: + """Only the fields you pass are sent.""" + mock_urlopen.return_value = _mock_response({"id": "wh-1"}) + client = _authed_client() + + client.update_webhook("wh-1", url="https://new.example.com/hook") + + req = _last_request(mock_urlopen) + assert req.get_method() == "PUT" + assert req.full_url == f"{BASE}/webhooks/wh-1" + assert _last_body(mock_urlopen) == {"url": "https://new.example.com/hook"} + + @patch("colony_sdk.client.urlopen") + def test_update_webhook_reactivate(self, mock_urlopen: MagicMock) -> None: + """``is_active=True`` is the canonical way to recover an auto-disabled webhook.""" + mock_urlopen.return_value = _mock_response({"id": "wh-1", "is_active": True}) + client = _authed_client() + + client.update_webhook("wh-1", is_active=True) + + assert _last_body(mock_urlopen) == {"is_active": True} + + @patch("colony_sdk.client.urlopen") + def test_update_webhook_all_fields(self, mock_urlopen: MagicMock) -> None: + mock_urlopen.return_value = _mock_response({"id": "wh-1"}) + client = _authed_client() + + client.update_webhook( + "wh-1", + url="https://new.example.com/hook", + secret="brand-new-secret-1234", + events=["post_created"], + is_active=True, + ) + + assert _last_body(mock_urlopen) == { + "url": "https://new.example.com/hook", + "secret": "brand-new-secret-1234", + "events": ["post_created"], + "is_active": True, + } + + def test_update_webhook_rejects_no_fields(self) -> None: + client = _authed_client() + with pytest.raises(ValueError, match="at least one field"): + client.update_webhook("wh-1") + # --------------------------------------------------------------------------- # Registration diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 6ffcadf..4b01296 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -411,6 +411,32 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["body"]["colony_id"] == COLONIES["general"] assert seen["body"]["post_type"] == "discussion" assert seen["body"]["client"] == "colony-sdk-python" + assert "metadata" not in seen["body"] + + async def test_create_post_with_metadata(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"id": "poll-1"}) + + client = _make_client(handler) + metadata = { + "poll_options": [ + {"id": "yes", "text": "Yes"}, + {"id": "no", "text": "No"}, + ], + "multiple_choice": False, + } + await client.create_post( + "Vote?", + "Pick one", + colony="general", + post_type="poll", + metadata=metadata, + ) + assert seen["body"]["metadata"] == metadata + assert seen["body"]["post_type"] == "poll" async def test_update_post(self) -> None: seen: dict = {} @@ -520,10 +546,27 @@ def handler(request: httpx.Request) -> httpx.Response: return _json_response({"voted": True}) client = _make_client(handler) - await client.vote_poll("p1", "opt-1") + await client.vote_poll("p1", ["opt-1"]) assert seen["url"].endswith("/polls/p1/vote") assert seen["body"] == {"option_ids": ["opt-1"]} + async def test_vote_poll_deprecated_option_id_kwarg(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"voted": True}) + + client = _make_client(handler) + with pytest.warns(DeprecationWarning, match="option_id"): + await client.vote_poll("p1", option_id="opt-1") + assert seen["body"] == {"option_ids": ["opt-1"]} + + async def test_vote_poll_rejects_no_args(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="requires option_ids"): + await client.vote_poll("p1") + async def test_send_message(self) -> None: seen: dict = {} @@ -664,6 +707,26 @@ def handler(request: httpx.Request) -> httpx.Response: await client.delete_webhook("wh1") assert seen["method"] == "DELETE" + async def test_update_webhook(self) -> None: + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["method"] = request.method + seen["url"] = str(request.url) + seen["body"] = json.loads(request.content) + return _json_response({"id": "wh1"}) + + client = _make_client(handler) + await client.update_webhook("wh1", is_active=True, events=["post_created"]) + assert seen["method"] == "PUT" + assert seen["url"].endswith("/webhooks/wh1") + assert seen["body"] == {"is_active": True, "events": ["post_created"]} + + async def test_update_webhook_rejects_no_fields(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="at least one field"): + await client.update_webhook("wh1") + # --------------------------------------------------------------------------- # Errors and retries From 723c78869f2f68699900f32c93fd42c84414b8c0 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 20:39:39 +0100 Subject: [PATCH 2/3] Cover async update_webhook url/secret and vote_poll error/wrap branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codecov 100% patch coverage threshold flagged 5 uncovered lines from the previous commit, all in async_client.py: - async vote_poll line 435: ValueError("not both") branch - async vote_poll lines 446-451: bare-string positional auto-wrap - async update_webhook lines 651, 653: url= and secret= branches (only is_active and events were exercised before) Adds three new async unit tests: - test_vote_poll_rejects_both_args — covers the ValueError branch - test_vote_poll_deprecated_string_positional — covers the auto-wrap - test_update_webhook_url_and_secret — covers both field branches Coverage now at 100% for src/colony_sdk on all four files. 250 unit tests pass (was 247). Co-Authored-By: Claude Opus 4.6 (1M context) --- .coverage | Bin 0 -> 53248 bytes tests/test_async_client.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..a7cdedb2c856209f8906b14568f6a89a622ec8c9 GIT binary patch literal 53248 zcmeI)Pi)&%90%~{IBlA)oeR?xMm6#{Kv&j3lx||cpew9`2_!by5CUnglf1TOiQU=G z+8(wn1!;hUxWNUD1Ggf?X*WcXKtci`;4p~;+IE@-640T+P`fVtp8qsSx1!uox385y zj{V;A?|t4M$4S~34?Jgjf{)ow!SMJX>tKq)_HfP^OVYia?$MHXmzY%_D+t=+GeS`(>Fo0L<+uan_q>9(P+HngEym3?DW zP}X*GGWv{TSlJ2T_HbjRhFP29oc4m+I(DJovw7aM{AyaR>6y00#k9y4J(0U<1$!h~ zv=ClxZK$kKS)tJ&XLzg{ooeQrjooiJmOs5ZoD=phbLv(iwt40lGBF@CJek10C2$Z7r{4Z3K2(Mq9U zSh?_gB~QaasWkV?!6}auOd^z#chR79X^5^WGb%LPD`%{z{Ju#tk);-lTrk{aqtui* zH6d;&E;t+QH9X;&1;J$!KN!DbO&KoFI)ZkF=Tbi~5}Q~ZRZz?`v?rQFgW7a=ssGh? zWE1NH@=;zTx5&40<+PGWZQrhxi{S_kCMVsccLD_?s%!W%R@bvu?W}gP=V_Lvw@9^} zOM=%J%9p_r1zM?HLx?zC;1Rwwb2tWV=5P$##AOHafK;YIB zP?eaHkk|h)?E=$&rw1$$fB*y_009U<00Izz00bZa0SK&1fwq{sRf~QW@?eLec5VrO z1MqO)z+m6bR;pD@yTr6h+F$Fkf(RM{5P$##AOHafKmY;|fB*y_0D%>OHg&5KeGCw5 zRXda6rvUQ%|G0KP)4tK((?)3p3j`nl0SG_<0uX=z1Rwwb2teSr71$M5*;Kz*n52K1 zlDDmyUN?87cXGy?u&sXA$%b2X+S+fpGgem5=1pOF^iK?Ss4BA?%jp+w3YT&{6jRxe z26L?(Non{tfosT9@{|X56+-jEX}50uX=z1Rwwb2tWV=5P$## z7<)vWQX0D+Uv>Q-->KRS=B&B?R|nN2jpmB3|6@B;JK0!_HP`?6|9|M-5P$##AOHaf zKmY;|fB*y_0D)UtK&2lHw9D`R73~_M8x{ya00Izz00bZa0SG_<0uX=z1a3hARc%lD zfB(Pn7p5&}?{55)Rf(pxyVWKoIlChk zyE1<#)#&7Rimca#dxvKS*`>$TvC#+F@xi+mDlOMai`UBbALqYjKP$I|@r z?)g%B*@sw|Iy=PvDJk>6rYU=6p>&0nUfIvsxypeNcAD*upHbLi|M^pMd@;eU4bAPp zhkY@Bf=#D~hvSD2u^(rfK5AjR52e}3O6sd2cKt-9qUbS-t^7nh#@HTx&zJ1v=cQNm zt3T;yE<9aX{`-%i?>|g0UYOR7q@9;>jeZ;+sxB#P*D;!QcO1WZIJUn|4vV z;@1oh5P$##AOHafKmY;|fB*y_009WBLxE=c>_buG{!>i#pW?ANeG=e*|Ia$sVU-Xv z1Rwwb2tWV=5P$##AOHafKmY;_3&`*PasA&g7ZO4M0uX=z1Rwwb2tWV=5P$##)}?^_ i`+r>jugj)G&=7zC1Rwwb2tWV=5P$##AOL}e1^xxiOi)4q literal 0 HcmV?d00001 diff --git a/tests/test_async_client.py b/tests/test_async_client.py index 4b01296..368f044 100644 --- a/tests/test_async_client.py +++ b/tests/test_async_client.py @@ -567,6 +567,24 @@ async def test_vote_poll_rejects_no_args(self) -> None: with pytest.raises(ValueError, match="requires option_ids"): await client.vote_poll("p1") + async def test_vote_poll_rejects_both_args(self) -> None: + client = _make_client(lambda r: _json_response({})) + with pytest.raises(ValueError, match="not both"): + await client.vote_poll("p1", option_ids=["a"], option_id="b") + + async def test_vote_poll_deprecated_string_positional(self) -> None: + """Bare string in the positional slot is auto-wrapped + warns.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"voted": True}) + + client = _make_client(handler) + with pytest.warns(DeprecationWarning, match="single"): + await client.vote_poll("p1", "opt-1") + assert seen["body"] == {"option_ids": ["opt-1"]} + async def test_send_message(self) -> None: seen: dict = {} @@ -722,6 +740,25 @@ def handler(request: httpx.Request) -> httpx.Response: assert seen["url"].endswith("/webhooks/wh1") assert seen["body"] == {"is_active": True, "events": ["post_created"]} + async def test_update_webhook_url_and_secret(self) -> None: + """Cover the ``url=`` and ``secret=`` branches.""" + seen: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return _json_response({"id": "wh1"}) + + client = _make_client(handler) + await client.update_webhook( + "wh1", + url="https://new.example.com/hook", + secret="brand-new-secret-1234", + ) + assert seen["body"] == { + "url": "https://new.example.com/hook", + "secret": "brand-new-secret-1234", + } + async def test_update_webhook_rejects_no_fields(self) -> None: client = _make_client(lambda r: _json_response({})) with pytest.raises(ValueError, match="at least one field"): From 2c11316af154ca4f5c9be1811768aa5d65466a52 Mon Sep 17 00:00:00 2001 From: ColonistOne Date: Thu, 9 Apr 2026 20:40:10 +0100 Subject: [PATCH 3/3] Stop tracking .coverage and friends; expand .gitignore Adds .coverage (binary), .coverage.*, htmlcov/, .pytest_cache/, .mypy_cache/, .ruff_cache/ to .gitignore. The previous commit accidentally committed .coverage from a local test run; this removes it from the index. Co-Authored-By: Claude Opus 4.6 (1M context) --- .coverage | Bin 53248 -> 0 bytes .gitignore | 6 ++++++ 2 files changed, 6 insertions(+) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index a7cdedb2c856209f8906b14568f6a89a622ec8c9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)Pi)&%90%~{IBlA)oeR?xMm6#{Kv&j3lx||cpew9`2_!by5CUnglf1TOiQU=G z+8(wn1!;hUxWNUD1Ggf?X*WcXKtci`;4p~;+IE@-640T+P`fVtp8qsSx1!uox385y zj{V;A?|t4M$4S~34?Jgjf{)ow!SMJX>tKq)_HfP^OVYia?$MHXmzY%_D+t=+GeS`(>Fo0L<+uan_q>9(P+HngEym3?DW zP}X*GGWv{TSlJ2T_HbjRhFP29oc4m+I(DJovw7aM{AyaR>6y00#k9y4J(0U<1$!h~ zv=ClxZK$kKS)tJ&XLzg{ooeQrjooiJmOs5ZoD=phbLv(iwt40lGBF@CJek10C2$Z7r{4Z3K2(Mq9U zSh?_gB~QaasWkV?!6}auOd^z#chR79X^5^WGb%LPD`%{z{Ju#tk);-lTrk{aqtui* zH6d;&E;t+QH9X;&1;J$!KN!DbO&KoFI)ZkF=Tbi~5}Q~ZRZz?`v?rQFgW7a=ssGh? zWE1NH@=;zTx5&40<+PGWZQrhxi{S_kCMVsccLD_?s%!W%R@bvu?W}gP=V_Lvw@9^} zOM=%J%9p_r1zM?HLx?zC;1Rwwb2tWV=5P$##AOHafK;YIB zP?eaHkk|h)?E=$&rw1$$fB*y_009U<00Izz00bZa0SK&1fwq{sRf~QW@?eLec5VrO z1MqO)z+m6bR;pD@yTr6h+F$Fkf(RM{5P$##AOHafKmY;|fB*y_0D%>OHg&5KeGCw5 zRXda6rvUQ%|G0KP)4tK((?)3p3j`nl0SG_<0uX=z1Rwwb2teSr71$M5*;Kz*n52K1 zlDDmyUN?87cXGy?u&sXA$%b2X+S+fpGgem5=1pOF^iK?Ss4BA?%jp+w3YT&{6jRxe z26L?(Non{tfosT9@{|X56+-jEX}50uX=z1Rwwb2tWV=5P$## z7<)vWQX0D+Uv>Q-->KRS=B&B?R|nN2jpmB3|6@B;JK0!_HP`?6|9|M-5P$##AOHaf zKmY;|fB*y_0D)UtK&2lHw9D`R73~_M8x{ya00Izz00bZa0SG_<0uX=z1a3hARc%lD zfB(Pn7p5&}?{55)Rf(pxyVWKoIlChk zyE1<#)#&7Rimca#dxvKS*`>$TvC#+F@xi+mDlOMai`UBbALqYjKP$I|@r z?)g%B*@sw|Iy=PvDJk>6rYU=6p>&0nUfIvsxypeNcAD*upHbLi|M^pMd@;eU4bAPp zhkY@Bf=#D~hvSD2u^(rfK5AjR52e}3O6sd2cKt-9qUbS-t^7nh#@HTx&zJ1v=cQNm zt3T;yE<9aX{`-%i?>|g0UYOR7q@9;>jeZ;+sxB#P*D;!QcO1WZIJUn|4vV z;@1oh5P$##AOHafKmY;|fB*y_009WBLxE=c>_buG{!>i#pW?ANeG=e*|Ia$sVU-Xv z1Rwwb2tWV=5P$##AOHafKmY;_3&`*PasA&g7ZO4M0uX=z1Rwwb2tWV=5P$##)}?^_ i`+r>jugj)G&=7zC1Rwwb2tWV=5P$##AOL}e1^xxiOi)4q diff --git a/.gitignore b/.gitignore index 36626b5..d8db073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ __pycache__/ dist/ *.egg-info/ +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/