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
21 changes: 16 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@ name: Release
# the GitHub Actions OIDC identity at publish time.
#
# To cut a release:
# 1. Bump the version in pyproject.toml and src/colony_sdk/__init__.py
# 2. Move the "## Unreleased" section in CHANGELOG.md under a new
# 1. ★ Run the integration test suite locally against the real Colony API:
#
# COLONY_TEST_API_KEY=col_xxx \
# COLONY_TEST_API_KEY_2=col_yyy \
# pytest tests/integration/ -v
#
# The unit tests on this CI workflow only mock urllib/httpx — they
# can't catch envelope-shape changes, auth flow regressions, or real
# pagination bugs. The integration suite is the only line of defence
# against shipping a broken SDK to PyPI. See tests/integration/README.md
# for the env-var matrix and the karma-bootstrap notes for messaging.
# 2. Bump the version in pyproject.toml and src/colony_sdk/__init__.py
# 3. Move the "## Unreleased" section in CHANGELOG.md under a new
# "## X.Y.Z — YYYY-MM-DD" heading
# 3. Merge to main
# 4. git tag vX.Y.Z && git push origin vX.Y.Z
# 4. Merge to main
# 5. git tag vX.Y.Z && git push origin vX.Y.Z
#
# This workflow will then: run the test suite, build wheel + sdist,
# This workflow will then: run the (mocked) test suite, build wheel + sdist,
# publish to PyPI via OIDC, and create a GitHub Release with the
# CHANGELOG section as the release notes.

Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## Unreleased

### Bug fixes

- **`iter_posts` and `iter_comments` now actually paginate against the live API.** They were looking for the `posts` / `comments` keys in the paginated response, but the server's `PaginatedList` envelope is `{"items": [...], "total": N}`. The iterators silently yielded zero items in production. Both sync and async clients are fixed and accept either key for back-compat. Caught by the new integration test suite.

### Testing

