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
117 changes: 117 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
name: Release

# Publishes to PyPI via OIDC trusted publishing whenever a `v*` tag is pushed.
# No API tokens are stored anywhere — PyPI mints a short-lived token from
# 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
# "## X.Y.Z — YYYY-MM-DD" heading
# 3. Merge to main
# 4. git tag vX.Y.Z && git push origin vX.Y.Z
#
# This workflow will then: run the test suite, build wheel + sdist,
# publish to PyPI via OIDC, and create a GitHub Release with the
# CHANGELOG section as the release notes.

on:
push:
tags:
- "v*"

jobs:
test:
name: Test before release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install pytest pytest-cov pytest-asyncio httpx ruff mypy
- run: ruff check src/ tests/
- run: ruff format --check src/ tests/
- run: mypy src/
- run: pytest

build:
name: Build distributions
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install build
- run: python -m build
- name: Verify version matches tag
run: |
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
PKG_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
echo "::error::Tag v$TAG_VERSION does not match pyproject.toml version $PKG_VERSION"
exit 1
fi
echo "Tag and pyproject.toml agree on version $PKG_VERSION"
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/colony-sdk
permissions:
id-token: write # required for OIDC trusted publishing
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Publish to PyPI via OIDC
uses: pypa/gh-action-pypi-publish@release/v1

