controlvar 0.1.1__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. controlvar-0.3.0/PKG-INFO +334 -0
  2. controlvar-0.3.0/README.md +308 -0
  3. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/__init__.py +8 -1
  4. controlvar-0.3.0/controlvar/attribution.py +108 -0
  5. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/cache.py +14 -0
  6. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/client.py +381 -38
  7. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/evaluator.py +97 -19
  8. controlvar-0.3.0/controlvar/exposure.py +52 -0
  9. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/local.py +8 -2
  10. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/transport.py +24 -1
  11. controlvar-0.3.0/controlvar/types.py +140 -0
  12. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/version.py +1 -1
  13. controlvar-0.3.0/controlvar.egg-info/PKG-INFO +334 -0
  14. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar.egg-info/SOURCES.txt +5 -0
  15. {controlvar-0.1.1 → controlvar-0.3.0}/pyproject.toml +1 -1
  16. controlvar-0.3.0/tests/test_attribution.py +90 -0
  17. controlvar-0.3.0/tests/test_client.py +1553 -0
  18. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_contract_smoke.py +120 -1
  19. controlvar-0.3.0/tests/test_evaluator.py +382 -0
  20. controlvar-0.3.0/tests/test_event_id.py +131 -0
  21. controlvar-0.3.0/tests/test_exposure.py +617 -0
  22. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_local.py +340 -4
  23. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_transport.py +26 -0
  24. controlvar-0.1.1/PKG-INFO +0 -199
  25. controlvar-0.1.1/README.md +0 -173
  26. controlvar-0.1.1/controlvar/types.py +0 -87
  27. controlvar-0.1.1/controlvar.egg-info/PKG-INFO +0 -199
  28. controlvar-0.1.1/tests/test_client.py +0 -650
  29. controlvar-0.1.1/tests/test_evaluator.py +0 -214
  30. {controlvar-0.1.1 → controlvar-0.3.0}/LICENSE +0 -0
  31. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/hashing.py +0 -0
  32. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/py.typed +0 -0
  33. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar/testing.py +0 -0
  34. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar.egg-info/dependency_links.txt +0 -0
  35. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar.egg-info/requires.txt +0 -0
  36. {controlvar-0.1.1 → controlvar-0.3.0}/controlvar.egg-info/top_level.txt +0 -0
  37. {controlvar-0.1.1 → controlvar-0.3.0}/setup.cfg +0 -0
  38. {controlvar-0.1.1 → controlvar-0.3.0}/setup.py +0 -0
  39. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_hashing.py +0 -0
  40. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_no_real_aws.py +0 -0
  41. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_package.py +0 -0
  42. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_readme.py +0 -0
  43. {controlvar-0.1.1 → controlvar-0.3.0}/tests/test_version.py +0 -0
