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.
@@ -0,0 +1,6 @@
1
+ """UpGrade Python Client Library."""
2
+
3
+ from upgrade_client_lib.client import UpgradeClient
4
+
5
+ __version__ = "6.5.0"
6
+ __all__ = ["UpgradeClient"]
@@ -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()}