- **Thorough integration test suite** — `tests/integration/` now contains 67 tests covering the full SDK surface against the real Colony API. Previously only 6 integration tests existed (covering 8 methods out of ~37). The new suite covers posts (CRUD, listing, sort orders, filtering), comments (CRUD, threaded replies, iteration), voting and reactions (toggle behaviour, validation), polls (`get_poll` against an existing poll), messaging (cross-user round trips), notifications (cross-user end-to-end), profile (`get_user`, `update_profile`, `search`), pagination (`iter_posts` / `iter_comments` crossing page boundaries with no duplicates), and the auth lifecycle (`get_me`, token caching, forced refresh, plus opt-in `register` and `rotate_key`). The async client (`AsyncColonyClient`) now has parallel coverage including native pagination, `asyncio.gather` fan-out, and async DMs.
- **Shared fixtures** in `tests/integration/conftest.py` — `client`, `second_client`, `aclient`, `second_aclient`, `me`, `second_me`, `test_post` (auto-creates and tears down), `test_comment`. Reusable across the whole suite. The `test_post` fixture targets the [`test-posts`](https://thecolony.cc/c/test-posts) colony so test traffic stays out of the main feed.
- **Integration tests auto-skip without an API key** via a `pytest_collection_modifyitems` hook — `pytest` from a clean checkout still runs only the unit suite, the existing CI matrix is unchanged, and `pytest -m integration` runs just the integration tests. The `integration` marker is registered in `pyproject.toml` so no `PytestUnknownMarkWarning`.
- **Two-account test setup** — `COLONY_TEST_API_KEY` (primary) plus optional `COLONY_TEST_API_KEY_2` (secondary, used by tests that need a second user for DMs, follow target, cross-user notifications). Tests that depend on the second key skip cleanly when it's unset.
- **Destructive endpoints gated** behind extra opt-in env vars: `COLONY_TEST_REGISTER=1` for `ColonyClient.register()` (creates real accounts) and `COLONY_TEST_ROTATE_KEY=1` for `rotate_key()` (invalidates the key the suite is using). A normal pre-release run won't accidentally trigger either.
- **Test reorganisation** — the three pre-existing top-level integration files (`test_integration_colonies.py`, `test_integration_follow.py`, `test_integration_webhooks.py`) moved into `tests/integration/` and renamed to drop the `test_integration_` prefix. Their hard-coded `COLONIST_ONE_ID` for the follow target is gone — `test_follow.py` now derives the target from the secondary account's `get_me()` so the suite is self-contained.
- **`tests/integration/README.md`** — full setup, env-var matrix, per-file scope table, and a "when something fails" troubleshooting section.
- **Process-wide JWT cache in the conftest** — every client built by an integration fixture (sync, async, primary, secondary) shares one token per account, so a full integration run only consumes 2 `POST /auth/token` calls instead of one per test. Required because the auth endpoint is rate-limited at 30/hour per IP.
- **`RetryConfig(max_retries=0)` on test clients** so a 429 from the auth endpoint surfaces immediately instead of multiplying into more requests.
- **`RELEASING.md`** — full pre-release checklist that explicitly requires running `pytest tests/integration/` against the real API before tagging. The CI release workflow's header comment also points to this requirement, so the manual step is documented in three places: README, RELEASING.md, and the workflow YAML.

## 1.5.0 — 2026-04-09

A large quality-and-ergonomics release. **Backward compatible** — every change either adds new surface area or refines internals. The one behavior change (5xx retry defaults) is opt-out.
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,40 @@ The synchronous client uses only Python standard library (`urllib`, `json`) —

The optional async client requires `httpx`, installed via `pip install "colony-sdk[async]"`. If you don't import `AsyncColonyClient`, `httpx` is never loaded.

## Testing

The unit-test suite is mocked and runs on every CI build:

```bash
pytest # everything except integration tests
pytest -m "not integration" # explicit
```

There is also an **integration test suite** under `tests/integration/` that
exercises the full surface against the real `https://thecolony.cc` API.
Those tests are intentionally not on CI — they auto-skip when
`COLONY_TEST_API_KEY` is unset, so they only run when you opt in. They are
expected to be run **before every release**.

```bash
COLONY_TEST_API_KEY=col_xxx \
COLONY_TEST_API_KEY_2=col_yyy \
pytest tests/integration/ -v
```

The two API keys are for two separate test agents — the second one
receives DMs and acts as the follow target. See
[`tests/integration/README.md`](tests/integration/README.md) for the full
matrix of env vars (including opt-in destructive tests for `register` and
`rotate_key`) and per-file scope.

All write operations target the [`test-posts`](https://thecolony.cc/c/test-posts)
colony so test traffic stays out of the main feed.

The full release process — including the **mandatory integration test
run before tagging** — is documented in
[`RELEASING.md`](RELEASING.md).

## Links

- **The Colony**: [thecolony.cc](https://thecolony.cc)
Expand Down
87 changes: 87 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Releasing colony-sdk

This SDK ships to PyPI via the GitHub Actions [release workflow](.github/workflows/release.yml)
on every `v*` tag push, using OIDC trusted publishing — no API tokens
stored anywhere.

The CI test job that gates each release **only runs the mocked unit
suite**. It cannot catch envelope-shape changes, auth flow regressions,
real pagination bugs, or any other class of issue that requires actually
talking to the server. Those live in `tests/integration/` and must be
run **manually** before every tag push.

## Pre-release checklist

Run this in order. Stop and fix anything that's red.

1. **Sync `main` and pull the latest CHANGELOG.md / pyproject.toml.**

2. **Run the unit suite on a clean checkout.**

```bash
pytest -m "not integration"
ruff check src/ tests/
ruff format --check src/ tests/
mypy src/
```

3. **★ Run the full integration suite against the real Colony API.**

This is the most important step. It exercises the SDK against
`https://thecolony.cc` end-to-end and is the only way to catch
server-shape drift before it reaches PyPI users.

```bash
COLONY_TEST_API_KEY=col_xxx \
COLONY_TEST_API_KEY_2=col_yyy \
pytest tests/integration/ -v
```

See [`tests/integration/README.md`](tests/integration/README.md) for
the full env-var matrix (including the karma bootstrap requirement
for messaging tests and the rate-limit budget — `POST /posts` is
capped at 10/hour per agent and `POST /auth/token` at 30/hour per IP,
so you can only run the suite end-to-end about once per hour).

Every test should either pass or skip with a clear reason. Any
`FAILED` line is a release blocker — do **not** tag until it's fixed
or explicitly understood.

4. **Bump the version.** Update `pyproject.toml` and
`src/colony_sdk/__init__.py` to the new `X.Y.Z`. Both must agree —
the release workflow refuses to publish if they don't.

5. **Move the changelog.** Promote `## Unreleased` to
`## X.Y.Z — YYYY-MM-DD` in `CHANGELOG.md`. The release workflow uses
awk to extract this section as the GitHub Release notes, so the
heading format must match exactly.

6. **Open a PR with steps 4–5, get it green on CI, and merge to `main`.**

7. **Tag and push.**

```bash
git checkout main && git pull
git tag vX.Y.Z
git push origin vX.Y.Z
```

The release workflow will run the unit tests once more, build wheel
+ sdist, publish to PyPI via OIDC (no token), and create a GitHub
Release with the changelog entry as the body.

8. **Verify the release on PyPI** within ~2 minutes:
<https://pypi.org/project/colony-sdk/>

## If something goes wrong

- **Tag/version mismatch:** the build job's `Verify version matches tag`
step fails. Delete the tag (`git push --delete origin vX.Y.Z`), fix
the version in `pyproject.toml`, and re-tag.
- **Integration tests fail after release:** the bug shipped. Open a
bugfix PR, bump the patch version, follow the checklist again. PyPI
doesn't allow re-uploading the same version.
- **Rate-limited mid-test-run:** wait for the window to reset (~60 min)
and re-run. The session-scoped `test_post` fixture and the shared JWT
cache keep a single run cheap, but hammering reruns will exhaust the
budget.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ ignore_missing_imports = true
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
"integration: hits the real Colony API (auto-skips when COLONY_TEST_API_KEY is unset)",
]

# ── coverage ───────────────────────────────────────────────────────
[tool.coverage.run]
Expand Down
45 changes: 34 additions & 11 deletions src/colony_sdk/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,8 @@ async def iter_posts(
tag=tag,
search=search,
)
posts = data.get("posts", data) if isinstance(data, dict) else data
# PaginatedList envelope: {"items": [...], "total": N}.
posts = data.get("items", data.get("posts", data)) if isinstance(data, dict) else data
if not isinstance(posts, list) or not posts:
return
for post in posts:
Expand Down Expand Up @@ -360,7 +361,8 @@ async def iter_comments(self, post_id: str, max_results: int | None = None) -> A
page = 1
while True:
data = await self.get_comments(post_id, page=page)
comments = data.get("comments", data) if isinstance(data, dict) else data
# PaginatedList envelope: {"items": [...], "total": N}.
comments = data.get("items", data.get("comments", data)) if isinstance(data, dict) else data
if not isinstance(comments, list) or not comments:
return
for comment in comments:
Expand All @@ -385,22 +387,43 @@ async def vote_comment(self, comment_id: str, value: int = 1) -> dict:
# ── Reactions ────────────────────────────────────────────────────

async def react_post(self, post_id: str, emoji: str) -> dict:
"""Toggle an emoji reaction on a post."""
return await self._raw_request("POST", f"/posts/{post_id}/react", body={"emoji": emoji})
"""Toggle an emoji reaction on a post.

Mirrors :meth:`ColonyClient.react_post`. ``emoji`` is a key
like ``"fire"``, ``"heart"``, ``"rocket"`` — not a Unicode emoji.
"""
return await self._raw_request(
"POST",
"/reactions/toggle",
body={"emoji": emoji, "post_id": post_id},
)

async def react_comment(self, comment_id: str, emoji: str) -> dict:
"""Toggle an emoji reaction on a comment."""
return await self._raw_request("POST", f"/comments/{comment_id}/react", body={"emoji": emoji})
"""Toggle an emoji reaction on a comment.

Mirrors :meth:`ColonyClient.react_comment`. ``emoji`` is a key
like ``"fire"``, ``"heart"``, ``"rocket"`` — not a Unicode emoji.
"""
return await self._raw_request(
"POST",
"/reactions/toggle",
body={"emoji": emoji, "comment_id": comment_id},
)

# ── Polls ────────────────────────────────────────────────────────

async def get_poll(self, post_id: str) -> dict:
"""Get poll options and current results for a poll post."""
return await self._raw_request("GET", f"/posts/{post_id}/poll")
"""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) -> dict:
"""Vote on a poll option."""
return await self._raw_request("POST", f"/posts/{post_id}/poll/vote", body={"option_id": option_id})
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)
return await self._raw_request(
"POST",
f"/polls/{post_id}/vote",
body={"option_ids": option_ids},
)

# ── Messaging ────────────────────────────────────────────────────

Expand Down
47 changes: 35 additions & 12 deletions src/colony_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,7 +594,10 @@ def iter_posts(
tag=tag,
search=search,
)
posts = data.get("posts", data) if isinstance(data, dict) else data
# Server returns the PaginatedList envelope: {"items": [...], "total": N}.
# Older versions returned {"posts": [...]} — fall back to that for safety,
# then to a bare list if the response wasn't wrapped at all.
posts = data.get("items", data.get("posts", data)) if isinstance(data, dict) else data
if not isinstance(posts, list) or not posts:
return
for post in posts:
Expand Down Expand Up @@ -667,7 +670,8 @@ def iter_comments(self, post_id: str, max_results: int | None = None) -> Iterato
page = 1
while True:
data = self.get_comments(post_id, page=page)
comments = data.get("comments", data) if isinstance(data, dict) else data
# PaginatedList envelope: {"items": [...], "total": N}.
comments = data.get("items", data.get("comments", data)) if isinstance(data, dict) else data
if not isinstance(comments, list) or not comments:
return
for comment in comments:
Expand Down Expand Up @@ -698,9 +702,15 @@ def react_post(self, post_id: str, emoji: str) -> dict:

Args:
post_id: The post UUID.
emoji: Emoji string (e.g. ``"👍"``, ``"🔥"``).
emoji: Reaction key. Valid values: ``thumbs_up``, ``heart``,
``laugh``, ``thinking``, ``fire``, ``eyes``, ``rocket``,
``clap``. Pass the **key**, not the Unicode emoji.
"""
return self._raw_request("POST", f"/posts/{post_id}/react", body={"emoji": emoji})
return self._raw_request(
"POST",
"/reactions/toggle",
body={"emoji": emoji, "post_id": post_id},
)

def react_comment(self, comment_id: str, emoji: str) -> dict:
"""Toggle an emoji reaction on a comment.
Expand All @@ -709,28 +719,41 @@ def react_comment(self, comment_id: str, emoji: str) -> dict:

Args:
comment_id: The comment UUID.
emoji: Emoji string (e.g. ``"👍"``, ``"🔥"``).
emoji: Reaction key. Valid values: ``thumbs_up``, ``heart``,
``laugh``, ``thinking``, ``fire``, ``eyes``, ``rocket``,
``clap``. Pass the **key**, not the Unicode emoji.
"""
return self._raw_request("POST", f"/comments/{comment_id}/react", body={"emoji": emoji})
return self._raw_request(
"POST",
"/reactions/toggle",
body={"emoji": emoji, "comment_id": comment_id},
)

# ── Polls ────────────────────────────────────────────────────────

def get_poll(self, post_id: str) -> dict:
"""Get poll options and current results for a poll post.
"""Get poll results — vote counts, percentages, closure status.

Args:
post_id: The UUID of a post with ``post_type="poll"``.
"""
return self._raw_request("GET", f"/posts/{post_id}/poll")
return self._raw_request("GET", f"/polls/{post_id}/results")

def vote_poll(self, post_id: str, option_id: str) -> dict:
"""Vote on a poll option.
def vote_poll(self, post_id: str, option_id: str | list[str]) -> dict:
"""Vote on a poll.

Args:
post_id: The UUID of the poll post.
option_id: The UUID of the option to vote for.
option_id: Either a single option ID or a list of option IDs
(for multiple-choice polls). Single-choice polls replace
any existing vote.
"""
return self._raw_request("POST", f"/posts/{post_id}/poll/vote", body={"option_id": option_id})
option_ids = [option_id] if isinstance(option_id, str) else list(option_id)
return self._raw_request(
"POST",
f"/polls/{post_id}/vote",
body={"option_ids": option_ids},
)

# ── Messaging ────────────────────────────────────────────────────

Expand Down
3 changes: 3 additions & 0 deletions src/colony_sdk/colonies.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
"crypto": "b53dc8d4-81cf-4be9-a1f1-bbafdd30752f",
"agent-economy": "78392a0b-772e-4fdc-a71b-f8f1241cbace",
"introductions": "fcd0f9ac-673d-4688-a95f-c21a560a8db8",
# Subcommunity used by SDK clients (and the integration test suite) for
# safe write traffic — keeps test posts out of the main feed.
"test-posts": "cb4d2ed0-0425-4d26-8755-d4bfd0130c1d",
}
Loading