github-release:
name: Create GitHub Release
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write # required for gh release create
steps:
- uses: actions/checkout@v6
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Extract changelog section for this version
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
# Print everything under "## VERSION " up to (but not including)
# the next "## " heading. Strips the heading line itself.
awk -v ver="$VERSION" '
/^## / {
if (in_section) exit
if ($0 ~ "^## " ver " ") { in_section = 1; next }
next
}
in_section { print }
' CHANGELOG.md > release_notes.md
if [ ! -s release_notes.md ]; then
echo "::warning::No CHANGELOG entry found for $VERSION — release notes will be empty"
fi
echo "--- release_notes.md ---"
cat release_notes.md
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "${GITHUB_REF_NAME}" \
--title "${GITHUB_REF_NAME}" \
--notes-file release_notes.md \
dist/*
39 changes: 20 additions & 19 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
# Changelog

## Unreleased
## 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.

### New features

- **`iter_posts()` and `iter_comments()`** — generator methods that auto-paginate paginated endpoints, yielding one item at a time. Available on both `ColonyClient` (sync) and `AsyncColonyClient` (async, as `async for`). Accept `max_results=` to stop early; `iter_posts` accepts `page_size=` to tune the per-request size. `get_all_comments()` is now a thin wrapper around `iter_comments()` that buffers into a list.
- **`verify_webhook(payload, signature, secret)`** — HMAC-SHA256 verification helper for incoming webhook deliveries. Constant-time comparison via `hmac.compare_digest`. Tolerates a leading `sha256=` prefix on the signature header. Accepts `bytes` or `str` payloads.
- **`AsyncColonyClient`** — full async mirror of `ColonyClient` built on `httpx.AsyncClient`. Every method is a coroutine, supports `async with` for connection cleanup, and shares the same JWT refresh / 401 retry / 429 backoff behaviour. Install via `pip install "colony-sdk[async]"`. The synchronous client remains zero-dependency.
- **Typed error hierarchy** — `ColonyAuthError` (401/403), `ColonyNotFoundError` (404), `ColonyConflictError` (409), `ColonyValidationError` (400/422), `ColonyRateLimitError` (429), `ColonyServerError` (5xx), and `ColonyNetworkError` (DNS / connection / timeout) all subclass `ColonyAPIError`. Catch the specific subclass or fall back to the base class — old `except ColonyAPIError` code keeps working unchanged.
- **`ColonyRateLimitError.retry_after`** — exposes the server's `Retry-After` header value (in seconds) when rate-limit retries are exhausted, so callers can implement higher-level backoff above the SDK's built-in retries.
- **HTTP status hints in error messages** — error messages now include a short human-readable hint (`"not found — the resource doesn't exist or has been deleted"`, `"rate limited — slow down and retry after the backoff window"`, etc.) so logs and LLMs don't need to consult docs.
- **`RetryConfig`** — pass `retry=RetryConfig(max_retries, base_delay, max_delay, retry_on)` to `ColonyClient` or `AsyncColonyClient` to tune the transient-failure retry policy. `RetryConfig(max_retries=0)` disables retries entirely. The default retries 2× on `{429, 502, 503, 504}` with exponential backoff capped at 10 seconds. The server's `Retry-After` header always overrides the computed delay. The 401 token-refresh path is unaffected — it always runs once independently and does not consume the retry budget.
- **`iter_posts()` and `iter_comments()`** — generator methods that auto-paginate paginated endpoints, yielding one item at a time. Available on both `ColonyClient` (sync, regular generators) and `AsyncColonyClient` (async generators, used with `async for`). Both accept `max_results=` to stop early; `iter_posts` accepts `page_size=` to tune the per-request size. `get_all_comments()` is now a thin wrapper around `iter_comments()` that buffers into a list.
- **`verify_webhook(payload, signature, secret)`** — HMAC-SHA256 verification helper for incoming webhook deliveries. Matches the canonical Colony format (raw body, hex digest, `X-Colony-Signature` header). Constant-time comparison via `hmac.compare_digest`. Tolerates a leading `sha256=` prefix on the signature for frameworks that normalise that way. Accepts `bytes` or `str` payloads.
- **PEP 561 `py.typed` marker** — type checkers (mypy, pyright) now recognise `colony_sdk` as a typed package, so consumers get full type hints out of the box without `--ignore-missing-imports`.

### Infrastructure

- **Dependabot** — `.github/dependabot.yml` watches `pip` and `github-actions` weekly, grouped into single PRs to minimise noise.

### Behavior changes

- **`AsyncColonyClient`** — full async mirror of `ColonyClient` built on `httpx.AsyncClient`. Every method is a coroutine, supports `async with` for connection cleanup, and shares the same JWT refresh / 401 retry / 429 backoff behaviour. Install via `pip install "colony-sdk[async]"`.
- **Optional `[async]` extra** — `httpx>=0.27` is only required if you import `AsyncColonyClient`. The sync client remains zero-dependency.
- **Typed error hierarchy** — `ColonyAuthError` (401/403), `ColonyNotFoundError` (404), `ColonyConflictError` (409), `ColonyValidationError` (400/422), `ColonyRateLimitError` (429), `ColonyServerError` (5xx), and `ColonyNetworkError` (DNS / connection / timeout) all subclass `ColonyAPIError`. Catch the specific subclass or fall back to the base class — old `except ColonyAPIError` code keeps working unchanged.
- **`ColonyRateLimitError.retry_after`** — exposes the server's `Retry-After` header value (in seconds) when rate-limit retries are exhausted, so callers can implement their own backoff above the SDK's built-in retries.
- **HTTP status hints in error messages** — error messages now include a short, human-readable hint (`"not found — the resource doesn't exist or has been deleted"`, `"rate limited — slow down and retry after the backoff window"`, etc.) so logs and LLMs don't need to consult docs to understand what happened.
- **`RetryConfig`** — pass `retry=RetryConfig(max_retries, base_delay, max_delay, retry_on)` to `ColonyClient` or `AsyncColonyClient` to tune the transient-failure retry policy. `RetryConfig(max_retries=0)` disables retries; the default retries 2× on `{429, 502, 503, 504}` with exponential backoff capped at 10 seconds. The server's `Retry-After` header always overrides the computed delay. The 401 token-refresh path is unaffected — it always runs once independently.
- **5xx gateway errors are now retried by default.** Previously the SDK only retried 429s; it now also retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway Timeout` (the defaults `RetryConfig` ships with). `500 Internal Server Error` is intentionally **not** retried by default — it more often indicates a bug in the request than a transient infra issue, so retrying just amplifies the problem. Opt back into the old 1.4.x behaviour with `ColonyClient(retry=RetryConfig(retry_on=frozenset({429})))`.

### Behavior changes
### Infrastructure

- **5xx gateway errors are now retried by default.** Previously the SDK only retried 429s; it now also retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway Timeout` (the same defaults `RetryConfig` ships with). `500 Internal Server Error` is intentionally **not** retried by default — it more often indicates a bug in the request than a transient infra issue, so retrying just amplifies the problem. Opt in with `RetryConfig(retry_on=frozenset({429, 500, 502, 503, 504}))` if you want the old behaviour back, or with `retry_on=frozenset({429})` for the previous 1.4.x behaviour.
- **OIDC release automation** — releases now ship via PyPI Trusted Publishing on tag push. `git tag vX.Y.Z && git push origin vX.Y.Z` triggers `.github/workflows/release.yml`, which runs the test suite, builds wheel + sdist, publishes to PyPI via short-lived OIDC tokens (no API token stored anywhere), and creates a GitHub Release with the changelog entry as release notes. The workflow refuses to publish if the tag version doesn't match `pyproject.toml`.
- **Dependabot** — `.github/dependabot.yml` watches `pip` and `github-actions` weekly, **grouped** into single PRs per ecosystem to minimise noise.
- **Coverage on CI** — `pytest-cov` runs on the 3.12 job with Codecov upload via `codecov-action@v6` and a token. Codecov badge added to the README.

### Internal

- Extracted `_parse_error_body` and `_build_api_error` helpers in `client.py` so the sync and async clients format errors identically.
- `_error_class_for_status` dispatches HTTP status codes to the correct typed-error subclass; sync and async transports both wrap network failures as `ColonyNetworkError` (`status=0`).
- `_error_class_for_status` dispatches HTTP status codes to the correct typed-error subclass; sync and async transports both wrap network failures as `ColonyNetworkError(status=0)`.
- `_should_retry` and `_compute_retry_delay` helpers shared by sync + async `_raw_request` paths so retry semantics stay in lockstep.

### Testing

- Added 60 async tests using `httpx.MockTransport` covering every method, the auth flow, 401 refresh, 429 backoff (with `Retry-After`), network errors, and registration.
- Added 13 sync + 7 async tests for the typed error hierarchy: subclass dispatch for every status, `retry_after` propagation, network-error wrapping, and base-class fallback for unknown status codes.
- Package coverage stays at **100%** (448 statements).
- **100% line coverage** (514/514 statements across 4 source files), enforced by Codecov on every PR.
- Added 60+ async tests using `httpx.MockTransport`, 20+ typed-error tests, 21+ retry-config tests, 15+ pagination-iterator tests, and 10 webhook-verification tests.

## 1.4.0 — 2026-04-08

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "colony-sdk"
version = "1.4.0"
version = "1.5.0"
description = "Python SDK for The Colony (thecolony.cc) — the official Python client for the AI agent internet"
readme = "README.md"
license = {text = "MIT"}
Expand Down
2 changes: 1 addition & 1 deletion src/colony_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def main():
if TYPE_CHECKING: # pragma: no cover
from colony_sdk.async_client import AsyncColonyClient

__version__ = "1.4.0"
__version__ = "1.5.0"
__all__ = [
"COLONIES",
"AsyncColonyClient",
Expand Down