exd Python SDK (exd-client)
The Python SDK is a thin Python facade around an FFI build of the
exd-client Rust crate. The Rust core owns the lint pipeline, eval
engine, HTTP transport, SSE consumer, and closure-delta apply;
Python handles ergonomic wrappers, sink registration, and stdlib
integration. This is the same architecture Statsig's Server Core
and Unleash's Yggdrasil converged on, and is the Python analogue of
the TypeScript SDK's WASM bridge.
Phase status. This document is a v0 spec. No source code lives at
sdks/python/yet. The test contract is intestplan/16-python-sdk.md.
Installation
pip install exd-client
Wheels are published for the cibuildwheel matrix:
| OS | Arch | Tag |
|---|---|---|
| Linux (manylinux2014) | x86_64 | manylinux2014_x86_64 |
| Linux (manylinux2014) | aarch64 | manylinux2014_aarch64 |
| Linux (musllinux) | x86_64 | musllinux_1_2_x86_64 |
| Linux (musllinux) | aarch64 | musllinux_1_2_aarch64 |
| macOS 11+ | x86_64 | macosx_11_0_x86_64 |
| macOS 11+ | arm64 | macosx_11_0_arm64 |
| Windows | x86_64 | win_amd64 |
Each wheel bundles the Rust cdylib (libexd_client.{so,dylib} /
exd_client.dll). There is no source build path for the FFI core —
operators on unsupported platforms should use
@exd/client/remote (over HTTP) or run the exd
CLI as a sidecar.
Python 3.10+ supported. The package is a single artifact — unlike the TypeScript SDK, there is no lite/full split. Sub-modules are import-on-demand and the Rust core is loaded lazily on first use of any surface that needs it.
Architecture
The package is a single distribution exd-client (PyPI) → Python
package exd_client. The Rust core is built with maturin
- PyO3 and ships as a compiled module
exd_client._coreinside the wheel. Pure-Python sub-modules wrap that core with idiomatic typed APIs, a context-manager lifecycle, and asyncio integration.
sdks/python/
├── pyproject.toml # maturin build backend
├── Cargo.toml # PyO3 bindings crate
├── src/ # Rust binding code (PyO3)
│ └── lib.rs
├── exd_client/ # Pure-Python wrappers + type stubs
│ ├── __init__.py # public re-exports
│ ├── _core.pyi # type stubs for the Rust extension
│ ├── client.py # ExdClient, Namespace
│ ├── context.py # EvalContext, AttrValue
│ ├── poll.py # Poller, archive_source_from_url
│ ├── sse.py # event_stream
│ ├── remote.py # ExdRemote (no FFI; pure Python over httpx)
│ ├── telemetry/
│ │ ├── __init__.py # EvaluationRecord, EvaluationSink, build_evaluation_record
│ │ ├── sinks.py # ConsoleSink, StdoutSink, InMemorySink, FileSink
│ │ └── otlp.py # OtlpSink (optional extra)
│ └── tar.py # extract_tar_gz (subprocess; tar always shells out)
└── tests/
├── conftest.py
├── test_lint.py
├── test_eval.py
├── test_poll.py
├── test_sse.py
├── test_remote.py
└── telemetry/
└── test_records.py
Optional extras
| Extra | Pulls in | Gives you |
|---|---|---|
exd-client[asyncio] | nothing extra (stdlib asyncio) | The async surface (AsyncExdClient, async Poller, event_stream). Default-on. |
exd-client[httpx] | httpx | Pure-Python HTTP transport for ExdRemote and the polling driver. Default-on. |
exd-client[otlp] | opentelemetry-sdk, opentelemetry-exporter-otlp-proto-http | OtlpSink for emitting EvaluationRecords as OTel LogRecords or span events. |
exd-client[parquet] | pyarrow | Parquet output mode for FileSink. |
exd-client[object-store] | boto3, google-cloud-storage, azure-storage-blob | ObjectStoreSink for S3 / GCS / Azure Blob. |
exd-client (no extras) covers the read path + telemetry to
console / stdout / NDJSON file; everything else is opt-in.
The Rust core's surface (exd_client._core)
The PyO3 bindings expose only the byte-for-byte equivalents of
what crates/exd-client-wasm/src/lib.rs exposes to TypeScript, plus
the few extras the Python SDK needs that the WASM doesn't (HTTP
transport, SSE consumer, closure-delta SSE driver — all of which
already live in crates/exd-client/src/ and are gated on
async-tokio).
| Symbol | Source | Why |
|---|---|---|
_core.lint_files(dir_name, files: dict[str, str]) | exd_client::lint_files | Single-validator entry. |
_core.Namespace.from_files(dir_name, files) | exd_client::Namespace::from_files | In-memory namespace construction. |
_core.Namespace.from_dir(path) / from_uri(uri, opts) / from_server(server, ns, token, opts) | sync Namespace::* | Sync loaders. |
_core.Namespace.eval(flag, env, ctx) / eval_bool / eval_string / eval_i64 / eval_f64 / eval_json | Namespace::eval* | Typed eval. Strict variants raise EvalError. |
_core.Namespace.refresh() / replace_from_files(files) | Namespace::refresh / replace_from_files | Atomic state swap. |
_core.Namespace.bucketing_attribute(env, flag) / telemetry_enabled / raw_entity_ids / *_private_attributes | telemetry accessors | Mirrors WASM telemetry bridge. |
_core.sha256_hex(input) | hashing helper | For telemetry parity with the Rust SDK. |
_core.AsyncClient | exd_client::ExdClient (async-tokio feature) | Drives the Tokio runtime; futures bridged to asyncio via pyo3-asyncio. |
_core.AsyncClient.start_event_stream(...) | exd_client::client::event_stream::run | SSE consumer with closure-delta apply. |
_core.build_variant() → "python" | identity tag | Telemetry sdk_name = "exd-client-python". |
The pure-Python wrappers under exd_client/ add type hints,
default arguments, context-manager support (__enter__ /
__exit__), and the EvaluationSink registration plumbing that
calls back from Tokio into Python under the GIL.
Tokio runtime
AsyncClient owns a single Tokio runtime spawned from a Rust
background thread. Python asyncio futures are bridged via
pyo3-asyncio. One runtime
per process; multiple AsyncExdClient instances share it. The
runtime is shut down on _core module unload (via
Py_AtExit-equivalent) and on explicit client.close().
For the sync surface, the Rust core uses the existing
synchronous Namespace path (no Tokio; subprocess for git/tar/curl
where needed) — the same code path the exd CLI uses.
Public API
Sync — Namespace
The thread-safe sync surface. Mirrors the Rust crate's
Namespace (always-available; no tokio dependency leaks into
caller code).
from exd_client import Namespace, EvalContext
ns = Namespace.from_dir("./manifest")
ctx = (
EvalContext.builder()
.str("user.country", "US")
.bool("user.is_premium", True)
.int("user.age", 28)
.str_list("user.roles", ["admin", "beta"])
.build()
)
enabled = ns.eval_bool("checkout-redesign", "production", ctx)
result = ns.eval("checkout-redesign", "production", ctx)
# result: EvalResult(value=..., variant_key=..., rule_matched=..., flag_version=...)
Constructors
| Method | Source | Refresh |
|---|---|---|
Namespace.from_dir(path: str | Path) | local directory | ns.refresh() re-reads from disk |
Namespace.from_archive(path: str | Path) | local tar.gz | static |
Namespace.from_uri(uri: str, *, auth=None, http_backend=None) | https://, git+ssh://, git+https://, file:// | poll via start_periodic_refresh |
Namespace.from_server(*, server, namespace, api_token, http_backend=None) | exd server | poll via start_periodic_refresh |
Namespace.from_files(dir_name, files: Mapping[str, str]) | in-memory map | ns.replace_from_files(files) |
Eval
| Method | Returns | On failure |
|---|---|---|
eval_bool / eval_string / eval_i64 / eval_f64 / eval_json | typed Python value | raises EvalError (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch) |
eval(flag, env, ctx) | EvalResult | raises EvalError |
bool_flag(flag, env, ctx, default) | typed value, never raises | warns on logger, returns default |
eval_all(env, ctx) | dict[str, EvalResult] | per-flag errors mapped into EvalResult.rule_matched = SdkDefault |
Refresh
| Method | Behavior |
|---|---|
ns.refresh() | one-shot blocking re-fetch + lint; previous state preserved on error |
ns.start_periodic_refresh(interval: timedelta) -> RefreshHandle | spawns a background thread, not an asyncio task |
ns.flag_version | monotonic counter; bumps only on successful refresh |
ns.last_refresh_error | str | None |
Namespace is thread-safe; the typed model is held behind an
internal Arc<NamespaceState> (the same atomic-swap primitive the
Rust SDK uses) so concurrent eval calls never block each other.
Async — AsyncExdClient
The asyncio counterpart. Builder pattern matches the Rust async client.
import asyncio
from exd_client import AsyncExdClient
from exd_client.context import EvalContext
async def main():
client = await (
AsyncExdClient.builder()
.server("https://flags.example.com")
.namespace("payments")
.environment("production")
.api_token("exd_read_...")
.poll_interval(timedelta(seconds=30))
.manifest_cache_path("/var/cache/exd/payments.tar.gz")
.build()
)
ctx = EvalContext.builder().str("user.country", "US").build()
enabled = await client.bool_flag("checkout-redesign", ctx, False)
await client.aclose()
asyncio.run(main())
AsyncExdClient is async with-compatible:
async with AsyncExdClient.from_manifest_file("./payments.tar.gz", "production") as client:
enabled = await client.bool_flag("checkout-redesign", ctx, False)
Builder methods
Mirror the Rust ExdClient builder field-for-field.
namespace_inline / namespace_uri / server are mutually
exclusive; combining two raises ConflictingNamespaceSourcesError.
| Method | Type | Default |
|---|---|---|
namespace_inline(ns: Namespace) | Namespace | — |
namespace_uri(uri: str) | str | — |
server(url: str) + namespace(slug: str) + api_token(token: str) | str | — |
environment(env: str) | str | required |
uri_auth(auth: UriAuth) | Bearer | Basic | none |
poll_interval(interval: timedelta) | timedelta | 30s |
manifest_cache_path(path: str | Path) | Path | none |
working_tree(path: str | Path) | Path | per-process tmpdir |
event_stream(enabled: bool) | bool | False |
event_stream_flags(flags: list[str]) | list[str] | ["*"] |
safety_net_interval(interval: timedelta) | timedelta | 30 min |
http_backend(backend: HttpBackend) | InProcess | Curl | HttpBackend.from_env_or_default() |
dry_run(enabled: bool) | bool | False |
sinks(sinks: Iterable[EvaluationSink]) | list[EvaluationSink] | [] |
on_evaluation(fn: Callable[[EvaluationRecord], None]) | callable | none |
build() is async because it performs the initial fetch +
lint and starts the background poll task / SSE stream before
resolving.
Multi-namespace — AsyncExdClientGroup
from exd_client import AsyncExdClientGroup
group = await (
AsyncExdClientGroup.builder()
.server("https://flags.example.com")
.api_token("exd_read_…")
.member("billing", environment="production")
.member("growth", environment="production", flags=["onboarding"])
.working_tree_root("/var/cache/exd")
.build()
)
billing = group.client("billing")
on = await billing.bool_flag("checkout-redesign", ctx, False)
await group.aclose()
Same single-SSE-connection-per-group semantics as the Rust SDK.
group.client(slug) returns an AsyncExdClient whose refresh is
driven by the shared consumer.
Remote eval — exd_client.remote.ExdRemote
from exd_client.remote import ExdRemote
remote = ExdRemote(
server="https://flags.example.com",
token="exd_read_...",
namespace="payments",
environment="production",
)
enabled = await remote.eval_bool("checkout-redesign", ctx, False)
all_flags = await remote.eval_all(ctx) # one round trip resolves every flag
Pure-Python; no FFI, no manifest cache, no telemetry emission
(the server emits records with sdk_name = "exd-server"). HTTP
transport via httpx (default extra) or stdlib urllib fallback
(off-network smoke tests). Falls back to caller-supplied default
on transport error.
A sync analogue (SyncExdRemote) is provided for non-asyncio
callers.
Polling — exd_client.poll
from exd_client.poll import Poller, archive_source_from_url
source = archive_source_from_url(
"https://flags.example.com/api/v1/tenants/acme/namespaces/demo/manifest",
token="exd_read_...",
)
poller = Poller(client, source, interval=timedelta(seconds=30))
await poller.start() # background asyncio task
# ...
await poller.stop()
archive_source_from_url tracks ETag between calls and short-
circuits on 304 (returns None from fetch_archive →
Poller skips the swap). Equivalent to the TS
@exd/client/poll.
SSE — exd_client.sse
from exd_client.sse import event_stream
stop = event_stream(
"https://flags.example.com/api/v1/events",
client, # AsyncExdClient
members=[("payments", ["*"])],
token="exd_read_...",
on_update=lambda e: print("flags updated to closure", e.closure_hash),
)
# ...
await stop()
Drives the v2 closure-delta protocol. Reconnects with exponential
backoff (1 s → 30 s cap) and resumes via Last-Event-ID. The
underlying state machine and apply-loop guarantees mirror
docs/sdks/01-rust.md § Event-stream refresh
exactly — same single-validator round trip, same fallback
hierarchy.
Telemetry — exd_client.telemetry
Mirrors crates/exd-client/src/telemetry/ field-for-field. Same
EvaluationRecord JSON wire format, same suppression matrix
(dry_run, namespace.telemetry_enabled, raw_entity_ids,
flag-namespace + per-flag private_attributes), same SHA-256 hashing
of bucketing identifiers (delegating to _core.sha256_hex so the
digest is byte-identical to the Rust SDK).
from exd_client import AsyncExdClient
from exd_client.telemetry import StdoutSink, InMemorySink, FileSink
records = InMemorySink()
client = await (
AsyncExdClient.builder()
# ... source / env / token ...
.sinks([StdoutSink(), FileSink("/var/log/exd/records.ndjson"), records])
.build()
)
await client.bool_flag("ship-it", ctx, False)
await client.aclose() # flushes every attached sink
| Export | Purpose |
|---|---|
EvaluationRecord (dataclass) | Wire type — schema_version, evaluation_id, timestamp, namespace, environment, flag_key, variant_key, variant_value, evaluation_reason, matched_rule_id, manifest_version, unit_id_hash, unit_id_type, secondary_unit_ids, context_attributes, sdk_name, sdk_version, request_id, trace_id, span_id. |
EvaluationReason (Enum) | MATCHED_RULE, FALLTHROUGH, OFF, PREREQUISITE_FAILED, ERROR, SDK_DEFAULT. |
VariantValue (dataclass) | type: Literal["bool", "string", "int", "float", "json"], value: Any. |
ContextValue (TypeAlias) | str | int | float | bool | list[str]. |
EvaluationSink (Protocol) | record(rec: EvaluationRecord) -> None; flush() -> Awaitable[None] | None. |
ClosureSink | Wraps a Callable[[EvaluationRecord], None] as a sink. |
ConsoleSink() | NDJSON via stdlib logging. |
StdoutSink() | NDJSON via sys.stdout.write. |
InMemorySink() | Records into .records: list[EvaluationRecord]; useful for tests. |
FileSink(path, *, format="ndjson" | "parquet", rotation=...) | NDJSON (always available) or Parquet (requires [parquet] extra). |
OtlpSink(endpoint, ...) (extras [otlp]) | Emits as OTel span event when an OTel context is active, otherwise as LogRecord over OTLP HTTP/protobuf. Same attributes the Rust OtlpSink uses. |
ObjectStoreSink(uri, ...) (extras [object-store]) | Parquet to s3 / gs / az / file / memory. Same column layout as the Rust sink. |
encode_ndjson(record) -> bytes | One NDJSON line, trailing \n. |
build_evaluation_record(bridge, *, ...) | The exposed equivalent of the Rust emit::build_record and TS buildEvaluationRecord. Short-circuits on dry_run = True and telemetry_enabled = False; routes the bucketing-id hash through _core.sha256_hex. |
Sink contract
Same five rules as the Rust + TS sinks (sinks § The sink contract):
- Accept records as-is.
- Non-blocking on the eval hot path —
record(...)SHOULD return in micro-bounded time. Network / disk / serialization happens out of band. - Survive transient downstream failure — a sink raising from
record(...)MUST NOT propagate to the caller; the SDK logs at WARN and continues fanning to the remaining sinks. - Best-effort flush on graceful shutdown —
client.aclose()(async) /client.close()(sync) await every attached sink'sflush(). - No record mutation — sinks MAY encode but MUST NOT rewrite fields.
Custom sinks from Python
from exd_client.telemetry import EvaluationRecord, EvaluationSink
class MyKafkaSink(EvaluationSink):
def __init__(self, producer, topic):
self._producer = producer
self._topic = topic
def record(self, rec: EvaluationRecord) -> None:
self._producer.send(self._topic, rec.to_json().encode())
async def flush(self) -> None:
self._producer.flush()
The Rust core invokes sink.record(...) from inside the Tokio
runtime under the GIL. Sinks MUST be picklable iff the consumer
expects to use multiprocessing; the SDK doesn't itself fork.
Type stubs
Every public symbol carries a type stub. exd_client._core.pyi
declares the FFI surface for type checkers (mypy, pyright); the
pure-Python modules use inline annotations. py.typed marker is
shipped in the wheel.
HTTP backend
Same selector semantics as the Rust SDK. HttpBackend.InProcess
uses Rust's reqwest over the FFI boundary (zero Python network
deps required); HttpBackend.Curl shells out to curl. Selection
precedence:
- Explicit
.http_backend(HttpBackend.Curl)builder method. EXD_HTTP_BACKEND=curl|in-processenvironment variable.- Compiled-in default (
InProcess).
tar and git always shell out, regardless of backend. The
Python SDK does not use python-requests / httpx for any
manifest-fetch path — that would create a second transport
implementation and a second observability surface. The remote-eval
client (ExdRemote) is the one exception: it's the no-FFI surface,
so it uses httpx (or urllib fallback) directly.
Working tree
The Python SDK reuses the Rust core's working-tree machinery
verbatim — working_tree(path) on the builder maps to the same
on-disk mirror documented for the Rust SDK. Configure to a
persistent path for warm-restart determinism; default is a
per-process subdirectory under tempfile.gettempdir().
Error model
| Exception | When it fires |
|---|---|
ExdError | base class |
ConflictingNamespaceSourcesError | builder mis-config |
MissingFieldError | builder mis-config |
LoadError | manifest load failed (sub-types: Io, Fetch, Lint, SlugMismatch, Builder) |
LintError | carries LintReport; raised when constructing a Namespace from an unlintable manifest |
EvalError | typed-eval mismatches (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch) |
RefreshError | wraps the underlying LoadError for periodic-refresh diagnostic surfacing |
TransportError | raised by ExdRemote and the polling driver on transport failures (5xx, network) — bool_flag / eval_bool etc. swallow this and return the caller's default |
The Rust panic-free invariant is preserved across the FFI: a panic
in the Rust core surfaces as ExdError("internal SDK panic: ...")
with the panic message; eval methods (bool_flag etc.) never
propagate. The strict typed-eval methods (eval_bool etc.)
raise EvalError on type mismatch.
Build pipeline
# Local development build — installs into the current venv as an editable wheel.
pip install maturin
maturin develop --release -m sdks/python/Cargo.toml
# Release build — emits wheels for the host platform under target/wheels/.
maturin build --release -m sdks/python/Cargo.toml
# CI matrix uses cibuildwheel:
pip install cibuildwheel
cibuildwheel --output-dir dist sdks/python
cibuildwheel configuration lives in sdks/python/pyproject.toml
under [tool.cibuildwheel]. The wheel matrix matches the
Installation table.
Single-validator invariant
The Python SDK preserves the project-wide single-validator invariant:
Namespace.from_*constructors always route through the FFI's_core.lint_files(in-memory) or_core.lint_dir(disk), which delegate toexd_client::lint::lint_files/exd_client::lint::lint_dir— the same entries the Rust SDK and the WASM bridge use.- The Rust core is built from the same
crates/exd-client/source the Rust SDK and theexd-servervalidator are built from. There is no Python re-implementation of any operator, diagnostic, or type rule. - The
_core.sha256_hexhelper is the only digest path the telemetry layer uses, so a Python-emittedunit_id_hashmatches a Rust-emitted one byte-for-byte for the same input.
testplan/16-python-sdk.md § lite-equivalent equivalence enforces
the round-trip.
Limitations
- The polling driver does not retry on transport failure on its
own — failures surface via
Poller.on_errorand the next tick retries. Wrap with your own backoff if needed. OtlpSinkrequires the[otlp]extra; without it, attempting to construct one raisesImportErrorearly.- The sync surface (
Namespace) does not consume SSE — SSE is asyncio-only. Long-lived sync processes that need sub-second freshness should run anAsyncExdClientin a background thread viaasyncio.run_coroutine_threadsafeor move to async.