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.
- controlvar-0.2.0/LICENSE +21 -0
- controlvar-0.2.0/PKG-INFO +231 -0
- controlvar-0.2.0/README.md +205 -0
- controlvar-0.2.0/controlvar/__init__.py +37 -0
- controlvar-0.2.0/controlvar/attribution.py +105 -0
- controlvar-0.2.0/controlvar/cache.py +58 -0
- controlvar-0.2.0/controlvar/client.py +701 -0
- controlvar-0.2.0/controlvar/evaluator.py +495 -0
- controlvar-0.2.0/controlvar/hashing.py +40 -0
- controlvar-0.2.0/controlvar/local.py +569 -0
- controlvar-0.2.0/controlvar/py.typed +0 -0
- controlvar-0.2.0/controlvar/testing.py +158 -0
- controlvar-0.2.0/controlvar/transport.py +101 -0
- controlvar-0.2.0/controlvar/types.py +89 -0
- controlvar-0.2.0/controlvar/version.py +3 -0
- controlvar-0.2.0/controlvar.egg-info/PKG-INFO +231 -0
- controlvar-0.2.0/controlvar.egg-info/SOURCES.txt +32 -0
- controlvar-0.2.0/controlvar.egg-info/requires.txt +3 -0
- controlvar-0.2.0/pyproject.toml +44 -0
- controlvar-0.2.0/setup.py +5 -0
- controlvar-0.2.0/tests/test_attribution.py +90 -0
- controlvar-0.2.0/tests/test_client.py +1008 -0
- controlvar-0.2.0/tests/test_contract_smoke.py +223 -0
- controlvar-0.2.0/tests/test_evaluator.py +214 -0
- controlvar-0.2.0/tests/test_hashing.py +55 -0
- controlvar-0.2.0/tests/test_local.py +789 -0
- controlvar-0.2.0/tests/test_no_real_aws.py +43 -0
- controlvar-0.2.0/tests/test_package.py +108 -0
- controlvar-0.2.0/tests/test_readme.py +95 -0
- controlvar-0.2.0/tests/test_transport.py +90 -0
- controlvar-0.2.0/tests/test_version.py +38 -0
- controlvar-0.0.0/PKG-INFO +0 -13
- controlvar-0.0.0/README.md +0 -3
- controlvar-0.0.0/pyproject.toml +0 -12
- controlvar-0.0.0/src/controlvar/__init__.py +0 -4
- controlvar-0.0.0/src/controlvar.egg-info/PKG-INFO +0 -13
- controlvar-0.0.0/src/controlvar.egg-info/SOURCES.txt +0 -7
- {controlvar-0.0.0/src → controlvar-0.2.0}/controlvar.egg-info/dependency_links.txt +0 -0
- {controlvar-0.0.0/src → controlvar-0.2.0}/controlvar.egg-info/top_level.txt +0 -0
- {controlvar-0.0.0 → controlvar-0.2.0}/setup.cfg +0 -0
controlvar-0.2.0/LICENSE
ADDED
|
@@ -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()
|