@@ -0,0 +1,334 @@
1
+ Metadata-Version: 2.4
2
+ Name: controlvar
3
+ Version: 0.3.0
4
+ Summary: Python client for the Controlvar public API (experiments, feature flags, event tracking)
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Controlvar/controlvar/tree/main/sdk/python#readme
7
+ Project-URL: Repository, https://github.com/Controlvar/controlvar
8
+ Project-URL: Issues, https://github.com/Controlvar/controlvar/issues
9
+ Keywords: experimentation,a/b-testing,feature-flags,sdk
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=7.0; extra == "dev"
25
+ Dynamic: license-file
26
+
27
+ # controlvar (Python)
28
+
29
+ **Beta.** This is a 0.x release: pin the exact version you test against.
30
+
31
+ Synchronous, dependency-free Python client for the Controlvar public API.
32
+ Experiment assignment and feature-flag evaluation are decided **by the server** by default (sticky
33
+ per user + experiment); the SDK caches the answers per user + key. Server-side code can opt in to
34
+ **local evaluation** (`evaluation="local"`, beta): flags are answered in-process from the server's
35
+ ruleset, with the server's answers, and anything that cannot be answered exactly goes to the
36
+ server. See [`docs/sdk/local-evaluation.md`](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/local-evaluation.md).
37
+
38
+ Requires Python 3.9+. HTTP goes through the standard library (`urllib`), so there is nothing
39
+ else to install.
40
+
41
+ ## Install
42
+
43
+ If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
44
+
45
+ The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.3.0 is a
46
+ beta release. It installs and imports as `controlvar`.
47
+
48
+ ```bash
49
+ pip install controlvar==0.3.0
50
+ ```
51
+
52
+ To work on the SDK itself, install it from the root of a clone of this repository instead:
53
+
54
+ ```bash
55
+ pip install -e sdk/python
56
+ ```
57
+
58
+ ## Changes in this version
59
+
60
+ - `get_string_value`, `get_number_value` and `get_json_value` are new: they read the value a
61
+ string, number or json flag serves. Each returns your `default` (keyword-only) when it cannot,
62
+ logs a warning, and passes the reason to `on_error(error, "value")`. See
63
+ [Flag values](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/python.md#flag-values).
64
+ - `FlagEvaluation` gains `value` and `value_type`, after `source`, so building one by position
65
+ still works. `FlagEvaluation` equality, `repr` and `astuple`/`asdict` include the two new fields.
66
+ - `ExperimentationError` gains `code`: `"TYPE_MISMATCH"` or `"INVALID_RESPONSE"` on the errors the
67
+ typed getters make, and `"HOOK_ERROR"` on the error `on_error` receives with `"exposure"`. Other
68
+ errors carry no code in this version; `status` describes them.
69
+ - `on_exposure` is new: a function you give the client when you create it, which it calls once each
70
+ time the server enrols a user in an experiment and the answer did not come from its cache. It
71
+ receives an `Exposure`, which `controlvar` exports. See Exposure hook below.
72
+ - `on_error` is now also called in server mode: with the operation `"value"` when a typed getter
73
+ returns your default, and with `"exposure"` when `on_exposure` raises. Code that treats an
74
+ operation or a `code` it does not know as a mistake should handle them.
75
+ - `track()` takes an `event_id`, and `track_batch()` sends the `event_id` key of an event's dict.
76
+ See Event ids below.
77
+ - `track()` returns `False`, and sends nothing, when `event_value` or `properties` holds NaN or an
78
+ infinite number; see Tracking fan-out rule for the other methods. Before this version the request
79
+ was sent.
80
+ - Local evaluation answers a string, number or json flag's value too. A flag entry with an
81
+ `evaluation` this SDK does not know sends only that flag to the server, and the rest of the
82
+ ruleset is still answered locally; before this version it refused the whole ruleset, and the
83
+ client kept answering from its last one. An entry with a `value_type` it does not know, or a
84
+ value of the wrong type, also sends only that flag to the server.
85
+ - Once local evaluation stops answering a flag (`max_stale_seconds` passed, the ruleset discarded,
86
+ `close()`), a call for a user it answered locally goes to the server; before this version that
87
+ user kept the local answer from the cache for up to `cache_ttl_seconds`.
88
+ - With a finite `cache_ttl_seconds` above 24 hours, a `track()` without a key still reaches an
89
+ experiment or flag whose answer is still cached; before this version it reached only those
90
+ answered in the last 24 hours.
91
+ - The message of a request that fails on the network, and the warning the client logs for it,
92
+ name the URL without its query string, which carries the user id and the user's attributes;
93
+ before this version they named the full URL.
94
+
95
+ ## Event ids
96
+
97
+ `track(..., event_id=None)` sends an `event_id`, and `track_batch()` sends the `event_id` key of
98
+ an event's dict as it is. The server stores an event sent again with the same `event_id`, user,
99
+ experiment, flag and event name only once. It needs a Controlvar server that includes the `event_id` change (#1640,
100
+ in the first platform release after it merged): an older server ignores the field.
101
+
102
+ - The SDK makes no id: without an `event_id` none is sent, and an event sent again is counted
103
+ again. Make the id once, when the event happens, and give the same one each time you send it.
104
+ - `None` and `""` send none. The server refuses an id of more than 255 characters or one holding
105
+ NUL with `422`; the SDK does not check it.
106
+ - An event fanned out to several experiments and flags carries the same id in every entry. A `429`
107
+ that is retried sends the same id. A request that fails on the network or times out is not sent
108
+ again by the SDK.
109
+
110
+ ## Quick start
111
+
112
+ Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
113
+ stand for your own code.
114
+
115
+ ```python
116
+ import os
117
+ from controlvar import ExperimentationClient, ExperimentationError
118
+
119
+ client = ExperimentationClient(
120
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"), # origin only
121
+ api_key=os.environ["CONTROLVAR_API_KEY"],
122
+ )
123
+
124
+ # Experiments — sticky assignment made by the server (POST /api/v1/tracking/assign)
125
+ variant = client.get_variant("checkout_flow", user_id="user-123", user_attributes={"plan": "pro"})
126
+ if variant == "treatment":
127
+ print("show the new checkout") # your own code
128
+
129
+ assignment = client.get_assignment("checkout_flow", "user-123") # raises on failure
130
+ assignment.variant_name, assignment.is_control, assignment.configuration
131
+
132
+ # Feature flags (GET /api/v1/feature-flags/evaluate/{key}?user_id=…&context=<url-encoded attributes>)
133
+ if client.is_feature_enabled("new_search", "user-123", user_attributes={"plan": "pro"}):
134
+ print("use the new search") # your own code
135
+ flag = client.get_feature_flag("new_search", "user-123") # FlagEvaluation(key, enabled, config, reason, source, value, value_type)
136
+ flags = client.get_all_flags("user-123", {"plan": "pro"}) # {"new_search": True, ...}
137
+
138
+ # Tracking (never raises)
139
+ client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
140
+ client.track("user-123", "page_view", properties={"page": "/"}) # no key: fanned out, see below
141
+ ```
142
+
143
+ Constructor: `ExperimentationClient(api_url, api_key, timeout_seconds=5.0, cache_ttl_seconds=300,
144
+ default_variant="control", transport=None)`. Instances are thread-safe.
145
+
146
+ ## API
147
+
148
+ | Method | Returns | Failure behaviour |
149
+ |---|---|---|
150
+ | `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 no experiment has the key, or for a draft with no control variant); an experiment that is not ACTIVE returns its control variant with `assigned` False and `reason` its status |
151
+ | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
152
+ | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason, source, value, value_type)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
153
+ | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
154
+ | `get_string_value(flag_key, user_id, user_attributes=None, *, default)` | `str`, the string a string flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
155
+ | `get_number_value(flag_key, user_id, user_attributes=None, *, default)` | `int` or `float` as saved (never a `bool`), the number a number flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
156
+ | `get_json_value(flag_key, user_id, user_attributes=None, *, default)` | a new copy of the `dict` or `list` a json flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
157
+ | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
158
+ | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None, event_id=None)` | `bool` — `True` when sent and answered 2xx | never raises; `False` on failure |
159
+ | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
160
+ | `report_error(error_type, message, *, feature_flag_key=None, experiment_key=None, user_id=None, stack_trace=None, metadata=None, timestamp=None)` | `bool` — `True` when the server stored the report (201) | never raises; `False` and a logged warning otherwise |
161
+ | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
162
+ | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
163
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
164
+ | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
165
+ | `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 |
166
+
167
+ `ExperimentationError` carries `.status` (HTTP status, `None` for network errors/timeouts),
168
+ `.body` (raw response text) and `.code`: `"TYPE_MISMATCH"` or `"INVALID_RESPONSE"` on an error a
169
+ typed getter makes itself, and `"HOOK_ERROR"` on the error `on_error` receives with `"exposure"`.
170
+ Other errors carry no code in this version; `.status` describes them. Failures are never cached, so
171
+ the next call retries. A `429` is retried once after `Retry-After` seconds (capped at 5 s).
172
+
173
+ `user_attributes` is sent as `context` on assignment **and** on flag evaluation
174
+ (`&context=<url-encoded JSON>`, omitted when empty), so flag targeting rules evaluate against it
175
+ (`country` also matches `user.country`; nested dicts flatten to dotted keys such as `app.version`).
176
+ `FlagEvaluation.reason` (`targeting_rule` / `rollout` / `inactive` / `error`, or `None`) says why
177
+ the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
178
+ user's attributes.
179
+
180
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
181
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
182
+ was created by a user who can change feature flags; the flag's safety check shows the others as
183
+ `uncounted_error_count`.
184
+
185
+ ### Enrolment: `assigned` and `reason`
186
+
187
+ An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
188
+ enrol the user (`reason` is `"holdout"`, `"mutual_exclusion"` or `"targeting"`) and returned the
189
+ control variant so you render the default experience; no exposure was recorded. **When you export
190
+ exposures to a warehouse, exclude assignments with `assigned is False`**: the user was not enrolled,
191
+ and `reason` says why. A server that predates the fields sends neither, so `assigned` is `None`
192
+ (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
193
+ `assigned is True` filter.
194
+
195
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
196
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
197
+
198
+ ## Tracking fan-out rule
199
+
200
+ - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
201
+ - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
202
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
203
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
204
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
205
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
206
+ counts in holdout results and as CUPED history, never in an experiment's results);
207
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
208
+
209
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
210
+ another process does not have them. In a server, pass `experiment_key` on conversions.
211
+
212
+ `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
213
+ `datetime` (naive values are treated as UTC) or an ISO-8601 string.
214
+
215
+ `track` returns `False`, and sends nothing, when `event_value` or `properties` holds NaN or an
216
+ infinite number. `get_assignment` raises `ExperimentationError`
217
+ for the same in `user_attributes`, and `get_variant` returns the default for a user with no
218
+ cached assignment (a cached assignment is returned as before).
219
+
220
+ ## Exposure hook
221
+
222
+ `on_exposure` is a function you give the client when you create it. The client calls it once each
223
+ time the server enrols the user in an experiment and the answer did not come from the client's
224
+ cache: the moment the server records the exposure. Use it to send the exposure to your analytics
225
+ tool or warehouse.
226
+
227
+ The example needs an ACTIVE experiment with the key `checkout_flow`; put your own key in its place.
228
+ It prints `exposure checkout_flow <variant> user-123` once, or nothing for a user the experiment
229
+ did not enrol.
230
+
231
+ ```python
232
+ import os
233
+ import queue
234
+ import threading
235
+
236
+ from controlvar import ExperimentationClient
237
+
238
+ exposures = queue.Queue(maxsize=10_000)
239
+
240
+
241
+ def send_exposures():
242
+ while True:
243
+ exposure = exposures.get()
244
+ # Your analytics tool's call goes here: it runs on this thread, not inside get_variant.
245
+ print("exposure", exposure.experiment_key, exposure.variant_name, exposure.user_id)
246
+ exposures.task_done()
247
+
248
+
249
+ threading.Thread(target=send_exposures, daemon=True).start()
250
+
251
+
252
+ def on_exposure(exposure):
253
+ try:
254
+ exposures.put_nowait(exposure)
255
+ except queue.Full:
256
+ pass # drop it rather than make get_variant wait
257
+
258
+
259
+ client = ExperimentationClient(
260
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"),
261
+ api_key=os.environ["CONTROLVAR_API_KEY"],
262
+ on_exposure=on_exposure,
263
+ )
264
+ client.get_variant("checkout_flow", "user-123")
265
+ exposures.join()
266
+ ```
267
+
268
+ It receives an `Exposure`: `experiment_key`, `user_id`, `variant_id` (`None` when the server sends
269
+ none) and `variant_name`. It is not called for a cached answer, a user the experiment did not enrol
270
+ or an experiment that is not running (`assigned` False), a call that fails, or a feature flag. It
271
+ runs in the thread that called `get_assignment` or `get_variant`, before that call returns, so keep
272
+ it quick. If it raises, the answer is unchanged, and the error is logged at ERROR and passed to
273
+ `on_error` with the operation `"exposure"` and `code` `"HOOK_ERROR"`.
274
+
275
+ Full details: [Exposure hook](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/python.md#exposure-hook).
276
+
277
+ ## Backend endpoints used
278
+
279
+ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `Accept: application/json`.
280
+
281
+ | SDK call | Method and path | Body / query | Response used |
282
+ |---|---|---|---|
283
+ | `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, assigned, reason}`; 404 when no experiment has the key, or for a draft with no control variant; an experiment that is not ACTIVE answers its control variant with `assigned: false` |
284
+ | `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 |
285
+ | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
286
+ | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?, event_id?}` | ignored |
287
+ | `track` without keys | `POST /api/v1/tracking/batch`, one entry per experiment and flag it reaches; with none, `POST /api/v1/tracking/track` | `{events: [<track body>, …]}` (≤ 100 per request); with none, the track body with no key | ignored |
288
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
289
+ | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
290
+
291
+ ## Contract smoke
292
+
293
+ ```bash
294
+ CONTROLVAR_API_KEY=<key> python sdk/python/examples/contract_smoke.py
295
+ ```
296
+
297
+ It prints:
298
+
299
+ ```text
300
+ {"sdk":"python","assign":{"variant_name":"treatment","is_control":false,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true},"event_id":{"user_id":"smoke-<uuid>","event_name":"contract_dedup_<uuid>","id":"contract-<uuid>","sends":2},"exposure":{"calls":1,"payload":{"experiment_key":"sdk_contract_ab","variant_name":"treatment","variant_id":"<id>","user_id":"smoke-<uuid>"}}}
301
+ ```
302
+
303
+ Env: `CONTROLVAR_API_URL` (default `http://localhost:8000`), `CONTROLVAR_API_KEY` (required),
304
+ `CONTRACT_EXPERIMENT_KEY` (default `sdk_contract_ab`), `CONTRACT_FLAG_KEY` (default `sdk_contract_flag`),
305
+ `CONTRACT_USER_ID` (default random `smoke-<uuid>`).
306
+
307
+ CI runs this SDK's contract smoke against a real API: `python` is in the `sdks:` list of the
308
+ SDK Live Contract job in [pr-qa-gate.yml](https://github.com/Controlvar/controlvar/blob/main/.github/workflows/pr-qa-gate.yml). Its fixtures,
309
+ `sdk_contract_ab` and `sdk_contract_flag`, are seeded with `backend/scripts/seed_sdk_contract.py`;
310
+ run it with `python tests/sdk-contract/live/run_live_contract.py --sdk python --strict`.
311
+
312
+ ## Testing your own code
313
+
314
+ `controlvar.testing.FakeTransport` records requests and serves canned responses:
315
+
316
+ ```python
317
+ from controlvar import ExperimentationClient
318
+ from controlvar.testing import FakeTransport
319
+
320
+ transport = FakeTransport().route("GET", "/api/v1/feature-flags/evaluate/new_search",
321
+ json={"key": "new_search", "enabled": True, "config": None})
322
+ client = ExperimentationClient("http://test", "key", transport=transport)
323
+ assert client.is_feature_enabled("new_search", "user-1")
324
+ assert transport.last.query == {"user_id": "user-1"}
325
+ ```
326
+
327
+ ## Development
328
+
329
+ ```bash
330
+ source venv/bin/activate
331
+ python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider
332
+ ```
333
+
334
+ - `python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider`: HTTP is faked; CI runs them on Python 3.11
@@ -0,0 +1,308 @@
1
+ # controlvar (Python)
2
+
3
+ **Beta.** This is a 0.x release: pin the exact version you test against.
4
+
5
+ Synchronous, dependency-free Python client for the Controlvar public API.
6
+ Experiment assignment and feature-flag evaluation are decided **by the server** by default (sticky
7
+ per user + experiment); the SDK caches the answers per user + key. Server-side code can opt in to
8
+ **local evaluation** (`evaluation="local"`, beta): flags are answered in-process from the server's
9
+ ruleset, with the server's answers, and anything that cannot be answered exactly goes to the
10
+ server. See [`docs/sdk/local-evaluation.md`](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/local-evaluation.md).
11
+
12
+ Requires Python 3.9+. HTTP goes through the standard library (`urllib`), so there is nothing
13
+ else to install.
14
+
15
+ ## Install
16
+
17
+ If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
18
+
19
+ The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.3.0 is a
20
+ beta release. It installs and imports as `controlvar`.
21
+
22
+ ```bash
23
+ pip install controlvar==0.3.0
24
+ ```
25
+
26
+ To work on the SDK itself, install it from the root of a clone of this repository instead:
27
+
28
+ ```bash
29
+ pip install -e sdk/python
30
+ ```
31
+
32
+ ## Changes in this version
33
+
34
+ - `get_string_value`, `get_number_value` and `get_json_value` are new: they read the value a
35
+ string, number or json flag serves. Each returns your `default` (keyword-only) when it cannot,
36
+ logs a warning, and passes the reason to `on_error(error, "value")`. See
37
+ [Flag values](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/python.md#flag-values).
38
+ - `FlagEvaluation` gains `value` and `value_type`, after `source`, so building one by position
39
+ still works. `FlagEvaluation` equality, `repr` and `astuple`/`asdict` include the two new fields.
40
+ - `ExperimentationError` gains `code`: `"TYPE_MISMATCH"` or `"INVALID_RESPONSE"` on the errors the
41
+ typed getters make, and `"HOOK_ERROR"` on the error `on_error` receives with `"exposure"`. Other
42
+ errors carry no code in this version; `status` describes them.
43
+ - `on_exposure` is new: a function you give the client when you create it, which it calls once each
44
+ time the server enrols a user in an experiment and the answer did not come from its cache. It
45
+ receives an `Exposure`, which `controlvar` exports. See Exposure hook below.
46
+ - `on_error` is now also called in server mode: with the operation `"value"` when a typed getter
47
+ returns your default, and with `"exposure"` when `on_exposure` raises. Code that treats an
48
+ operation or a `code` it does not know as a mistake should handle them.
49
+ - `track()` takes an `event_id`, and `track_batch()` sends the `event_id` key of an event's dict.
50
+ See Event ids below.
51
+ - `track()` returns `False`, and sends nothing, when `event_value` or `properties` holds NaN or an
52
+ infinite number; see Tracking fan-out rule for the other methods. Before this version the request
53
+ was sent.
54
+ - Local evaluation answers a string, number or json flag's value too. A flag entry with an
55
+ `evaluation` this SDK does not know sends only that flag to the server, and the rest of the
56
+ ruleset is still answered locally; before this version it refused the whole ruleset, and the
57
+ client kept answering from its last one. An entry with a `value_type` it does not know, or a
58
+ value of the wrong type, also sends only that flag to the server.
59
+ - Once local evaluation stops answering a flag (`max_stale_seconds` passed, the ruleset discarded,
60
+ `close()`), a call for a user it answered locally goes to the server; before this version that
61
+ user kept the local answer from the cache for up to `cache_ttl_seconds`.
62
+ - With a finite `cache_ttl_seconds` above 24 hours, a `track()` without a key still reaches an
63
+ experiment or flag whose answer is still cached; before this version it reached only those
64
+ answered in the last 24 hours.
65
+ - The message of a request that fails on the network, and the warning the client logs for it,
66
+ name the URL without its query string, which carries the user id and the user's attributes;
67
+ before this version they named the full URL.
68
+
69
+ ## Event ids
70
+
71
+ `track(..., event_id=None)` sends an `event_id`, and `track_batch()` sends the `event_id` key of
72
+ an event's dict as it is. The server stores an event sent again with the same `event_id`, user,
73
+ experiment, flag and event name only once. It needs a Controlvar server that includes the `event_id` change (#1640,
74
+ in the first platform release after it merged): an older server ignores the field.
75
+
76
+ - The SDK makes no id: without an `event_id` none is sent, and an event sent again is counted
77
+ again. Make the id once, when the event happens, and give the same one each time you send it.
78
+ - `None` and `""` send none. The server refuses an id of more than 255 characters or one holding
79
+ NUL with `422`; the SDK does not check it.
80
+ - An event fanned out to several experiments and flags carries the same id in every entry. A `429`
81
+ that is retried sends the same id. A request that fails on the network or times out is not sent
82
+ again by the SDK.
83
+
84
+ ## Quick start
85
+
86
+ Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
87
+ stand for your own code.
88
+
89
+ ```python
90
+ import os
91
+ from controlvar import ExperimentationClient, ExperimentationError
92
+
93
+ client = ExperimentationClient(
94
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"), # origin only
95
+ api_key=os.environ["CONTROLVAR_API_KEY"],
96
+ )
97
+
98
+ # Experiments — sticky assignment made by the server (POST /api/v1/tracking/assign)
99
+ variant = client.get_variant("checkout_flow", user_id="user-123", user_attributes={"plan": "pro"})
100
+ if variant == "treatment":
101
+ print("show the new checkout") # your own code
102
+
103
+ assignment = client.get_assignment("checkout_flow", "user-123") # raises on failure
104
+ assignment.variant_name, assignment.is_control, assignment.configuration
105
+
106
+ # Feature flags (GET /api/v1/feature-flags/evaluate/{key}?user_id=…&context=<url-encoded attributes>)
107
+ if client.is_feature_enabled("new_search", "user-123", user_attributes={"plan": "pro"}):
108
+ print("use the new search") # your own code
109
+ flag = client.get_feature_flag("new_search", "user-123") # FlagEvaluation(key, enabled, config, reason, source, value, value_type)
110
+ flags = client.get_all_flags("user-123", {"plan": "pro"}) # {"new_search": True, ...}
111
+
112
+ # Tracking (never raises)
113
+ client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
114
+ client.track("user-123", "page_view", properties={"page": "/"}) # no key: fanned out, see below
115
+ ```
116
+
117
+ Constructor: `ExperimentationClient(api_url, api_key, timeout_seconds=5.0, cache_ttl_seconds=300,
118
+ default_variant="control", transport=None)`. Instances are thread-safe.
119
+
120
+ ## API
121
+
122
+ | Method | Returns | Failure behaviour |
123
+ |---|---|---|
124
+ | `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 no experiment has the key, or for a draft with no control variant); an experiment that is not ACTIVE returns its control variant with `assigned` False and `reason` its status |
125
+ | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
126
+ | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason, source, value, value_type)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
127
+ | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
128
+ | `get_string_value(flag_key, user_id, user_attributes=None, *, default)` | `str`, the string a string flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
129
+ | `get_number_value(flag_key, user_id, user_attributes=None, *, default)` | `int` or `float` as saved (never a `bool`), the number a number flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
130
+ | `get_json_value(flag_key, user_id, user_attributes=None, *, default)` | a new copy of the `dict` or `list` a json flag serves this user | returns `default`; never raises; logs a warning and calls `on_error(error, "value")` |
131
+ | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
132
+ | `track(user_id, event_name, event_value=None, properties=None, experiment_key=None, feature_flag_key=None, event_type=None, timestamp=None, event_id=None)` | `bool` — `True` when sent and answered 2xx | never raises; `False` on failure |
133
+ | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
134
+ | `report_error(error_type, message, *, feature_flag_key=None, experiment_key=None, user_id=None, stack_trace=None, metadata=None, timestamp=None)` | `bool` — `True` when the server stored the report (201) | never raises; `False` and a logged warning otherwise |
135
+ | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
136
+ | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
137
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
138
+ | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
139
+ | `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 |
140
+
141
+ `ExperimentationError` carries `.status` (HTTP status, `None` for network errors/timeouts),
142
+ `.body` (raw response text) and `.code`: `"TYPE_MISMATCH"` or `"INVALID_RESPONSE"` on an error a
143
+ typed getter makes itself, and `"HOOK_ERROR"` on the error `on_error` receives with `"exposure"`.
144
+ Other errors carry no code in this version; `.status` describes them. Failures are never cached, so
145
+ the next call retries. A `429` is retried once after `Retry-After` seconds (capped at 5 s).
146
+
147
+ `user_attributes` is sent as `context` on assignment **and** on flag evaluation
148
+ (`&context=<url-encoded JSON>`, omitted when empty), so flag targeting rules evaluate against it
149
+ (`country` also matches `user.country`; nested dicts flatten to dotted keys such as `app.version`).
150
+ `FlagEvaluation.reason` (`targeting_rule` / `rollout` / `inactive` / `error`, or `None`) says why
151
+ the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
152
+ user's attributes.
153
+
154
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
155
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
156
+ was created by a user who can change feature flags; the flag's safety check shows the others as
157
+ `uncounted_error_count`.
158
+
159
+ ### Enrolment: `assigned` and `reason`
160
+
161
+ An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
162
+ enrol the user (`reason` is `"holdout"`, `"mutual_exclusion"` or `"targeting"`) and returned the
163
+ control variant so you render the default experience; no exposure was recorded. **When you export
164
+ exposures to a warehouse, exclude assignments with `assigned is False`**: the user was not enrolled,
165
+ and `reason` says why. A server that predates the fields sends neither, so `assigned` is `None`
166
+ (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
167
+ `assigned is True` filter.
168
+
169
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
170
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
171
+
172
+ ## Tracking fan-out rule
173
+
174
+ - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
175
+ - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
176
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
177
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
178
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
179
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
180
+ counts in holdout results and as CUPED history, never in an experiment's results);
181
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
182
+
183
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
184
+ another process does not have them. In a server, pass `experiment_key` on conversions.
185
+
186
+ `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
187
+ `datetime` (naive values are treated as UTC) or an ISO-8601 string.
188
+
189
+ `track` returns `False`, and sends nothing, when `event_value` or `properties` holds NaN or an
190
+ infinite number. `get_assignment` raises `ExperimentationError`
191
+ for the same in `user_attributes`, and `get_variant` returns the default for a user with no
192
+ cached assignment (a cached assignment is returned as before).
193
+
194
+ ## Exposure hook
195
+
196
+ `on_exposure` is a function you give the client when you create it. The client calls it once each
197
+ time the server enrols the user in an experiment and the answer did not come from the client's
198
+ cache: the moment the server records the exposure. Use it to send the exposure to your analytics
199
+ tool or warehouse.
200
+
201
+ The example needs an ACTIVE experiment with the key `checkout_flow`; put your own key in its place.
202
+ It prints `exposure checkout_flow <variant> user-123` once, or nothing for a user the experiment
203
+ did not enrol.
204
+
205
+ ```python
206
+ import os
207
+ import queue
208
+ import threading
209
+
210
+ from controlvar import ExperimentationClient
211
+
212
+ exposures = queue.Queue(maxsize=10_000)
213
+
214
+
215
+ def send_exposures():
216
+ while True:
217
+ exposure = exposures.get()
218
+ # Your analytics tool's call goes here: it runs on this thread, not inside get_variant.
219
+ print("exposure", exposure.experiment_key, exposure.variant_name, exposure.user_id)
220
+ exposures.task_done()
221
+
222
+
223
+ threading.Thread(target=send_exposures, daemon=True).start()
224
+
225
+
226
+ def on_exposure(exposure):
227
+ try:
228
+ exposures.put_nowait(exposure)
229
+ except queue.Full:
230
+ pass # drop it rather than make get_variant wait
231
+
232
+
233
+ client = ExperimentationClient(
234
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"),
235
+ api_key=os.environ["CONTROLVAR_API_KEY"],
236
+ on_exposure=on_exposure,
237
+ )
238
+ client.get_variant("checkout_flow", "user-123")
239
+ exposures.join()
240
+ ```
241
+
242
+ It receives an `Exposure`: `experiment_key`, `user_id`, `variant_id` (`None` when the server sends
243
+ none) and `variant_name`. It is not called for a cached answer, a user the experiment did not enrol
244
+ or an experiment that is not running (`assigned` False), a call that fails, or a feature flag. It
245
+ runs in the thread that called `get_assignment` or `get_variant`, before that call returns, so keep
246
+ it quick. If it raises, the answer is unchanged, and the error is logged at ERROR and passed to
247
+ `on_error` with the operation `"exposure"` and `code` `"HOOK_ERROR"`.
248
+
249
+ Full details: [Exposure hook](https://github.com/Controlvar/controlvar/blob/main/docs/sdk/python.md#exposure-hook).
250
+
251
+ ## Backend endpoints used
252
+
253
+ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `Accept: application/json`.
254
+
255
+ | SDK call | Method and path | Body / query | Response used |
256
+ |---|---|---|---|
257
+ | `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, assigned, reason}`; 404 when no experiment has the key, or for a draft with no control variant; an experiment that is not ACTIVE answers its control variant with `assigned: false` |
258
+ | `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 |
259
+ | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
260
+ | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?, event_id?}` | ignored |
261
+ | `track` without keys | `POST /api/v1/tracking/batch`, one entry per experiment and flag it reaches; with none, `POST /api/v1/tracking/track` | `{events: [<track body>, …]}` (≤ 100 per request); with none, the track body with no key | ignored |
262
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
263
+ | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
264
+
265
+ ## Contract smoke
266
+
267
+ ```bash
268
+ CONTROLVAR_API_KEY=<key> python sdk/python/examples/contract_smoke.py
269
+ ```
270
+
271
+ It prints:
272
+
273
+ ```text
274
+ {"sdk":"python","assign":{"variant_name":"treatment","is_control":false,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true},"event_id":{"user_id":"smoke-<uuid>","event_name":"contract_dedup_<uuid>","id":"contract-<uuid>","sends":2},"exposure":{"calls":1,"payload":{"experiment_key":"sdk_contract_ab","variant_name":"treatment","variant_id":"<id>","user_id":"smoke-<uuid>"}}}
275
+ ```
276
+
277
+ Env: `CONTROLVAR_API_URL` (default `http://localhost:8000`), `CONTROLVAR_API_KEY` (required),
278
+ `CONTRACT_EXPERIMENT_KEY` (default `sdk_contract_ab`), `CONTRACT_FLAG_KEY` (default `sdk_contract_flag`),
279
+ `CONTRACT_USER_ID` (default random `smoke-<uuid>`).
280
+
281
+ CI runs this SDK's contract smoke against a real API: `python` is in the `sdks:` list of the
282
+ SDK Live Contract job in [pr-qa-gate.yml](https://github.com/Controlvar/controlvar/blob/main/.github/workflows/pr-qa-gate.yml). Its fixtures,
283
+ `sdk_contract_ab` and `sdk_contract_flag`, are seeded with `backend/scripts/seed_sdk_contract.py`;
284
+ run it with `python tests/sdk-contract/live/run_live_contract.py --sdk python --strict`.
285
+
286
+ ## Testing your own code
287
+
288
+ `controlvar.testing.FakeTransport` records requests and serves canned responses:
289
+
290
+ ```python
291
+ from controlvar import ExperimentationClient
292
+ from controlvar.testing import FakeTransport
293
+
294
+ transport = FakeTransport().route("GET", "/api/v1/feature-flags/evaluate/new_search",
295
+ json={"key": "new_search", "enabled": True, "config": None})
296
+ client = ExperimentationClient("http://test", "key", transport=transport)
297
+ assert client.is_feature_enabled("new_search", "user-1")
298
+ assert transport.last.query == {"user_id": "user-1"}
299
+ ```
300
+
301
+ ## Development
302
+
303
+ ```bash
304
+ source venv/bin/activate
305
+ python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider
306
+ ```
307
+
308
+ - `python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider`: HTTP is faked; CI runs them on Python 3.11