This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.0.0 instead.
Reason given by maintainers: Renamed: this package is now controlvar. Install it with: pip install controlvar
experimently (Python)
0.x beta. The API may change between 0.x minor versions; pin the exact version you test against.
Synchronous, dependency-free Python client for the Experimently public API.
Experiment assignment and feature-flag evaluation are decided by the server by default (sticky
per user + experiment); the SDK caches the answers per user + key. Server-side code can opt in to
local evaluation (evaluation="local", beta): flags are answered in-process from the server's
ruleset, with the server's answers, and anything that cannot be answered exactly goes to the
server. See docs/sdk/local-evaluation.md.
Requires Python 3.9+. HTTP goes through the standard library (urllib), so there is nothing
else to install.
Install
Not yet published. The experimently package is not on PyPI yet, so the first line below
fails today. Install from a clone of this repository with the second line, or without a clone
with pip install "experimently @ git+https://github.com/getexperimently/experimently.git#subdirectory=sdk/python".
pip install experimently
pip install -e sdk/python
pip install experimently: once publishedpip install -e sdk/python: from this repository
Quick start
from experimentation import ExperimentationClient
client = ExperimentationClient(
api_url="http://localhost:8000", # backend origin; the SDK appends /api/v1/...
api_key=os.environ["EXPERIMENTLY_API_KEY"],
)
# Experiments — sticky assignment made by the server
variant = client.get_variant("checkout_flow", user_id="user-123", user_attributes={"plan": "pro"})
if variant == "treatment":
...
assignment = client.get_assignment("checkout_flow", "user-123") # raises ExperimentationError on failure
assignment.variant_name, assignment.is_control, assignment.configuration
# Feature flags — user_attributes (optional) is sent as ?context=<url-encoded JSON> for targeting rules
if client.is_feature_enabled("new_search", "user-123", user_attributes={"plan": "pro"}):
...
flag = client.get_feature_flag("new_search", "user-123") # FlagEvaluation(key, enabled, config, reason)
flags = client.get_all_flags("user-123", {"plan": "pro"}) # {"new_search": True, ...}
# Tracking (never raises)
client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
client.track("user-123", "page_view", properties={"page": "/"}) # no key: fanned out, see below
Constructor: ExperimentationClient(api_url, api_key, timeout_seconds=5.0, cache_ttl_seconds=300, default_variant="control", transport=None). Instances are thread-safe.
API
| Method | Returns | Failure behaviour |
|---|---|---|
get_assignment(experiment_key, user_id, user_attributes=None) |
Assignment(experiment_key, user_id, variant_id, variant_name, is_control, configuration, assigned, reason) |
raises ExperimentationError (status == 404 when the experiment is not ACTIVE) |
get_variant(experiment_key, user_id, user_attributes=None) |
str variant name |
returns default_variant |
get_feature_flag(flag_key, user_id, user_attributes=None) |
FlagEvaluation(key, enabled, config, reason) |
raises ExperimentationError (status == 404 when the flag is not ACTIVE) |
is_feature_enabled(flag_key, user_id, user_attributes=None) |
bool |
returns False |
get_all_flags(user_id, user_attributes=None) |
dict[str, bool] (not cached) |
raises ExperimentationError |
track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None) |
bool — True when accepted |
never raises; False on failure or when nothing was sent |
track_batch(events) |
BatchResult(success_count, failure_count, errors); .ok |
never raises; a failed chunk counts all its events as failures |
get_assignments(user_id, active_only=True) |
list[dict] from the server |
raises ExperimentationError |
cached_assignments(user_id) / cached_flags(user_id) |
cached, unexpired entries | — |
clear_cache() |
— | — |
ready(timeout_seconds=None), status(), close() / with |
local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
consistent_hash(user_id, flag_key) / md5_hex(user_id, flag_key) |
cross-SDK MD5 bucket in [0, 1) / hex digest |
pure functions; not the flag bucketing function |
ExperimentationError carries .status (HTTP status, None for network errors/timeouts) and
.body (raw response text). Failures are never cached, so the next call retries. A 429 is
retried once after Retry-After seconds (capped at 5 s).
user_attributes is sent as context on assignment and on flag evaluation
(&context=<url-encoded JSON>, omitted when empty), so flag targeting rules evaluate against it
(country also matches user.country; nested dicts flatten to dotted keys such as app.version).
FlagEvaluation.reason (targeting_rule / rollout / inactive / error, or None) says why
the server decided. Caches are keyed by user + key only — call clear_cache() after changing a
user's attributes.
Enrolment: assigned and reason
An Assignment also carries assigned and reason. assigned is False means the server did not
enrol the user (reason is "holdout", "mutual_exclusion" or "targeting") and returned the
control variant so you render the default experience; no exposure was recorded. When you export
exposures to a warehouse, exclude assignments with assigned is False: the user was not enrolled,
and reason says why. A server that predates the fields sends neither, so assigned is None
(never False). Decide deliberately how to treat those rows rather than dropping them with an
assigned is True filter.
Tracking fan-out rule
- with
experiment_keyand/orfeature_flag_key→ onePOST /api/v1/tracking/track; - without a key → one
POST /api/v1/tracking/batch(chunks of 100) containing one entry per experiment the user has been assigned to in this client (experiment_key) plus one per flag evaluated for the user (feature_flag_key), taken from the cache (successful, unexpired only); - nothing cached for the user → nothing is sent and
trackreturnsFalse.
event_type defaults to event_name; properties is sent as metadata; timestamp may be a
datetime (naive values are treated as UTC) or an ISO-8601 string.
Backend endpoints used
Every request carries X-API-Key: <key>, Content-Type: application/json and Accept: application/json.
| SDK call | Method and path | Body / query | Response used |
|---|---|---|---|
get_assignment, get_variant |
POST /api/v1/tracking/assign |
{experiment_key, user_id, context?} |
{experiment_key, user_id, variant_id, variant_name, is_control, configuration}; 404 when not ACTIVE |
get_feature_flag, is_feature_enabled |
GET /api/v1/feature-flags/evaluate/{flag_key}?user_id=…&context=<url-encoded JSON> |
context = user_attributes (omitted when empty) |
{key, enabled, config, reason}; off with reason: "inactive" when the flag exists but is not ACTIVE; 404 only for an unknown key |
get_all_flags |
GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON> |
context = user_attributes (omitted when empty) |
{"<flag_key>": bool, …} |
track with a key |
POST /api/v1/tracking/track |
{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?} |
ignored |
track without keys, track_batch |
POST /api/v1/tracking/batch |
{events: [<track body>, …]} (≤ 100 per request) |
{success_count, failure_count, errors} |
get_assignments |
GET /api/v1/tracking/assignments/{user_id}?active_only=true |
— | list of dicts |
Contract smoke
EXPERIMENTLY_API_KEY=<key> python sdk/python/examples/contract_smoke.py
It prints:
{"sdk":"python","assign":{"variant_name":"treatment","is_control":false,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
Env: EXPERIMENTLY_API_URL (default http://localhost:8000), EXPERIMENTLY_API_KEY (required),
CONTRACT_EXPERIMENT_KEY (default sdk_contract_ab), CONTRACT_FLAG_KEY (default sdk_contract_flag),
CONTRACT_USER_ID (default random smoke-<uuid>).
Verified against a live backend: yes (2026-09-11) — sdk_contract_ab / sdk_contract_flag
seeded with backend/scripts/seed_sdk_contract.py, run via
python tests/sdk-contract/live/run_live_contract.py --sdk python --strict.
Testing your own code
experimentation.testing.FakeTransport records requests and serves canned responses:
from experimentation import ExperimentationClient
from experimentation.testing import FakeTransport
transport = FakeTransport().route("GET", "/api/v1/feature-flags/evaluate/new_search",
json={"key": "new_search", "enabled": True, "config": None})
client = ExperimentationClient("http://test", "key", transport=transport)
assert client.is_feature_enabled("new_search", "user-1")
assert transport.last.query == {"user_id": "user-1"}
Development
source venv/bin/activate
python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider
python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider: 182 tests, HTTP is faked; runs on Python 3.9+
Metadata
Release files for experimently 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 | Provenance | Actions |
|---|---|---|---|---|
| experimently-0.1.0.tar.gz | 49.4 kB | With provenance | Details for experimently-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| experimently-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for experimently-0.1.0-py3-none-any.whl |
Total release size: 82.1 kB
Release files / experimently-0.1.0.tar.gz
| Download URL | experimently-0.1.0.tar.gz |
|---|---|
| Size | 49.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e45ab084800dc6a4cc068789ccfb5b478e085a8d6b37874aa221506a796a5c77
|
|
BLAKE2b-256 checksum How to use checksums |
719b715c0c9e8e5519767b441fc9c785758f69a26458c61ff7f0d58d52e28bfc
|
| 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 Oct 4, 2026.
Transparency logRelease files / experimently-0.1.0-py3-none-any.whl
| Download URL | experimently-0.1.0-py3-none-any.whl |
|---|---|
| Size | 32.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0e1523828b7ce17645642da6b390256cbb99a45b784ad78769db8f577a77fac5
|
|
BLAKE2b-256 checksum How to use checksums |
3ab8305f47b3cca807edb0d9db12964d317156fc5965a9e1ed3d9c76e9f88037
|
| 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 Oct 4, 2026.
Transparency logSecurity for experimently 0.1.0
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for experimently-0.1.0.tar.gz
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 Oct 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.