Skip to main content

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 in testplan/16-python-sdk.md.

Installation

pip install exd-client

Wheels are published for the cibuildwheel matrix:

OSArchTag
Linux (manylinux2014)x86_64manylinux2014_x86_64
Linux (manylinux2014)aarch64manylinux2014_aarch64
Linux (musllinux)x86_64musllinux_1_2_x86_64
Linux (musllinux)aarch64musllinux_1_2_aarch64
macOS 11+x86_64macosx_11_0_x86_64
macOS 11+arm64macosx_11_0_arm64
Windowsx86_64win_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._core inside 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

ExtraPulls inGives you
exd-client[asyncio]nothing extra (stdlib asyncio)The async surface (AsyncExdClient, async Poller, event_stream). Default-on.
exd-client[httpx]httpxPure-Python HTTP transport for ExdRemote and the polling driver. Default-on.
exd-client[otlp]opentelemetry-sdk, opentelemetry-exporter-otlp-proto-httpOtlpSink for emitting EvaluationRecords as OTel LogRecords or span events.
exd-client[parquet]pyarrowParquet output mode for FileSink.
exd-client[object-store]boto3, google-cloud-storage, azure-storage-blobObjectStoreSink 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).

SymbolSourceWhy
_core.lint_files(dir_name, files: dict[str, str])exd_client::lint_filesSingle-validator entry.
_core.Namespace.from_files(dir_name, files)exd_client::Namespace::from_filesIn-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_jsonNamespace::eval*Typed eval. Strict variants raise EvalError.
_core.Namespace.refresh() / replace_from_files(files)Namespace::refresh / replace_from_filesAtomic state swap.
_core.Namespace.bucketing_attribute(env, flag) / telemetry_enabled / raw_entity_ids / *_private_attributestelemetry accessorsMirrors WASM telemetry bridge.
_core.sha256_hex(input)hashing helperFor telemetry parity with the Rust SDK.
_core.AsyncClientexd_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::runSSE consumer with closure-delta apply.
_core.build_variant()"python"identity tagTelemetry 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

MethodSourceRefresh
Namespace.from_dir(path: str | Path)local directoryns.refresh() re-reads from disk
Namespace.from_archive(path: str | Path)local tar.gzstatic
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 serverpoll via start_periodic_refresh
Namespace.from_files(dir_name, files: Mapping[str, str])in-memory mapns.replace_from_files(files)

Eval

MethodReturnsOn failure
eval_bool / eval_string / eval_i64 / eval_f64 / eval_jsontyped Python valueraises EvalError (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch)
eval(flag, env, ctx)EvalResultraises EvalError
bool_flag(flag, env, ctx, default)typed value, never raiseswarns on logger, returns default
eval_all(env, ctx)dict[str, EvalResult]per-flag errors mapped into EvalResult.rule_matched = SdkDefault

Refresh

MethodBehavior
ns.refresh()one-shot blocking re-fetch + lint; previous state preserved on error
ns.start_periodic_refresh(interval: timedelta) -> RefreshHandlespawns a background thread, not an asyncio task
ns.flag_versionmonotonic counter; bumps only on successful refresh
ns.last_refresh_errorstr | 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.

MethodTypeDefault
namespace_inline(ns: Namespace)Namespace
namespace_uri(uri: str)str
server(url: str) + namespace(slug: str) + api_token(token: str)str
environment(env: str)strrequired
uri_auth(auth: UriAuth)Bearer | Basicnone
poll_interval(interval: timedelta)timedelta30s
manifest_cache_path(path: str | Path)Pathnone
working_tree(path: str | Path)Pathper-process tmpdir
event_stream(enabled: bool)boolFalse
event_stream_flags(flags: list[str])list[str]["*"]
safety_net_interval(interval: timedelta)timedelta30 min
http_backend(backend: HttpBackend)InProcess | CurlHttpBackend.from_env_or_default()
dry_run(enabled: bool)boolFalse
sinks(sinks: Iterable[EvaluationSink])list[EvaluationSink][]
on_evaluation(fn: Callable[[EvaluationRecord], None])callablenone

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_archivePoller 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
ExportPurpose
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.
ClosureSinkWraps 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) -> bytesOne 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):

  1. Accept records as-is.
  2. Non-blocking on the eval hot path — record(...) SHOULD return in micro-bounded time. Network / disk / serialization happens out of band.
  3. 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.
  4. Best-effort flush on graceful shutdown — client.aclose() (async) / client.close() (sync) await every attached sink's flush().
  5. 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:

  1. Explicit .http_backend(HttpBackend.Curl) builder method.
  2. EXD_HTTP_BACKEND=curl|in-process environment variable.
  3. 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

ExceptionWhen it fires
ExdErrorbase class
ConflictingNamespaceSourcesErrorbuilder mis-config
MissingFieldErrorbuilder mis-config
LoadErrormanifest load failed (sub-types: Io, Fetch, Lint, SlugMismatch, Builder)
LintErrorcarries LintReport; raised when constructing a Namespace from an unlintable manifest
EvalErrortyped-eval mismatches (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch)
RefreshErrorwraps the underlying LoadError for periodic-refresh diagnostic surfacing
TransportErrorraised 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 to exd_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 the exd-server validator are built from. There is no Python re-implementation of any operator, diagnostic, or type rule.
  • The _core.sha256_hex helper is the only digest path the telemetry layer uses, so a Python-emitted unit_id_hash matches 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_error and the next tick retries. Wrap with your own backoff if needed.
  • OtlpSink requires the [otlp] extra; without it, attempting to construct one raises ImportError early.
  • The sync surface (Namespace) does not consume SSE — SSE is asyncio-only. Long-lived sync processes that need sub-second freshness should run an AsyncExdClient in a background thread via asyncio.run_coroutine_threadsafe or move to async.