nextmv 0.39.0.dev1__py3-none-any.whl → 1.0.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.
Files changed (161) hide show
  1. nextmv/__about__.py +1 -1
  2. nextmv/__entrypoint__.py +1 -2
  3. nextmv/__init__.py +2 -4
  4. nextmv/cli/CONTRIBUTING.md +583 -0
  5. nextmv/cli/cloud/__init__.py +49 -0
  6. nextmv/cli/cloud/acceptance/__init__.py +27 -0
  7. nextmv/cli/cloud/acceptance/create.py +391 -0
  8. nextmv/cli/cloud/acceptance/delete.py +64 -0
  9. nextmv/cli/cloud/acceptance/get.py +103 -0
  10. nextmv/cli/cloud/acceptance/list.py +62 -0
  11. nextmv/cli/cloud/acceptance/update.py +95 -0
  12. nextmv/cli/cloud/account/__init__.py +28 -0
  13. nextmv/cli/cloud/account/create.py +83 -0
  14. nextmv/cli/cloud/account/delete.py +59 -0
  15. nextmv/cli/cloud/account/get.py +66 -0
  16. nextmv/cli/cloud/account/update.py +70 -0
  17. nextmv/cli/cloud/app/__init__.py +35 -0
  18. nextmv/cli/cloud/app/create.py +140 -0
  19. nextmv/cli/cloud/app/delete.py +57 -0
  20. nextmv/cli/cloud/app/exists.py +44 -0
  21. nextmv/cli/cloud/app/get.py +66 -0
  22. nextmv/cli/cloud/app/list.py +61 -0
  23. nextmv/cli/cloud/app/push.py +432 -0
  24. nextmv/cli/cloud/app/update.py +124 -0
  25. nextmv/cli/cloud/batch/__init__.py +29 -0
  26. nextmv/cli/cloud/batch/create.py +452 -0
  27. nextmv/cli/cloud/batch/delete.py +64 -0
  28. nextmv/cli/cloud/batch/get.py +104 -0
  29. nextmv/cli/cloud/batch/list.py +63 -0
  30. nextmv/cli/cloud/batch/metadata.py +66 -0
  31. nextmv/cli/cloud/batch/update.py +95 -0
  32. nextmv/cli/cloud/data/__init__.py +26 -0
  33. nextmv/cli/cloud/data/upload.py +162 -0
  34. nextmv/cli/cloud/ensemble/__init__.py +33 -0
  35. nextmv/cli/cloud/ensemble/create.py +413 -0
  36. nextmv/cli/cloud/ensemble/delete.py +63 -0
  37. nextmv/cli/cloud/ensemble/get.py +65 -0
  38. nextmv/cli/cloud/ensemble/list.py +63 -0
  39. nextmv/cli/cloud/ensemble/update.py +103 -0
  40. nextmv/cli/cloud/input_set/__init__.py +32 -0
  41. nextmv/cli/cloud/input_set/create.py +168 -0
  42. nextmv/cli/cloud/input_set/delete.py +64 -0
  43. nextmv/cli/cloud/input_set/get.py +63 -0
  44. nextmv/cli/cloud/input_set/list.py +63 -0
  45. nextmv/cli/cloud/input_set/update.py +123 -0
  46. nextmv/cli/cloud/instance/__init__.py +35 -0
  47. nextmv/cli/cloud/instance/create.py +289 -0
  48. nextmv/cli/cloud/instance/delete.py +61 -0
  49. nextmv/cli/cloud/instance/exists.py +39 -0
  50. nextmv/cli/cloud/instance/get.py +62 -0
  51. nextmv/cli/cloud/instance/list.py +60 -0
  52. nextmv/cli/cloud/instance/update.py +216 -0
  53. nextmv/cli/cloud/managed_input/__init__.py +31 -0
  54. nextmv/cli/cloud/managed_input/create.py +144 -0
  55. nextmv/cli/cloud/managed_input/delete.py +64 -0
  56. nextmv/cli/cloud/managed_input/get.py +63 -0
  57. nextmv/cli/cloud/managed_input/list.py +60 -0
  58. nextmv/cli/cloud/managed_input/update.py +97 -0
  59. nextmv/cli/cloud/run/__init__.py +37 -0
  60. nextmv/cli/cloud/run/cancel.py +37 -0
  61. nextmv/cli/cloud/run/create.py +524 -0
  62. nextmv/cli/cloud/run/get.py +199 -0
  63. nextmv/cli/cloud/run/input.py +86 -0
  64. nextmv/cli/cloud/run/list.py +80 -0
  65. nextmv/cli/cloud/run/logs.py +166 -0
  66. nextmv/cli/cloud/run/metadata.py +67 -0
  67. nextmv/cli/cloud/run/track.py +500 -0
  68. nextmv/cli/cloud/scenario/__init__.py +29 -0
  69. nextmv/cli/cloud/scenario/create.py +451 -0
  70. nextmv/cli/cloud/scenario/delete.py +61 -0
  71. nextmv/cli/cloud/scenario/get.py +102 -0
  72. nextmv/cli/cloud/scenario/list.py +63 -0
  73. nextmv/cli/cloud/scenario/metadata.py +67 -0
  74. nextmv/cli/cloud/scenario/update.py +93 -0
  75. nextmv/cli/cloud/secrets/__init__.py +33 -0
  76. nextmv/cli/cloud/secrets/create.py +206 -0
  77. nextmv/cli/cloud/secrets/delete.py +63 -0
  78. nextmv/cli/cloud/secrets/get.py +66 -0
  79. nextmv/cli/cloud/secrets/list.py +60 -0
  80. nextmv/cli/cloud/secrets/update.py +144 -0
  81. nextmv/cli/cloud/shadow/__init__.py +33 -0
  82. nextmv/cli/cloud/shadow/create.py +184 -0
  83. nextmv/cli/cloud/shadow/delete.py +64 -0
  84. nextmv/cli/cloud/shadow/get.py +61 -0
  85. nextmv/cli/cloud/shadow/list.py +63 -0
  86. nextmv/cli/cloud/shadow/metadata.py +66 -0
  87. nextmv/cli/cloud/shadow/start.py +43 -0
  88. nextmv/cli/cloud/shadow/stop.py +53 -0
  89. nextmv/cli/cloud/shadow/update.py +96 -0
  90. nextmv/cli/cloud/switchback/__init__.py +33 -0
  91. nextmv/cli/cloud/switchback/create.py +151 -0
  92. nextmv/cli/cloud/switchback/delete.py +64 -0
  93. nextmv/cli/cloud/switchback/get.py +62 -0
  94. nextmv/cli/cloud/switchback/list.py +63 -0
  95. nextmv/cli/cloud/switchback/metadata.py +68 -0
  96. nextmv/cli/cloud/switchback/start.py +43 -0
  97. nextmv/cli/cloud/switchback/stop.py +53 -0
  98. nextmv/cli/cloud/switchback/update.py +96 -0
  99. nextmv/cli/cloud/upload/__init__.py +22 -0
  100. nextmv/cli/cloud/upload/create.py +39 -0
  101. nextmv/cli/cloud/version/__init__.py +33 -0
  102. nextmv/cli/cloud/version/create.py +96 -0
  103. nextmv/cli/cloud/version/delete.py +61 -0
  104. nextmv/cli/cloud/version/exists.py +39 -0
  105. nextmv/cli/cloud/version/get.py +62 -0
  106. nextmv/cli/cloud/version/list.py +60 -0
  107. nextmv/cli/cloud/version/update.py +92 -0
  108. nextmv/cli/community/__init__.py +24 -0
  109. nextmv/cli/community/clone.py +86 -0
  110. nextmv/cli/community/list.py +200 -0
  111. nextmv/cli/configuration/__init__.py +23 -0
  112. nextmv/cli/configuration/config.py +228 -0
  113. nextmv/cli/configuration/create.py +94 -0
  114. nextmv/cli/configuration/delete.py +67 -0
  115. nextmv/cli/configuration/list.py +77 -0
  116. nextmv/cli/confirm.py +34 -0
  117. nextmv/cli/main.py +161 -3
  118. nextmv/cli/message.py +170 -0
  119. nextmv/cli/options.py +220 -0
  120. nextmv/cli/version.py +22 -2
  121. nextmv/cloud/__init__.py +17 -38
  122. nextmv/cloud/acceptance_test.py +20 -83
  123. nextmv/cloud/account.py +269 -30
  124. nextmv/cloud/application/__init__.py +898 -0
  125. nextmv/cloud/application/_acceptance.py +424 -0
  126. nextmv/cloud/application/_batch_scenario.py +845 -0
  127. nextmv/cloud/application/_ensemble.py +251 -0
  128. nextmv/cloud/application/_input_set.py +263 -0
  129. nextmv/cloud/application/_instance.py +289 -0
  130. nextmv/cloud/application/_managed_input.py +227 -0
  131. nextmv/cloud/application/_run.py +1393 -0
  132. nextmv/cloud/application/_secrets.py +294 -0
  133. nextmv/cloud/application/_shadow.py +320 -0
  134. nextmv/cloud/application/_switchback.py +332 -0
  135. nextmv/cloud/application/_utils.py +54 -0
  136. nextmv/cloud/application/_version.py +304 -0
  137. nextmv/cloud/batch_experiment.py +6 -2
  138. nextmv/cloud/community.py +446 -0
  139. nextmv/cloud/instance.py +11 -1
  140. nextmv/cloud/integration.py +8 -5
  141. nextmv/cloud/package.py +50 -9
  142. nextmv/cloud/shadow.py +254 -0
  143. nextmv/cloud/switchback.py +228 -0
  144. nextmv/deprecated.py +5 -3
  145. nextmv/input.py +20 -88
  146. nextmv/local/application.py +3 -15
  147. nextmv/local/runner.py +1 -1
  148. nextmv/model.py +50 -11
  149. nextmv/options.py +11 -256
  150. nextmv/output.py +0 -62
  151. nextmv/polling.py +54 -16
  152. nextmv/run.py +84 -37
  153. nextmv/status.py +1 -51
  154. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/METADATA +37 -11
  155. nextmv-1.0.0.dist-info/RECORD +185 -0
  156. nextmv-1.0.0.dist-info/entry_points.txt +2 -0
  157. nextmv/cloud/application.py +0 -4204
  158. nextmv-0.39.0.dev1.dist-info/RECORD +0 -55
  159. nextmv-0.39.0.dev1.dist-info/entry_points.txt +0 -2
  160. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/WHEEL +0 -0
  161. {nextmv-0.39.0.dev1.dist-info → nextmv-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,424 @@
1
+ """
2
+ Application mixin for managing acceptance tests.
3
+ """
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ import requests
8
+
9
+ from nextmv.cloud.acceptance_test import AcceptanceTest, Metric
10
+ from nextmv.cloud.batch_experiment import BatchExperimentRun, ExperimentStatus
11
+ from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
12
+ from nextmv.safe import safe_id
13
+
14
+ if TYPE_CHECKING:
15
+ from . import Application
16
+
17
+
18
+ class ApplicationAcceptanceMixin:
19
+ """
20
+ Mixin class providing acceptance test methods for Application.
21
+ """
22
+
23
+ def acceptance_test(self: "Application", acceptance_test_id: str) -> AcceptanceTest:
24
+ """
25
+ Retrieve details of an acceptance test.
26
+
27
+ Parameters
28
+ ----------
29
+ acceptance_test_id : str
30
+ ID of the acceptance test to retrieve.
31
+
32
+ Returns
33
+ -------
34
+ AcceptanceTest
35
+ The requested acceptance test details.
36
+
37
+ Raises
38
+ ------
39
+ requests.HTTPError
40
+ If the response status code is not 2xx.
41
+
42
+ Examples
43
+ --------
44
+ >>> test = app.acceptance_test("test-123")
45
+ >>> print(test.name)
46
+ 'My Test'
47
+ """
48
+ response = self.client.request(
49
+ method="GET",
50
+ endpoint=f"{self.experiments_endpoint}/acceptance/{acceptance_test_id}",
51
+ )
52
+
53
+ return AcceptanceTest.from_dict(response.json())
54
+
55
+ def acceptance_test_with_polling(
56
+ self: "Application",
57
+ acceptance_test_id: str,
58
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
59
+ ) -> AcceptanceTest:
60
+ """
61
+ Retrieve details of an acceptance test using polling.
62
+
63
+ Retrieves the result of an acceptance test. This method polls for the
64
+ result until the test finishes executing or the polling strategy is
65
+ exhausted.
66
+
67
+ Parameters
68
+ ----------
69
+ acceptance_test_id : str
70
+ ID of the acceptance test to retrieve.
71
+
72
+ Returns
73
+ -------
74
+ AcceptanceTest
75
+ The requested acceptance test details.
76
+
77
+ Raises
78
+ ------
79
+ requests.HTTPError
80
+ If the response status code is not 2xx.
81
+
82
+ Examples
83
+ --------
84
+ >>> test = app.acceptance_test_with_polling("test-123")
85
+ >>> print(test.name)
86
+ 'My Test'
87
+ """
88
+
89
+ def polling_func() -> tuple[Any, bool]:
90
+ acceptance_test_result = self.acceptance_test(acceptance_test_id=acceptance_test_id)
91
+ if acceptance_test_result.status in {
92
+ ExperimentStatus.COMPLETED,
93
+ ExperimentStatus.FAILED,
94
+ ExperimentStatus.DRAFT,
95
+ ExperimentStatus.CANCELED,
96
+ ExperimentStatus.DELETE_FAILED,
97
+ }:
98
+ return acceptance_test_result, True
99
+
100
+ return None, False
101
+
102
+ acceptance_test = poll(polling_options=polling_options, polling_func=polling_func)
103
+
104
+ return self.acceptance_test(acceptance_test_id=acceptance_test.id)
105
+
106
+ def delete_acceptance_test(self: "Application", acceptance_test_id: str) -> None:
107
+ """
108
+ Delete an acceptance test.
109
+
110
+ Deletes an acceptance test along with all the associated information
111
+ such as the underlying batch experiment.
112
+
113
+ Parameters
114
+ ----------
115
+ acceptance_test_id : str
116
+ ID of the acceptance test to delete.
117
+
118
+ Raises
119
+ ------
120
+ requests.HTTPError
121
+ If the response status code is not 2xx.
122
+
123
+ Examples
124
+ --------
125
+ >>> app.delete_acceptance_test("test-123")
126
+ """
127
+
128
+ _ = self.client.request(
129
+ method="DELETE",
130
+ endpoint=f"{self.experiments_endpoint}/acceptance/{acceptance_test_id}",
131
+ )
132
+
133
+ def list_acceptance_tests(self: "Application") -> list[AcceptanceTest]:
134
+ """
135
+ List all acceptance tests.
136
+
137
+ Returns
138
+ -------
139
+ list[AcceptanceTest]
140
+ List of all acceptance tests associated with this application.
141
+
142
+ Raises
143
+ ------
144
+ requests.HTTPError
145
+ If the response status code is not 2xx.
146
+
147
+ Examples
148
+ --------
149
+ >>> tests = app.list_acceptance_tests()
150
+ >>> for test in tests:
151
+ ... print(test.name)
152
+ 'Test 1'
153
+ 'Test 2'
154
+ """
155
+
156
+ response = self.client.request(
157
+ method="GET",
158
+ endpoint=f"{self.experiments_endpoint}/acceptance",
159
+ )
160
+
161
+ return [AcceptanceTest.from_dict(acceptance_test) for acceptance_test in response.json()]
162
+
163
+ def new_acceptance_test( # noqa: C901
164
+ self: "Application",
165
+ candidate_instance_id: str,
166
+ baseline_instance_id: str,
167
+ metrics: list[Metric | dict[str, Any]],
168
+ id: str | None = None,
169
+ name: str | None = None,
170
+ input_set_id: str | None = None,
171
+ description: str | None = None,
172
+ ) -> AcceptanceTest:
173
+ """
174
+ Create a new acceptance test.
175
+
176
+ The acceptance test is based on a batch experiment. If you already
177
+ started a batch experiment, you don't need to provide the input_set_id
178
+ parameter. In that case, the ID of the acceptance test and the batch
179
+ experiment must be the same. If the batch experiment does not exist,
180
+ you can provide the input_set_id parameter and a new batch experiment
181
+ will be created for you.
182
+
183
+ Parameters
184
+ ----------
185
+ candidate_instance_id : str
186
+ ID of the candidate instance.
187
+ baseline_instance_id : str
188
+ ID of the baseline instance.
189
+ id : str | None, default=None
190
+ ID of the acceptance test. Will be generated if not provided.
191
+ metrics : list[Union[Metric, dict[str, Any]]]
192
+ List of metrics to use for the acceptance test.
193
+ name : Optional[str], default=None
194
+ Name of the acceptance test. If not provided, the ID will be used as the name.
195
+ input_set_id : Optional[str], default=None
196
+ ID of the input set to use for the underlying batch experiment,
197
+ in case it hasn't been started.
198
+ description : Optional[str], default=None
199
+ Description of the acceptance test.
200
+
201
+ Returns
202
+ -------
203
+ AcceptanceTest
204
+ The created acceptance test.
205
+
206
+ Raises
207
+ ------
208
+ requests.HTTPError
209
+ If the response status code is not 2xx.
210
+ ValueError
211
+ If the batch experiment ID does not match the acceptance test ID.
212
+ """
213
+
214
+ # Generate ID if not provided
215
+ if id is None or id == "":
216
+ id = safe_id("acceptance")
217
+
218
+ # Use ID as name if name not provided
219
+ if name is None or name == "":
220
+ name = id
221
+
222
+ if input_set_id is None:
223
+ try:
224
+ batch_experiment = self.batch_experiment(batch_id=id)
225
+ batch_experiment_id = batch_experiment.id
226
+ except requests.HTTPError as e:
227
+ if e.response.status_code != 404:
228
+ raise e
229
+
230
+ raise ValueError(
231
+ f"batch experiment {id} does not exist, input_set_id must be defined to create a new one"
232
+ ) from e
233
+ else:
234
+ # Get all input IDs from the input set.
235
+ input_set = self.input_set(input_set_id=input_set_id)
236
+ if not input_set.input_ids:
237
+ raise ValueError(f"input set {input_set_id} does not contain any inputs")
238
+ runs = []
239
+ for input_id in input_set.input_ids:
240
+ runs.append(
241
+ BatchExperimentRun(
242
+ instance_id=candidate_instance_id,
243
+ input_set_id=input_set_id,
244
+ input_id=input_id,
245
+ )
246
+ )
247
+ runs.append(
248
+ BatchExperimentRun(
249
+ instance_id=baseline_instance_id,
250
+ input_set_id=input_set_id,
251
+ input_id=input_id,
252
+ )
253
+ )
254
+ batch_experiment_id = self.new_batch_experiment(
255
+ name=name,
256
+ description=description,
257
+ id=id,
258
+ runs=runs,
259
+ )
260
+
261
+ if batch_experiment_id != id:
262
+ raise ValueError(f"batch experiment_id ({batch_experiment_id}) does not match acceptance test id ({id})")
263
+
264
+ payload_metrics = [{}] * len(metrics)
265
+ for i, metric in enumerate(metrics):
266
+ payload_metrics[i] = metric.to_dict() if isinstance(metric, Metric) else metric
267
+
268
+ payload = {
269
+ "candidate": {"instance_id": candidate_instance_id},
270
+ "control": {"instance_id": baseline_instance_id},
271
+ "metrics": payload_metrics,
272
+ "experiment_id": batch_experiment_id,
273
+ "name": name,
274
+ "id": id,
275
+ }
276
+ if description is not None:
277
+ payload["description"] = description
278
+
279
+ response = self.client.request(
280
+ method="POST",
281
+ endpoint=f"{self.experiments_endpoint}/acceptance",
282
+ payload=payload,
283
+ )
284
+
285
+ return AcceptanceTest.from_dict(response.json())
286
+
287
+ def new_acceptance_test_with_result(
288
+ self: "Application",
289
+ candidate_instance_id: str,
290
+ baseline_instance_id: str,
291
+ metrics: list[Metric | dict[str, Any]],
292
+ id: str | None = None,
293
+ name: str | None = None,
294
+ input_set_id: str | None = None,
295
+ description: str | None = None,
296
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
297
+ ) -> AcceptanceTest:
298
+ """
299
+ Create a new acceptance test and poll for the result.
300
+
301
+ This is a convenience method that combines the new_acceptance_test with polling
302
+ logic to check when the acceptance test is done.
303
+
304
+ Parameters
305
+ ----------
306
+ candidate_instance_id : str
307
+ ID of the candidate instance.
308
+ baseline_instance_id : str
309
+ ID of the baseline instance.
310
+ id : str | None, default=None
311
+ ID of the acceptance test. Will be generated if not provided.
312
+ metrics : list[Union[Metric, dict[str, Any]]]
313
+ List of metrics to use for the acceptance test.
314
+ name : Optional[str], default=None
315
+ Name of the acceptance test. If not provided, the ID will be used as the name.
316
+ input_set_id : Optional[str], default=None
317
+ ID of the input set to use for the underlying batch experiment,
318
+ in case it hasn't been started.
319
+ description : Optional[str], default=None
320
+ Description of the acceptance test.
321
+ polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
322
+ Options to use when polling for the acceptance test result.
323
+
324
+ Returns
325
+ -------
326
+ AcceptanceTest
327
+ The completed acceptance test with results.
328
+
329
+ Raises
330
+ ------
331
+ requests.HTTPError
332
+ If the response status code is not 2xx.
333
+ TimeoutError
334
+ If the acceptance test does not succeed after the
335
+ polling strategy is exhausted based on time duration.
336
+ RuntimeError
337
+ If the acceptance test does not succeed after the
338
+ polling strategy is exhausted based on number of tries.
339
+
340
+ Examples
341
+ --------
342
+ >>> test = app.new_acceptance_test_with_result(
343
+ ... candidate_instance_id="candidate-123",
344
+ ... baseline_instance_id="baseline-456",
345
+ ... id="test-789",
346
+ ... metrics=[Metric(name="objective", type="numeric")],
347
+ ... name="Performance Test",
348
+ ... input_set_id="input-set-123"
349
+ ... )
350
+ >>> print(test.status)
351
+ 'completed'
352
+ """
353
+
354
+ acceptance_test = self.new_acceptance_test(
355
+ candidate_instance_id=candidate_instance_id,
356
+ baseline_instance_id=baseline_instance_id,
357
+ id=id,
358
+ metrics=metrics,
359
+ name=name,
360
+ input_set_id=input_set_id,
361
+ description=description,
362
+ )
363
+
364
+ return self.acceptance_test_with_polling(
365
+ acceptance_test_id=acceptance_test.id,
366
+ polling_options=polling_options,
367
+ )
368
+
369
+ def update_acceptance_test(
370
+ self: "Application",
371
+ acceptance_test_id: str,
372
+ name: str | None = None,
373
+ description: str | None = None,
374
+ ) -> AcceptanceTest:
375
+ """
376
+ Update an acceptance test.
377
+
378
+ Parameters
379
+ ----------
380
+ acceptance_test_id : str
381
+ ID of the acceptance test to update.
382
+ name : Optional[str], default=None
383
+ Optional name of the acceptance test.
384
+ description : Optional[str], default=None
385
+ Optional description of the acceptance test.
386
+
387
+ Returns
388
+ -------
389
+ AcceptanceTest
390
+ The updated acceptance test.
391
+
392
+ Raises
393
+ ------
394
+ requests.HTTPError
395
+ If the response status code is not 2xx.
396
+
397
+ Examples
398
+ --------
399
+ >>> test = app.update_acceptance_test(
400
+ ... acceptance_test_id="test-123",
401
+ ... name="Updated Test Name",
402
+ ... description="Updated description"
403
+ ... )
404
+ >>> print(test.name)
405
+ 'Updated Test Name'
406
+ """
407
+
408
+ if (name is None or name == "") and (description is None or description == ""):
409
+ raise ValueError("at least one of name or description must be provided for update")
410
+
411
+ payload = {}
412
+
413
+ if name is not None:
414
+ payload["name"] = name
415
+ if description is not None:
416
+ payload["description"] = description
417
+
418
+ response = self.client.request(
419
+ method="PATCH",
420
+ endpoint=f"{self.experiments_endpoint}/acceptance/{acceptance_test_id}",
421
+ payload=payload,
422
+ )
423
+
424
+ return AcceptanceTest.from_dict(response.json())