controlvar 0.1.1__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.
- {controlvar-0.1.1 → controlvar-0.2.0}/PKG-INFO +43 -11
- {controlvar-0.1.1 → controlvar-0.2.0}/README.md +42 -10
- controlvar-0.2.0/controlvar/attribution.py +105 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/client.py +134 -26
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/types.py +3 -1
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/version.py +1 -1
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/PKG-INFO +43 -11
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/SOURCES.txt +2 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/pyproject.toml +1 -1
- controlvar-0.2.0/tests/test_attribution.py +90 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_client.py +370 -12
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_contract_smoke.py +37 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/LICENSE +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/__init__.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/cache.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/evaluator.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/hashing.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/local.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/py.typed +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/testing.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar/transport.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/dependency_links.txt +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/requires.txt +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/controlvar.egg-info/top_level.txt +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/setup.cfg +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/setup.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_evaluator.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_hashing.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_local.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_no_real_aws.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_package.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_readme.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_transport.py +0 -0
- {controlvar-0.1.1 → controlvar-0.2.0}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: controlvar
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python client for the Controlvar public API (experiments, feature flags, event tracking)
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/Controlvar/controlvar/tree/main/sdk/python#readme
|
|
@@ -42,11 +42,11 @@ else to install.
|
|
|
42
42
|
|
|
43
43
|
If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
|
|
44
44
|
|
|
45
|
-
The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.
|
|
45
|
+
The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.2.0 is a
|
|
46
46
|
beta release. It installs and imports as `controlvar`.
|
|
47
47
|
|
|
48
48
|
```bash
|
|
49
|
-
pip install controlvar==0.
|
|
49
|
+
pip install controlvar==0.2.0
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
To work on the SDK itself, install it from the root of a clone of this repository instead:
|
|
@@ -55,6 +55,22 @@ To work on the SDK itself, install it from the root of a clone of this repositor
|
|
|
55
55
|
pip install -e sdk/python
|
|
56
56
|
```
|
|
57
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
|
+
|
|
58
74
|
## Quick start
|
|
59
75
|
|
|
60
76
|
Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
|
|
@@ -95,16 +111,17 @@ default_variant="control", transport=None)`. Instances are thread-safe.
|
|
|
95
111
|
|
|
96
112
|
| Method | Returns | Failure behaviour |
|
|
97
113
|
|---|---|---|
|
|
98
|
-
| `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
|
|
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 |
|
|
99
115
|
| `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
|
|
100
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"`) |
|
|
101
117
|
| `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
|
|
102
118
|
| `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
|
|
103
|
-
| `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
|
|
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 |
|
|
104
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 |
|
|
105
122
|
| `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
|
|
106
123
|
| `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
|
|
107
|
-
| `clear_cache()` | — | — |
|
|
124
|
+
| `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
|
|
108
125
|
| `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
|
|
109
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 |
|
|
110
127
|
|
|
@@ -119,6 +136,11 @@ retried once after `Retry-After` seconds (capped at 5 s).
|
|
|
119
136
|
the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
|
|
120
137
|
user's attributes.
|
|
121
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
|
+
|
|
122
144
|
### Enrolment: `assigned` and `reason`
|
|
123
145
|
|
|
124
146
|
An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
|
|
@@ -129,13 +151,22 @@ and `reason` says why. A server that predates the fields sends neither, so `assi
|
|
|
129
151
|
(never `False`). Decide deliberately how to treat those rows rather than dropping them with an
|
|
130
152
|
`assigned is True` filter.
|
|
131
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
|
+
|
|
132
157
|
## Tracking fan-out rule
|
|
133
158
|
|
|
134
159
|
- **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
|
|
135
160
|
- **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
|
|
136
|
-
experiment
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
139
170
|
|
|
140
171
|
`event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
|
|
141
172
|
`datetime` (naive values are treated as UTC) or an ISO-8601 string.
|
|
@@ -146,11 +177,12 @@ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `
|
|
|
146
177
|
|
|
147
178
|
| SDK call | Method and path | Body / query | Response used |
|
|
148
179
|
|---|---|---|---|
|
|
149
|
-
| `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 |
|
|
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` |
|
|
150
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 |
|
|
151
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, …}` |
|
|
152
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 |
|
|
153
|
-
| `track` without keys
|
|
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}` |
|
|
154
186
|
| `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
|
|
155
187
|
|
|
156
188
|
## Contract smoke
|
|
@@ -16,11 +16,11 @@ else to install.
|
|
|
16
16
|
|
|
17
17
|
If you installed `experimently`, the name this package had before the project was renamed, uninstall it and install `controlvar`.
|
|
18
18
|
|
|
19
|
-
The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.
|
|
19
|
+
The `controlvar` package is on [PyPI](https://pypi.org/project/controlvar/). 0.2.0 is a
|
|
20
20
|
beta release. It installs and imports as `controlvar`.
|
|
21
21
|
|
|
22
22
|
```bash
|
|
23
|
-
pip install controlvar==0.
|
|
23
|
+
pip install controlvar==0.2.0
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
To work on the SDK itself, install it from the root of a clone of this repository instead:
|
|
@@ -29,6 +29,22 @@ To work on the SDK itself, install it from the root of a clone of this repositor
|
|
|
29
29
|
pip install -e sdk/python
|
|
30
30
|
```
|
|
31
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
|
+
|
|
32
48
|
## Quick start
|
|
33
49
|
|
|
34
50
|
Set `CONTROLVAR_API_KEY` to an API key, then run the block as it is; the two `print` calls
|
|
@@ -69,16 +85,17 @@ default_variant="control", transport=None)`. Instances are thread-safe.
|
|
|
69
85
|
|
|
70
86
|
| Method | Returns | Failure behaviour |
|
|
71
87
|
|---|---|---|
|
|
72
|
-
| `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
|
|
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 |
|
|
73
89
|
| `get_variant(experiment_key, user_id, user_attributes=None)` | `str` variant name | returns `default_variant` |
|
|
74
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"`) |
|
|
75
91
|
| `is_feature_enabled(flag_key, user_id, user_attributes=None)` | `bool` | returns `False` |
|
|
76
92
|
| `get_all_flags(user_id, user_attributes=None)` | `dict[str, bool]` (not cached) | raises `ExperimentationError` |
|
|
77
|
-
| `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
|
|
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 |
|
|
78
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 |
|
|
79
96
|
| `get_assignments(user_id, active_only=True)` | `list[dict]` from the server | raises `ExperimentationError` |
|
|
80
97
|
| `cached_assignments(user_id)` / `cached_flags(user_id)` | cached, unexpired entries | — |
|
|
81
|
-
| `clear_cache()` | — | — |
|
|
98
|
+
| `clear_cache()` | — (keeps the keys a key-less `track` is sent to) | — |
|
|
82
99
|
| `ready(timeout_seconds=None)`, `status()`, `close()` / `with` | local mode: readiness, ruleset state, shutdown (sends evaluation counts) | never raise |
|
|
83
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 |
|
|
84
101
|
|
|
@@ -93,6 +110,11 @@ retried once after `Retry-After` seconds (capped at 5 s).
|
|
|
93
110
|
the server decided. Caches are keyed by user + key only — call `clear_cache()` after changing a
|
|
94
111
|
user's attributes.
|
|
95
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
|
+
|
|
96
118
|
### Enrolment: `assigned` and `reason`
|
|
97
119
|
|
|
98
120
|
An `Assignment` also carries `assigned` and `reason`. `assigned is False` means the server did not
|
|
@@ -103,13 +125,22 @@ and `reason` says why. A server that predates the fields sends neither, so `assi
|
|
|
103
125
|
(never `False`). Decide deliberately how to treat those rows rather than dropping them with an
|
|
104
126
|
`assigned is True` filter.
|
|
105
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
|
+
|
|
106
131
|
## Tracking fan-out rule
|
|
107
132
|
|
|
108
133
|
- **with** `experiment_key` and/or `feature_flag_key` → one `POST /api/v1/tracking/track`;
|
|
109
134
|
- **without** a key → one `POST /api/v1/tracking/batch` (chunks of 100) containing one entry per
|
|
110
|
-
experiment
|
|
111
|
-
|
|
112
|
-
|
|
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.
|
|
113
144
|
|
|
114
145
|
`event_type` defaults to `event_name`; `properties` is sent as `metadata`; `timestamp` may be a
|
|
115
146
|
`datetime` (naive values are treated as UTC) or an ISO-8601 string.
|
|
@@ -120,11 +151,12 @@ Every request carries `X-API-Key: <key>`, `Content-Type: application/json` and `
|
|
|
120
151
|
|
|
121
152
|
| SDK call | Method and path | Body / query | Response used |
|
|
122
153
|
|---|---|---|---|
|
|
123
|
-
| `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 |
|
|
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` |
|
|
124
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 |
|
|
125
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, …}` |
|
|
126
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 |
|
|
127
|
-
| `track` without keys
|
|
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}` |
|
|
128
160
|
| `get_assignments` | `GET /api/v1/tracking/assignments/{user_id}?active_only=true` | — | list of dicts |
|
|
129
161
|
|
|
130
162
|
## Contract smoke
|
|
@@ -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
|
|
@@ -9,11 +9,13 @@
|
|
|
9
9
|
ruleset whenever the answer is provably the server's, and by the server
|
|
10
10
|
otherwise; experiments always stay on the server.
|
|
11
11
|
* Successful assignments/evaluations are cached per user + key for
|
|
12
|
-
``cache_ttl_seconds``; failures are never cached.
|
|
12
|
+
``cache_ttl_seconds``; failures are never cached. Every successful answer also
|
|
13
|
+
records its key for the key-less ``track()`` fan-out, for 24 hours
|
|
14
|
+
(:mod:`controlvar.attribution`); ``clear_cache()`` keeps that record.
|
|
13
15
|
* ``get_assignment``/``get_feature_flag``/``get_all_flags``/``get_assignments``
|
|
14
16
|
raise :class:`~controlvar.ExperimentationError`; the convenience
|
|
15
17
|
wrappers ``get_variant``/``is_feature_enabled`` swallow it and return a
|
|
16
|
-
default; ``track``/``track_batch`` never raise.
|
|
18
|
+
default; ``track``/``track_batch``/``report_error`` never raise.
|
|
17
19
|
* Safe to share between threads.
|
|
18
20
|
"""
|
|
19
21
|
|
|
@@ -26,6 +28,7 @@ from datetime import datetime, timezone
|
|
|
26
28
|
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Union
|
|
27
29
|
from urllib.parse import quote, urlencode
|
|
28
30
|
|
|
31
|
+
from .attribution import AttributedKeys
|
|
29
32
|
from .cache import UserKeyCache
|
|
30
33
|
from .evaluator import DEFER
|
|
31
34
|
from .local import (
|
|
@@ -146,6 +149,8 @@ class ExperimentationClient:
|
|
|
146
149
|
self._transport: Transport = transport if transport is not None else UrllibTransport()
|
|
147
150
|
self._assignments: UserKeyCache[Assignment] = UserKeyCache(cache_ttl_seconds)
|
|
148
151
|
self._flags: UserKeyCache[FlagEvaluation] = UserKeyCache(cache_ttl_seconds)
|
|
152
|
+
# The keys a key-less track() is sent to; outlives the cache entries.
|
|
153
|
+
self._attributed = AttributedKeys()
|
|
149
154
|
self._sleep = time.sleep
|
|
150
155
|
self._headers: Dict[str, str] = {
|
|
151
156
|
"X-API-Key": api_key,
|
|
@@ -235,19 +240,19 @@ class ExperimentationClient:
|
|
|
235
240
|
except Exception as exc: # noqa: BLE001 - normalise any transport failure
|
|
236
241
|
raise ExperimentationError(f"{method} {url} failed: {exc}") from exc
|
|
237
242
|
|
|
238
|
-
def
|
|
243
|
+
def _exchange(
|
|
239
244
|
self,
|
|
240
245
|
method: str,
|
|
241
246
|
path: str,
|
|
242
247
|
*,
|
|
243
248
|
body: Optional[Mapping[str, Any]] = None,
|
|
244
249
|
query: Optional[Mapping[str, str]] = None,
|
|
245
|
-
) ->
|
|
246
|
-
"""
|
|
250
|
+
) -> Response:
|
|
251
|
+
"""Send one API call and return its response, whatever the status.
|
|
247
252
|
|
|
248
253
|
Retries exactly once on 429, sleeping for ``Retry-After`` (capped at
|
|
249
254
|
:data:`MAX_RETRY_AFTER_SECONDS`). Raises :class:`ExperimentationError`
|
|
250
|
-
for
|
|
255
|
+
for a network failure, and ``TypeError`` for a body JSON cannot encode.
|
|
251
256
|
"""
|
|
252
257
|
url = self.api_url + path
|
|
253
258
|
if query:
|
|
@@ -260,7 +265,23 @@ class ExperimentationClient:
|
|
|
260
265
|
logger.debug("429 from %s %s; retrying once after %.1fs", method, path, wait)
|
|
261
266
|
self._sleep(wait)
|
|
262
267
|
response = self._send(method, url, data)
|
|
268
|
+
return response
|
|
269
|
+
|
|
270
|
+
def _request(
|
|
271
|
+
self,
|
|
272
|
+
method: str,
|
|
273
|
+
path: str,
|
|
274
|
+
*,
|
|
275
|
+
body: Optional[Mapping[str, Any]] = None,
|
|
276
|
+
query: Optional[Mapping[str, str]] = None,
|
|
277
|
+
) -> Any:
|
|
278
|
+
"""Perform one API call and return the decoded JSON body (``None`` if empty).
|
|
263
279
|
|
|
280
|
+
Retries exactly once on 429, sleeping for ``Retry-After`` (capped at
|
|
281
|
+
:data:`MAX_RETRY_AFTER_SECONDS`). Raises :class:`ExperimentationError`
|
|
282
|
+
for any non-2xx response, network failure or undecodable body.
|
|
283
|
+
"""
|
|
284
|
+
response = self._exchange(method, path, body=body, query=query)
|
|
264
285
|
if response.status < 200 or response.status >= 300:
|
|
265
286
|
raise ExperimentationError(
|
|
266
287
|
f"{method} {path} failed with HTTP {response.status}",
|
|
@@ -290,8 +311,10 @@ class ExperimentationClient:
|
|
|
290
311
|
|
|
291
312
|
Sticky on the server and cached here per user + key. ``user_attributes``
|
|
292
313
|
is sent as ``context`` (used by targeting rules). Raises
|
|
293
|
-
:class:`ExperimentationError` (``status == 404`` when
|
|
294
|
-
|
|
314
|
+
:class:`ExperimentationError` (``status == 404`` when no experiment has
|
|
315
|
+
the key, or for a draft with no control variant). An experiment that is
|
|
316
|
+
not ACTIVE returns its control variant with ``assigned`` False and
|
|
317
|
+
``reason`` its status.
|
|
295
318
|
"""
|
|
296
319
|
cached = self._assignments.get(user_id, experiment_key)
|
|
297
320
|
if cached is not None:
|
|
@@ -300,7 +323,13 @@ class ExperimentationClient:
|
|
|
300
323
|
body: Dict[str, Any] = {"experiment_key": experiment_key, "user_id": user_id}
|
|
301
324
|
if user_attributes is not None:
|
|
302
325
|
body["context"] = dict(user_attributes)
|
|
303
|
-
|
|
326
|
+
try:
|
|
327
|
+
data = self._request("POST", "/api/v1/tracking/assign", body=body)
|
|
328
|
+
except ExperimentationError as exc:
|
|
329
|
+
# A 404: a key-less track() must no longer reach this experiment.
|
|
330
|
+
if exc.status == 404:
|
|
331
|
+
self._attributed.forget_experiment(user_id, experiment_key)
|
|
332
|
+
raise
|
|
304
333
|
|
|
305
334
|
if not isinstance(data, dict) or not isinstance(data.get("variant_name"), str):
|
|
306
335
|
raise ExperimentationError(
|
|
@@ -322,6 +351,7 @@ class ExperimentationClient:
|
|
|
322
351
|
reason=data["reason"] if isinstance(data.get("reason"), str) else None,
|
|
323
352
|
)
|
|
324
353
|
self._assignments.set(user_id, experiment_key, assignment)
|
|
354
|
+
self._attributed.record_experiment(user_id, assignment.experiment_key, assignment.reason)
|
|
325
355
|
return assignment
|
|
326
356
|
|
|
327
357
|
def get_variant(
|
|
@@ -381,6 +411,7 @@ class ExperimentationClient:
|
|
|
381
411
|
)
|
|
382
412
|
# Recorded for the key-less track() fan-out, as a server answer is.
|
|
383
413
|
self._flags.set(user_id, flag_key, local)
|
|
414
|
+
self._attributed.record_flag(user_id, flag_key)
|
|
384
415
|
return local
|
|
385
416
|
cached = self._flags.get(user_id, flag_key)
|
|
386
417
|
if cached is not None:
|
|
@@ -410,6 +441,7 @@ class ExperimentationClient:
|
|
|
410
441
|
source="server" if self._local is not None else None,
|
|
411
442
|
)
|
|
412
443
|
self._flags.set(user_id, flag_key, evaluation)
|
|
444
|
+
self._attributed.record_flag(user_id, flag_key)
|
|
413
445
|
return evaluation
|
|
414
446
|
|
|
415
447
|
def is_feature_enabled(
|
|
@@ -494,13 +526,19 @@ class ExperimentationClient:
|
|
|
494
526
|
* With ``experiment_key`` and/or ``feature_flag_key``: one
|
|
495
527
|
``POST /api/v1/tracking/track``.
|
|
496
528
|
* Without a key: one ``POST /api/v1/tracking/batch`` (chunked at 100)
|
|
497
|
-
containing one entry per
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
529
|
+
containing one entry per experiment (``experiment_key``) plus one per
|
|
530
|
+
flag (``feature_flag_key``) this client answered for the user in the
|
|
531
|
+
last 24 hours, cached or not. An experiment whose latest answer said it
|
|
532
|
+
is a draft, paused, completed or archived gets no entry.
|
|
533
|
+
* Without a key and with nothing to attribute it to: one
|
|
534
|
+
``POST /api/v1/tracking/track`` with no key, which the server stores as
|
|
535
|
+
history.
|
|
536
|
+
|
|
537
|
+
``event_type`` defaults to ``event_name``. Returns ``True`` when the
|
|
538
|
+
event was sent and the server answered 2xx (every request, when a
|
|
539
|
+
fan-out takes more than one); ``False`` otherwise. A 2xx batch can still
|
|
540
|
+
report a failed entry, for example a key the server no longer has; use
|
|
541
|
+
:meth:`track_batch` to read the per-entry results.
|
|
504
542
|
"""
|
|
505
543
|
try:
|
|
506
544
|
base = self._event_body(user_id, event_name, event_value, properties, event_type, timestamp)
|
|
@@ -513,19 +551,30 @@ class ExperimentationClient:
|
|
|
513
551
|
self._request("POST", "/api/v1/tracking/track", body=body)
|
|
514
552
|
return True
|
|
515
553
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
]
|
|
520
|
-
events.extend(dict(base, feature_flag_key=flag.key) for flag in self.cached_flags(user_id))
|
|
554
|
+
experiments, flags = self._attributed.keys(user_id)
|
|
555
|
+
events: List[Dict[str, Any]] = [dict(base, experiment_key=key) for key in experiments]
|
|
556
|
+
events.extend(dict(base, feature_flag_key=key) for key in flags)
|
|
521
557
|
if not events:
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
558
|
+
# Nothing to attribute it to: sent once with no key, stored as history.
|
|
559
|
+
self._request("POST", "/api/v1/tracking/track", body=base)
|
|
560
|
+
return True
|
|
561
|
+
return self._post_batches(events)
|
|
525
562
|
except Exception as exc: # noqa: BLE001 - fire-and-forget
|
|
526
563
|
logger.warning("track(%s, %s) failed: %s", user_id, event_name, exc)
|
|
527
564
|
return False
|
|
528
565
|
|
|
566
|
+
def _post_batches(self, events: List[Dict[str, Any]]) -> bool:
|
|
567
|
+
"""``POST /api/v1/tracking/batch`` in chunks of 100; ``True`` when every
|
|
568
|
+
chunk was answered 2xx. A failed chunk is logged and the rest are still sent."""
|
|
569
|
+
ok = True
|
|
570
|
+
for start in range(0, len(events), BATCH_LIMIT):
|
|
571
|
+
try:
|
|
572
|
+
self._request("POST", "/api/v1/tracking/batch", body={"events": events[start : start + BATCH_LIMIT]})
|
|
573
|
+
except Exception as exc: # noqa: BLE001 - never raise
|
|
574
|
+
logger.warning("track fan-out chunk starting at %d failed: %s", start, exc)
|
|
575
|
+
ok = False
|
|
576
|
+
return ok
|
|
577
|
+
|
|
529
578
|
def track_batch(self, events: Iterable[Mapping[str, Any]]) -> BatchResult:
|
|
530
579
|
"""Send raw track bodies via ``POST /api/v1/tracking/batch`` in chunks of 100. Never raises.
|
|
531
580
|
|
|
@@ -577,10 +626,68 @@ class ExperimentationClient:
|
|
|
577
626
|
body["timestamp"] = _iso_timestamp(body["timestamp"])
|
|
578
627
|
return body
|
|
579
628
|
|
|
629
|
+
# ---------------------------------------------------------- error reports
|
|
630
|
+
|
|
631
|
+
def report_error(
|
|
632
|
+
self,
|
|
633
|
+
error_type: str,
|
|
634
|
+
message: str,
|
|
635
|
+
*,
|
|
636
|
+
feature_flag_key: Optional[str] = None,
|
|
637
|
+
experiment_key: Optional[str] = None,
|
|
638
|
+
user_id: Optional[str] = None,
|
|
639
|
+
stack_trace: Optional[str] = None,
|
|
640
|
+
metadata: Optional[Mapping[str, Any]] = None,
|
|
641
|
+
timestamp: Union[datetime, str, None] = None,
|
|
642
|
+
) -> bool:
|
|
643
|
+
"""Report an error that happened while a flag or experiment was active, with one
|
|
644
|
+
``POST /api/v1/tracking/errors``. Works in both evaluation modes. Never raises.
|
|
645
|
+
|
|
646
|
+
Returns ``True`` exactly when the server answered 201: the report is stored. It
|
|
647
|
+
counts toward the flag's error rate and automatic rollback only when this client's
|
|
648
|
+
key is a server-side key (the ``sdk:ruleset`` scope, created by a user who can change
|
|
649
|
+
flags); the flag's safety check shows the others as ``uncounted_error_count``.
|
|
650
|
+
Returns ``False``, and logs a warning, on any other answer (401, 404, 422, a 429
|
|
651
|
+
after its one retry, 5xx) or a network failure. The server checks the report (it
|
|
652
|
+
needs ``feature_flag_key`` or ``experiment_key``); the SDK does not. ``timestamp``
|
|
653
|
+
(a ``datetime``, naive meaning UTC, or an ISO-8601 string) is kept with the report
|
|
654
|
+
as ``client_timestamp``; the stored row is timed when the server receives it.
|
|
655
|
+
"""
|
|
656
|
+
path = "/api/v1/tracking/errors"
|
|
657
|
+
try:
|
|
658
|
+
body: Dict[str, Any] = {"error_type": error_type, "message": message}
|
|
659
|
+
if feature_flag_key is not None:
|
|
660
|
+
body["feature_flag_key"] = feature_flag_key
|
|
661
|
+
if experiment_key is not None:
|
|
662
|
+
body["experiment_key"] = experiment_key
|
|
663
|
+
if user_id is not None:
|
|
664
|
+
body["user_id"] = user_id
|
|
665
|
+
if stack_trace is not None:
|
|
666
|
+
body["stack_trace"] = stack_trace
|
|
667
|
+
if metadata is not None:
|
|
668
|
+
body["metadata"] = dict(metadata)
|
|
669
|
+
stamp = _iso_timestamp(timestamp)
|
|
670
|
+
if stamp is not None:
|
|
671
|
+
body["timestamp"] = stamp
|
|
672
|
+
response = self._exchange("POST", path, body=body)
|
|
673
|
+
except Exception as exc: # noqa: BLE001 - never raise
|
|
674
|
+
logger.warning("report_error(%s) failed: %s", error_type, exc)
|
|
675
|
+
return False
|
|
676
|
+
if response.status == 201:
|
|
677
|
+
return True
|
|
678
|
+
logger.warning(
|
|
679
|
+
"report_error(%s) was not stored: POST %s answered HTTP %d: %s",
|
|
680
|
+
error_type,
|
|
681
|
+
path,
|
|
682
|
+
response.status,
|
|
683
|
+
response.text()[:200],
|
|
684
|
+
)
|
|
685
|
+
return False
|
|
686
|
+
|
|
580
687
|
# ------------------------------------------------------------------ cache
|
|
581
688
|
|
|
582
689
|
def cached_assignments(self, user_id: str) -> List[Assignment]:
|
|
583
|
-
"""Successful, unexpired assignments cached for the user
|
|
690
|
+
"""Successful, unexpired assignments cached for the user."""
|
|
584
691
|
return [assignment for _, assignment in self._assignments.entries(user_id)]
|
|
585
692
|
|
|
586
693
|
def cached_flags(self, user_id: str) -> List[FlagEvaluation]:
|
|
@@ -588,6 +695,7 @@ class ExperimentationClient:
|
|
|
588
695
|
return [evaluation for _, evaluation in self._flags.entries(user_id)]
|
|
589
696
|
|
|
590
697
|
def clear_cache(self) -> None:
|
|
591
|
-
"""Drop every cached assignment and evaluation
|
|
698
|
+
"""Drop every cached assignment and evaluation, so the next calls ask the
|
|
699
|
+
server again. The keys a key-less ``track()`` is sent to are kept."""
|
|
592
700
|
self._assignments.clear()
|
|
593
701
|
self._flags.clear()
|
|
@@ -42,7 +42,9 @@ class Assignment:
|
|
|
42
42
|
returned the control variant instead, with no exposure recorded. ``reason``
|
|
43
43
|
says why: ``"assigned"``, ``"holdout"``, ``"mutual_exclusion"`` or
|
|
44
44
|
``"targeting"``. Both are ``None`` when the server predates the fields; such
|
|
45
|
-
a server cannot tell you, so do not read ``None`` as either answer.
|
|
45
|
+
a server cannot tell you, so do not read ``None`` as either answer. For an
|
|
46
|
+
experiment that is not ACTIVE, ``assigned`` is ``False`` and ``reason`` is its
|
|
47
|
+
status (``"draft"``, ``"paused"``, ``"completed"`` or ``"archived"``).
|
|
46
48
|
"""
|
|
47
49
|
|
|
48
50
|
experiment_key: str
|