controlvar 0.0.0__tar.gz → 0.2.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 (40) hide show
  1. controlvar-0.2.0/LICENSE +21 -0
  2. controlvar-0.2.0/PKG-INFO +231 -0
  3. controlvar-0.2.0/README.md +205 -0
  4. controlvar-0.2.0/controlvar/__init__.py +37 -0
  5. controlvar-0.2.0/controlvar/attribution.py +105 -0
  6. controlvar-0.2.0/controlvar/cache.py +58 -0
  7. controlvar-0.2.0/controlvar/client.py +701 -0
  8. controlvar-0.2.0/controlvar/evaluator.py +495 -0
  9. controlvar-0.2.0/controlvar/hashing.py +40 -0
  10. controlvar-0.2.0/controlvar/local.py +569 -0
  11. controlvar-0.2.0/controlvar/py.typed +0 -0
  12. controlvar-0.2.0/controlvar/testing.py +158 -0
  13. controlvar-0.2.0/controlvar/transport.py +101 -0
  14. controlvar-0.2.0/controlvar/types.py +89 -0
  15. controlvar-0.2.0/controlvar/version.py +3 -0
  16. controlvar-0.2.0/controlvar.egg-info/PKG-INFO +231 -0
  17. controlvar-0.2.0/controlvar.egg-info/SOURCES.txt +32 -0
  18. controlvar-0.2.0/controlvar.egg-info/requires.txt +3 -0
  19. controlvar-0.2.0/pyproject.toml +44 -0
  20. controlvar-0.2.0/setup.py +5 -0
  21. controlvar-0.2.0/tests/test_attribution.py +90 -0
  22. controlvar-0.2.0/tests/test_client.py +1008 -0
  23. controlvar-0.2.0/tests/test_contract_smoke.py +223 -0
  24. controlvar-0.2.0/tests/test_evaluator.py +214 -0
  25. controlvar-0.2.0/tests/test_hashing.py +55 -0
  26. controlvar-0.2.0/tests/test_local.py +789 -0
  27. controlvar-0.2.0/tests/test_no_real_aws.py +43 -0
  28. controlvar-0.2.0/tests/test_package.py +108 -0
  29. controlvar-0.2.0/tests/test_readme.py +95 -0
  30. controlvar-0.2.0/tests/test_transport.py +90 -0
  31. controlvar-0.2.0/tests/test_version.py +38 -0
  32. controlvar-0.0.0/PKG-INFO +0 -13
  33. controlvar-0.0.0/README.md +0 -3
  34. controlvar-0.0.0/pyproject.toml +0 -12
  35. controlvar-0.0.0/src/controlvar/__init__.py +0 -4
  36. controlvar-0.0.0/src/controlvar.egg-info/PKG-INFO +0 -13
  37. controlvar-0.0.0/src/controlvar.egg-info/SOURCES.txt +0 -7
  38. {controlvar-0.0.0/src → controlvar-0.2.0}/controlvar.egg-info/dependency_links.txt +0 -0
  39. {controlvar-0.0.0/src → controlvar-0.2.0}/controlvar.egg-info/top_level.txt +0 -0
  40. {controlvar-0.0.0 → controlvar-0.2.0}/setup.cfg +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 The Controlvar authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: controlvar
