upgrade-client-lib 6.5.0__py3-none-any.whl
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.
- upgrade_client_lib/__init__.py +6 -0
- upgrade_client_lib/api_service.py +339 -0
- upgrade_client_lib/assignment.py +190 -0
- upgrade_client_lib/client.py +347 -0
- upgrade_client_lib/data_service.py +107 -0
- upgrade_client_lib/exceptions.py +14 -0
- upgrade_client_lib/types/__init__.py +80 -0
- upgrade_client_lib/types/enums.py +25 -0
- upgrade_client_lib/types/requests.py +92 -0
- upgrade_client_lib/types/responses.py +102 -0
- upgrade_client_lib-6.5.0.dist-info/METADATA +214 -0
- upgrade_client_lib-6.5.0.dist-info/RECORD +13 -0
- upgrade_client_lib-6.5.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""ApiService — maps library operations to HTTP endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from upgrade_client_lib.exceptions import UpgradeApiError
|
|
10
|
+
from upgrade_client_lib.types.enums import BinaryRewardValue, ExperimentType, MarkedDecisionPointStatus
|
|
11
|
+
from upgrade_client_lib.types.requests import LogInput
|
|
12
|
+
from upgrade_client_lib.types.responses import (
|
|
13
|
+
ExperimentAssignment,
|
|
14
|
+
InitializeUserResponse,
|
|
15
|
+
LogEventResponse,
|
|
16
|
+
MarkDecisionPointResponse,
|
|
17
|
+
SendRewardResponse,
|
|
18
|
+
UserAliasResponse,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_API_VERSION = "v6"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiService:
|
|
25
|
+
"""Low-level HTTP client for the UpGrade v6 API.
|
|
26
|
+
|
|
27
|
+
Each operation is available in both async and sync variants. The async
|
|
28
|
+
variants use ``httpx.AsyncClient``; the sync variants use ``httpx.Client``.
|
|
29
|
+
Both raise :class:`~upgrade_client_lib.exceptions.UpgradeApiError` on
|
|
30
|
+
non-2xx responses.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
host_url: str,
|
|
36
|
+
user_id: str,
|
|
37
|
+
context: str,
|
|
38
|
+
token: str = "",
|
|
39
|
+
client_session_id: str = "",
|
|
40
|
+
) -> None:
|
|
41
|
+
self._host_url = host_url.rstrip("/")
|
|
42
|
+
self._user_id = user_id
|
|
43
|
+
self._context = context
|
|
44
|
+
self._token = token
|
|
45
|
+
self._client_session_id = client_session_id
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------------
|
|
48
|
+
# Internal helpers
|
|
49
|
+
# ------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def _url(self, path: str) -> str:
|
|
52
|
+
return f"{self._host_url}/api/{_API_VERSION}/{path}"
|
|
53
|
+
|
|
54
|
+
def _headers(self) -> dict[str, str]:
|
|
55
|
+
headers: dict[str, str] = {
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
"Session-Id": self._client_session_id,
|
|
58
|
+
"User-Id": self._user_id,
|
|
59
|
+
}
|
|
60
|
+
if self._token:
|
|
61
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
62
|
+
return headers
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
66
|
+
if response.is_error:
|
|
67
|
+
raise UpgradeApiError(
|
|
68
|
+
status_code=response.status_code,
|
|
69
|
+
message=f"UpGrade API error {response.status_code}",
|
|
70
|
+
response_body=response.text,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def _post_async(self, path: str, body: dict[str, Any]) -> Any:
|
|
74
|
+
async with httpx.AsyncClient() as client:
|
|
75
|
+
response = await client.post(self._url(path), json=body, headers=self._headers())
|
|
76
|
+
self._raise_for_status(response)
|
|
77
|
+
return response.json()
|
|
78
|
+
|
|
79
|
+
async def _patch_async(self, path: str, body: dict[str, Any]) -> Any:
|
|
80
|
+
async with httpx.AsyncClient() as client:
|
|
81
|
+
response = await client.patch(self._url(path), json=body, headers=self._headers())
|
|
82
|
+
self._raise_for_status(response)
|
|
83
|
+
return response.json()
|
|
84
|
+
|
|
85
|
+
def _post_sync(self, path: str, body: dict[str, Any]) -> Any:
|
|
86
|
+
with httpx.Client() as client:
|
|
87
|
+
response = client.post(self._url(path), json=body, headers=self._headers())
|
|
88
|
+
self._raise_for_status(response)
|
|
89
|
+
return response.json()
|
|
90
|
+
|
|
91
|
+
def _patch_sync(self, path: str, body: dict[str, Any]) -> Any:
|
|
92
|
+
with httpx.Client() as client:
|
|
93
|
+
response = client.patch(self._url(path), json=body, headers=self._headers())
|
|
94
|
+
self._raise_for_status(response)
|
|
95
|
+
return response.json()
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------
|
|
98
|
+
# init POST /api/v6/init
|
|
99
|
+
# ------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
async def init_user(
|
|
102
|
+
self,
|
|
103
|
+
group: dict[str, list[str]] | None = None,
|
|
104
|
+
working_group: dict[str, str] | None = None,
|
|
105
|
+
) -> InitializeUserResponse:
|
|
106
|
+
body: dict[str, Any] = {}
|
|
107
|
+
if group is not None:
|
|
108
|
+
body["group"] = group
|
|
109
|
+
if working_group is not None:
|
|
110
|
+
body["workingGroup"] = working_group
|
|
111
|
+
data = await self._post_async("init", body)
|
|
112
|
+
return InitializeUserResponse.model_validate(data)
|
|
113
|
+
|
|
114
|
+
def init_user_sync(
|
|
115
|
+
self,
|
|
116
|
+
group: dict[str, list[str]] | None = None,
|
|
117
|
+
working_group: dict[str, str] | None = None,
|
|
118
|
+
) -> InitializeUserResponse:
|
|
119
|
+
body: dict[str, Any] = {}
|
|
120
|
+
if group is not None:
|
|
121
|
+
body["group"] = group
|
|
122
|
+
if working_group is not None:
|
|
123
|
+
body["workingGroup"] = working_group
|
|
124
|
+
data = self._post_sync("init", body)
|
|
125
|
+
return InitializeUserResponse.model_validate(data)
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# set_group_membership PATCH /api/v6/groupmembership
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def set_group_membership(self, group: dict[str, list[str]]) -> InitializeUserResponse:
|
|
132
|
+
data = await self._patch_async("groupmembership", {"group": group})
|
|
133
|
+
return InitializeUserResponse.model_validate(data)
|
|
134
|
+
|
|
135
|
+
def set_group_membership_sync(self, group: dict[str, list[str]]) -> InitializeUserResponse:
|
|
136
|
+
data = self._patch_sync("groupmembership", {"group": group})
|
|
137
|
+
return InitializeUserResponse.model_validate(data)
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# set_working_group PATCH /api/v6/workinggroup
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
async def set_working_group(self, working_group: dict[str, str]) -> InitializeUserResponse:
|
|
144
|
+
data = await self._patch_async("workinggroup", {"workingGroup": working_group})
|
|
145
|
+
return InitializeUserResponse.model_validate(data)
|
|
146
|
+
|
|
147
|
+
def set_working_group_sync(self, working_group: dict[str, str]) -> InitializeUserResponse:
|
|
148
|
+
data = self._patch_sync("workinggroup", {"workingGroup": working_group})
|
|
149
|
+
return InitializeUserResponse.model_validate(data)
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
# get_all_experiment_conditions POST /api/v6/assign
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
async def get_all_experiment_conditions(self) -> list[ExperimentAssignment]:
|
|
156
|
+
data = await self._post_async("assign", {"context": self._context, "userId": self._user_id})
|
|
157
|
+
return [ExperimentAssignment.model_validate(item) for item in data]
|
|
158
|
+
|
|
159
|
+
def get_all_experiment_conditions_sync(self) -> list[ExperimentAssignment]:
|
|
160
|
+
data = self._post_sync("assign", {"context": self._context, "userId": self._user_id})
|
|
161
|
+
return [ExperimentAssignment.model_validate(item) for item in data]
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
# mark_decision_point POST /api/v6/mark
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
async def mark_decision_point(
|
|
168
|
+
self,
|
|
169
|
+
site: str,
|
|
170
|
+
target: str | None,
|
|
171
|
+
condition_code: str,
|
|
172
|
+
status: MarkedDecisionPointStatus,
|
|
173
|
+
experiment_type: ExperimentType | None = None,
|
|
174
|
+
experiment_id: str | None = None,
|
|
175
|
+
condition_id: str | None = None,
|
|
176
|
+
assigned_factor: dict[str, Any] | None = None,
|
|
177
|
+
uniquifier: str | None = None,
|
|
178
|
+
client_error: str | None = None,
|
|
179
|
+
) -> MarkDecisionPointResponse:
|
|
180
|
+
body = self._build_mark_body(
|
|
181
|
+
site,
|
|
182
|
+
target,
|
|
183
|
+
condition_code,
|
|
184
|
+
status,
|
|
185
|
+
experiment_type,
|
|
186
|
+
experiment_id,
|
|
187
|
+
condition_id,
|
|
188
|
+
assigned_factor,
|
|
189
|
+
uniquifier,
|
|
190
|
+
client_error,
|
|
191
|
+
)
|
|
192
|
+
data = await self._post_async("mark", body)
|
|
193
|
+
return MarkDecisionPointResponse.model_validate(data)
|
|
194
|
+
|
|
195
|
+
def mark_decision_point_sync(
|
|
196
|
+
self,
|
|
197
|
+
site: str,
|
|
198
|
+
target: str | None,
|
|
199
|
+
condition_code: str,
|
|
200
|
+
status: MarkedDecisionPointStatus,
|
|
201
|
+
experiment_type: ExperimentType | None = None,
|
|
202
|
+
experiment_id: str | None = None,
|
|
203
|
+
condition_id: str | None = None,
|
|
204
|
+
assigned_factor: dict[str, Any] | None = None,
|
|
205
|
+
uniquifier: str | None = None,
|
|
206
|
+
client_error: str | None = None,
|
|
207
|
+
) -> MarkDecisionPointResponse:
|
|
208
|
+
body = self._build_mark_body(
|
|
209
|
+
site,
|
|
210
|
+
target,
|
|
211
|
+
condition_code,
|
|
212
|
+
status,
|
|
213
|
+
experiment_type,
|
|
214
|
+
experiment_id,
|
|
215
|
+
condition_id,
|
|
216
|
+
assigned_factor,
|
|
217
|
+
uniquifier,
|
|
218
|
+
client_error,
|
|
219
|
+
)
|
|
220
|
+
data = self._post_sync("mark", body)
|
|
221
|
+
return MarkDecisionPointResponse.model_validate(data)
|
|
222
|
+
|
|
223
|
+
@staticmethod
|
|
224
|
+
def _build_mark_body(
|
|
225
|
+
site: str,
|
|
226
|
+
target: str | None,
|
|
227
|
+
condition_code: str,
|
|
228
|
+
status: MarkedDecisionPointStatus,
|
|
229
|
+
experiment_type: ExperimentType | None,
|
|
230
|
+
experiment_id: str | None,
|
|
231
|
+
condition_id: str | None,
|
|
232
|
+
assigned_factor: dict[str, Any] | None,
|
|
233
|
+
uniquifier: str | None,
|
|
234
|
+
client_error: str | None,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
assigned_condition: dict[str, Any] = {"conditionCode": condition_code}
|
|
237
|
+
if experiment_id is not None:
|
|
238
|
+
assigned_condition["experimentId"] = experiment_id
|
|
239
|
+
if condition_id is not None:
|
|
240
|
+
assigned_condition["id"] = condition_id
|
|
241
|
+
|
|
242
|
+
mark_data: dict[str, Any] = {
|
|
243
|
+
"site": site,
|
|
244
|
+
"target": target,
|
|
245
|
+
"assignedCondition": assigned_condition,
|
|
246
|
+
}
|
|
247
|
+
if experiment_type is not None:
|
|
248
|
+
mark_data["experimentType"] = experiment_type.value
|
|
249
|
+
if assigned_factor is not None:
|
|
250
|
+
mark_data["assignedFactor"] = assigned_factor
|
|
251
|
+
|
|
252
|
+
body: dict[str, Any] = {"status": status.value, "data": mark_data}
|
|
253
|
+
if uniquifier is not None:
|
|
254
|
+
body["uniquifier"] = uniquifier
|
|
255
|
+
if client_error is not None:
|
|
256
|
+
body["clientError"] = client_error
|
|
257
|
+
return body
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------
|
|
260
|
+
# get_all_feature_flags POST /api/v6/featureflag
|
|
261
|
+
# ------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
async def get_all_feature_flags(self) -> list[str]:
|
|
264
|
+
data = await self._post_async("featureflag", {"context": self._context, "userId": self._user_id})
|
|
265
|
+
return list(data)
|
|
266
|
+
|
|
267
|
+
def get_all_feature_flags_sync(self) -> list[str]:
|
|
268
|
+
data = self._post_sync("featureflag", {"context": self._context, "userId": self._user_id})
|
|
269
|
+
return list(data)
|
|
270
|
+
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
# log POST /api/v6/log
|
|
273
|
+
# ------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
async def log(self, log_inputs: list[LogInput]) -> list[LogEventResponse]:
|
|
276
|
+
body = {"userId": self._user_id, "value": [item.model_dump() for item in log_inputs]}
|
|
277
|
+
data = await self._post_async("log", body)
|
|
278
|
+
return [LogEventResponse.model_validate(item) for item in data]
|
|
279
|
+
|
|
280
|
+
def log_sync(self, log_inputs: list[LogInput]) -> list[LogEventResponse]:
|
|
281
|
+
body = {"userId": self._user_id, "value": [item.model_dump() for item in log_inputs]}
|
|
282
|
+
data = self._post_sync("log", body)
|
|
283
|
+
return [LogEventResponse.model_validate(item) for item in data]
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# set_alt_user_ids PATCH /api/v6/useraliases
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async def set_alt_user_ids(self, aliases: list[str]) -> UserAliasResponse:
|
|
290
|
+
body = {"userId": self._user_id, "aliases": aliases}
|
|
291
|
+
data = await self._patch_async("useraliases", body)
|
|
292
|
+
return UserAliasResponse.model_validate(data)
|
|
293
|
+
|
|
294
|
+
def set_alt_user_ids_sync(self, aliases: list[str]) -> UserAliasResponse:
|
|
295
|
+
body = {"userId": self._user_id, "aliases": aliases}
|
|
296
|
+
data = self._patch_sync("useraliases", body)
|
|
297
|
+
return UserAliasResponse.model_validate(data)
|
|
298
|
+
|
|
299
|
+
# ------------------------------------------------------------------
|
|
300
|
+
# send_reward POST /api/v6/reward
|
|
301
|
+
# ------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
async def send_reward(
|
|
304
|
+
self,
|
|
305
|
+
reward_value: BinaryRewardValue,
|
|
306
|
+
experiment_id: str | None = None,
|
|
307
|
+
context: str | None = None,
|
|
308
|
+
decision_point: dict[str, str] | None = None,
|
|
309
|
+
) -> SendRewardResponse:
|
|
310
|
+
body = self._build_reward_body(reward_value, experiment_id, context, decision_point)
|
|
311
|
+
data = await self._post_async("reward", body)
|
|
312
|
+
return SendRewardResponse.model_validate(data)
|
|
313
|
+
|
|
314
|
+
def send_reward_sync(
|
|
315
|
+
self,
|
|
316
|
+
reward_value: BinaryRewardValue,
|
|
317
|
+
experiment_id: str | None = None,
|
|
318
|
+
context: str | None = None,
|
|
319
|
+
decision_point: dict[str, str] | None = None,
|
|
320
|
+
) -> SendRewardResponse:
|
|
321
|
+
body = self._build_reward_body(reward_value, experiment_id, context, decision_point)
|
|
322
|
+
data = self._post_sync("reward", body)
|
|
323
|
+
return SendRewardResponse.model_validate(data)
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _build_reward_body(
|
|
327
|
+
reward_value: BinaryRewardValue,
|
|
328
|
+
experiment_id: str | None,
|
|
329
|
+
context: str | None,
|
|
330
|
+
decision_point: dict[str, str] | None,
|
|
331
|
+
) -> dict[str, Any]:
|
|
332
|
+
body: dict[str, Any] = {"rewardValue": reward_value.value}
|
|
333
|
+
if experiment_id is not None:
|
|
334
|
+
body["experimentId"] = experiment_id
|
|
335
|
+
if context is not None:
|
|
336
|
+
body["context"] = context
|
|
337
|
+
if decision_point is not None:
|
|
338
|
+
body["decisionPoint"] = decision_point
|
|
339
|
+
return body
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Assignment wrapper class.
|
|
2
|
+
|
|
3
|
+
Wraps a raw :class:`~upgrade_client_lib.types.responses.ExperimentAssignment`
|
|
4
|
+
and exposes a high-level interface for reading condition/payload/factor data
|
|
5
|
+
and marking the decision point.
|
|
6
|
+
|
|
7
|
+
The class mirrors the JS ``Assignment`` class in
|
|
8
|
+
``clientlibs/js/src/Assignment/Assignment.ts``.
|
|
9
|
+
|
|
10
|
+
Usage
|
|
11
|
+
-----
|
|
12
|
+
Instances are created by :class:`~upgrade_client_lib.client.UpgradeClient`,
|
|
13
|
+
not constructed directly::
|
|
14
|
+
|
|
15
|
+
assignments = await client.get_all_experiment_conditions()
|
|
16
|
+
a = assignments[0]
|
|
17
|
+
|
|
18
|
+
condition = a.get_condition() # "control"
|
|
19
|
+
payload = a.get_payload() # Payload(type=STRING, value="…") | None
|
|
20
|
+
exp_type = a.get_experiment_type() # ExperimentType.SIMPLE
|
|
21
|
+
|
|
22
|
+
# Factorial only
|
|
23
|
+
for factor in a.factors:
|
|
24
|
+
level = a.get_factor_level(factor)
|
|
25
|
+
payload = a.get_factor_payload(factor)
|
|
26
|
+
|
|
27
|
+
await a.mark_decision_point(MarkedDecisionPointStatus.CONDITION_APPLIED)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from typing import TYPE_CHECKING
|
|
33
|
+
|
|
34
|
+
from upgrade_client_lib.types.enums import ExperimentType, MarkedDecisionPointStatus
|
|
35
|
+
from upgrade_client_lib.types.responses import AssignedFactor, ExperimentAssignment, MarkDecisionPointResponse, Payload
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from upgrade_client_lib.api_service import ApiService
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Assignment:
|
|
42
|
+
"""High-level wrapper over a single :class:`ExperimentAssignment`.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
raw:
|
|
47
|
+
The raw assignment returned by the UpGrade API (after any rotation
|
|
48
|
+
applied by :class:`~upgrade_client_lib.data_service.DataService`).
|
|
49
|
+
api_service:
|
|
50
|
+
The :class:`~upgrade_client_lib.api_service.ApiService` instance used
|
|
51
|
+
to send mark requests. Injected by ``UpgradeClient``.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, raw: ExperimentAssignment, api_service: ApiService) -> None:
|
|
55
|
+
self._site = raw.site
|
|
56
|
+
self._target = raw.target
|
|
57
|
+
self._experiment_type = raw.experimentType
|
|
58
|
+
|
|
59
|
+
first = raw.assignedCondition[0] if raw.assignedCondition else None
|
|
60
|
+
self._condition_code: str = first.conditionCode if first else ""
|
|
61
|
+
self._payload: Payload | None = first.payload if first else None
|
|
62
|
+
self._experiment_id: str | None = first.experimentId if first else None
|
|
63
|
+
self._condition_id: str | None = first.id if first else None
|
|
64
|
+
|
|
65
|
+
# Take the head of the (already-rotated) factor list, matching JS behaviour.
|
|
66
|
+
self._assigned_factor: dict[str, AssignedFactor] | None = (
|
|
67
|
+
raw.assignedFactor[0] if raw.assignedFactor else None
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._api_service = api_service
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# Accessors
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def get_condition(self) -> str:
|
|
77
|
+
"""Return the assigned condition code (empty string when none assigned)."""
|
|
78
|
+
return self._condition_code
|
|
79
|
+
|
|
80
|
+
def get_payload(self) -> Payload | None:
|
|
81
|
+
"""Return the condition payload, or ``None`` if there is no payload."""
|
|
82
|
+
return self._payload
|
|
83
|
+
|
|
84
|
+
def get_experiment_type(self) -> ExperimentType:
|
|
85
|
+
"""Return the experiment type (``SIMPLE`` or ``FACTORIAL``)."""
|
|
86
|
+
return self._experiment_type
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Factorial accessors
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def factors(self) -> list[str]:
|
|
94
|
+
"""Names of all factors for a ``FACTORIAL`` experiment.
|
|
95
|
+
|
|
96
|
+
Returns an empty list for ``SIMPLE`` experiments or when no factor
|
|
97
|
+
data is present.
|
|
98
|
+
"""
|
|
99
|
+
if self._experiment_type == ExperimentType.FACTORIAL and self._assigned_factor:
|
|
100
|
+
return list(self._assigned_factor.keys())
|
|
101
|
+
return []
|
|
102
|
+
|
|
103
|
+
def get_factor_level(self, factor: str) -> str | None:
|
|
104
|
+
"""Return the assigned level for *factor*, or ``None``.
|
|
105
|
+
|
|
106
|
+
Returns ``None`` for ``SIMPLE`` experiments or when the factor name is
|
|
107
|
+
not present in the assignment.
|
|
108
|
+
"""
|
|
109
|
+
if self._experiment_type != ExperimentType.FACTORIAL or not self._assigned_factor:
|
|
110
|
+
return None
|
|
111
|
+
entry = self._assigned_factor.get(factor)
|
|
112
|
+
return entry.level if entry is not None else None
|
|
113
|
+
|
|
114
|
+
def get_factor_payload(self, factor: str) -> Payload | None:
|
|
115
|
+
"""Return the payload for *factor*, or ``None``.
|
|
116
|
+
|
|
117
|
+
Returns ``None`` for ``SIMPLE`` experiments, unknown factor names, or
|
|
118
|
+
when the factor has no payload value.
|
|
119
|
+
"""
|
|
120
|
+
if self._experiment_type != ExperimentType.FACTORIAL or not self._assigned_factor:
|
|
121
|
+
return None
|
|
122
|
+
entry = self._assigned_factor.get(factor)
|
|
123
|
+
if entry is None or entry.payload is None or not entry.payload.value:
|
|
124
|
+
return None
|
|
125
|
+
return entry.payload
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Mark convenience methods
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
async def mark_decision_point(
|
|
132
|
+
self,
|
|
133
|
+
status: MarkedDecisionPointStatus,
|
|
134
|
+
uniquifier: str = "",
|
|
135
|
+
client_error: str = "",
|
|
136
|
+
) -> MarkDecisionPointResponse:
|
|
137
|
+
"""Mark this decision point asynchronously.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
status:
|
|
142
|
+
What the client application did with the assigned condition.
|
|
143
|
+
uniquifier:
|
|
144
|
+
Optional string that ties logged metrics to this specific mark
|
|
145
|
+
event. Required for within-subjects experiments.
|
|
146
|
+
client_error:
|
|
147
|
+
Optional string describing why a condition was not applied.
|
|
148
|
+
"""
|
|
149
|
+
return await self._api_service.mark_decision_point(
|
|
150
|
+
site=self._site,
|
|
151
|
+
target=self._target,
|
|
152
|
+
condition_code=self._condition_code,
|
|
153
|
+
status=status,
|
|
154
|
+
experiment_type=self._experiment_type,
|
|
155
|
+
experiment_id=self._experiment_id,
|
|
156
|
+
condition_id=self._condition_id,
|
|
157
|
+
assigned_factor=self._serialized_factor(),
|
|
158
|
+
uniquifier=uniquifier or None,
|
|
159
|
+
client_error=client_error or None,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
def mark_decision_point_sync(
|
|
163
|
+
self,
|
|
164
|
+
status: MarkedDecisionPointStatus,
|
|
165
|
+
uniquifier: str = "",
|
|
166
|
+
client_error: str = "",
|
|
167
|
+
) -> MarkDecisionPointResponse:
|
|
168
|
+
"""Synchronous variant of :meth:`mark_decision_point`."""
|
|
169
|
+
return self._api_service.mark_decision_point_sync(
|
|
170
|
+
site=self._site,
|
|
171
|
+
target=self._target,
|
|
172
|
+
condition_code=self._condition_code,
|
|
173
|
+
status=status,
|
|
174
|
+
experiment_type=self._experiment_type,
|
|
175
|
+
experiment_id=self._experiment_id,
|
|
176
|
+
condition_id=self._condition_id,
|
|
177
|
+
assigned_factor=self._serialized_factor(),
|
|
178
|
+
uniquifier=uniquifier or None,
|
|
179
|
+
client_error=client_error or None,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Internal helpers
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _serialized_factor(self) -> dict[str, object] | None:
|
|
187
|
+
"""Convert ``AssignedFactor`` objects to plain dicts for the API."""
|
|
188
|
+
if not self._assigned_factor:
|
|
189
|
+
return None
|
|
190
|
+
return {k: v.model_dump() for k, v in self._assigned_factor.items()}
|