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