3
+ Version: 0.2.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.2.0 is a
46
+ beta release. It installs and imports as `controlvar`.
47
+
48
+ ```bash
49
+ pip install controlvar==0.2.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
+ - `report_error()` is new: it sends one error report to the server and returns `True` when the
61
+ server stored it.
62
+ - `track()` returns `True` when the event was sent and the server answered 2xx, and `False`
63
+ otherwise. Before this version, a `track()` without a key returned `True` only when the server
64
+ accepted every entry it was fanned out to. Now a batch answered 2xx returns `True` even when the
65
+ server refused one of its entries; `track_batch()` returns the per-entry results.
66
+ - A `track()` without a key still reaches the experiments and flags this client answered for the
67
+ user in the last 24 hours (with the exceptions under Tracking fan-out rule) after their cached
68
+ answers expire; before this version it reached only the answers still in the cache. Those keys
69
+ are this client instance's only: an event tracked by another instance, in this process or
70
+ another, reaches none of them.
71
+ - With nothing to reach, a `track()` without a key is now sent once with no key and stored as
72
+ history, and returns `True` on a 2xx; before this version it sent nothing and returned `False`.
73
+
74
+ ## Quick start
75
+
76
+ Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
77
+ stand for your own code.
78
+
79
+ ```python
80
+ import os
81
+ from controlvar import ExperimentationClient, ExperimentationError
82
+
83
+ client = ExperimentationClient(
84
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"), # origin only
85
+ api_key=os.environ["CONTROLVAR_API_KEY"],
86
+ )
87
+
88
+ # Experiments — sticky assignment made by the server (POST /api/v1/tracking/assign)
89
+ variant = client.get_variant("checkout_flow", user_id="user-123", user_attributes={"plan": "pro"})
90
+ if variant == "treatment":
91
+ print("show the new checkout") # your own code
92
+
93
+ assignment = client.get_assignment("checkout_flow", "user-123") # raises on failure
94
+ assignment.variant_name, assignment.is_control, assignment.configuration
95
+
96
+ # Feature flags (GET /api/v1/feature-flags/evaluate/{key}?user_id=…&context=<url-encoded attributes>)
97
+ if client.is_feature_enabled("new_search", "user-123", user_attributes={"plan": "pro"}):
98
+ print("use the new search") # your own code
99
+ flag = client.get_feature_flag("new_search", "user-123") # FlagEvaluation(key, enabled, config, reason)
100
+ flags = client.get_all_flags("user-123", {"plan": "pro"}) # {"new_search": True, ...}
101
+
102
+ # Tracking (never raises)
103
+ client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
104
+ client.track("user-123", "page_view", properties={"page": "/"}) # no key: fanned out, see below
105
+ ```
106
+
107
+ Constructor: `ExperimentationClient(api_url, api_key, timeout_seconds=5.0, cache_ttl_seconds=300,
108
+ default_variant="control", transport=None)`. Instances are thread-safe.
109
+
110
+ ## API
111
+
112
+ | Method | Returns | Failure behaviour |
113
+ |---|---|---|
114
+ | `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 |
115
+ | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
116
+ | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
117
+ | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
118
+ | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
119
+ | `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 sent and answered 2xx | never raises; `False` on failure |
120
+ | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
121
+ | `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 |
122
+ | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
123
+ | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
124
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
125
+ | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
126
+ | `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 |
127
+
128
+ `ExperimentationError` carries `.status` (HTTP status, `None` for network errors/timeouts) and
129
+ `.body` (raw response text). Failures are never cached, so the next call retries. A `429` is
130
+ retried once after `Retry-After` seconds (capped at 5 s).
131
+
132
+ `user_attributes` is sent as `context` on assignment **and** on flag evaluation
133
+ (`&context=<url-encoded JSON>`, omitted when empty), so flag targeting rules evaluate against it
134
+ (`country` also matches `user.country`; nested dicts flatten to dotted keys such as `app.version`).
135
+ `FlagEvaluation.reason` (`targeting_rule` / `rollout` / `inactive` / `error`, or `None`) says why
136
+ the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
137
+ user's attributes.
138
+
139
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
140
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
141
+ was created by a user who can change feature flags; the flag's safety check shows the others as
142
+ `uncounted_error_count`.
143
+
144
+ ### Enrolment: `assigned` and `reason`
145
+
146
+ An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
147
+ enrol the user (`reason` is `"holdout"`, `"mutual_exclusion"` or `"targeting"`) and returned the
148
+ control variant so you render the default experience; no exposure was recorded. **When you export
149
+ exposures to a warehouse, exclude assignments with `assigned is False`**: the user was not enrolled,
150
+ and `reason` says why. A server that predates the fields sends neither, so `assigned` is `None`
151
+ (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
152
+ `assigned is True` filter.
153
+
154
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
155
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
156
+
157
+ ## Tracking fan-out rule
158
+
159
+ - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
160
+ - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
161
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
162
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
163
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
164
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
165
+ counts in holdout results and as CUPED history, never in an experiment's results);
166
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
167
+
168
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
169
+ another process does not have them. In a server, pass `experiment_key` on conversions.
170
+
171
+ `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
172
+ `datetime` (naive values are treated as UTC) or an ISO-8601 string.
173
+
174
+ ## Backend endpoints used
175
+
176
+ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `Accept: application/json`.
177
+
178
+ | SDK call | Method and path | Body / query | Response used |
179
+ |---|---|---|---|
180
+ | `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 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` |
181
+ | `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 |
182
+ | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
183
+ | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?}` | ignored |
184
+ | `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 |
185
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
186
+ | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
187
+
188
+ ## Contract smoke
189
+
190
+ ```bash
191
+ CONTROLVAR_API_KEY=<key> python sdk/python/examples/contract_smoke.py
192
+ ```
193
+
194
+ It prints:
195
+
196
+ ```text
197
+ {"sdk":"python","assign":{"variant_name":"treatment","is_control":false,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
198
+ ```
199
+
200
+ Env: `CONTROLVAR_API_URL` (default `http://localhost:8000`), `CONTROLVAR_API_KEY` (required),
201
+ `CONTRACT_EXPERIMENT_KEY` (default `sdk_contract_ab`), `CONTRACT_FLAG_KEY` (default `sdk_contract_flag`),
202
+ `CONTRACT_USER_ID` (default random `smoke-<uuid>`).
203
+
204
+ CI runs this SDK's contract smoke against a real API: `python` is in the `sdks:` list of the
205
+ SDK Live Contract job in [pr-qa-gate.yml](https://github.com/Controlvar/controlvar/blob/main/.github/workflows/pr-qa-gate.yml). Its fixtures,
206
+ `sdk_contract_ab` and `sdk_contract_flag`, are seeded with `backend/scripts/seed_sdk_contract.py`;
207
+ run it with `python tests/sdk-contract/live/run_live_contract.py --sdk python --strict`.
208
+
209
+ ## Testing your own code
210
+
211
+ `controlvar.testing.FakeTransport` records requests and serves canned responses:
212
+
213
+ ```python
214
+ from controlvar import ExperimentationClient
215
+ from controlvar.testing import FakeTransport
216
+
217
+ transport = FakeTransport().route("GET", "/api/v1/feature-flags/evaluate/new_search",
218
+ json={"key": "new_search", "enabled": True, "config": None})
219
+ client = ExperimentationClient("http://test", "key", transport=transport)
220
+ assert client.is_feature_enabled("new_search", "user-1")
221
+ assert transport.last.query == {"user_id": "user-1"}
222
+ ```
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ source venv/bin/activate
228
+ python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider
229
+ ```
230
+
231
+ - `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,205 @@
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.2.0 is a
20
+ beta release. It installs and imports as `controlvar`.
21
+
22
+ ```bash
23
+ pip install controlvar==0.2.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
+ - `report_error()` is new: it sends one error report to the server and returns `True` when the
35
+ server stored it.
36
+ - `track()` returns `True` when the event was sent and the server answered 2xx, and `False`
37
+ otherwise. Before this version, a `track()` without a key returned `True` only when the server
38
+ accepted every entry it was fanned out to. Now a batch answered 2xx returns `True` even when the
39
+ server refused one of its entries; `track_batch()` returns the per-entry results.
40
+ - A `track()` without a key still reaches the experiments and flags this client answered for the
41
+ user in the last 24 hours (with the exceptions under Tracking fan-out rule) after their cached
42
+ answers expire; before this version it reached only the answers still in the cache. Those keys
43
+ are this client instance's only: an event tracked by another instance, in this process or
44
+ another, reaches none of them.
45
+ - With nothing to reach, a `track()` without a key is now sent once with no key and stored as
46
+ history, and returns `True` on a 2xx; before this version it sent nothing and returned `False`.
47
+
48
+ ## Quick start
49
+
50
+ Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
51
+ stand for your own code.
52
+
53
+ ```python
54
+ import os
55
+ from controlvar import ExperimentationClient, ExperimentationError
56
+
57
+ client = ExperimentationClient(
58
+ api_url=os.environ.get("CONTROLVAR_API_URL", "http://localhost:8000"), # origin only
59
+ api_key=os.environ["CONTROLVAR_API_KEY"],
60
+ )
61
+
62
+ # Experiments — sticky assignment made by the server (POST /api/v1/tracking/assign)
63
+ variant = client.get_variant("checkout_flow", user_id="user-123", user_attributes={"plan": "pro"})
64
+ if variant == "treatment":
65
+ print("show the new checkout") # your own code
66
+
67
+ assignment = client.get_assignment("checkout_flow", "user-123") # raises on failure
68
+ assignment.variant_name, assignment.is_control, assignment.configuration
69
+
70
+ # Feature flags (GET /api/v1/feature-flags/evaluate/{key}?user_id=…&context=<url-encoded attributes>)
71
+ if client.is_feature_enabled("new_search", "user-123", user_attributes={"plan": "pro"}):
72
+ print("use the new search") # your own code
73
+ flag = client.get_feature_flag("new_search", "user-123") # FlagEvaluation(key, enabled, config, reason)
74
+ flags = client.get_all_flags("user-123", {"plan": "pro"}) # {"new_search": True, ...}
75
+
76
+ # Tracking (never raises)
77
+ client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
78
+ client.track("user-123", "page_view", properties={"page": "/"}) # no key: fanned out, see below
79
+ ```
80
+
81
+ Constructor: `ExperimentationClient(api_url, api_key, timeout_seconds=5.0, cache_ttl_seconds=300,
82
+ default_variant="control", transport=None)`. Instances are thread-safe.
83
+
84
+ ## API
85
+
86
+ | Method | Returns | Failure behaviour |
87
+ |---|---|---|
88
+ | `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 |
89
+ | `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
90
+ | `get_feature_flag(flag_key, user_id, user_attributes=None)` | `FlagEvaluation(key, enabled, config, reason)` | raises `ExperimentationError` (`status == 404` when the flag key is unknown; a flag that is not ACTIVE returns `enabled=False`, `reason="inactive"`) |
91
+ | `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
92
+ | `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
93
+ | `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 sent and answered 2xx | never raises; `False` on failure |
94
+ | `track_batch(events)` | `BatchResult(success_count, failure_count, errors)`; `.ok` | never raises; a failed chunk counts all its events as failures |
95
+ | `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 |
96
+ | `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
97
+ | `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
98
+ | `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
99
+ | `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
100
+ | `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 |
101
+
102
+ `ExperimentationError` carries `.status` (HTTP status, `None` for network errors/timeouts) and
103
+ `.body` (raw response text). Failures are never cached, so the next call retries. A `429` is
104
+ retried once after `Retry-After` seconds (capped at 5 s).
105
+
106
+ `user_attributes` is sent as `context` on assignment **and** on flag evaluation
107
+ (`&context=<url-encoded JSON>`, omitted when empty), so flag targeting rules evaluate against it
108
+ (`country` also matches `user.country`; nested dicts flatten to dotted keys such as `app.version`).
109
+ `FlagEvaluation.reason` (`targeting_rule` / `rollout` / `inactive` / `error`, or `None`) says why
110
+ the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
111
+ user's attributes.
112
+
113
+ `report_error` returns `True` when the server stored the report. A stored report counts toward the
114
+ flag's error rate and automatic rollback only when the client's key has the `sdk:ruleset` scope and
115
+ was created by a user who can change feature flags; the flag's safety check shows the others as
116
+ `uncounted_error_count`.
117
+
118
+ ### Enrolment: `assigned` and `reason`
119
+
120
+ An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
121
+ enrol the user (`reason` is `"holdout"`, `"mutual_exclusion"` or `"targeting"`) and returned the
122
+ control variant so you render the default experience; no exposure was recorded. **When you export
123
+ exposures to a warehouse, exclude assignments with `assigned is False`**: the user was not enrolled,
124
+ and `reason` says why. A server that predates the fields sends neither, so `assigned` is `None`
125
+ (never `False`). Decide deliberately how to treat those rows rather than dropping them with an
126
+ `assigned is True` filter.
127
+
128
+ An experiment that is not ACTIVE answers every user with its control variant, `assigned` False and
129
+ `reason` set to its status (`"draft"`, `"paused"`, `"completed"` or `"archived"`).
130
+
131
+ ## Tracking fan-out rule
132
+
133
+ - **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
134
+ - **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
135
+ experiment (`experiment_key`) and one per flag (`feature_flag_key`) this client answered for the
136
+ user in the last 24 hours, cached or not, except an experiment whose latest answer has `reason`
137
+ `"draft"`, `"paused"`, `"completed"` or `"archived"`;
138
+ - nothing to attribute it to → one `POST /api/v1/tracking/track` with no key, stored as history (it
139
+ counts in holdout results and as CUPED history, never in an experiment's results);
140
+ - `track` returns `True` when the event was sent and the server answered 2xx, `False` otherwise.
141
+
142
+ The keys live in this client's memory, for up to 10,000 users; `clear_cache()` keeps them, and
143
+ another process does not have them. In a server, pass `experiment_key` on conversions.
144
+
145
+ `event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
146
+ `datetime` (naive values are treated as UTC) or an ISO-8601 string.
147
+
148
+ ## Backend endpoints used
149
+
150
+ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `Accept: application/json`.
151
+
152
+ | SDK call | Method and path | Body / query | Response used |
153
+ |---|---|---|---|
154
+ | `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 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` |
155
+ | `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 |
156
+ | `get_all_flags` | `GET /api/v1/feature-flags/user/{user_id}?context=<url-encoded JSON>` | `context` = `user_attributes` (omitted when empty) | `{"<flag_key>": bool, …}` |
157
+ | `track` with a key | `POST /api/v1/tracking/track` | `{event_type, event_name, user_id, experiment_key?, feature_flag_key?, value?, metadata?, timestamp?}` | ignored |
158
+ | `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 |
159
+ | `track_batch` | `POST /api/v1/tracking/batch` | `{events: [<track body>, …]}` (≤ 100 per request) | `{success_count, failure_count, errors}` |
160
+ | `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
161
+
162
+ ## Contract smoke
163
+
164
+ ```bash
165
+ CONTROLVAR_API_KEY=<key> python sdk/python/examples/contract_smoke.py
166
+ ```
167
+
168
+ It prints:
169
+
170
+ ```text
171
+ {"sdk":"python","assign":{"variant_name":"treatment","is_control":false,"sticky":true},"flag":{"enabled":true},"track":{"ok":true},"fanout":{"ok":true}}
172
+ ```
173
+
174
+ Env: `CONTROLVAR_API_URL` (default `http://localhost:8000`), `CONTROLVAR_API_KEY` (required),
175
+ `CONTRACT_EXPERIMENT_KEY` (default `sdk_contract_ab`), `CONTRACT_FLAG_KEY` (default `sdk_contract_flag`),
176
+ `CONTRACT_USER_ID` (default random `smoke-<uuid>`).
177
+
178
+ CI runs this SDK's contract smoke against a real API: `python` is in the `sdks:` list of the
179
+ SDK Live Contract job in [pr-qa-gate.yml](https://github.com/Controlvar/controlvar/blob/main/.github/workflows/pr-qa-gate.yml). Its fixtures,
180
+ `sdk_contract_ab` and `sdk_contract_flag`, are seeded with `backend/scripts/seed_sdk_contract.py`;
181
+ run it with `python tests/sdk-contract/live/run_live_contract.py --sdk python --strict`.
182
+
183
+ ## Testing your own code
184
+
185
+ `controlvar.testing.FakeTransport` records requests and serves canned responses:
186
+
187
+ ```python
188
+ from controlvar import ExperimentationClient
189
+ from controlvar.testing import FakeTransport
190
+
191
+ transport = FakeTransport().route("GET", "/api/v1/feature-flags/evaluate/new_search",
192
+ json={"key": "new_search", "enabled": True, "config": None})
193
+ client = ExperimentationClient("http://test", "key", transport=transport)
194
+ assert client.is_feature_enabled("new_search", "user-1")
195
+ assert transport.last.query == {"user_id": "user-1"}
196
+ ```
197
+
198
+ ## Development
199
+
200
+ ```bash
201
+ source venv/bin/activate
202
+ python -m pytest sdk/python/tests -q -o addopts="" -p no:cacheprovider
203
+ ```
204
+
205
+ - `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,37 @@
1
+ """Controlvar Python SDK.
2
+
3
+ Usage::
4
+
5
+ from controlvar import ExperimentationClient
6
+
7
+ client = ExperimentationClient("http://localhost:8000", api_key="...")
8
+ variant = client.get_variant("checkout_flow", user_id="user-123")
9
+ if client.is_feature_enabled("new_search", "user-123"):
10
+ ...
11
+ client.track("user-123", "purchase", event_value=49.99, experiment_key="checkout_flow")
12
+ """
13
+
14
+ from .client import BATCH_LIMIT, ExperimentationClient
15
+ from .hashing import consistent_hash, md5_hex
16
+ from .local import LocalEvaluationStatus, ReadyResult
17
+ from .transport import Response, Transport, TransportError, UrllibTransport
18
+ from .types import Assignment, BatchResult, ExperimentationError, FlagEvaluation
19
+ from .version import __version__
20
+
21
+ __all__ = [
22
+ "BATCH_LIMIT",
23
+ "Assignment",
24
+ "BatchResult",
25
+ "ExperimentationClient",
26
+ "ExperimentationError",
27
+ "FlagEvaluation",
28
+ "LocalEvaluationStatus",
29
+ "ReadyResult",
30
+ "Response",
31
+ "Transport",
32
+ "TransportError",
33
+ "UrllibTransport",
34
+ "__version__",
35
+ "consistent_hash",
36
+ "md5_hex",
37
+ ]
@@ -0,0 +1,105 @@
1
+ """The keys a key-less ``track()`` is sent to, kept apart from the TTL value cache.
2
+
3
+ Every successful answer (an assignment, ``assigned=False`` included, or a flag
4
+ evaluation, local or from the server) records its key for the user. A key-less
5
+ event goes to each key recorded for its user, so a purchase made after the cache
6
+ entry expired still reaches the experiment the user was answered for.
7
+ ``clear_cache()`` does not touch this record: a user's sticky assignments do not
8
+ change when their attributes do.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import threading
14
+ from collections import OrderedDict
15
+ from typing import Dict, FrozenSet, List, Optional, Tuple
16
+
17
+ from . import cache
18
+
19
+ __all__ = ["ATTRIBUTION_HORIZON_SECONDS", "LIFECYCLE_REASONS", "MAX_ATTRIBUTED_USERS", "AttributedKeys"]
20
+
21
+ #: Users kept per client instance; the user answered least recently is dropped first.
22
+ MAX_ATTRIBUTED_USERS = 10_000
23
+
24
+ #: A key is used for this long after its latest successful answer, then dropped.
25
+ ATTRIBUTION_HORIZON_SECONDS = 24 * 60 * 60
26
+
27
+ #: An experiment whose latest answer gave one of these reasons is not running, so a
28
+ #: key-less event is not sent to it. Other reasons (``holdout``,
29
+ #: ``mutual_exclusion``, ``targeting``) are kept: holdout results count those
30
+ #: users' events.
31
+ LIFECYCLE_REASONS: FrozenSet[str] = frozenset({"draft", "paused", "completed", "archived"})
32
+
33
+ # key -> (answered_at, reason); dict order is first-answer order.
34
+ _Answers = Dict[str, Tuple[float, Optional[str]]]
35
+
36
+
37
+ class AttributedKeys:
38
+ """``user_id -> (experiment answers, flag answers)``, least recently answered user first.
39
+
40
+ Time is ``controlvar.cache._now`` (monotonic), read at call time, so a test that
41
+ patches it moves this record and the cache together.
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self._lock = threading.Lock()
46
+ self._users: "OrderedDict[str, Tuple[_Answers, _Answers]]" = OrderedDict()
47
+
48
+ def record_experiment(self, user_id: str, experiment_key: str, reason: Optional[str] = None) -> None:
49
+ self._record(user_id, experiment_key, reason, 0)
50
+
51
+ def record_flag(self, user_id: str, flag_key: str) -> None:
52
+ self._record(user_id, flag_key, None, 1)
53
+
54
+ def forget_experiment(self, user_id: str, experiment_key: str) -> None:
55
+ """Drop one experiment's key for the user: the server answered its assignment 404, so
56
+ a key-less event must not reach it (a server that answers 404 for an experiment that is
57
+ not running would otherwise credit the user's stored arm while the user is shown
58
+ control)."""
59
+ with self._lock:
60
+ answers = self._users.get(user_id)
61
+ if answers is None:
62
+ return
63
+ answers[0].pop(experiment_key, None)
64
+ if not answers[0] and not answers[1]:
65
+ del self._users[user_id]
66
+
67
+ def keys(self, user_id: str) -> Tuple[List[str], List[str]]:
68
+ """The user's experiment keys and flag keys within the horizon, each in the
69
+ order they were first answered. Keys past the horizon are dropped here."""
70
+ with self._lock:
71
+ answers = self._users.get(user_id)
72
+ if answers is None:
73
+ return [], []
74
+ now = cache._now()
75
+ experiments, flags = _live(answers[0], now), _live(answers[1], now)
76
+ if not answers[0] and not answers[1]:
77
+ del self._users[user_id]
78
+ return experiments, flags
79
+
80
+ def __len__(self) -> int:
81
+ with self._lock:
82
+ return len(self._users)
83
+
84
+ def _record(self, user_id: str, key: str, reason: Optional[str], kind: int) -> None:
85
+ with self._lock:
86
+ answers = self._users.get(user_id)
87
+ if answers is None:
88
+ answers = ({}, {})
89
+ self._users[user_id] = answers
90
+ else:
91
+ self._users.move_to_end(user_id) # now the most recently answered user
92
+ # Assigning an existing key keeps its place: the order stays first-answer order.
93
+ answers[kind][key] = (cache._now(), reason)
94
+ while len(self._users) > MAX_ATTRIBUTED_USERS:
95
+ self._users.popitem(last=False)
96
+
97
+
98
+ def _live(answers: _Answers, now: float) -> List[str]:
99
+ out: List[str] = []
100
+ for key, (answered_at, reason) in list(answers.items()):
101
+ if now - answered_at > ATTRIBUTION_HORIZON_SECONDS:
102
+ del answers[key]
103
+ elif reason not in LIFECYCLE_REASONS:
104
+ out.append(key)
105
+ return out
@@ -0,0 +1,58 @@
1
+ """Thread-safe per-user, per-key TTL cache. Only successful results are ever stored."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import Dict, Generic, List, Optional, Tuple, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+ # Indirection so tests can patch ``controlvar.cache._now``.
12
+ _now = time.monotonic
13
+
14
+
15
+ class UserKeyCache(Generic[T]):
16
+ """``user_id -> key -> (expires_at, value)`` guarded by a single lock."""
17
+
18
+ def __init__(self, ttl_seconds: float) -> None:
19
+ self._ttl = float(ttl_seconds)
20
+ self._lock = threading.Lock()
21
+ self._users: Dict[str, Dict[str, Tuple[float, T]]] = {}
22
+
23
+ def get(self, user_id: str, key: str) -> Optional[T]:
24
+ with self._lock:
25
+ by_key = self._users.get(user_id)
26
+ if not by_key:
27
+ return None
28
+ entry = by_key.get(key)
29
+ if entry is None:
30
+ return None
31
+ expires_at, value = entry
32
+ if _now() >= expires_at:
33
+ del by_key[key]
34
+ return None
35
+ return value
36
+
37
+ def set(self, user_id: str, key: str, value: T) -> None:
38
+ with self._lock:
39
+ self._users.setdefault(user_id, {})[key] = (_now() + self._ttl, value)
40
+
41
+ def entries(self, user_id: str) -> List[Tuple[str, T]]:
42
+ """All live (unexpired) entries for a user, in insertion order."""
43
+ with self._lock:
44
+ by_key = self._users.get(user_id)
45
+ if not by_key:
46
+ return []
47
+ now = _now()
48
+ live: List[Tuple[str, T]] = []
49
+ for key, (expires_at, value) in list(by_key.items()):
50
+ if now >= expires_at:
51
+ del by_key[key]
52
+ else:
53
+ live.append((key, value))
54
+ return live
55
+
56
+ def clear(self) -> None:
57
+ with self._lock:
58
+ self._users.clear()