Skip to main content
Yanked

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 published
  • pip 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_key and/or feature_flag_key → one POST /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 track returns False.

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)

Source distribution for experimently 0.1.0
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)

Table of built distributions (wheels) for experimently 0.1.0
File Interpreter ABI Platform Actions
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 log

Release 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 log

Security 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.

Select from 2 files

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 log

A 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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page