The Convert Experiences FullStack SDK for Python — server-side A/B testing, feature flags, and personalizations for Python 3.9+ applications (Django, Flask, FastAPI, and plain Python services).
The SDK is framework-agnostic and sync-first: you can reach your first experiment value in plain Python, with no web framework and — using direct config — no network call.
pip install convert-python-sdk- Distribution name (PyPI):
convert-python-sdk - Import package:
convert_sdk
The two differ by design — the hyphenated name is the discoverability surface on PyPI, the snake_case name is the ergonomic import path.
Compatibility
- Python 3.9+
- No required web framework
- No JavaScript runtime dependency
- One runtime dependency (
httpx), used only forsdk_keyinitialization
The fastest path to a first successful run uses direct config — a preloaded config payload, no network call:
from convert_sdk import Core, SDKConfig
config_data = {
"account_id": "100123",
"project": {"id": "200456"},
"experiences": [
{
"id": "e1",
"key": "checkout-experiment",
"variations": [
{"id": "v1", "key": "control", "traffic_allocation": 50.0},
{"id": "v2", "key": "treatment", "traffic_allocation": 50.0},
],
}
],
}
# Initialize from direct config — ready immediately, no network.
core = Core(SDKConfig(data=config_data)).initialize()
# Create a visitor-scoped context.
context = core.create_context("visitor-001")
# Evaluate an experience.
result = context.run_experience("checkout-experiment")
if result is not None:
print("Bucketed into:", result.variation_key)
core.close()Core is the entry point. Construct it with an SDKConfig, then call
initialize(). Provide exactly one of data (direct config) or sdk_key
(remote config).
from convert_sdk import Core, SDKConfig
core = Core(SDKConfig(data=config_data)).initialize()
assert core.is_readyDirect-config initialization makes no network call and is ideal for local development, tests, and environments that load config out of band.
import os
from convert_sdk import Core, SDKConfig
# Read the key from the environment — never hard-code credentials.
core = Core(SDKConfig(sdk_key=os.environ["CONVERT_SDK_KEY"])).initialize()sdk_key initialization fetches config over HTTPS through the built-in
transport. Inject the key from an environment variable or your secret store;
do not embed real keys in source.
Core is also a context manager, so it releases transport resources cleanly:
with Core(SDKConfig(data=config_data)).initialize() as core:
context = core.create_context("visitor-001")
...create_context binds a visitor identity (and optional visitor attributes) to
the current immutable config snapshot:
context = core.create_context(
"visitor-001",
visitor_attributes={"country": "US", "plan": "pro"},
)Visitor attributes are used for audience qualification. They are copied
defensively — later mutations to the dict you pass never affect the context.
Keep and reuse the returned context to evaluate the same visitor repeatedly;
the SDK does not cache contexts for you.
run_experience evaluates a single experience for the visitor. It returns a
typed ExperienceResult when the visitor qualifies and buckets into a
variation, or None for any normal miss (missing experience, unqualified
visitor, no active variation). It never raises for normal outcomes and performs
no network I/O.
result = context.run_experience("checkout-experiment")
if result is not None:
print(result.experience_key, result.variation_key, result.variation_id)
# Evaluate all applicable experiences at once:
for result in context.run_experiences():
print(result.experience_key, "->", result.variation_key)You can overlay request-time attributes for a single call without mutating the stored context:
result = context.run_experience(
"checkout-experiment",
attributes={"country": "DE"},
)run_feature resolves a feature flag and its typed variables for the visitor.
It reads the feature change from the visitor's selected variation and casts each
variable using the feature's declared types. It returns a typed FeatureResult
when the feature is enabled for the visitor, or None for a normal miss
(undeclared, unavailable, or disabled feature). It never raises for normal
outcomes and performs no network I/O.
feature = context.run_feature("checkout-banner")
if feature is not None:
print(feature.status.value) # "enabled"
print(feature.variables["enabled"]) # typed per the feature definition (bool)
print(feature.variables["headline"]) # str
# Resolve all applicable features:
for feature in context.run_features():
print(feature.feature_key, feature.variables)track_conversion records a goal conversion for the visitor. It is lightweight
and synchronous — it deduplicates by (visitor_id, goal_id) and appends to an
in-process batch queue. No network call happens on track_conversion;
queued events are delivered when the queue is released via core.flush(),
batch-size release (SDKConfig.batch_size, default 10), an opt-in periodic
timer, or a best-effort atexit hook.
result = context.track_conversion("purchase_completed", revenue=49.99)
print(result.tracked, result.reason) # True None
# A default duplicate for the same (visitor, goal) is suppressed:
again = context.track_conversion("purchase_completed")
print(again.tracked, again.reason) # False "deduplicated"
# force_multiple re-tracks (e.g. repeated revenue/transactions):
context.track_conversion("purchase_completed", revenue=10.0, force_multiple=True)
# Deliver queued events explicitly (the canonical control point):
core.flush()Choosing when to flush depends on your runtime (Lambda, Cloud Run, gunicorn,
uvicorn, Celery, CLI). The default lifecycle is explicit-flush-only, which is
safe everywhere. See the project wiki
for per-runtime decision tables and copy-pasteable flush snippets, including the
opt-in daemonic periodic timer (SDKConfig.auto_flush_interval_ms), the
best-effort atexit hook, and the documented SIGTERM pattern.
Self-contained, framework-agnostic examples live in examples/ and
run locally with no external services:
python examples/direct_config.py # direct-config initialization
python examples/basic_experience.py # bucket a visitor into a variation
python examples/basic_feature.py # resolve a feature and read typed variablesThey share a small sample config (examples/_sample_config.py) and read any
sdk_key from the CONVERT_SDK_KEY environment variable rather than embedding
credentials.
The advanced guides live on the project wiki:
- Topic guides: Initialization, Evaluation, Tracking, Queue control, Debugging, Extending, Support workflows, Runtime integration
- Migration guides: Migrating from raw REST, Migrating from the JavaScript SDK
Importable from convert_sdk:
Core,ContextSDKConfig,TransportConfigExperienceResult,FeatureResult,FeatureStatusConversionResult,ConversionStatus- error types:
ConvertSDKError,ConfigError,InvalidConfigError,ConfigLoadError,TransportError,TrackingDeliveryError __version__
This project uses uv and the hatchling build
backend.
# Install dev tooling (pytest, ruff, mypy, coverage)
uv sync --group dev
# Run the test suite
uv run pytest
# Lint and type-check (the CI gates)
uv run ruff check src tests scripts
uv run mypy --strict
# Build wheel and sdist
uv buildEvery change runs through CI (.github/workflows/ci.yml): Ruff lint, mypy
--strict, a 15-cell test matrix (Python 3.9–3.13 × {ubuntu, macos, windows})
with an 85% project / 95% evaluation/ coverage floor, a release-blocking parity
suite, and a dependency-bounds check.
Reproduce all the release gates locally in one command:
python scripts/verify_release.pyThe SDK publishes to PyPI as convert-python-sdk via a workflow_run-
triggered GitHub Actions workflow using OIDC Trusted Publishing — there are
no long-lived PyPI tokens in repository secrets.
Releases are fully automatic: merge a Conventional-Commit PR to main and the
pipeline handles the rest. See RELEASE.md for the full
maintainer workflow, one-time setup, dry-run instructions, and troubleshooting.
Apache-2.0