Catalyst Python SDK
A zero-latency, in-memory feature flag client for the Catalyst platform.
Fetch the bootstrap snapshot once, then evaluate flags locally with no network
access per call. Typical is_enabled() latency is a couple of microseconds,
against a budget of 1 ms.
from catalyst_sdk import CatalystClient
client = CatalystClient(
sdk_key="cp_prod_a1b2c3d4e5f6g7h8i9j0k1", # API Keys tab in the dashboard
project_id="7c9e6679-7425-40de-944b-e07fc1f90ae7",
host="http://localhost:8000",
env="prod",
)
if client.is_enabled("new-checkout", user_id="user_123",
attributes={"email": "alice@acme.com"}):
...
Install
pip install sdk-catalyst
The distribution is named sdk-catalyst (the catalyst-sdk name is taken on PyPI by an
unrelated project), but the import name is unchanged:
from catalyst_sdk import CatalystClient
From a checkout:
uv sync # or: pip install -e ".[dev]"
Only runtime dependency is httpx. Requires Python 3.11+.
Configuration
| Argument | Required | Notes |
|---|---|---|
sdk_key |
yes | An SDK key from the dashboard's API Keys tab, sent as X-SDK-Key. |
project_id |
yes | The bootstrap endpoint is strictly project scoped. |
host |
no | Defaults to http://localhost:8000. |
env |
no | Snapshot to load. Defaults to prod. |
timeout |
no | Per-request HTTP timeout, seconds. Default 5.0. |
default_value |
no | Served for an unknown flag or before the first load. Default False. |
offline |
no | Skip the API and load only from the disk cache. |
cache_path |
no | None/True uses ~/.cache/catalyst, or a path you choose. False disables it. |
raise_on_error |
no | Re-raise refresh errors instead of keeping the last good snapshot. |
project_id is worth calling out: /api/v1/bootstrap takes it as a query
parameter, so the SDK cannot infer it from the key.
How it works
┌──────────────────────────────┐
startup ───▶ │ GET /api/v1/bootstrap │ 200 → replace snapshot
│ If-None-Match: <last etag> │ 304 → keep snapshot
└──────────────────────────────┘
│
background refresh thread
│
is_enabled() ──▶ in-memory snapshot ──▶ no I/O
- Fetch on load. The snapshot is fetched in the constructor.
- Conditional refresh. Every refresh sends
If-None-Match, so an unchanged environment costs a304with no body. The ETag derives from the project's environment version, which every snapshot-affecting mutation bumps. - Atomic swap. A single attribute assignment replaces the snapshot, so evaluation never sees a half-updated view.
- Degrades, never throws. A failed refresh keeps serving the last good
snapshot. Only
AuthorizationErrorpropagates, because a rejected key will not fix itself.
Evaluation precedence
The SDK mirrors the server exactly, in this order:
- Emergency kill switch → serve the flag default
- Targeting rules, ascending
priority, first match wins → serve its value - Percentage rollout → deterministic Murmur3 bucket over
flag_key:user_id - Flag default
Supported operators: equals, not_equals, in, not_in, contains,
starts_with, ends_with, greater_than, greater_than_or_equal,
less_than, less_than_or_equal, exists, not_exists. Short aliases
(eq, neq, gt, gte, lt, lte, notExists) are accepted and
normalized.
A missing context attribute makes a condition false, so the rule safely skips instead of raising.
API
is_enabled(flag_key, user_id="", attributes=None, default_value=None) -> bool
The hot path. Never performs I/O.
evaluate(flag_key, ...) -> EvaluationResult
Same evaluation, but returns the full decision:
result = client.evaluate("new-checkout", user_id="user_123",
attributes={"email": "alice@acme.com"})
result.value # True
result.reason # 'RULE_MATCH' | 'PERCENTAGE_ROLLOUT' | 'KILL_SWITCH_ACTIVE'
# | 'DEFAULT_VALUE' | 'FLAG_NOT_FOUND' | 'NO_SNAPSHOT'
result.rule_id # the matched rule, when a rule fired
result.flag_version # snapshot version the decision came from
get_all(user_id="", attributes=None) -> dict[str, bool]
Evaluates the whole snapshot in one pass, for rendering a UI.
refresh() -> bool
Conditionally re-fetches. Returns True if a new snapshot was applied,
False if the server replied 304 Not Modified.
start_auto_refresh(interval=30.0, on_error=None) -> Thread
Daemon thread that calls refresh() on an interval. on_error receives every
refresh failure, including ones that were safely absorbed.
client.start_auto_refresh(interval=15, on_error=lambda e: log.warning(e))
...
client.stop_auto_refresh()
Health and introspection
client.is_ready # bool - a snapshot is loaded
client.version # int - environment cache version
client.flag_keys() # list[str]
client.stats # dict - refresh counters, etag, last error
client.last_error # str | None
Lifecycle
CatalystClient is a context manager and closes its HTTP pool:
with CatalystClient(...) as client:
...
Offline and disk cache
The last good snapshot is written to ~/.cache/catalyst (override the
directory with CATALYST_CACHE_DIR) using an atomic write-then-rename. On a
cold start with no network the SDK loads from there, so a restart keeps serving
correct decisions.
client = CatalystClient(..., offline=True) # cache only, no network
Error handling
| Exception | Meaning |
|---|---|
ConfigurationError |
Missing sdk_key / project_id / host. Raised at construction. |
AuthorizationError |
Key rejected or revoked. Always propagates. |
BootstrapError |
Any other transport or protocol failure. Logged, and the previous snapshot keeps serving. Set raise_on_error=True to propagate. |
Evaluation never raises. An unknown flag key, a missing snapshot, and a failed
refresh all resolve to default_value (False unless configured otherwise).
Demo
examples/fastapi_demo/app.py is a runnable FastAPI service that gates a
checkout endpoint and explains its decisions.
export CATALYST_SDK_KEY="cp_prod_..."
export CATALYST_PROJECT_ID="<project uuid>"
export CATALYST_HOST="http://localhost:8000"
export CATALYST_ENV="prod"
python examples/fastapi_demo/app.py
curl localhost:9000/api/checkout?user_id=user_123 -H 'x-user-email: alice@acme.com'
curl localhost:9000/api/inspect?flag=new-checkout\&user_id=user_123
curl localhost:9000/health
Tests
pytest
Two layers guard correctness:
-
The SDK's own suite covers hashing, the operator matrix, precedence, ETag handling, refresh, auto-refresh, and the disk cache.
-
backend/tests/test_sdk_parity.pyis a differential test that runs the server's evaluator and the SDK's evaluator over the same fuzzed inputs and requires identical values, reasons, and rule ids. It also checks Murmur3 against the realmmh3library. Run it with the backend suite:cd backend && uv run pytest tests/test_sdk_parity.py
Releasing
Releases are automated. .github/workflows/publish-sdk.yml runs on every push to main that
touches this package, and publishes to PyPI if the version in pyproject.toml is new.
To cut a release:
- Bump
versioninpyproject.toml - Update
CHANGELOGnotes in the commit message - Merge to
main
The workflow runs the SDK suite and the server parity suite before publishing, so a broken version never reaches PyPI. If the version already exists on PyPI the publish is skipped, which means a merge that only edits docs will not fail. Re-running a failed release is possible from the Actions tab via Run workflow.
Publishing uses PyPI Trusted Publishing (OIDC), so no API token is stored in this repository.
License
MIT
Release files for sdk-catalyst 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sdk_catalyst-0.1.0.tar.gz | 40.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sdk_catalyst-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 59.1 kB
Release files / sdk_catalyst-0.1.0.tar.gz
| Download URL | sdk_catalyst-0.1.0.tar.gz |
|---|---|
| Size | 40.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
873414737b0181a9050dbbff92fb3c84a8e8350b88603545b6170ac2e1a80e66
|
|
BLAKE2b-256 checksum How to use checksums |
7823b516eb82603ad9191cff934eaee242231f827c61f136b68fd7510511fa00
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency logRelease files / sdk_catalyst-0.1.0-py3-none-any.whl
| Download URL | sdk_catalyst-0.1.0-py3-none-any.whl |
|---|---|
| Size | 19.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4dcb3055f610eb7b1e79138055def3047723e93b6c83f5550b8cf4d52b1fb3c8
|
|
BLAKE2b-256 checksum How to use checksums |
eddba1d67f584b28524df8067fad68725e57a7ba9a1b5ab6c1b40e6104d927e2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.
Transparency log