nextmv 0.40.0__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 (163) 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 +20 -204
  110. nextmv/cli/community/list.py +61 -126
  111. nextmv/cli/configuration/__init__.py +23 -0
  112. nextmv/cli/configuration/config.py +103 -6
  113. nextmv/cli/configuration/create.py +17 -18
  114. nextmv/cli/configuration/delete.py +25 -13
  115. nextmv/cli/configuration/list.py +4 -4
  116. nextmv/cli/confirm.py +34 -0
  117. nextmv/cli/main.py +68 -36
  118. nextmv/cli/message.py +170 -0
  119. nextmv/cli/options.py +196 -0
  120. nextmv/cli/version.py +20 -1
  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.40.0.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/cli/community/community.py +0 -24
  158. nextmv/cli/configuration/configuration.py +0 -23
  159. nextmv/cli/error.py +0 -22
  160. nextmv/cloud/application.py +0 -4204
  161. nextmv-0.40.0.dist-info/RECORD +0 -66
  162. {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/WHEEL +0 -0
  163. {nextmv-0.40.0.dist-info → nextmv-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,4204 +0,0 @@
1
- """
2
- Application module for interacting with Nextmv Cloud applications.
3
-
4
- This module provides functionality to interact with applications in Nextmv Cloud,
5
- including application management, running applications, and managing experiments
6
- and inputs.
7
-
8
- Classes
9
- -------
10
- DownloadURL
11
- Result of getting a download URL.
12
- PollingOptions
13
- Options for polling when waiting for run results.
14
- UploadURL
15
- Result of getting an upload URL.
16
- Application
17
- Class for interacting with applications in Nextmv Cloud.
18
-
19
- Functions
20
- ---------
21
- poll
22
- Function to poll for results with configurable options.
23
- """
24
-
25
- import io
26
- import json
27
- import os
28
- import pathlib
29
- import shutil
30
- import tarfile
31
- import tempfile
32
- from dataclasses import dataclass
33
- from datetime import datetime
34
- from typing import Any
35
-
36
- import requests
37
-
38
- from nextmv._serialization import deflated_serialize_json
39
- from nextmv.base_model import BaseModel
40
- from nextmv.cloud import package
41
- from nextmv.cloud.acceptance_test import AcceptanceTest, Metric
42
- from nextmv.cloud.assets import RunAsset
43
- from nextmv.cloud.batch_experiment import (
44
- BatchExperiment,
45
- BatchExperimentInformation,
46
- BatchExperimentMetadata,
47
- BatchExperimentRun,
48
- ExperimentStatus,
49
- to_runs,
50
- )
51
- from nextmv.cloud.client import Client, get_size
52
- from nextmv.cloud.ensemble import EnsembleDefinition, EvaluationRule, RunGroup
53
- from nextmv.cloud.input_set import InputSet, ManagedInput
54
- from nextmv.cloud.instance import Instance, InstanceConfiguration
55
- from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
56
- from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
57
- from nextmv.cloud.url import DownloadURL, UploadURL
58
- from nextmv.cloud.version import Version
59
- from nextmv.input import Input, InputFormat
60
- from nextmv.logger import log
61
- from nextmv.manifest import Manifest
62
- from nextmv.model import Model, ModelConfiguration
63
- from nextmv.options import Options
64
- from nextmv.output import ASSETS_KEY, STATISTICS_KEY, Asset, Output, OutputFormat, Statistics
65
- from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
66
- from nextmv.run import (
67
- ExternalRunResult,
68
- Format,
69
- FormatInput,
70
- FormatOutput,
71
- Run,
72
- RunConfiguration,
73
- RunInformation,
74
- RunLog,
75
- RunResult,
76
- TrackedRun,
77
- )
78
- from nextmv.safe import safe_id, safe_name_and_id
79
- from nextmv.status import StatusV2
80
-
81
- # Maximum size of the run input/output in bytes. This constant defines the
82
- # maximum allowed size for run inputs and outputs. When the size exceeds this
83
- # value, the system will automatically use the large input upload and/or large
84
- # result download endpoints.
85
- _MAX_RUN_SIZE: int = 5 * 1024 * 1024
86
-
87
-
88
- @dataclass
89
- class Application:
90
- """
91
- A published decision model that can be executed.
92
-
93
- You can import the `Application` class directly from `cloud`:
94
-
95
- ```python
96
- from nextmv.cloud import Application
97
- ```
98
-
99
- This class represents an application in Nextmv Cloud, providing methods to
100
- interact with the application, run it with different inputs, manage versions,
101
- instances, experiments, and more.
102
-
103
- Parameters
104
- ----------
105
- client : Client
106
- Client to use for interacting with the Nextmv Cloud API.
107
- id : str
108
- ID of the application.
109
- default_instance_id : str, default=None
110
- Default instance ID to use for submitting runs.
111
- endpoint : str, default="v1/applications/{id}"
112
- Base endpoint for the application.
113
- experiments_endpoint : str, default="{base}/experiments"
114
- Base endpoint for the experiments in the application.
115
-
116
- Examples
117
- --------
118
- >>> from nextmv.cloud import Client, Application
119
- >>> client = Client(api_key="your-api-key")
120
- >>> app = Application(client=client, id="your-app-id")
121
- >>> # Retrieve app information
122
- >>> instances = app.list_instances()
123
- """
124
-
125
- client: Client
126
- """Client to use for interacting with the Nextmv Cloud API."""
127
- id: str
128
- """ID of the application."""
129
-
130
- default_instance_id: str = None
131
- """Default instance ID to use for submitting runs."""
132
- endpoint: str = "v1/applications/{id}"
133
- """Base endpoint for the application."""
134
- experiments_endpoint: str = "{base}/experiments"
135
- """Base endpoint for the experiments in the application."""
136
- ensembles_endpoint: str = "{base}/ensembles"
137
- """Base endpoint for managing the ensemble definitions in the application"""
138
-
139
- def __post_init__(self):
140
- """Initialize the endpoint and experiments_endpoint attributes.
141
-
142
- This method is automatically called after class initialization to
143
- format the endpoint and experiments_endpoint URLs with the application ID.
144
- """
145
- self.endpoint = self.endpoint.format(id=self.id)
146
- self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
147
- self.ensembles_endpoint = self.ensembles_endpoint.format(base=self.endpoint)
148
-
149
- @classmethod
150
- def new(
151
- cls,
152
- client: Client,
153
- name: str,
154
- id: str | None = None,
155
- description: str | None = None,
156
- is_workflow: bool | None = None,
157
- exist_ok: bool = False,
158
- ) -> "Application":
159
- """
160
- Create a new application directly in Nextmv Cloud.
161
-
162
- The application is created as an empty shell, and executable code must
163
- be pushed to the app before running it remotely.
164
-
165
- Parameters
166
- ----------
167
- client : Client
168
- Client to use for interacting with the Nextmv Cloud API.
169
- name : str
170
- Name of the application.
171
- id : str, optional
172
- ID of the application. Will be generated if not provided.
173
- description : str, optional
174
- Description of the application.
175
- is_workflow : bool, optional
176
- Whether the application is a Decision Workflow.
177
- exist_ok : bool, default=False
178
- If True and an application with the same ID already exists,
179
- return the existing application instead of creating a new one.
180
-
181
- Returns
182
- -------
183
- Application
184
- The newly created (or existing) application.
185
-
186
- Examples
187
- --------
188
- >>> from nextmv.cloud import Client
189
- >>> client = Client(api_key="your-api-key")
190
- >>> app = Application.new(client=client, name="My New App", id="my-app")
191
- """
192
-
193
- if id is None:
194
- id = safe_id("app")
195
-
196
- if exist_ok and cls.exists(client=client, id=id):
197
- return Application(client=client, id=id)
198
-
199
- payload = {
200
- "name": name,
201
- "id": id,
202
- }
203
-
204
- if description is not None:
205
- payload["description"] = description
206
-
207
- if is_workflow is not None:
208
- payload["is_pipeline"] = is_workflow
209
-
210
- response = client.request(
211
- method="POST",
212
- endpoint="v1/applications",
213
- payload=payload,
214
- )
215
-
216
- return cls(client=client, id=response.json()["id"])
217
-
218
- def acceptance_test(self, acceptance_test_id: str) -> AcceptanceTest:
219
- """
220
- Retrieve details of an acceptance test.
221
-
222
- Parameters
223
- ----------
224
- acceptance_test_id : str
225
- ID of the acceptance test to retrieve.
226
-
227
- Returns
228
- -------
229
- AcceptanceTest
230
- The requested acceptance test details.
231
-
232
- Raises
233
- ------
234
- requests.HTTPError
235
- If the response status code is not 2xx.
236
-
237
- Examples
238
- --------
239
- >>> test = app.acceptance_test("test-123")
240
- >>> print(test.name)
241
- 'My Test'
242
- """
243
- response = self.client.request(
244
- method="GET",
245
- endpoint=f"{self.experiments_endpoint}/acceptance/{acceptance_test_id}",
246
- )
247
-
248
- return AcceptanceTest.from_dict(response.json())
249
-
250
- def acceptance_test_with_polling(
251
- self,
252
- acceptance_test_id: str,
253
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
254
- ) -> AcceptanceTest:
255
- """
256
- Retrieve details of an acceptance test using polling.
257
-
258
- Retrieves the result of an acceptance test. This method polls for the
259
- result until the test finishes executing or the polling strategy is
260
- exhausted.
261
-
262
- Parameters
263
- ----------
264
- acceptance_test_id : str
265
- ID of the acceptance test to retrieve.
266
-
267
- Returns
268
- -------
269
- AcceptanceTest
270
- The requested acceptance test details.
271
-
272
- Raises
273
- ------
274
- requests.HTTPError
275
- If the response status code is not 2xx.
276
-
277
- Examples
278
- --------
279
- >>> test = app.acceptance_test_with_polling("test-123")
280
- >>> print(test.name)
281
- 'My Test'
282
- """
283
-
284
- def polling_func() -> tuple[Any, bool]:
285
- acceptance_test_result = self.acceptance_test(acceptance_test_id=acceptance_test_id)
286
- if acceptance_test_result.status in {
287
- ExperimentStatus.COMPLETED,
288
- ExperimentStatus.FAILED,
289
- ExperimentStatus.DRAFT,
290
- ExperimentStatus.CANCELED,
291
- ExperimentStatus.DELETE_FAILED,
292
- }:
293
- return acceptance_test_result, True
294
-
295
- return None, False
296
-
297
- acceptance_test = poll(polling_options=polling_options, polling_func=polling_func)
298
-
299
- return self.acceptance_test(acceptance_test_id=acceptance_test.id)
300
-
301
- def batch_experiment(self, batch_id: str) -> BatchExperiment:
302
- """
303
- Get a batch experiment. This method also returns the runs of the batch
304
- experiment under the `.runs` attribute.
305
-
306
- Parameters
307
- ----------
308
- batch_id : str
309
- ID of the batch experiment.
310
-
311
- Returns
312
- -------
313
- BatchExperiment
314
- The requested batch experiment details.
315
-
316
- Raises
317
- ------
318
- requests.HTTPError
319
- If the response status code is not 2xx.
320
-
321
- Examples
322
- --------
323
- >>> batch_exp = app.batch_experiment("batch-123")
324
- >>> print(batch_exp.name)
325
- 'My Batch Experiment'
326
- """
327
-
328
- response = self.client.request(
329
- method="GET",
330
- endpoint=f"{self.experiments_endpoint}/batch/{batch_id}",
331
- )
332
-
333
- exp = BatchExperiment.from_dict(response.json())
334
-
335
- runs_response = self.client.request(
336
- method="GET",
337
- endpoint=f"{self.experiments_endpoint}/batch/{batch_id}/runs",
338
- )
339
-
340
- runs = [Run.from_dict(run) for run in runs_response.json().get("runs", [])]
341
- exp.runs = runs
342
-
343
- return exp
344
-
345
- def batch_experiment_metadata(self, batch_id: str) -> BatchExperimentMetadata:
346
- """
347
- Get metadata for a batch experiment.
348
-
349
- Parameters
350
- ----------
351
- batch_id : str
352
- ID of the batch experiment.
353
-
354
- Returns
355
- -------
356
- BatchExperimentMetadata
357
- The requested batch experiment metadata.
358
-
359
- Raises
360
- ------
361
- requests.HTTPError
362
- If the response status code is not 2xx.
363
-
364
- Examples
365
- --------
366
- >>> metadata = app.batch_experiment_metadata("batch-123")
367
- >>> print(metadata.name)
368
- 'My Batch Experiment'
369
- """
370
-
371
- response = self.client.request(
372
- method="GET",
373
- endpoint=f"{self.experiments_endpoint}/batch/{batch_id}/metadata",
374
- )
375
-
376
- return BatchExperimentMetadata.from_dict(response.json())
377
-
378
- def batch_experiment_with_polling(
379
- self,
380
- batch_id: str,
381
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
382
- ) -> BatchExperiment:
383
- """
384
- Get a batch experiment with polling.
385
-
386
- Retrieves the result of an experiment. This method polls for the result
387
- until the experiment finishes executing or the polling strategy is
388
- exhausted.
389
-
390
- Parameters
391
- ----------
392
- batch_id : str
393
- ID of the batch experiment.
394
-
395
- Returns
396
- -------
397
- BatchExperiment
398
- The requested batch experiment details.
399
-
400
- Raises
401
- ------
402
- requests.HTTPError
403
- If the response status code is not 2xx.
404
-
405
- Examples
406
- --------
407
- >>> batch_exp = app.batch_experiment_with_polling("batch-123")
408
- >>> print(batch_exp.name)
409
- 'My Batch Experiment'
410
- """
411
-
412
- def polling_func() -> tuple[Any, bool]:
413
- batch_metadata = self.batch_experiment_metadata(batch_id=batch_id)
414
- if batch_metadata.status in {
415
- ExperimentStatus.COMPLETED,
416
- ExperimentStatus.FAILED,
417
- ExperimentStatus.DRAFT,
418
- ExperimentStatus.CANCELED,
419
- ExperimentStatus.DELETE_FAILED,
420
- }:
421
- return batch_metadata, True
422
-
423
- return None, False
424
-
425
- batch_information = poll(polling_options=polling_options, polling_func=polling_func)
426
-
427
- return self.batch_experiment(batch_id=batch_information.id)
428
-
429
- def cancel_run(self, run_id: str) -> None:
430
- """
431
- Cancel a run.
432
-
433
- Parameters
434
- ----------
435
- run_id : str
436
- ID of the run to cancel.
437
-
438
- Raises
439
- ------
440
- requests.HTTPError
441
- If the response status code is not 2xx.
442
-
443
- Examples
444
- --------
445
- >>> app.cancel_run("run-456")
446
- """
447
-
448
- _ = self.client.request(
449
- method="PATCH",
450
- endpoint=f"{self.endpoint}/runs/{run_id}/cancel",
451
- )
452
-
453
- def delete(self) -> None:
454
- """
455
- Delete the application.
456
-
457
- Permanently removes the application from Nextmv Cloud.
458
-
459
- Raises
460
- ------
461
- requests.HTTPError
462
- If the response status code is not 2xx.
463
-
464
- Examples
465
- --------
466
- >>> app.delete() # Permanently deletes the application
467
- """
468
-
469
- _ = self.client.request(
470
- method="DELETE",
471
- endpoint=self.endpoint,
472
- )
473
-
474
- def delete_acceptance_test(self, acceptance_test_id: str) -> None:
475
- """
476
- Delete an acceptance test.
477
-
478
- Deletes an acceptance test along with all the associated information
479
- such as the underlying batch experiment.
480
-
481
- Parameters
482
- ----------
483
- acceptance_test_id : str
484
- ID of the acceptance test to delete.
485
-
486
- Raises
487
- ------
488
- requests.HTTPError
489
- If the response status code is not 2xx.
490
-
491
- Examples
492
- --------
493
- >>> app.delete_acceptance_test("test-123")
494
- """
495
-
496
- _ = self.client.request(
497
- method="DELETE",
498
- endpoint=f"{self.experiments_endpoint}/acceptance/{acceptance_test_id}",
499
- )
500
-
501
- def delete_batch_experiment(self, batch_id: str) -> None:
502
- """
503
- Delete a batch experiment.
504
-
505
- Deletes a batch experiment along with all the associated information,
506
- such as its runs.
507
-
508
- Parameters
509
- ----------
510
- batch_id : str
511
- ID of the batch experiment to delete.
512
-
513
- Raises
514
- ------
515
- requests.HTTPError
516
- If the response status code is not 2xx.
517
-
518
- Examples
519
- --------
520
- >>> app.delete_batch_experiment("batch-123")
521
- """
522
-
523
- _ = self.client.request(
524
- method="DELETE",
525
- endpoint=f"{self.experiments_endpoint}/batch/{batch_id}",
526
- )
527
-
528
- def delete_ensemble_definition(self, ensemble_definition_id: str) -> None:
529
- """
530
- Delete an ensemble definition.
531
-
532
- Parameters
533
- ----------
534
- ensemble_definition_id : str
535
- ID of the ensemble definition to delete.
536
-
537
- Raises
538
- ------
539
- requests.HTTPError
540
- If the response status code is not 2xx.
541
-
542
- Examples
543
- --------
544
- >>> app.delete_ensemble_definition("development-ensemble-definition")
545
- """
546
-
547
- _ = self.client.request(
548
- method="DELETE",
549
- endpoint=f"{self.ensembles_endpoint}/{ensemble_definition_id}",
550
- )
551
-
552
- def delete_scenario_test(self, scenario_test_id: str) -> None:
553
- """
554
- Delete a scenario test.
555
-
556
- Deletes a scenario test. Scenario tests are based on the batch
557
- experiments API, so this function summons `delete_batch_experiment`.
558
-
559
- Parameters
560
- ----------
561
- scenario_test_id : str
562
- ID of the scenario test to delete.
563
-
564
- Raises
565
- ------
566
- requests.HTTPError
567
- If the response status code is not 2xx.
568
-
569
- Examples
570
- --------
571
- >>> app.delete_scenario_test("scenario-123")
572
- """
573
-
574
- self.delete_batch_experiment(batch_id=scenario_test_id)
575
-
576
- def delete_secrets_collection(self, secrets_collection_id: str) -> None:
577
- """
578
- Delete a secrets collection.
579
-
580
- Parameters
581
- ----------
582
- secrets_collection_id : str
583
- ID of the secrets collection to delete.
584
-
585
- Raises
586
- ------
587
- requests.HTTPError
588
- If the response status code is not 2xx.
589
-
590
- Examples
591
- --------
592
- >>> app.delete_secrets_collection("secrets-123")
593
- """
594
-
595
- _ = self.client.request(
596
- method="DELETE",
597
- endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
598
- )
599
-
600
- def ensemble_definition(self, ensemble_definition_id: str) -> EnsembleDefinition:
601
- """
602
- Get an ensemble definition.
603
-
604
- Parameters
605
- ----------
606
- ensemble_definition_id : str
607
- ID of the ensemble definition to retrieve.
608
-
609
- Returns
610
- -------
611
- EnsembleDefintion
612
- The requested ensemble definition details.
613
-
614
- Raises
615
- ------
616
- requests.HTTPError
617
- If the response status code is not 2xx.
618
-
619
- Examples
620
- --------
621
- >>> ensemble_definition = app.ensemble_definition("instance-123")
622
- >>> print(ensemble_definition.name)
623
- 'Production Ensemble Definition'
624
- """
625
-
626
- response = self.client.request(
627
- method="GET",
628
- endpoint=f"{self.ensembles_endpoint}/{ensemble_definition_id}",
629
- )
630
-
631
- return EnsembleDefinition.from_dict(response.json())
632
-
633
- @staticmethod
634
- def exists(client: Client, id: str) -> bool:
635
- """
636
- Check if an application exists.
637
-
638
- Parameters
639
- ----------
640
- client : Client
641
- Client to use for interacting with the Nextmv Cloud API.
642
- id : str
643
- ID of the application to check.
644
-
645
- Returns
646
- -------
647
- bool
648
- True if the application exists, False otherwise.
649
-
650
- Examples
651
- --------
652
- >>> from nextmv.cloud import Client
653
- >>> client = Client(api_key="your-api-key")
654
- >>> Application.exists(client, "app-123")
655
- True
656
- """
657
-
658
- try:
659
- _ = client.request(
660
- method="GET",
661
- endpoint=f"v1/applications/{id}",
662
- )
663
- # If the request was successful, the application exists.
664
- return True
665
- except requests.HTTPError as e:
666
- if _is_not_exist_error(e):
667
- return False
668
- # Re-throw the exception if it is not the expected 404 error.
669
- raise e from None
670
-
671
- def input_set(self, input_set_id: str) -> InputSet:
672
- """
673
- Get an input set.
674
-
675
- Parameters
676
- ----------
677
- input_set_id : str
678
- ID of the input set to retrieve.
679
-
680
- Returns
681
- -------
682
- InputSet
683
- The requested input set.
684
-
685
- Raises
686
- ------
687
- requests.HTTPError
688
- If the response status code is not 2xx.
689
-
690
- Examples
691
- --------
692
- >>> input_set = app.input_set("input-set-123")
693
- >>> print(input_set.name)
694
- 'My Input Set'
695
- """
696
-
697
- response = self.client.request(
698
- method="GET",
699
- endpoint=f"{self.experiments_endpoint}/inputsets/{input_set_id}",
700
- )
701
-
702
- return InputSet.from_dict(response.json())
703
-
704
- def instance(self, instance_id: str) -> Instance:
705
- """
706
- Get an instance.
707
-
708
- Parameters
709
- ----------
710
- instance_id : str
711
- ID of the instance to retrieve.
712
-
713
- Returns
714
- -------
715
- Instance
716
- The requested instance details.
717
-
718
- Raises
719
- ------
720
- requests.HTTPError
721
- If the response status code is not 2xx.
722
-
723
- Examples
724
- --------
725
- >>> instance = app.instance("instance-123")
726
- >>> print(instance.name)
727
- 'Production Instance'
728
- """
729
-
730
- response = self.client.request(
731
- method="GET",
732
- endpoint=f"{self.endpoint}/instances/{instance_id}",
733
- )
734
-
735
- return Instance.from_dict(response.json())
736
-
737
- def instance_exists(self, instance_id: str) -> bool:
738
- """
739
- Check if an instance exists.
740
-
741
- Parameters
742
- ----------
743
- instance_id : str
744
- ID of the instance to check.
745
-
746
- Returns
747
- -------
748
- bool
749
- True if the instance exists, False otherwise.
750
-
751
- Examples
752
- --------
753
- >>> app.instance_exists("instance-123")
754
- True
755
- """
756
-
757
- try:
758
- self.instance(instance_id=instance_id)
759
- return True
760
- except requests.HTTPError as e:
761
- if _is_not_exist_error(e):
762
- return False
763
- raise e
764
-
765
- def list_acceptance_tests(self) -> list[AcceptanceTest]:
766
- """
767
- List all acceptance tests.
768
-
769
- Returns
770
- -------
771
- list[AcceptanceTest]
772
- List of all acceptance tests associated with this application.
773
-
774
- Raises
775
- ------
776
- requests.HTTPError
777
- If the response status code is not 2xx.
778
-
779
- Examples
780
- --------
781
- >>> tests = app.list_acceptance_tests()
782
- >>> for test in tests:
783
- ... print(test.name)
784
- 'Test 1'
785
- 'Test 2'
786
- """
787
-
788
- response = self.client.request(
789
- method="GET",
790
- endpoint=f"{self.experiments_endpoint}/acceptance",
791
- )
792
-
793
- return [AcceptanceTest.from_dict(acceptance_test) for acceptance_test in response.json()]
794
-
795
- def list_batch_experiments(self) -> list[BatchExperimentMetadata]:
796
- """
797
- List all batch experiments.
798
-
799
- Returns
800
- -------
801
- list[BatchExperimentMetadata]
802
- List of batch experiments.
803
-
804
- Raises
805
- ------
806
- requests.HTTPError
807
- If the response status code is not 2xx.
808
- """
809
-
810
- response = self.client.request(
811
- method="GET",
812
- endpoint=f"{self.experiments_endpoint}/batch",
813
- query_params={"type": "batch"},
814
- )
815
-
816
- return [BatchExperimentMetadata.from_dict(batch_experiment) for batch_experiment in response.json()]
817
-
818
- def list_ensemble_definitions(self) -> list[EnsembleDefinition]:
819
- """
820
- List all ensemble_definitions.
821
-
822
- Returns
823
- -------
824
- list[EnsembleDefinition]
825
- List of all ensemble definitions associated with this application.
826
-
827
- Raises
828
- ------
829
- requests.HTTPError
830
- If the response status code is not 2xx.
831
-
832
- Examples
833
- --------
834
- >>> ensemble_definitions = app.list_ensemble_definitions()
835
- >>> for ensemble_definition in ensemble_definitions:
836
- ... print(ensemble_definition.name)
837
- 'Development Ensemble Definition'
838
- 'Production Ensemble Definition'
839
- """
840
-
841
- response = self.client.request(
842
- method="GET",
843
- endpoint=f"{self.ensembles_endpoint}",
844
- )
845
-
846
- return [EnsembleDefinition.from_dict(ensemble_definition) for ensemble_definition in response.json()["items"]]
847
-
848
- def list_input_sets(self) -> list[InputSet]:
849
- """
850
- List all input sets.
851
-
852
- Returns
853
- -------
854
- list[InputSet]
855
- List of all input sets associated with this application.
856
-
857
- Raises
858
- ------
859
- requests.HTTPError
860
- If the response status code is not 2xx.
861
-
862
- Examples
863
- --------
864
- >>> input_sets = app.list_input_sets()
865
- >>> for input_set in input_sets:
866
- ... print(input_set.name)
867
- 'Input Set 1'
868
- 'Input Set 2'
869
- """
870
-
871
- response = self.client.request(
872
- method="GET",
873
- endpoint=f"{self.experiments_endpoint}/inputsets",
874
- )
875
-
876
- return [InputSet.from_dict(input_set) for input_set in response.json()]
877
-
878
- def list_instances(self) -> list[Instance]:
879
- """
880
- List all instances.
881
-
882
- Returns
883
- -------
884
- list[Instance]
885
- List of all instances associated with this application.
886
-
887
- Raises
888
- ------
889
- requests.HTTPError
890
- If the response status code is not 2xx.
891
-
892
- Examples
893
- --------
894
- >>> instances = app.list_instances()
895
- >>> for instance in instances:
896
- ... print(instance.name)
897
- 'Development Instance'
898
- 'Production Instance'
899
- """
900
-
901
- response = self.client.request(
902
- method="GET",
903
- endpoint=f"{self.endpoint}/instances",
904
- )
905
-
906
- return [Instance.from_dict(instance) for instance in response.json()]
907
-
908
- def list_managed_inputs(self) -> list[ManagedInput]:
909
- """
910
- List all managed inputs.
911
-
912
- Returns
913
- -------
914
- list[ManagedInput]
915
- List of managed inputs.
916
-
917
- Raises
918
- ------
919
- requests.HTTPError
920
- If the response status code is not 2xx.
921
- """
922
-
923
- response = self.client.request(
924
- method="GET",
925
- endpoint=f"{self.endpoint}/inputs",
926
- )
927
-
928
- return [ManagedInput.from_dict(managed_input) for managed_input in response.json()]
929
-
930
- def list_runs(self) -> list[Run]:
931
- """
932
- List all runs.
933
-
934
- Returns
935
- -------
936
- list[Run]
937
- List of runs.
938
-
939
- Raises
940
- ------
941
- requests.HTTPError
942
- If the response status code is not 2xx.
943
- """
944
-
945
- response = self.client.request(
946
- method="GET",
947
- endpoint=f"{self.endpoint}/runs",
948
- )
949
-
950
- return [Run.from_dict(run) for run in response.json().get("runs", [])]
951
-
952
- def list_scenario_tests(self) -> list[BatchExperimentMetadata]:
953
- """
954
- List all batch scenario tests. Scenario tests are based on the batch
955
- experiments API, so this function returns the same information as
956
- `list_batch_experiments`, albeit using a different query parameter.
957
-
958
- Returns
959
- -------
960
- list[BatchExperimentMetadata]
961
- List of scenario tests.
962
-
963
- Raises
964
- ------
965
- requests.HTTPError
966
- If the response status code is not 2xx.
967
- """
968
-
969
- response = self.client.request(
970
- method="GET",
971
- endpoint=f"{self.experiments_endpoint}/batch",
972
- query_params={"type": "scenario"},
973
- )
974
-
975
- return [BatchExperimentMetadata.from_dict(batch_experiment) for batch_experiment in response.json()]
976
-
977
- def list_secrets_collections(self) -> list[SecretsCollectionSummary]:
978
- """
979
- List all secrets collections.
980
-
981
- Returns
982
- -------
983
- list[SecretsCollectionSummary]
984
- List of all secrets collections associated with this application.
985
-
986
- Raises
987
- ------
988
- requests.HTTPError
989
- If the response status code is not 2xx.
990
-
991
- Examples
992
- --------
993
- >>> collections = app.list_secrets_collections()
994
- >>> for collection in collections:
995
- ... print(collection.name)
996
- 'API Keys'
997
- 'Database Credentials'
998
- """
999
-
1000
- response = self.client.request(
1001
- method="GET",
1002
- endpoint=f"{self.endpoint}/secrets",
1003
- )
1004
-
1005
- return [SecretsCollectionSummary.from_dict(secrets) for secrets in response.json()["items"]]
1006
-
1007
- def list_versions(self) -> list[Version]:
1008
- """
1009
- List all versions.
1010
-
1011
- Returns
1012
- -------
1013
- list[Version]
1014
- List of all versions associated with this application.
1015
-
1016
- Raises
1017
- ------
1018
- requests.HTTPError
1019
- If the response status code is not 2xx.
1020
-
1021
- Examples
1022
- --------
1023
- >>> versions = app.list_versions()
1024
- >>> for version in versions:
1025
- ... print(version.name)
1026
- 'v1.0.0'
1027
- 'v1.1.0'
1028
- """
1029
-
1030
- response = self.client.request(
1031
- method="GET",
1032
- endpoint=f"{self.endpoint}/versions",
1033
- )
1034
-
1035
- return [Version.from_dict(version) for version in response.json()]
1036
-
1037
- def managed_input(self, managed_input_id: str) -> ManagedInput:
1038
- """
1039
- Get a managed input.
1040
-
1041
- Parameters
1042
- ----------
1043
- managed_input_id: str
1044
- ID of the managed input.
1045
-
1046
- Returns
1047
- -------
1048
- ManagedInput
1049
- The managed input.
1050
-
1051
- Raises
1052
- ------
1053
- requests.HTTPError
1054
- If the response status code is not 2xx.
1055
- """
1056
-
1057
- response = self.client.request(
1058
- method="GET",
1059
- endpoint=f"{self.endpoint}/inputs/{managed_input_id}",
1060
- )
1061
-
1062
- return ManagedInput.from_dict(response.json())
1063
-
1064
- def new_acceptance_test(
1065
- self,
1066
- candidate_instance_id: str,
1067
- baseline_instance_id: str,
1068
- id: str,
1069
- metrics: list[Metric | dict[str, Any]],
1070
- name: str,
1071
- input_set_id: str | None = None,
1072
- description: str | None = None,
1073
- ) -> AcceptanceTest:
1074
- """
1075
- Create a new acceptance test.
1076
-
1077
- The acceptance test is based on a batch experiment. If you already
1078
- started a batch experiment, you don't need to provide the input_set_id
1079
- parameter. In that case, the ID of the acceptance test and the batch
1080
- experiment must be the same. If the batch experiment does not exist,
1081
- you can provide the input_set_id parameter and a new batch experiment
1082
- will be created for you.
1083
-
1084
- Parameters
1085
- ----------
1086
- candidate_instance_id : str
1087
- ID of the candidate instance.
1088
- baseline_instance_id : str
1089
- ID of the baseline instance.
1090
- id : str
1091
- ID of the acceptance test.
1092
- metrics : list[Union[Metric, dict[str, Any]]]
1093
- List of metrics to use for the acceptance test.
1094
- name : str
1095
- Name of the acceptance test.
1096
- input_set_id : Optional[str], default=None
1097
- ID of the input set to use for the underlying batch experiment,
1098
- in case it hasn't been started.
1099
- description : Optional[str], default=None
1100
- Description of the acceptance test.
1101
-
1102
- Returns
1103
- -------
1104
- AcceptanceTest
1105
- The created acceptance test.
1106
-
1107
- Raises
1108
- ------
1109
- requests.HTTPError
1110
- If the response status code is not 2xx.
1111
- ValueError
1112
- If the batch experiment ID does not match the acceptance test ID.
1113
- """
1114
-
1115
- if input_set_id is None:
1116
- try:
1117
- batch_experiment = self.batch_experiment(batch_id=id)
1118
- batch_experiment_id = batch_experiment.id
1119
- except requests.HTTPError as e:
1120
- if e.response.status_code != 404:
1121
- raise e
1122
-
1123
- raise ValueError(
1124
- f"batch experiment {id} does not exist, input_set_id must be defined to create a new one"
1125
- ) from e
1126
- else:
1127
- # Get all input IDs from the input set.
1128
- input_set = self.input_set(input_set_id=input_set_id)
1129
- if not input_set.input_ids:
1130
- raise ValueError(f"input set {input_set_id} does not contain any inputs")
1131
- runs = []
1132
- for input_id in input_set.input_ids:
1133
- runs.append(
1134
- BatchExperimentRun(
1135
- instance_id=candidate_instance_id,
1136
- input_set_id=input_set_id,
1137
- input_id=input_id,
1138
- )
1139
- )
1140
- runs.append(
1141
- BatchExperimentRun(
1142
- instance_id=baseline_instance_id,
1143
- input_set_id=input_set_id,
1144
- input_id=input_id,
1145
- )
1146
- )
1147
- batch_experiment_id = self.new_batch_experiment(
1148
- name=name,
1149
- description=description,
1150
- id=id,
1151
- runs=runs,
1152
- )
1153
-
1154
- if batch_experiment_id != id:
1155
- raise ValueError(f"batch experiment_id ({batch_experiment_id}) does not match acceptance test id ({id})")
1156
-
1157
- payload_metrics = [{}] * len(metrics)
1158
- for i, metric in enumerate(metrics):
1159
- payload_metrics[i] = metric.to_dict() if isinstance(metric, Metric) else metric
1160
-
1161
- payload = {
1162
- "candidate": {"instance_id": candidate_instance_id},
1163
- "control": {"instance_id": baseline_instance_id},
1164
- "metrics": payload_metrics,
1165
- "experiment_id": batch_experiment_id,
1166
- "name": name,
1167
- }
1168
- if description is not None:
1169
- payload["description"] = description
1170
- if id is not None:
1171
- payload["id"] = id
1172
-
1173
- response = self.client.request(
1174
- method="POST",
1175
- endpoint=f"{self.experiments_endpoint}/acceptance",
1176
- payload=payload,
1177
- )
1178
-
1179
- return AcceptanceTest.from_dict(response.json())
1180
-
1181
- def new_acceptance_test_with_result(
1182
- self,
1183
- candidate_instance_id: str,
1184
- baseline_instance_id: str,
1185
- id: str,
1186
- metrics: list[Metric | dict[str, Any]],
1187
- name: str,
1188
- input_set_id: str | None = None,
1189
- description: str | None = None,
1190
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1191
- ) -> AcceptanceTest:
1192
- """
1193
- Create a new acceptance test and poll for the result.
1194
-
1195
- This is a convenience method that combines the new_acceptance_test with polling
1196
- logic to check when the acceptance test is done.
1197
-
1198
- Parameters
1199
- ----------
1200
- candidate_instance_id : str
1201
- ID of the candidate instance.
1202
- baseline_instance_id : str
1203
- ID of the baseline instance.
1204
- id : str
1205
- ID of the acceptance test.
1206
- metrics : list[Union[Metric, dict[str, Any]]]
1207
- List of metrics to use for the acceptance test.
1208
- name : str
1209
- Name of the acceptance test.
1210
- input_set_id : Optional[str], default=None
1211
- ID of the input set to use for the underlying batch experiment,
1212
- in case it hasn't been started.
1213
- description : Optional[str], default=None
1214
- Description of the acceptance test.
1215
- polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
1216
- Options to use when polling for the acceptance test result.
1217
-
1218
- Returns
1219
- -------
1220
- AcceptanceTest
1221
- The completed acceptance test with results.
1222
-
1223
- Raises
1224
- ------
1225
- requests.HTTPError
1226
- If the response status code is not 2xx.
1227
- TimeoutError
1228
- If the acceptance test does not succeed after the
1229
- polling strategy is exhausted based on time duration.
1230
- RuntimeError
1231
- If the acceptance test does not succeed after the
1232
- polling strategy is exhausted based on number of tries.
1233
-
1234
- Examples
1235
- --------
1236
- >>> test = app.new_acceptance_test_with_result(
1237
- ... candidate_instance_id="candidate-123",
1238
- ... baseline_instance_id="baseline-456",
1239
- ... id="test-789",
1240
- ... metrics=[Metric(name="objective", type="numeric")],
1241
- ... name="Performance Test",
1242
- ... input_set_id="input-set-123"
1243
- ... )
1244
- >>> print(test.status)
1245
- 'completed'
1246
- """
1247
-
1248
- acceptance_test = self.new_acceptance_test(
1249
- candidate_instance_id=candidate_instance_id,
1250
- baseline_instance_id=baseline_instance_id,
1251
- id=id,
1252
- metrics=metrics,
1253
- name=name,
1254
- input_set_id=input_set_id,
1255
- description=description,
1256
- )
1257
-
1258
- return self.acceptance_test_with_polling(
1259
- acceptance_test_id=acceptance_test.id,
1260
- polling_options=polling_options,
1261
- )
1262
-
1263
- def new_batch_experiment(
1264
- self,
1265
- name: str,
1266
- input_set_id: str | None = None,
1267
- instance_ids: list[str] | None = None,
1268
- description: str | None = None,
1269
- id: str | None = None,
1270
- option_sets: dict[str, dict[str, str]] | None = None,
1271
- runs: list[BatchExperimentRun | dict[str, Any]] | None = None,
1272
- type: str | None = "batch",
1273
- ) -> str:
1274
- """
1275
- Create a new batch experiment.
1276
-
1277
- Parameters
1278
- ----------
1279
- name: str
1280
- Name of the batch experiment.
1281
- input_set_id: str
1282
- ID of the input set to use for the batch experiment.
1283
- instance_ids: list[str]
1284
- List of instance IDs to use for the batch experiment.
1285
- This argument is deprecated, use `runs` instead.
1286
- description: Optional[str]
1287
- Optional description of the batch experiment.
1288
- id: Optional[str]
1289
- ID of the batch experiment. Will be generated if not provided.
1290
- option_sets: Optional[dict[str, dict[str, str]]]
1291
- Option sets to use for the batch experiment. This is a dictionary
1292
- where the keys are option set IDs and the values are dictionaries
1293
- with the actual options.
1294
- runs: Optional[list[BatchExperimentRun]]
1295
- List of runs to use for the batch experiment.
1296
- type: Optional[str]
1297
- Type of the batch experiment. This is used to determine the
1298
- experiment type. The default value is "batch". If you want to
1299
- create a scenario test, set this to "scenario".
1300
-
1301
- Returns
1302
- -------
1303
- str
1304
- ID of the batch experiment.
1305
-
1306
- Raises
1307
- ------
1308
- requests.HTTPError
1309
- If the response status code is not 2xx.
1310
- """
1311
-
1312
- payload = {
1313
- "name": name,
1314
- }
1315
- if input_set_id is not None:
1316
- payload["input_set_id"] = input_set_id
1317
- if instance_ids is not None:
1318
- input_set = self.input_set(input_set_id)
1319
- runs = to_runs(instance_ids, input_set)
1320
- payload_runs = [run.to_dict() for run in runs]
1321
- payload["runs"] = payload_runs
1322
- if description is not None:
1323
- payload["description"] = description
1324
- if id is not None:
1325
- payload["id"] = id
1326
- if option_sets is not None:
1327
- payload["option_sets"] = option_sets
1328
- if runs is not None:
1329
- payload_runs = [{}] * len(runs)
1330
- for i, run in enumerate(runs):
1331
- payload_runs[i] = run.to_dict() if isinstance(run, BatchExperimentRun) else run
1332
- payload["runs"] = payload_runs
1333
- if type is not None:
1334
- payload["type"] = type
1335
-
1336
- response = self.client.request(
1337
- method="POST",
1338
- endpoint=f"{self.experiments_endpoint}/batch",
1339
- payload=payload,
1340
- )
1341
-
1342
- return response.json()["id"]
1343
-
1344
- def new_batch_experiment_with_result(
1345
- self,
1346
- name: str,
1347
- input_set_id: str | None = None,
1348
- instance_ids: list[str] | None = None,
1349
- description: str | None = None,
1350
- id: str | None = None,
1351
- option_sets: dict[str, dict[str, str]] | None = None,
1352
- runs: list[BatchExperimentRun | dict[str, Any]] | None = None,
1353
- type: str | None = "batch",
1354
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1355
- ) -> BatchExperiment:
1356
- """
1357
- Convenience method to create a new batch experiment and poll for the
1358
- result.
1359
-
1360
- This method combines the `new_batch_experiment` and
1361
- `batch_experiment_with_polling` methods, applying polling logic to
1362
- check when the experiment succeeded.
1363
-
1364
- Parameters
1365
- ----------
1366
- name: str
1367
- Name of the batch experiment.
1368
- input_set_id: str
1369
- ID of the input set to use for the batch experiment.
1370
- instance_ids: list[str]
1371
- List of instance IDs to use for the batch experiment. This argument
1372
- is deprecated, use `runs` instead.
1373
- description: Optional[str]
1374
- Optional description of the batch experiment.
1375
- id: Optional[str]
1376
- ID of the batch experiment. Will be generated if not provided.
1377
- option_sets: Optional[dict[str, dict[str, str]]]
1378
- Option sets to use for the batch experiment. This is a dictionary
1379
- where the keys are option set IDs and the values are dictionaries
1380
- with the actual options.
1381
- runs: Optional[list[BatchExperimentRun]]
1382
- List of runs to use for the batch experiment.
1383
- type: Optional[str]
1384
- Type of the batch experiment. This is used to determine the
1385
- experiment type. The default value is "batch". If you want to
1386
- create a scenario test, set this to "scenario".
1387
- polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
1388
- Options to use when polling for the batch experiment result.
1389
-
1390
- Returns
1391
- -------
1392
- BatchExperiment
1393
- The completed batch experiment with results.
1394
-
1395
- Raises
1396
- ------
1397
- requests.HTTPError
1398
- If the response status code is not 2xx.
1399
- """
1400
-
1401
- batch_id = self.new_batch_experiment(
1402
- name=name,
1403
- input_set_id=input_set_id,
1404
- instance_ids=instance_ids,
1405
- description=description,
1406
- id=id,
1407
- option_sets=option_sets,
1408
- runs=runs,
1409
- type=type,
1410
- )
1411
-
1412
- return self.batch_experiment_with_polling(batch_id=batch_id, polling_options=polling_options)
1413
-
1414
- def new_ensemble_defintion(
1415
- self,
1416
- id: str,
1417
- run_groups: list[RunGroup],
1418
- rules: list[EvaluationRule],
1419
- name: str | None = None,
1420
- description: str | None = None,
1421
- ) -> EnsembleDefinition:
1422
- """
1423
- Create a new ensemble definition.
1424
-
1425
- Parameters
1426
- ----------
1427
- id: str
1428
- ID of the ensemble defintion.
1429
- run_groups: list[RunGroup]
1430
- Information to facilitate the execution of child runs.
1431
- rules: list[EvaluationRule]
1432
- Information to facilitate the selection of
1433
- a result for the ensemble run from child runs.
1434
- name: Optional[str]
1435
- Name of the ensemble definition.
1436
- description: Optional[str]
1437
- Description of the ensemble definition.
1438
- """
1439
-
1440
- if name is None:
1441
- name = id
1442
- if description is None:
1443
- description = name
1444
-
1445
- payload = {
1446
- "id": id,
1447
- "run_groups": [run_group.to_dict() for run_group in run_groups],
1448
- "rules": [rule.to_dict() for rule in rules],
1449
- "name": name,
1450
- "description": description,
1451
- }
1452
-
1453
- response = self.client.request(
1454
- method="POST",
1455
- endpoint=f"{self.ensembles_endpoint}",
1456
- payload=payload,
1457
- )
1458
-
1459
- return EnsembleDefinition.from_dict(response.json())
1460
-
1461
- def new_input_set(
1462
- self,
1463
- id: str,
1464
- name: str,
1465
- description: str | None = None,
1466
- end_time: datetime | None = None,
1467
- instance_id: str | None = None,
1468
- maximum_runs: int | None = None,
1469
- run_ids: list[str] | None = None,
1470
- start_time: datetime | None = None,
1471
- inputs: list[ManagedInput] | None = None,
1472
- ) -> InputSet:
1473
- """
1474
- Create a new input set. You can create an input set from three
1475
- different methodologies:
1476
-
1477
- 1. Using `instance_id`, `start_time`, `end_time` and `maximum_runs`.
1478
- Instance runs will be obtained from the application matching the
1479
- criteria of dates and maximum number of runs.
1480
- 2. Using `run_ids`. The input set will be created using the list of
1481
- runs specified by the user.
1482
- 3. Using `inputs`. The input set will be created using the list of
1483
- inputs specified by the user. This is useful for creating an input
1484
- set from a list of inputs that are already available in the
1485
- application.
1486
-
1487
- Parameters
1488
- ----------
1489
- id: str
1490
- ID of the input set
1491
- name: str
1492
- Name of the input set.
1493
- description: Optional[str]
1494
- Optional description of the input set.
1495
- end_time: Optional[datetime]
1496
- End time of the input set. This is used to filter the runs
1497
- associated with the input set.
1498
- instance_id: Optional[str]
1499
- ID of the instance to use for the input set. This is used to
1500
- filter the runs associated with the input set. If not provided,
1501
- the application's `default_instance_id` is used.
1502
- maximum_runs: Optional[int]
1503
- Maximum number of runs to use for the input set. This is used to
1504
- filter the runs associated with the input set. If not provided,
1505
- all runs are used.
1506
- run_ids: Optional[list[str]]
1507
- List of run IDs to use for the input set.
1508
- start_time: Optional[datetime]
1509
- Start time of the input set. This is used to filter the runs
1510
- associated with the input set.
1511
- inputs: Optional[list[ExperimentInput]]
1512
- List of inputs to use for the input set. This is used to create
1513
- the input set from a list of inputs that are already available in
1514
- the application.
1515
-
1516
- Returns
1517
- -------
1518
- InputSet
1519
- The new input set.
1520
-
1521
- Raises
1522
- ------
1523
- requests.HTTPError
1524
- If the response status code is not 2xx.
1525
- """
1526
-
1527
- payload = {
1528
- "id": id,
1529
- "name": name,
1530
- }
1531
- if description is not None:
1532
- payload["description"] = description
1533
- if end_time is not None:
1534
- payload["end_time"] = end_time.isoformat()
1535
- if instance_id is not None:
1536
- payload["instance_id"] = instance_id
1537
- if maximum_runs is not None:
1538
- payload["maximum_runs"] = maximum_runs
1539
- if run_ids is not None:
1540
- payload["run_ids"] = run_ids
1541
- if start_time is not None:
1542
- payload["start_time"] = start_time.isoformat()
1543
- if inputs is not None:
1544
- payload["inputs"] = [input.to_dict() for input in inputs]
1545
-
1546
- response = self.client.request(
1547
- method="POST",
1548
- endpoint=f"{self.experiments_endpoint}/inputsets",
1549
- payload=payload,
1550
- )
1551
-
1552
- return InputSet.from_dict(response.json())
1553
-
1554
- def new_instance(
1555
- self,
1556
- version_id: str,
1557
- id: str,
1558
- name: str,
1559
- description: str | None = None,
1560
- configuration: InstanceConfiguration | None = None,
1561
- exist_ok: bool = False,
1562
- ) -> Instance:
1563
- """
1564
- Create a new instance and associate it with a version.
1565
-
1566
- This method creates a new instance associated with a specific version of the application.
1567
- Instances are configurations of an application version that can be executed.
1568
-
1569
- Parameters
1570
- ----------
1571
- version_id : str
1572
- ID of the version to associate the instance with.
1573
- id : str
1574
- ID of the instance. Will be generated if not provided.
1575
- name : str
1576
- Name of the instance. Will be generated if not provided.
1577
- description : Optional[str], default=None
1578
- Description of the instance.
1579
- configuration : Optional[InstanceConfiguration], default=None
1580
- Configuration to use for the instance. This can include resources,
1581
- timeouts, and other execution parameters.
1582
- exist_ok : bool, default=False
1583
- If True and an instance with the same ID already exists,
1584
- return the existing instance instead of creating a new one.
1585
-
1586
- Returns
1587
- -------
1588
- Instance
1589
- The newly created (or existing) instance.
1590
-
1591
- Raises
1592
- ------
1593
- requests.HTTPError
1594
- If the response status code is not 2xx.
1595
- ValueError
1596
- If exist_ok is True and id is None.
1597
-
1598
- Examples
1599
- --------
1600
- >>> # Create a new instance for a specific version
1601
- >>> instance = app.new_instance(
1602
- ... version_id="version-123",
1603
- ... id="prod-instance",
1604
- ... name="Production Instance",
1605
- ... description="Instance for production use"
1606
- ... )
1607
- >>> print(instance.name)
1608
- 'Production Instance'
1609
- """
1610
-
1611
- if exist_ok and id is None:
1612
- raise ValueError("If exist_ok is True, id must be provided")
1613
-
1614
- if exist_ok and self.instance_exists(instance_id=id):
1615
- return self.instance(instance_id=id)
1616
-
1617
- payload = {
1618
- "version_id": version_id,
1619
- }
1620
-
1621
- if id is not None:
1622
- payload["id"] = id
1623
- if name is not None:
1624
- payload["name"] = name
1625
- if description is not None:
1626
- payload["description"] = description
1627
- if configuration is not None:
1628
- payload["configuration"] = configuration.to_dict()
1629
-
1630
- response = self.client.request(
1631
- method="POST",
1632
- endpoint=f"{self.endpoint}/instances",
1633
- payload=payload,
1634
- )
1635
-
1636
- return Instance.from_dict(response.json())
1637
-
1638
- def new_managed_input(
1639
- self,
1640
- id: str,
1641
- name: str,
1642
- description: str | None = None,
1643
- upload_id: str | None = None,
1644
- run_id: str | None = None,
1645
- format: Format | dict[str, Any] | None = None,
1646
- ) -> ManagedInput:
1647
- """
1648
- Create a new managed input. There are two methods for creating a
1649
- managed input:
1650
-
1651
- 1. Specifying the `upload_id` parameter. You may use the `upload_url`
1652
- method to obtain the upload ID and the `upload_large_input` method
1653
- to upload the data to it.
1654
- 2. Specifying the `run_id` parameter. The managed input will be
1655
- created from the run specified by the `run_id` parameter.
1656
-
1657
- Either the `upload_id` or the `run_id` parameter must be specified.
1658
-
1659
- Parameters
1660
- ----------
1661
- id: str
1662
- ID of the managed input.
1663
- name: str
1664
- Name of the managed input.
1665
- description: Optional[str]
1666
- Optional description of the managed input.
1667
- upload_id: Optional[str]
1668
- ID of the upload to use for the managed input.
1669
- run_id: Optional[str]
1670
- ID of the run to use for the managed input.
1671
- format: Optional[Format]
1672
- Format of the managed input. Default will be formatted as `JSON`.
1673
-
1674
- Returns
1675
- -------
1676
- ManagedInput
1677
- The new managed input.
1678
-
1679
- Raises
1680
- ------
1681
- requests.HTTPError
1682
- If the response status code is not 2xx.
1683
- ValueError
1684
- If neither the `upload_id` nor the `run_id` parameter is
1685
- specified.
1686
- """
1687
-
1688
- if upload_id is None and run_id is None:
1689
- raise ValueError("Either upload_id or run_id must be specified")
1690
-
1691
- payload = {
1692
- "id": id,
1693
- "name": name,
1694
- }
1695
-
1696
- if description is not None:
1697
- payload["description"] = description
1698
- if upload_id is not None:
1699
- payload["upload_id"] = upload_id
1700
- if run_id is not None:
1701
- payload["run_id"] = run_id
1702
-
1703
- if format is not None:
1704
- payload["format"] = format.to_dict() if isinstance(format, Format) else format
1705
- else:
1706
- payload["format"] = Format(
1707
- format_input=FormatInput(input_type=InputFormat.JSON),
1708
- format_output=FormatOutput(output_type=OutputFormat.JSON),
1709
- ).to_dict()
1710
-
1711
- response = self.client.request(
1712
- method="POST",
1713
- endpoint=f"{self.endpoint}/inputs",
1714
- payload=payload,
1715
- )
1716
-
1717
- return ManagedInput.from_dict(response.json())
1718
-
1719
- def new_run( # noqa: C901 # Refactor this function at some point.
1720
- self,
1721
- input: Input | dict[str, Any] | BaseModel | str = None,
1722
- instance_id: str | None = None,
1723
- name: str | None = None,
1724
- description: str | None = None,
1725
- upload_id: str | None = None,
1726
- options: Options | dict[str, str] | None = None,
1727
- configuration: RunConfiguration | dict[str, Any] | None = None,
1728
- batch_experiment_id: str | None = None,
1729
- external_result: ExternalRunResult | dict[str, Any] | None = None,
1730
- json_configurations: dict[str, Any] | None = None,
1731
- input_dir_path: str | None = None,
1732
- ) -> str:
1733
- """
1734
- Submit an input to start a new run of the application. Returns the
1735
- `run_id` of the submitted run.
1736
-
1737
- Parameters
1738
- ----------
1739
- input: Union[Input, dict[str, Any], BaseModel, str]
1740
- Input to use for the run. This can be a `nextmv.Input` object,
1741
- `dict`, `BaseModel` or `str`.
1742
-
1743
- If `nextmv.Input` is used, and the `input_format` is either
1744
- `nextmv.InputFormat.JSON` or `nextmv.InputFormat.TEXT`, then the
1745
- input data is extracted from the `.data` property.
1746
-
1747
- If you want to work with `nextmv.InputFormat.CSV_ARCHIVE` or
1748
- `nextmv.InputFormat.MULTI_FILE`, you should use the `input_dir_path`
1749
- argument instead. This argument takes precedence over the `input`.
1750
- If `input_dir_path` is specified, this function looks for files in that
1751
- directory and tars them, to later be uploaded using the
1752
- `upload_large_input` method. If both the `input_dir_path` and `input`
1753
- arguments are provided, the `input` is ignored.
1754
-
1755
- When `input_dir_path` is specified, the `configuration` argument must
1756
- also be provided. More specifically, the
1757
- `RunConfiguration.format.format_input.input_type` parameter
1758
- dictates what kind of input is being submitted to the Nextmv Cloud.
1759
- Make sure that this parameter is specified when working with the
1760
- following input formats:
1761
-
1762
- - `nextmv.InputFormat.CSV_ARCHIVE`
1763
- - `nextmv.InputFormat.MULTI_FILE`
1764
-
1765
- When working with JSON or text data, use the `input` argument
1766
- directly.
1767
-
1768
- In general, if an input is too large, it will be uploaded with the
1769
- `upload_large_input` method.
1770
- instance_id: Optional[str]
1771
- ID of the instance to use for the run. If not provided, the default
1772
- instance ID associated to the Class (`default_instance_id`) is
1773
- used.
1774
- name: Optional[str]
1775
- Name of the run.
1776
- description: Optional[str]
1777
- Description of the run.
1778
- upload_id: Optional[str]
1779
- ID to use when running a large input. If the `input` exceeds the
1780
- maximum allowed size, then it is uploaded and the corresponding
1781
- `upload_id` is used.
1782
- options: Optional[Union[Options, dict[str, str]]]
1783
- Options to use for the run. This can be a `nextmv.Options` object
1784
- or a dict. If a dict is used, the keys must be strings and the
1785
- values must be strings as well. If a `nextmv.Options` object is
1786
- used, the options are extracted from the `.to_cloud_dict()` method.
1787
- Note that specifying `options` overrides the `input.options` (if
1788
- the `input` is of type `nextmv.Input`).
1789
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
1790
- Configuration to use for the run. This can be a
1791
- `cloud.RunConfiguration` object or a dict. If the object is used,
1792
- then the `.to_dict()` method is applied to extract the
1793
- configuration.
1794
- batch_experiment_id: Optional[str]
1795
- ID of a batch experiment to associate the run with. This is used
1796
- when the run is part of a batch experiment.
1797
- external_result: Optional[Union[ExternalRunResult, dict[str, Any]]]
1798
- External result to use for the run. This can be a
1799
- `nextmv.ExternalRunResult` object or a dict. If the object is used,
1800
- then the `.to_dict()` method is applied to extract the
1801
- configuration. This is used when the run is an external run. We
1802
- suggest that instead of specifying this parameter, you use the
1803
- `track_run` method of the class.
1804
- json_configurations: Optional[dict[str, Any]]
1805
- Optional configurations for JSON serialization. This is used to
1806
- customize the serialization before data is sent.
1807
- input_dir_path: Optional[str]
1808
- Path to a directory containing input files. If specified, the
1809
- function will package the files in the directory into a tar file
1810
- and upload it as a large input. This is useful for input formats
1811
- like `nextmv.InputFormat.CSV_ARCHIVE` or `nextmv.InputFormat.MULTI_FILE`.
1812
- If both `input` and `input_dir_path` are specified, the `input` is
1813
- ignored, and the files in the directory are used instead.
1814
-
1815
- Returns
1816
- ----------
1817
- str
1818
- ID (`run_id`) of the run that was submitted.
1819
-
1820
- Raises
1821
- ----------
1822
- requests.HTTPError
1823
- If the response status code is not 2xx.
1824
- ValueError
1825
- If the `input` is of type `nextmv.Input` and the .input_format` is
1826
- not `JSON`. If the final `options` are not of type `dict[str,str]`.
1827
- """
1828
-
1829
- tar_file = ""
1830
- if input_dir_path is not None and input_dir_path != "":
1831
- if not os.path.exists(input_dir_path):
1832
- raise ValueError(f"Directory {input_dir_path} does not exist.")
1833
-
1834
- if not os.path.isdir(input_dir_path):
1835
- raise ValueError(f"Path {input_dir_path} is not a directory.")
1836
-
1837
- tar_file = self.__package_inputs(input_dir_path)
1838
-
1839
- input_data = self.__extract_input_data(input)
1840
-
1841
- input_size = 0
1842
- if input_data is not None:
1843
- input_size = get_size(input_data)
1844
-
1845
- upload_id_used = upload_id is not None
1846
- if self.__upload_url_required(upload_id_used, input_size, tar_file, input):
1847
- upload_url = self.upload_url()
1848
- self.upload_large_input(input=input_data, upload_url=upload_url, tar_file=tar_file)
1849
- upload_id = upload_url.upload_id
1850
- upload_id_used = True
1851
-
1852
- options_dict = self.__extract_options_dict(options, json_configurations)
1853
-
1854
- # Builds the payload progressively based on the different arguments
1855
- # that must be provided.
1856
- payload = {}
1857
- if upload_id_used:
1858
- payload["upload_id"] = upload_id
1859
- else:
1860
- payload["input"] = input_data
1861
-
1862
- if name is not None:
1863
- payload["name"] = name
1864
- if description is not None:
1865
- payload["description"] = description
1866
- if len(options_dict) > 0:
1867
- for k, v in options_dict.items():
1868
- if not isinstance(v, str):
1869
- raise ValueError(f"options must be dict[str,str], option {k} has type {type(v)} instead.")
1870
- payload["options"] = options_dict
1871
-
1872
- configuration_dict = self.__extract_run_config(input, configuration, input_dir_path)
1873
- payload["configuration"] = configuration_dict
1874
-
1875
- if batch_experiment_id is not None:
1876
- payload["batch_experiment_id"] = batch_experiment_id
1877
- if external_result is not None:
1878
- external_dict = (
1879
- external_result.to_dict() if isinstance(external_result, ExternalRunResult) else external_result
1880
- )
1881
- payload["result"] = external_dict
1882
-
1883
- query_params = {}
1884
- if instance_id is not None or self.default_instance_id is not None:
1885
- query_params["instance_id"] = instance_id if instance_id is not None else self.default_instance_id
1886
-
1887
- response = self.client.request(
1888
- method="POST",
1889
- endpoint=f"{self.endpoint}/runs",
1890
- payload=payload,
1891
- query_params=query_params,
1892
- json_configurations=json_configurations,
1893
- )
1894
-
1895
- return response.json()["run_id"]
1896
-
1897
- def new_run_with_result(
1898
- self,
1899
- input: Input | dict[str, Any] | BaseModel | str = None,
1900
- instance_id: str | None = None,
1901
- name: str | None = None,
1902
- description: str | None = None,
1903
- upload_id: str | None = None,
1904
- run_options: Options | dict[str, str] | None = None,
1905
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1906
- configuration: RunConfiguration | dict[str, Any] | None = None,
1907
- batch_experiment_id: str | None = None,
1908
- external_result: ExternalRunResult | dict[str, Any] | None = None,
1909
- json_configurations: dict[str, Any] | None = None,
1910
- input_dir_path: str | None = None,
1911
- output_dir_path: str | None = ".",
1912
- ) -> RunResult:
1913
- """
1914
- Submit an input to start a new run of the application and poll for the
1915
- result. This is a convenience method that combines the `new_run` and
1916
- `run_result_with_polling` methods, applying polling logic to check when
1917
- the run succeeded.
1918
-
1919
- Parameters
1920
- ----------
1921
- input: Union[Input, dict[str, Any], BaseModel, str]
1922
- Input to use for the run. This can be a `nextmv.Input` object,
1923
- `dict`, `BaseModel` or `str`.
1924
-
1925
- If `nextmv.Input` is used, and the `input_format` is either
1926
- `nextmv.InputFormat.JSON` or `nextmv.InputFormat.TEXT`, then the
1927
- input data is extracted from the `.data` property.
1928
-
1929
- If you want to work with `nextmv.InputFormat.CSV_ARCHIVE` or
1930
- `nextmv.InputFormat.MULTI_FILE`, you should use the `input_dir_path`
1931
- argument instead. This argument takes precedence over the `input`.
1932
- If `input_dir_path` is specified, this function looks for files in that
1933
- directory and tars them, to later be uploaded using the
1934
- `upload_large_input` method. If both the `input_dir_path` and `input`
1935
- arguments are provided, the `input` is ignored.
1936
-
1937
- When `input_dir_path` is specified, the `configuration` argument must
1938
- also be provided. More specifically, the
1939
- `RunConfiguration.format.format_input.input_type` parameter
1940
- dictates what kind of input is being submitted to the Nextmv Cloud.
1941
- Make sure that this parameter is specified when working with the
1942
- following input formats:
1943
-
1944
- - `nextmv.InputFormat.CSV_ARCHIVE`
1945
- - `nextmv.InputFormat.MULTI_FILE`
1946
-
1947
- When working with JSON or text data, use the `input` argument
1948
- directly.
1949
-
1950
- In general, if an input is too large, it will be uploaded with the
1951
- `upload_large_input` method.
1952
- instance_id: Optional[str]
1953
- ID of the instance to use for the run. If not provided, the default
1954
- instance ID associated to the Class (`default_instance_id`) is
1955
- used.
1956
- name: Optional[str]
1957
- Name of the run.
1958
- description: Optional[str]
1959
- Description of the run.
1960
- upload_id: Optional[str]
1961
- ID to use when running a large input. If the `input` exceeds the
1962
- maximum allowed size, then it is uploaded and the corresponding
1963
- `upload_id` is used.
1964
- run_options: Optional[Union[Options, dict[str, str]]]
1965
- Options to use for the run. This can be a `nextmv.Options` object
1966
- or a dict. If a dict is used, the keys must be strings and the
1967
- values must be strings as well. If a `nextmv.Options` object is
1968
- used, the options are extracted from the `.to_cloud_dict()` method.
1969
- Note that specifying `options` overrides the `input.options` (if
1970
- the `input` is of type `nextmv.Input`).
1971
- polling_options: PollingOptions
1972
- Options to use when polling for the run result. This is a
1973
- convenience method that combines the `new_run` and
1974
- `run_result_with_polling` methods, applying polling logic to check
1975
- when the run succeeded.
1976
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
1977
- Configuration to use for the run. This can be a
1978
- `cloud.RunConfiguration` object or a dict. If the object is used,
1979
- then the `.to_dict()` method is applied to extract the
1980
- configuration.
1981
- batch_experiment_id: Optional[str]
1982
- ID of a batch experiment to associate the run with. This is used
1983
- when the run is part of a batch experiment.
1984
- external_result: Optional[Union[ExternalRunResult, dict[str, Any]]] = None
1985
- External result to use for the run. This can be a
1986
- `cloud.ExternalRunResult` object or a dict. If the object is used,
1987
- then the `.to_dict()` method is applied to extract the
1988
- configuration. This is used when the run is an external run. We
1989
- suggest that instead of specifying this parameter, you use the
1990
- `track_run_with_result` method of the class.
1991
- json_configurations: Optional[dict[str, Any]]
1992
- Optional configurations for JSON serialization. This is used to
1993
- customize the serialization before data is sent.
1994
- input_dir_path: Optional[str]
1995
- Path to a directory containing input files. If specified, the
1996
- function will package the files in the directory into a tar file
1997
- and upload it as a large input. This is useful for input formats
1998
- like `nextmv.InputFormat.CSV_ARCHIVE` or `nextmv.InputFormat.MULTI_FILE`.
1999
- If both `input` and `input_dir_path` are specified, the `input` is
2000
- ignored, and the files in the directory are used instead.
2001
- output_dir_path : Optional[str], default="."
2002
- Path to a directory where non-JSON output files will be saved. This is
2003
- required if the output is non-JSON. If the directory does not exist, it
2004
- will be created. Uses the current directory by default.
2005
-
2006
- Returns
2007
- ----------
2008
- RunResult
2009
- Result of the run.
2010
-
2011
- Raises
2012
- ----------
2013
- ValueError
2014
- If the `input` is of type `nextmv.Input` and the `.input_format` is
2015
- not `JSON`. If the final `options` are not of type `dict[str,str]`.
2016
- requests.HTTPError
2017
- If the response status code is not 2xx.
2018
- TimeoutError
2019
- If the run does not succeed after the polling strategy is exhausted
2020
- based on time duration.
2021
- RuntimeError
2022
- If the run does not succeed after the polling strategy is exhausted
2023
- based on number of tries.
2024
- """
2025
-
2026
- run_id = self.new_run(
2027
- input=input,
2028
- instance_id=instance_id,
2029
- name=name,
2030
- description=description,
2031
- upload_id=upload_id,
2032
- options=run_options,
2033
- configuration=configuration,
2034
- batch_experiment_id=batch_experiment_id,
2035
- external_result=external_result,
2036
- json_configurations=json_configurations,
2037
- input_dir_path=input_dir_path,
2038
- )
2039
-
2040
- return self.run_result_with_polling(
2041
- run_id=run_id,
2042
- polling_options=polling_options,
2043
- output_dir_path=output_dir_path,
2044
- )
2045
-
2046
- def new_scenario_test(
2047
- self,
2048
- id: str,
2049
- name: str,
2050
- scenarios: list[Scenario],
2051
- description: str | None = None,
2052
- repetitions: int | None = 0,
2053
- ) -> str:
2054
- """
2055
- Create a new scenario test. The test is based on `scenarios` and you
2056
- may specify `repetitions` to run the test multiple times. 0 repetitions
2057
- means that the tests will be executed once. 1 repetition means that the
2058
- test will be repeated once, i.e.: it will be executed twice. 2
2059
- repetitions equals 3 executions, so on, and so forth.
2060
-
2061
- For each scenario, consider the `scenario_input` and `configuration`.
2062
- The `scenario_input.scenario_input_type` allows you to specify the data
2063
- that will be used for that scenario.
2064
-
2065
- - `ScenarioInputType.INPUT_SET`: the data should be taken from an
2066
- existing input set.
2067
- - `ScenarioInputType.INPUT`: the data should be taken from a list of
2068
- existing inputs. When using this type, an input set will be created
2069
- from this set of managed inputs.
2070
- - `ScenarioInputType.New`: a new set of data will be uploaded as a set
2071
- of managed inputs. A new input set will be created from this set of
2072
- managed inputs.
2073
-
2074
- On the other hand, the `configuration` allows you to specify multiple
2075
- option variations for the scenario. Please see the
2076
- `ScenarioConfiguration` class for more information.
2077
-
2078
- The scenario tests uses the batch experiments API under the hood.
2079
-
2080
- Parameters
2081
- ----------
2082
- id: str
2083
- ID of the scenario test.
2084
- name: str
2085
- Name of the scenario test.
2086
- scenarios: list[Scenario]
2087
- List of scenarios to use for the scenario test. At least one
2088
- scenario should be provided.
2089
- description: Optional[str]
2090
- Optional description of the scenario test.
2091
- repetitions: Optional[int]
2092
- Number of repetitions to use for the scenario test. 0
2093
- repetitions means that the tests will be executed once. 1
2094
- repetition means that the test will be repeated once, i.e.: it
2095
- will be executed twice. 2 repetitions equals 3 executions, so on,
2096
- and so forth.
2097
-
2098
- Returns
2099
- -------
2100
- str
2101
- ID of the scenario test.
2102
-
2103
- Raises
2104
- ------
2105
- requests.HTTPError
2106
- If the response status code is not 2xx.
2107
- ValueError
2108
- If no scenarios are provided.
2109
- """
2110
-
2111
- if len(scenarios) < 1:
2112
- raise ValueError("At least one scenario must be provided")
2113
-
2114
- scenarios_by_id = _scenarios_by_id(scenarios)
2115
-
2116
- # Save all the information needed by scenario.
2117
- input_sets = {}
2118
- instances = {}
2119
- for scenario_id, scenario in scenarios_by_id.items():
2120
- instance = self.instance(instance_id=scenario.instance_id)
2121
-
2122
- # Each scenario is associated to an input set, so we must either
2123
- # get it or create it.
2124
- input_set = self.__input_set_for_scenario(scenario, scenario_id)
2125
-
2126
- instances[scenario_id] = instance
2127
- input_sets[scenario_id] = input_set
2128
-
2129
- # Calculate the combinations of all the option sets across scenarios.
2130
- opt_sets_by_scenario = _option_sets(scenarios)
2131
-
2132
- # The scenario tests results in multiple individual runs.
2133
- runs = []
2134
- run_counter = 0
2135
- opt_sets = {}
2136
- for scenario_id, scenario_opt_sets in opt_sets_by_scenario.items():
2137
- opt_sets = {**opt_sets, **scenario_opt_sets}
2138
- input_set = input_sets[scenario_id]
2139
- scenario = scenarios_by_id[scenario_id]
2140
-
2141
- for set_key in scenario_opt_sets.keys():
2142
- inputs = input_set.input_ids if len(input_set.input_ids) > 0 else input_set.inputs
2143
- for input in inputs:
2144
- input_id = input.id if isinstance(input, ManagedInput) else input
2145
- for repetition in range(repetitions + 1):
2146
- run_counter += 1
2147
- run = BatchExperimentRun(
2148
- input_id=input_id,
2149
- input_set_id=input_set.id,
2150
- instance_id=scenario.instance_id,
2151
- option_set=set_key,
2152
- scenario_id=scenario_id,
2153
- repetition=repetition,
2154
- run_number=f"{run_counter}",
2155
- )
2156
- runs.append(run)
2157
-
2158
- return self.new_batch_experiment(
2159
- id=id,
2160
- name=name,
2161
- description=description,
2162
- type="scenario",
2163
- option_sets=opt_sets,
2164
- runs=runs,
2165
- )
2166
-
2167
- def new_scenario_test_with_result(
2168
- self,
2169
- id: str,
2170
- name: str,
2171
- scenarios: list[Scenario],
2172
- description: str | None = None,
2173
- repetitions: int | None = 0,
2174
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2175
- ) -> BatchExperiment:
2176
- """
2177
- Convenience method to create a new scenario test and poll for the
2178
- result.
2179
-
2180
- This method combines the `new_scenario_test` and
2181
- `scenario_test_with_polling` methods, applying polling logic to
2182
- check when the test succeeded.
2183
-
2184
- The scenario tests uses the batch experiments API under the hood.
2185
-
2186
- Parameters
2187
- ----------
2188
- id: str
2189
- ID of the scenario test.
2190
- name: str
2191
- Name of the scenario test.
2192
- scenarios: list[Scenario]
2193
- List of scenarios to use for the scenario test. At least one
2194
- scenario should be provided.
2195
- description: Optional[str]
2196
- Optional description of the scenario test.
2197
- repetitions: Optional[int]
2198
- Number of repetitions to use for the scenario test. 0
2199
- repetitions means that the tests will be executed once. 1
2200
- repetition means that the test will be repeated once, i.e.: it
2201
- will be executed twice. 2 repetitions equals 3 executions, so on,
2202
- and so forth.
2203
-
2204
- Returns
2205
- -------
2206
- BatchExperiment
2207
- The completed scenario test as a BatchExperiment.
2208
-
2209
- Raises
2210
- ------
2211
- requests.HTTPError
2212
- If the response status code is not 2xx.
2213
- ValueError
2214
- If no scenarios are provided.
2215
- """
2216
-
2217
- test_id = self.new_scenario_test(
2218
- id=id,
2219
- name=name,
2220
- scenarios=scenarios,
2221
- description=description,
2222
- repetitions=repetitions,
2223
- )
2224
-
2225
- return self.scenario_test_with_polling(
2226
- scenario_test_id=test_id,
2227
- polling_options=polling_options,
2228
- )
2229
-
2230
- def new_secrets_collection(
2231
- self,
2232
- secrets: list[Secret],
2233
- id: str,
2234
- name: str,
2235
- description: str | None = None,
2236
- ) -> SecretsCollectionSummary:
2237
- """
2238
- Create a new secrets collection.
2239
-
2240
- This method creates a new secrets collection with the provided secrets.
2241
- A secrets collection is a group of key-value pairs that can be used by
2242
- your application instances during execution. If no secrets are provided,
2243
- a ValueError is raised.
2244
-
2245
- Parameters
2246
- ----------
2247
- secrets : list[Secret]
2248
- List of secrets to use for the secrets collection. Each secret
2249
- should be an instance of the Secret class containing a key and value.
2250
- id : str
2251
- ID of the secrets collection.
2252
- name : str
2253
- Name of the secrets collection.
2254
- description : Optional[str], default=None
2255
- Description of the secrets collection.
2256
-
2257
- Returns
2258
- -------
2259
- SecretsCollectionSummary
2260
- Summary of the secrets collection including its metadata.
2261
-
2262
- Raises
2263
- ------
2264
- ValueError
2265
- If no secrets are provided.
2266
- requests.HTTPError
2267
- If the response status code is not 2xx.
2268
-
2269
- Examples
2270
- --------
2271
- >>> # Create a new secrets collection with API keys
2272
- >>> from nextmv.cloud import Secret
2273
- >>> secrets = [
2274
- ... Secret(
2275
- ... location="API_KEY",
2276
- ... value="your-api-key",
2277
- ... secret_type=SecretType.ENV,
2278
- ... ),
2279
- ... Secret(
2280
- ... location="DATABASE_URL",
2281
- ... value="your-database-url",
2282
- ... secret_type=SecretType.ENV,
2283
- ... ),
2284
- ... ]
2285
- >>> collection = app.new_secrets_collection(
2286
- ... secrets=secrets,
2287
- ... id="api-secrets",
2288
- ... name="API Secrets",
2289
- ... description="Collection of API secrets for external services"
2290
- ... )
2291
- >>> print(collection.id)
2292
- 'api-secrets'
2293
- """
2294
-
2295
- if len(secrets) == 0:
2296
- raise ValueError("secrets must be provided")
2297
-
2298
- payload = {
2299
- "secrets": [secret.to_dict() for secret in secrets],
2300
- }
2301
-
2302
- if id is not None:
2303
- payload["id"] = id
2304
- if name is not None:
2305
- payload["name"] = name
2306
- if description is not None:
2307
- payload["description"] = description
2308
-
2309
- response = self.client.request(
2310
- method="POST",
2311
- endpoint=f"{self.endpoint}/secrets",
2312
- payload=payload,
2313
- )
2314
-
2315
- return SecretsCollectionSummary.from_dict(response.json())
2316
-
2317
- def new_version(
2318
- self,
2319
- id: str | None = None,
2320
- name: str | None = None,
2321
- description: str | None = None,
2322
- exist_ok: bool = False,
2323
- ) -> Version:
2324
- """
2325
- Create a new version using the current dev binary.
2326
-
2327
- This method creates a new version of the application using the current development
2328
- binary. Application versions represent different iterations of your application's
2329
- code and configuration that can be deployed.
2330
-
2331
- Parameters
2332
- ----------
2333
- id : Optional[str], default=None
2334
- ID of the version. If not provided, a unique ID will be generated.
2335
- name : Optional[str], default=None
2336
- Name of the version. If not provided, a name will be generated.
2337
- description : Optional[str], default=None
2338
- Description of the version. If not provided, a description will be generated.
2339
- exist_ok : bool, default=False
2340
- If True and a version with the same ID already exists,
2341
- return the existing version instead of creating a new one.
2342
- If True, the 'id' parameter must be provided.
2343
-
2344
- Returns
2345
- -------
2346
- Version
2347
- The newly created (or existing) version.
2348
-
2349
- Raises
2350
- ------
2351
- ValueError
2352
- If exist_ok is True and id is None.
2353
- requests.HTTPError
2354
- If the response status code is not 2xx.
2355
-
2356
- Examples
2357
- --------
2358
- >>> # Create a new version
2359
- >>> version = app.new_version(
2360
- ... id="v1.0.0",
2361
- ... name="Initial Release",
2362
- ... description="First stable version"
2363
- ... )
2364
- >>> print(version.id)
2365
- 'v1.0.0'
2366
-
2367
- >>> # Get or create a version with exist_ok
2368
- >>> version = app.new_version(
2369
- ... id="v1.0.0",
2370
- ... exist_ok=True
2371
- ... )
2372
- """
2373
-
2374
- if exist_ok and id is None:
2375
- raise ValueError("If exist_ok is True, id must be provided")
2376
-
2377
- if exist_ok and self.version_exists(version_id=id):
2378
- return self.version(version_id=id)
2379
-
2380
- if id is None:
2381
- id = safe_id(prefix="version")
2382
- if name is None:
2383
- name = id
2384
-
2385
- payload = {
2386
- "id": id,
2387
- "name": name,
2388
- }
2389
-
2390
- if description is not None:
2391
- payload["description"] = description
2392
-
2393
- response = self.client.request(
2394
- method="POST",
2395
- endpoint=f"{self.endpoint}/versions",
2396
- payload=payload,
2397
- )
2398
-
2399
- return Version.from_dict(response.json())
2400
-
2401
- def push(
2402
- self,
2403
- manifest: Manifest | None = None,
2404
- app_dir: str | None = None,
2405
- verbose: bool = False,
2406
- model: Model | None = None,
2407
- model_configuration: ModelConfiguration | None = None,
2408
- ) -> None:
2409
- """
2410
- Push an app to Nextmv Cloud.
2411
-
2412
- If the manifest is not provided, an `app.yaml` file will be searched
2413
- for in the provided path. If there is no manifest file found, an
2414
- exception will be raised.
2415
-
2416
- There are two ways to push an app to Nextmv Cloud:
2417
- 1. Specifying `app_dir`, which is the path to an app's root directory.
2418
- This acts as an external strategy, where the app is composed of files
2419
- in a directory and those apps are packaged and pushed to Nextmv Cloud.
2420
- 2. Specifying a `model` and `model_configuration`. This acts as an
2421
- internal (or Python-native) strategy, where the app is actually a
2422
- `nextmv.Model`. The model is encoded, some dependencies and
2423
- accompanying files are packaged, and the app is pushed to Nextmv Cloud.
2424
-
2425
- Parameters
2426
- ----------
2427
- manifest : Optional[Manifest], default=None
2428
- The manifest for the app. If None, an `app.yaml` file in the provided
2429
- app directory will be used.
2430
- app_dir : Optional[str], default=None
2431
- The path to the app's root directory. If None, the current directory
2432
- will be used. This is for the external strategy approach.
2433
- verbose : bool, default=False
2434
- Whether to print verbose output during the push process.
2435
- model : Optional[Model], default=None
2436
- The Python-native model to push. Must be specified together with
2437
- `model_configuration`. This is for the internal strategy approach.
2438
- model_configuration : Optional[ModelConfiguration], default=None
2439
- Configuration for the Python-native model. Must be specified together
2440
- with `model`.
2441
-
2442
- Returns
2443
- -------
2444
- None
2445
-
2446
- Raises
2447
- ------
2448
- ValueError
2449
- If neither app_dir nor model/model_configuration is provided correctly,
2450
- or if only one of model and model_configuration is provided.
2451
- TypeError
2452
- If model is not an instance of nextmv.Model or if model_configuration
2453
- is not an instance of nextmv.ModelConfiguration.
2454
- Exception
2455
- If there's an error in the build, packaging, or cleanup process.
2456
-
2457
- Examples
2458
- --------
2459
- 1. Push an app using an external strategy (directory-based):
2460
-
2461
- >>> import os
2462
- >>> from nextmv import cloud
2463
- >>> client = cloud.Client(api_key=os.getenv("NEXTMV_API_KEY"))
2464
- >>> app = cloud.Application(client=client, id="<YOUR-APP-ID>")
2465
- >>> app.push() # Use verbose=True for step-by-step output.
2466
-
2467
- 2. Push an app using an internal strategy (Python-native model):
2468
-
2469
- >>> import os
2470
- >>> import nextroute
2471
- >>> import nextmv
2472
- >>> import nextmv.cloud
2473
- >>>
2474
- >>> # Define the model that makes decisions
2475
- >>> class DecisionModel(nextmv.Model):
2476
- ... def solve(self, input: nextmv.Input) -> nextmv.Output:
2477
- ... nextroute_input = nextroute.schema.Input.from_dict(input.data)
2478
- ... nextroute_options = nextroute.Options.extract_from_dict(input.options.to_dict())
2479
- ... nextroute_output = nextroute.solve(nextroute_input, nextroute_options)
2480
- ...
2481
- ... return nextmv.Output(
2482
- ... options=input.options,
2483
- ... solution=nextroute_output.solutions[0].to_dict(),
2484
- ... statistics=nextroute_output.statistics.to_dict(),
2485
- ... )
2486
- >>>
2487
- >>> # Define the options that the model needs
2488
- >>> opt = []
2489
- >>> default_options = nextroute.Options()
2490
- >>> for name, default_value in default_options.to_dict().items():
2491
- ... opt.append(nextmv.Option(name.lower(), type(default_value), default_value, name, False))
2492
- >>> options = nextmv.Options(*opt)
2493
- >>>
2494
- >>> # Instantiate the model and model configuration
2495
- >>> model = DecisionModel()
2496
- >>> model_configuration = nextmv.ModelConfiguration(
2497
- ... name="python_nextroute_model",
2498
- ... requirements=[
2499
- ... "nextroute==1.8.1",
2500
- ... "nextmv==0.14.0.dev1",
2501
- ... ],
2502
- ... options=options,
2503
- ... )
2504
- >>>
2505
- >>> # Push the model to Nextmv Cloud
2506
- >>> client = cloud.Client(api_key=os.getenv("NEXTMV_API_KEY"))
2507
- >>> app = cloud.Application(client=client, id="<YOUR-APP-ID>")
2508
- >>> manifest = nextmv.cloud.default_python_manifest()
2509
- >>> app.push(
2510
- ... manifest=manifest,
2511
- ... verbose=True,
2512
- ... model=model,
2513
- ... model_configuration=model_configuration,
2514
- ... )
2515
- """
2516
-
2517
- if verbose:
2518
- log("💽 Starting build for Nextmv application.")
2519
-
2520
- if app_dir is None or app_dir == "":
2521
- app_dir = "."
2522
-
2523
- if manifest is None:
2524
- manifest = Manifest.from_yaml(app_dir)
2525
-
2526
- if model is not None and not isinstance(model, Model):
2527
- raise TypeError("model must be an instance of nextmv.Model")
2528
-
2529
- if model_configuration is not None and not isinstance(model_configuration, ModelConfiguration):
2530
- raise TypeError("model_configuration must be an instance of nextmv.ModelConfiguration")
2531
-
2532
- if (model is None and model_configuration is not None) or (model is not None and model_configuration is None):
2533
- raise ValueError("model and model_configuration must be provided together")
2534
-
2535
- package._run_build_command(app_dir, manifest.build, verbose)
2536
- package._run_pre_push_command(app_dir, manifest.pre_push, verbose)
2537
- tar_file, output_dir = package._package(app_dir, manifest, model, model_configuration, verbose)
2538
- self.__update_app_binary(tar_file, manifest, verbose)
2539
-
2540
- try:
2541
- shutil.rmtree(output_dir)
2542
- except OSError as e:
2543
- raise Exception(f"error deleting output directory: {e}") from e
2544
-
2545
- def list_assets(self, run_id: str) -> list[RunAsset]:
2546
- """
2547
- List the assets of a run.
2548
-
2549
- Retrieves a list of assets associated with a specific run. This method ONLY
2550
- returns the asset metadata, the content needs to be fetched via the
2551
- `download_asset_content` method.
2552
-
2553
- Parameters
2554
- ----------
2555
- run_id : str
2556
- ID of the run to list assets for.
2557
-
2558
- Returns
2559
- -------
2560
- list[RunAsset]
2561
- List of assets associated with the run.
2562
-
2563
- Raises
2564
- ------
2565
- requests.HTTPError
2566
- If the response status code is not 2xx.
2567
-
2568
- Examples
2569
- --------
2570
- >>> assets = app.list_assets("run-123")
2571
- >>> for asset in assets:
2572
- ... print(asset.id, asset.name)
2573
- b459daa6-1c13-48c6-b4c3-a262ea94cd04 clustering_polygons
2574
- a1234567-89ab-cdef-0123-456789abcdef histogram
2575
- """
2576
- response = self.client.request(
2577
- method="GET",
2578
- endpoint=f"{self.endpoint}/runs/{run_id}/assets",
2579
- )
2580
- assets_data = response.json().get("items", [])
2581
- for asset_data in assets_data:
2582
- asset_data["run_id"] = run_id
2583
- return [RunAsset.from_dict(asset) for asset in assets_data]
2584
-
2585
- def download_asset_content(
2586
- self,
2587
- asset: RunAsset,
2588
- destination: str | pathlib.Path | io.BytesIO | None = None,
2589
- ) -> Any | None:
2590
- """
2591
- Downloads an asset's content to a specified destination.
2592
-
2593
- Parameters
2594
- ----------
2595
- asset : RunAsset
2596
- The asset to be downloaded.
2597
- destination : Union[str, pathlib.Path, io.BytesIO, None]
2598
- The destination where the asset will be saved. This can be a file path
2599
- (as a string or pathlib.Path) or an io.BytesIO object. If None, the asset
2600
- content will not be saved to a file, but returned immediately. If the asset
2601
- type is JSON, the content will be returned as a dict.
2602
-
2603
- Returns
2604
- -------
2605
- Any or None
2606
- If `destination` is None, returns the asset content: for JSON assets, a
2607
- `dict` parsed from the JSON response; for other asset types, the raw
2608
- `bytes` content. If `destination` is provided, the content is written
2609
- to the given destination and the method returns `None`.
2610
-
2611
- Raises
2612
- ------
2613
- requests.HTTPError
2614
- If the response status code is not 2xx.
2615
-
2616
- Examples
2617
- --------
2618
- >>> assets = app.list_assets("run-123")
2619
- >>> asset = assets[0] # Assume we want to download the first asset
2620
- >>> # Download to a file path
2621
- >>> app.download_asset_content(asset, "polygons.geojson")
2622
- >>> # Download to an in-memory bytes buffer
2623
- >>> import io
2624
- >>> buffer = io.BytesIO()
2625
- >>> app.download_asset_content(asset, buffer)
2626
- >>> # Download and get content directly (for JSON assets)
2627
- >>> content = app.download_asset_content(asset)
2628
- >>> print(content)
2629
- {'type': 'FeatureCollection', 'features': [...]}
2630
- """
2631
- # First, get the download_url for the asset.
2632
- download_url_response = self.client.request(
2633
- method="GET",
2634
- endpoint=f"{self.endpoint}/runs/{asset.run_id}/assets/{asset.id}",
2635
- ).json()
2636
- download_url = download_url_response["download_url"]
2637
- asset_type = download_url_response.get("type", "json")
2638
-
2639
- # Now, download the asset content using the download_url.
2640
- download_response = self.client.request(
2641
- method="GET",
2642
- endpoint=download_url,
2643
- headers={"Content-Type": "application/json" if asset_type == "json" else "application/octet-stream"},
2644
- )
2645
-
2646
- # Save the content to the specified destination.
2647
- if destination is None:
2648
- if asset_type == "json":
2649
- return download_response.json()
2650
- return download_response.content
2651
- elif isinstance(destination, io.BytesIO):
2652
- destination.write(download_response.content)
2653
- return None
2654
- else:
2655
- with open(destination, "wb") as file:
2656
- file.write(download_response.content)
2657
- return None
2658
-
2659
- def run_input(self, run_id: str) -> dict[str, Any]:
2660
- """
2661
- Get the input of a run.
2662
-
2663
- Retrieves the input data that was used for a specific run. This method
2664
- handles both small and large inputs automatically - if the input size
2665
- exceeds the maximum allowed size, it will fetch the input from a
2666
- download URL.
2667
-
2668
- Parameters
2669
- ----------
2670
- run_id : str
2671
- ID of the run to retrieve the input for.
2672
-
2673
- Returns
2674
- -------
2675
- dict[str, Any]
2676
- Input data of the run as a dictionary.
2677
-
2678
- Raises
2679
- ------
2680
- requests.HTTPError
2681
- If the response status code is not 2xx.
2682
-
2683
- Examples
2684
- --------
2685
- >>> input_data = app.run_input("run-123")
2686
- >>> print(input_data)
2687
- {'locations': [...], 'vehicles': [...]}
2688
- """
2689
- run_information = self.run_metadata(run_id=run_id)
2690
-
2691
- query_params = None
2692
- large = False
2693
- if run_information.metadata.input_size > _MAX_RUN_SIZE:
2694
- query_params = {"format": "url"}
2695
- large = True
2696
-
2697
- response = self.client.request(
2698
- method="GET",
2699
- endpoint=f"{self.endpoint}/runs/{run_id}/input",
2700
- query_params=query_params,
2701
- )
2702
- if not large:
2703
- return response.json()
2704
-
2705
- download_url = DownloadURL.from_dict(response.json())
2706
- download_response = self.client.request(
2707
- method="GET",
2708
- endpoint=download_url.url,
2709
- headers={"Content-Type": "application/json"},
2710
- )
2711
-
2712
- return download_response.json()
2713
-
2714
- def run_metadata(self, run_id: str) -> RunInformation:
2715
- """
2716
- Get the metadata of a run.
2717
-
2718
- Retrieves information about a run without including the run output.
2719
- This is useful when you only need the run's status and metadata.
2720
-
2721
- Parameters
2722
- ----------
2723
- run_id : str
2724
- ID of the run to retrieve metadata for.
2725
-
2726
- Returns
2727
- -------
2728
- RunInformation
2729
- Metadata of the run (run information without output).
2730
-
2731
- Raises
2732
- ------
2733
- requests.HTTPError
2734
- If the response status code is not 2xx.
2735
-
2736
- Examples
2737
- --------
2738
- >>> metadata = app.run_metadata("run-123")
2739
- >>> print(metadata.metadata.status_v2)
2740
- StatusV2.succeeded
2741
- """
2742
-
2743
- response = self.client.request(
2744
- method="GET",
2745
- endpoint=f"{self.endpoint}/runs/{run_id}/metadata",
2746
- )
2747
-
2748
- info = RunInformation.from_dict(response.json())
2749
- info.console_url = self.__console_url(info.id)
2750
-
2751
- return info
2752
-
2753
- def run_logs(self, run_id: str) -> RunLog:
2754
- """
2755
- Get the logs of a run.
2756
-
2757
- Parameters
2758
- ----------
2759
- run_id : str
2760
- ID of the run to get logs for.
2761
-
2762
- Returns
2763
- -------
2764
- RunLog
2765
- Logs of the run.
2766
-
2767
- Raises
2768
- ------
2769
- requests.HTTPError
2770
- If the response status code is not 2xx.
2771
-
2772
- Examples
2773
- --------
2774
- >>> logs = app.run_logs("run-123")
2775
- >>> print(logs.stderr)
2776
- 'Warning: resource usage exceeded'
2777
- """
2778
- response = self.client.request(
2779
- method="GET",
2780
- endpoint=f"{self.endpoint}/runs/{run_id}/logs",
2781
- )
2782
- return RunLog.from_dict(response.json())
2783
-
2784
- def run_result(self, run_id: str, output_dir_path: str | None = ".") -> RunResult:
2785
- """
2786
- Get the result of a run.
2787
-
2788
- Retrieves the complete result of a run, including the run output.
2789
-
2790
- Parameters
2791
- ----------
2792
- run_id : str
2793
- ID of the run to get results for.
2794
- output_dir_path : Optional[str], default="."
2795
- Path to a directory where non-JSON output files will be saved. This is
2796
- required if the output is non-JSON. If the directory does not exist, it
2797
- will be created. Uses the current directory by default.
2798
-
2799
- Returns
2800
- -------
2801
- RunResult
2802
- Result of the run, including output.
2803
-
2804
- Raises
2805
- ------
2806
- requests.HTTPError
2807
- If the response status code is not 2xx.
2808
-
2809
- Examples
2810
- --------
2811
- >>> result = app.run_result("run-123")
2812
- >>> print(result.metadata.status_v2)
2813
- 'succeeded'
2814
- """
2815
-
2816
- run_information = self.run_metadata(run_id=run_id)
2817
-
2818
- return self.__run_result(
2819
- run_id=run_id,
2820
- run_information=run_information,
2821
- output_dir_path=output_dir_path,
2822
- )
2823
-
2824
- def run_result_with_polling(
2825
- self,
2826
- run_id: str,
2827
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2828
- output_dir_path: str | None = ".",
2829
- ) -> RunResult:
2830
- """
2831
- Get the result of a run with polling.
2832
-
2833
- Retrieves the result of a run including the run output. This method polls
2834
- for the result until the run finishes executing or the polling strategy
2835
- is exhausted.
2836
-
2837
- Parameters
2838
- ----------
2839
- run_id : str
2840
- ID of the run to retrieve the result for.
2841
- polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
2842
- Options to use when polling for the run result.
2843
- output_dir_path : Optional[str], default="."
2844
- Path to a directory where non-JSON output files will be saved. This is
2845
- required if the output is non-JSON. If the directory does not exist, it
2846
- will be created. Uses the current directory by default.
2847
-
2848
- Returns
2849
- -------
2850
- RunResult
2851
- Complete result of the run including output data.
2852
-
2853
- Raises
2854
- ------
2855
- requests.HTTPError
2856
- If the response status code is not 2xx.
2857
- TimeoutError
2858
- If the run does not complete after the polling strategy is
2859
- exhausted based on time duration.
2860
- RuntimeError
2861
- If the run does not complete after the polling strategy is
2862
- exhausted based on number of tries.
2863
-
2864
- Examples
2865
- --------
2866
- >>> from nextmv.cloud import PollingOptions
2867
- >>> # Create custom polling options
2868
- >>> polling_opts = PollingOptions(max_tries=50, max_duration=600)
2869
- >>> # Get run result with polling
2870
- >>> result = app.run_result_with_polling("run-123", polling_opts)
2871
- >>> print(result.output)
2872
- {'solution': {...}}
2873
- """
2874
-
2875
- def polling_func() -> tuple[Any, bool]:
2876
- run_information = self.run_metadata(run_id=run_id)
2877
- if run_information.metadata.status_v2 in {
2878
- StatusV2.succeeded,
2879
- StatusV2.failed,
2880
- StatusV2.canceled,
2881
- }:
2882
- return run_information, True
2883
-
2884
- return None, False
2885
-
2886
- run_information = poll(polling_options=polling_options, polling_func=polling_func)
2887
-
2888
- return self.__run_result(
2889
- run_id=run_id,
2890
- run_information=run_information,
2891
- output_dir_path=output_dir_path,
2892
- )
2893
-
2894
- def scenario_test(self, scenario_test_id: str) -> BatchExperiment:
2895
- """
2896
- Get a scenario test.
2897
-
2898
- Retrieves a scenario test by ID. Scenario tests are based on batch
2899
- experiments, so this function returns the corresponding batch
2900
- experiment associated with the scenario test.
2901
-
2902
- Parameters
2903
- ----------
2904
- scenario_test_id : str
2905
- ID of the scenario test to retrieve.
2906
-
2907
- Returns
2908
- -------
2909
- BatchExperiment
2910
- The scenario test details as a batch experiment.
2911
-
2912
- Raises
2913
- ------
2914
- requests.HTTPError
2915
- If the response status code is not 2xx.
2916
-
2917
- Examples
2918
- --------
2919
- >>> test = app.scenario_test("scenario-123")
2920
- >>> print(test.name)
2921
- 'My Scenario Test'
2922
- >>> print(test.type)
2923
- 'scenario'
2924
- """
2925
-
2926
- return self.batch_experiment(batch_id=scenario_test_id)
2927
-
2928
- def scenario_test_metadata(self, scenario_test_id: str) -> BatchExperimentMetadata:
2929
- """
2930
- Get the metadata for a scenario test, given its ID.
2931
-
2932
- Scenario tests are based on batch experiments, so this function returns
2933
- the corresponding batch experiment metadata associated with the
2934
- scenario test.
2935
-
2936
- Parameters
2937
- ----------
2938
- scenario_test_id : str
2939
- ID of the scenario test to retrieve.
2940
-
2941
- Returns
2942
- -------
2943
- BatchExperimentMetadata
2944
- The scenario test metadata as a batch experiment.
2945
-
2946
- Raises
2947
- ------
2948
- requests.HTTPError
2949
- If the response status code is not 2xx.
2950
-
2951
- Examples
2952
- --------
2953
- >>> metadata = app.scenario_test_metadata("scenario-123")
2954
- >>> print(metadata.name)
2955
- 'My Scenario Test'
2956
- >>> print(metadata.type)
2957
- 'scenario'
2958
- """
2959
-
2960
- return self.batch_experiment_metadata(batch_id=scenario_test_id)
2961
-
2962
- def scenario_test_with_polling(
2963
- self,
2964
- scenario_test_id: str,
2965
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2966
- ) -> BatchExperiment:
2967
- """
2968
- Get a scenario test with polling.
2969
-
2970
- Retrieves the result of a scenario test. This method polls for the
2971
- result until the test finishes executing or the polling strategy is
2972
- exhausted.
2973
-
2974
- The scenario tests uses the batch experiments API under the hood.
2975
-
2976
- Parameters
2977
- ----------
2978
- scenario_test_id : str
2979
- ID of the scenario test to retrieve.
2980
- polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
2981
- Options to use when polling for the scenario test result.
2982
-
2983
- Returns
2984
- -------
2985
- BatchExperiment
2986
- The scenario test details as a batch experiment.
2987
-
2988
- Raises
2989
- ------
2990
- requests.HTTPError
2991
- If the response status code is not 2xx.
2992
-
2993
- Examples
2994
- --------
2995
- >>> test = app.scenario_test_with_polling("scenario-123")
2996
- >>> print(test.name)
2997
- 'My Scenario Test'
2998
- >>> print(test.type)
2999
- 'scenario'
3000
- """
3001
-
3002
- return self.batch_experiment_with_polling(batch_id=scenario_test_id, polling_options=polling_options)
3003
-
3004
- def track_run( # noqa: C901
3005
- self,
3006
- tracked_run: TrackedRun,
3007
- instance_id: str | None = None,
3008
- configuration: RunConfiguration | dict[str, Any] | None = None,
3009
- ) -> str:
3010
- """
3011
- Track an external run.
3012
-
3013
- This method allows you to register in Nextmv a run that happened
3014
- elsewhere, as though it were executed in the Nextmv platform. Having
3015
- information about a run in Nextmv is useful for things like
3016
- experimenting and testing.
3017
-
3018
- Please read the documentation on the `TrackedRun` class carefully, as
3019
- there are important considerations to take into account when using this
3020
- method. For example, if you intend to upload JSON input/output, use the
3021
- `input`/`output` attributes of the `TrackedRun` class. On the other
3022
- hand, if you intend to track files-based input/output, use the
3023
- `input_dir_path`/`output_dir_path` attributes of the `TrackedRun`
3024
- class.
3025
-
3026
- Parameters
3027
- ----------
3028
- tracked_run : TrackedRun
3029
- The run to track.
3030
- instance_id : Optional[str], default=None
3031
- Optional instance ID if you want to associate your tracked run with
3032
- an instance.
3033
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
3034
- Configuration to use for the run. This can be a
3035
- `cloud.RunConfiguration` object or a dict. If the object is used,
3036
- then the `.to_dict()` method is applied to extract the
3037
- configuration.
3038
-
3039
- Returns
3040
- -------
3041
- str
3042
- The ID of the run that was tracked.
3043
-
3044
- Raises
3045
- ------
3046
- requests.HTTPError
3047
- If the response status code is not 2xx.
3048
- ValueError
3049
- If the tracked run does not have an input or output.
3050
-
3051
- Examples
3052
- --------
3053
- >>> from nextmv.cloud import Application
3054
- >>> from nextmv import TrackedRun
3055
- >>> app = Application(id="app_123")
3056
- >>> tracked_run = TrackedRun(input={"data": [...]}, output={"solution": [...]})
3057
- >>> run_id = app.track_run(tracked_run)
3058
- """
3059
-
3060
- # Get the URL to upload the input to.
3061
- url_input = self.upload_url()
3062
-
3063
- # Handle the case where the input is being uploaded as files. We need
3064
- # to tar them.
3065
- input_tar_file = ""
3066
- input_dir_path = tracked_run.input_dir_path
3067
- if input_dir_path is not None and input_dir_path != "":
3068
- if not os.path.exists(input_dir_path):
3069
- raise ValueError(f"Directory {input_dir_path} does not exist.")
3070
-
3071
- if not os.path.isdir(input_dir_path):
3072
- raise ValueError(f"Path {input_dir_path} is not a directory.")
3073
-
3074
- input_tar_file = self.__package_inputs(input_dir_path)
3075
-
3076
- # Handle the case where the input is uploaded as Input or a dict.
3077
- upload_input = tracked_run.input
3078
- if upload_input is not None and isinstance(tracked_run.input, Input):
3079
- upload_input = tracked_run.input.data
3080
-
3081
- # Actually uploads de input.
3082
- self.upload_large_input(input=upload_input, upload_url=url_input, tar_file=input_tar_file)
3083
-
3084
- # Get the URL to upload the output to.
3085
- url_output = self.upload_url()
3086
-
3087
- # Handle the case where the output is being uploaded as files. We need
3088
- # to tar them.
3089
- output_tar_file = ""
3090
- output_dir_path = tracked_run.output_dir_path
3091
- if output_dir_path is not None and output_dir_path != "":
3092
- if not os.path.exists(output_dir_path):
3093
- raise ValueError(f"Directory {output_dir_path} does not exist.")
3094
-
3095
- if not os.path.isdir(output_dir_path):
3096
- raise ValueError(f"Path {output_dir_path} is not a directory.")
3097
-
3098
- output_tar_file = self.__package_inputs(output_dir_path)
3099
-
3100
- # Handle the case where the output is uploaded as Output or a dict.
3101
- upload_output = tracked_run.output
3102
- if upload_output is not None and isinstance(tracked_run.output, Output):
3103
- upload_output = tracked_run.output.to_dict()
3104
-
3105
- # Actually uploads the output.
3106
- self.upload_large_input(input=upload_output, upload_url=url_output, tar_file=output_tar_file)
3107
-
3108
- # Create the external run result and appends logs if required.
3109
- external_result = ExternalRunResult(
3110
- output_upload_id=url_output.upload_id,
3111
- status=tracked_run.status.value,
3112
- execution_duration=tracked_run.duration,
3113
- )
3114
-
3115
- # Handle the stderr logs if provided.
3116
- if tracked_run.logs is not None:
3117
- url_stderr = self.upload_url()
3118
- self.upload_large_input(input=tracked_run.logs_text(), upload_url=url_stderr)
3119
- external_result.error_upload_id = url_stderr.upload_id
3120
-
3121
- if tracked_run.error is not None and tracked_run.error != "":
3122
- external_result.error_message = tracked_run.error
3123
-
3124
- # Handle the statistics upload if provided.
3125
- stats = tracked_run.statistics
3126
- if stats is not None:
3127
- if isinstance(stats, Statistics):
3128
- stats_dict = stats.to_dict()
3129
- stats_dict = {STATISTICS_KEY: stats_dict}
3130
- elif isinstance(stats, dict):
3131
- stats_dict = stats
3132
- if STATISTICS_KEY not in stats_dict:
3133
- stats_dict = {STATISTICS_KEY: stats_dict}
3134
- else:
3135
- raise ValueError("tracked_run.statistics must be either a `Statistics` or `dict` object")
3136
-
3137
- url_stats = self.upload_url()
3138
- self.upload_large_input(input=stats_dict, upload_url=url_stats)
3139
- external_result.statistics_upload_id = url_stats.upload_id
3140
-
3141
- # Handle the assets upload if provided.
3142
- assets = tracked_run.assets
3143
- if assets is not None:
3144
- if isinstance(assets, list):
3145
- assets_list = []
3146
- for ix, asset in enumerate(assets):
3147
- if isinstance(asset, Asset):
3148
- assets_list.append(asset.to_dict())
3149
- elif isinstance(asset, dict):
3150
- assets_list.append(asset)
3151
- else:
3152
- raise ValueError(f"tracked_run.assets, index {ix} must be an `Asset` or `dict` object")
3153
- assets_dict = {ASSETS_KEY: assets_list}
3154
- elif isinstance(assets, dict):
3155
- assets_dict = assets
3156
- if ASSETS_KEY not in assets_dict:
3157
- assets_dict = {ASSETS_KEY: assets_dict}
3158
- else:
3159
- raise ValueError("tracked_run.assets must be either a `list[Asset]`, `list[dict]`, or `dict` object")
3160
-
3161
- url_assets = self.upload_url()
3162
- self.upload_large_input(input=assets_dict, upload_url=url_assets)
3163
- external_result.assets_upload_id = url_assets.upload_id
3164
-
3165
- return self.new_run(
3166
- upload_id=url_input.upload_id,
3167
- external_result=external_result,
3168
- instance_id=instance_id,
3169
- name=tracked_run.name,
3170
- description=tracked_run.description,
3171
- configuration=configuration,
3172
- )
3173
-
3174
- def track_run_with_result(
3175
- self,
3176
- tracked_run: TrackedRun,
3177
- polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
3178
- instance_id: str | None = None,
3179
- output_dir_path: str | None = ".",
3180
- configuration: RunConfiguration | dict[str, Any] | None = None,
3181
- ) -> RunResult:
3182
- """
3183
- Track an external run and poll for the result. This is a convenience
3184
- method that combines the `track_run` and `run_result_with_polling`
3185
- methods. It applies polling logic to check when the run was
3186
- successfully registered.
3187
-
3188
- Parameters
3189
- ----------
3190
- tracked_run : TrackedRun
3191
- The run to track.
3192
- polling_options : PollingOptions
3193
- Options to use when polling for the run result.
3194
- instance_id: Optional[str]
3195
- Optional instance ID if you want to associate your tracked run with
3196
- an instance.
3197
- output_dir_path : Optional[str], default="."
3198
- Path to a directory where non-JSON output files will be saved. This is
3199
- required if the output is non-JSON. If the directory does not exist, it
3200
- will be created. Uses the current directory by default.
3201
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
3202
- Configuration to use for the run. This can be a
3203
- `cloud.RunConfiguration` object or a dict. If the object is used,
3204
- then the `.to_dict()` method is applied to extract the
3205
- configuration.
3206
-
3207
- Returns
3208
- -------
3209
- RunResult
3210
- Result of the run.
3211
-
3212
- Raises
3213
- ------
3214
- requests.HTTPError
3215
- If the response status code is not 2xx.
3216
- ValueError
3217
- If the tracked run does not have an input or output.
3218
- TimeoutError
3219
- If the run does not succeed after the polling strategy is
3220
- exhausted based on time duration.
3221
- RuntimeError
3222
- If the run does not succeed after the polling strategy is
3223
- exhausted based on number of tries.
3224
- """
3225
- run_id = self.track_run(
3226
- tracked_run=tracked_run,
3227
- instance_id=instance_id,
3228
- configuration=configuration,
3229
- )
3230
-
3231
- return self.run_result_with_polling(
3232
- run_id=run_id,
3233
- polling_options=polling_options,
3234
- output_dir_path=output_dir_path,
3235
- )
3236
-
3237
- def update_batch_experiment(
3238
- self,
3239
- batch_experiment_id: str,
3240
- name: str | None = None,
3241
- description: str | None = None,
3242
- ) -> BatchExperimentInformation:
3243
- """
3244
- Update a batch experiment.
3245
-
3246
- Parameters
3247
- ----------
3248
- batch_experiment_id : str
3249
- ID of the batch experiment to update.
3250
- name : Optional[str], default=None
3251
- Optional name of the batch experiment.
3252
- description : Optional[str], default=None
3253
- Optional description of the batch experiment.
3254
-
3255
- Returns
3256
- -------
3257
- BatchExperimentInformation
3258
- The information with the updated batch experiment.
3259
-
3260
- Raises
3261
- ------
3262
- requests.HTTPError
3263
- If the response status code is not 2xx.
3264
- """
3265
-
3266
- payload = {}
3267
-
3268
- if name is not None:
3269
- payload["name"] = name
3270
- if description is not None:
3271
- payload["description"] = description
3272
-
3273
- response = self.client.request(
3274
- method="PATCH",
3275
- endpoint=f"{self.experiments_endpoint}/batch/{batch_experiment_id}",
3276
- payload=payload,
3277
- )
3278
-
3279
- return BatchExperimentInformation.from_dict(response.json())
3280
-
3281
- def update_ensemble_definition(
3282
- self,
3283
- id: str,
3284
- name: str | None = None,
3285
- description: str | None = None,
3286
- ) -> EnsembleDefinition:
3287
- """
3288
- Update an ensemble definition.
3289
-
3290
- Parameters
3291
- ----------
3292
- id : str
3293
- ID of the ensemble definition to update.
3294
- name : Optional[str], default=None
3295
- Optional name of the ensemble definition.
3296
- description : Optional[str], default=None
3297
- Optional description of the ensemble definition.
3298
-
3299
- Returns
3300
- -------
3301
- EnsembleDefinition
3302
- The updated ensemble definition.
3303
-
3304
- Raises
3305
- ------
3306
- ValueError
3307
- If neither name nor description is updated
3308
- requests.HTTPError
3309
- If the response status code is not 2xx.
3310
- """
3311
-
3312
- payload = {}
3313
-
3314
- if name is None and description is None:
3315
- raise ValueError("Must define at least one value among name and description to modify")
3316
- if name is not None:
3317
- payload["name"] = name
3318
- if description is not None:
3319
- payload["description"] = description
3320
-
3321
- response = self.client.request(
3322
- method="PATCH",
3323
- endpoint=f"{self.ensembles_endpoint}/{id}",
3324
- payload=payload,
3325
- )
3326
-
3327
- return EnsembleDefinition.from_dict(response.json())
3328
-
3329
- def update_instance(
3330
- self,
3331
- id: str,
3332
- name: str | None = None,
3333
- version_id: str | None = None,
3334
- description: str | None = None,
3335
- configuration: InstanceConfiguration | dict[str, Any] | None = None,
3336
- ) -> Instance:
3337
- """
3338
- Update an instance.
3339
-
3340
- Parameters
3341
- ----------
3342
- id : str
3343
- ID of the instance to update.
3344
- name : Optional[str], default=None
3345
- Optional name of the instance.
3346
- version_id : Optional[str], default=None
3347
- Optional ID of the version to associate the instance with.
3348
- description : Optional[str], default=None
3349
- Optional description of the instance.
3350
- configuration : Optional[InstanceConfiguration | dict[str, Any]], default=None
3351
- Optional configuration to use for the instance.
3352
-
3353
- Returns
3354
- -------
3355
- Instance
3356
- The updated instance.
3357
-
3358
- Raises
3359
- ------
3360
- requests.HTTPError
3361
- If the response status code is not 2xx.
3362
- """
3363
-
3364
- # Get the instance as it currently exsits.
3365
- instance = self.instance(id)
3366
- instance_dict = instance.to_dict()
3367
- payload = instance_dict
3368
-
3369
- if name is not None:
3370
- payload["name"] = name
3371
- if version_id is not None:
3372
- payload["version_id"] = version_id
3373
- if description is not None:
3374
- payload["description"] = description
3375
- if configuration is not None:
3376
- if isinstance(configuration, dict):
3377
- config_dict = configuration
3378
- elif isinstance(configuration, InstanceConfiguration):
3379
- config_dict = configuration.to_dict()
3380
- else:
3381
- raise TypeError("configuration must be either a dict or InstanceConfiguration object")
3382
-
3383
- payload["configuration"] = config_dict
3384
-
3385
- response = self.client.request(
3386
- method="PUT",
3387
- endpoint=f"{self.endpoint}/instances/{id}",
3388
- payload=payload,
3389
- )
3390
-
3391
- return Instance.from_dict(response.json())
3392
-
3393
- def update_managed_input(
3394
- self,
3395
- managed_input_id: str,
3396
- name: str | None = None,
3397
- description: str | None = None,
3398
- ) -> ManagedInput:
3399
- """
3400
- Update a managed input.
3401
-
3402
- Parameters
3403
- ----------
3404
- managed_input_id : str
3405
- ID of the managed input to update.
3406
- name : Optional[str], default=None
3407
- Optional new name for the managed input.
3408
- description : Optional[str], default=None
3409
- Optional new description for the managed input.
3410
-
3411
- Returns
3412
- -------
3413
- ManagedInput
3414
- The updated managed input.
3415
-
3416
- Raises
3417
- ------
3418
- requests.HTTPError
3419
- If the response status code is not 2xx.
3420
- """
3421
-
3422
- managed_input = self.managed_input(managed_input_id)
3423
- managed_input_dict = managed_input.to_dict()
3424
- payload = managed_input_dict
3425
-
3426
- if name is not None:
3427
- payload["name"] = name
3428
- if description is not None:
3429
- payload["description"] = description
3430
-
3431
- response = self.client.request(
3432
- method="PUT",
3433
- endpoint=f"{self.endpoint}/inputs/{managed_input_id}",
3434
- payload=payload,
3435
- )
3436
-
3437
- return ManagedInput.from_dict(response.json())
3438
-
3439
- def update_scenario_test(
3440
- self,
3441
- scenario_test_id: str,
3442
- name: str | None = None,
3443
- description: str | None = None,
3444
- ) -> BatchExperimentInformation:
3445
- """
3446
- Update a scenario test.
3447
-
3448
- Updates a scenario test with new name and description. Scenario tests
3449
- use the batch experiments API, so this method calls the
3450
- `update_batch_experiment` method, and thus the return type is the same.
3451
-
3452
- Parameters
3453
- ----------
3454
- scenario_test_id : str
3455
- ID of the scenario test to update.
3456
- name : Optional[str], default=None
3457
- Optional new name for the scenario test.
3458
- description : Optional[str], default=None
3459
- Optional new description for the scenario test.
3460
-
3461
- Returns
3462
- -------
3463
- BatchExperimentInformation
3464
- The information about the updated scenario test.
3465
-
3466
- Raises
3467
- ------
3468
- requests.HTTPError
3469
- If the response status code is not 2xx.
3470
-
3471
- Examples
3472
- --------
3473
- >>> info = app.update_scenario_test(
3474
- ... scenario_test_id="scenario-123",
3475
- ... name="Updated Test Name",
3476
- ... description="Updated description for this test"
3477
- ... )
3478
- >>> print(info.name)
3479
- 'Updated Test Name'
3480
- """
3481
-
3482
- return self.update_batch_experiment(
3483
- batch_experiment_id=scenario_test_id,
3484
- name=name,
3485
- description=description,
3486
- )
3487
-
3488
- def update_secrets_collection(
3489
- self,
3490
- secrets_collection_id: str,
3491
- name: str | None = None,
3492
- description: str | None = None,
3493
- secrets: list[Secret | dict[str, Any]] | None = None,
3494
- ) -> SecretsCollectionSummary:
3495
- """
3496
- Update a secrets collection.
3497
-
3498
- This method updates an existing secrets collection with new values for name,
3499
- description, and secrets. A secrets collection is a group of key-value pairs
3500
- that can be used by your application instances during execution.
3501
-
3502
- Parameters
3503
- ----------
3504
- secrets_collection_id : str
3505
- ID of the secrets collection to update.
3506
- name : Optional[str], default=None
3507
- Optional new name for the secrets collection.
3508
- description : Optional[str], default=None
3509
- Optional new description for the secrets collection.
3510
- secrets : Optional[list[Secret | dict[str, Any]]], default=None
3511
- Optional list of secrets to update. Each secret should be an
3512
- instance of the Secret class containing a key and value.
3513
-
3514
- Returns
3515
- -------
3516
- SecretsCollectionSummary
3517
- Summary of the updated secrets collection including its metadata.
3518
-
3519
- Raises
3520
- ------
3521
- ValueError
3522
- If no secrets are provided.
3523
- requests.HTTPError
3524
- If the response status code is not 2xx.
3525
-
3526
- Examples
3527
- --------
3528
- >>> # Update an existing secrets collection
3529
- >>> from nextmv.cloud import Secret
3530
- >>> updated_secrets = [
3531
- ... Secret(key="API_KEY", value="new-api-key"),
3532
- ... Secret(key="DATABASE_URL", value="new-database-url")
3533
- ... ]
3534
- >>> updated_collection = app.update_secrets_collection(
3535
- ... secrets_collection_id="api-secrets",
3536
- ... name="Updated API Secrets",
3537
- ... description="Updated collection of API secrets",
3538
- ... secrets=updated_secrets
3539
- ... )
3540
- >>> print(updated_collection.id)
3541
- 'api-secrets'
3542
- """
3543
-
3544
- collection = self.secrets_collection(secrets_collection_id)
3545
- collection_dict = collection.to_dict()
3546
- payload = collection_dict
3547
-
3548
- if name is not None:
3549
- payload["name"] = name
3550
- if description is not None:
3551
- payload["description"] = description
3552
- if secrets is not None and len(secrets) > 0:
3553
- secrets_dicts = []
3554
- for ix, secret in enumerate(secrets):
3555
- if isinstance(secret, dict):
3556
- secrets_dicts.append(secret)
3557
- elif isinstance(secret, Secret):
3558
- secrets_dicts.append(secret.to_dict())
3559
- else:
3560
- raise ValueError(f"secret at index {ix} must be either a Secret or dict object")
3561
-
3562
- payload["secrets"] = secrets_dicts
3563
-
3564
- response = self.client.request(
3565
- method="PUT",
3566
- endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
3567
- payload=payload,
3568
- )
3569
-
3570
- return SecretsCollectionSummary.from_dict(response.json())
3571
-
3572
- def upload_large_input(
3573
- self,
3574
- input: dict[str, Any] | str | None,
3575
- upload_url: UploadURL,
3576
- json_configurations: dict[str, Any] | None = None,
3577
- tar_file: str | None = None,
3578
- ) -> None:
3579
- """
3580
- Upload large input data to the provided upload URL.
3581
-
3582
- This method allows uploading large input data (either a dictionary or string)
3583
- to a pre-signed URL. If the input is a dictionary, it will be converted to
3584
- a JSON string before upload.
3585
-
3586
- Parameters
3587
- ----------
3588
- input : Optional[Union[dict[str, Any], str]]
3589
- Input data to upload. Can be either a dictionary that will be
3590
- converted to JSON, or a pre-formatted JSON string.
3591
- upload_url : UploadURL
3592
- Upload URL object containing the pre-signed URL to use for uploading.
3593
- json_configurations : Optional[dict[str, Any]], default=None
3594
- Optional configurations for JSON serialization. If provided, these
3595
- configurations will be used when serializing the data via
3596
- `json.dumps`.
3597
- tar_file : Optional[str], default=None
3598
- If provided, this will be used to upload a tar file instead of
3599
- a JSON string or dictionary. This is useful for uploading large
3600
- files that are already packaged as a tarball.
3601
-
3602
- Returns
3603
- -------
3604
- None
3605
- This method doesn't return anything.
3606
-
3607
- Raises
3608
- ------
3609
- requests.HTTPError
3610
- If the response status code is not 2xx.
3611
-
3612
- Examples
3613
- --------
3614
- >>> # Upload a dictionary as JSON
3615
- >>> data = {"locations": [...], "vehicles": [...]}
3616
- >>> url = app.upload_url()
3617
- >>> app.upload_large_input(input=data, upload_url=url)
3618
- >>>
3619
- >>> # Upload a pre-formatted JSON string
3620
- >>> json_str = '{"locations": [...], "vehicles": [...]}'
3621
- >>> app.upload_large_input(input=json_str, upload_url=url)
3622
- """
3623
-
3624
- if input is not None and isinstance(input, dict):
3625
- input = deflated_serialize_json(input, json_configurations=json_configurations)
3626
-
3627
- self.client.upload_to_presigned_url(
3628
- url=upload_url.upload_url,
3629
- data=input,
3630
- tar_file=tar_file,
3631
- )
3632
-
3633
- def upload_url(self) -> UploadURL:
3634
- """
3635
- Get an upload URL to use for uploading a file.
3636
-
3637
- This method generates a pre-signed URL that can be used to upload large files
3638
- to Nextmv Cloud. It's primarily used for uploading large input data, output
3639
- results, or log files that exceed the size limits for direct API calls.
3640
-
3641
- Returns
3642
- -------
3643
- UploadURL
3644
- An object containing both the upload URL and an upload ID for reference.
3645
- The upload URL is a pre-signed URL that allows temporary write access.
3646
-
3647
- Raises
3648
- ------
3649
- requests.HTTPError
3650
- If the response status code is not 2xx.
3651
-
3652
- Examples
3653
- --------
3654
- >>> # Get an upload URL and upload large input data
3655
- >>> upload_url = app.upload_url()
3656
- >>> large_input = {"locations": [...], "vehicles": [...]}
3657
- >>> app.upload_large_input(input=large_input, upload_url=upload_url)
3658
- """
3659
-
3660
- response = self.client.request(
3661
- method="POST",
3662
- endpoint=f"{self.endpoint}/runs/uploadurl",
3663
- )
3664
-
3665
- return UploadURL.from_dict(response.json())
3666
-
3667
- def secrets_collection(self, secrets_collection_id: str) -> SecretsCollection:
3668
- """
3669
- Get a secrets collection.
3670
-
3671
- This method retrieves a secrets collection by its ID. A secrets collection
3672
- is a group of key-value pairs that can be used by your application
3673
- instances during execution.
3674
-
3675
- Parameters
3676
- ----------
3677
- secrets_collection_id : str
3678
- ID of the secrets collection to retrieve.
3679
-
3680
- Returns
3681
- -------
3682
- SecretsCollection
3683
- The requested secrets collection, including all secret values
3684
- and metadata.
3685
-
3686
- Raises
3687
- ------
3688
- requests.HTTPError
3689
- If the response status code is not 2xx.
3690
-
3691
- Examples
3692
- --------
3693
- >>> # Retrieve a secrets collection
3694
- >>> collection = app.secrets_collection("api-secrets")
3695
- >>> print(collection.name)
3696
- 'API Secrets'
3697
- >>> print(len(collection.secrets))
3698
- 2
3699
- >>> for secret in collection.secrets:
3700
- ... print(secret.location)
3701
- 'API_KEY'
3702
- 'DATABASE_URL'
3703
- """
3704
-
3705
- response = self.client.request(
3706
- method="GET",
3707
- endpoint=f"{self.endpoint}/secrets/{secrets_collection_id}",
3708
- )
3709
-
3710
- return SecretsCollection.from_dict(response.json())
3711
-
3712
- def version(self, version_id: str) -> Version:
3713
- """
3714
- Get a version.
3715
-
3716
- Retrieves a specific version of the application by its ID. Application versions
3717
- represent different iterations of your application's code and configuration.
3718
-
3719
- Parameters
3720
- ----------
3721
- version_id : str
3722
- ID of the version to retrieve.
3723
-
3724
- Returns
3725
- -------
3726
- Version
3727
- The version object containing details about the requested application version.
3728
-
3729
- Raises
3730
- ------
3731
- requests.HTTPError
3732
- If the response status code is not 2xx.
3733
-
3734
- Examples
3735
- --------
3736
- >>> # Retrieve a specific version
3737
- >>> version = app.version("v1.0.0")
3738
- >>> print(version.id)
3739
- 'v1.0.0'
3740
- >>> print(version.name)
3741
- 'Initial Release'
3742
- """
3743
-
3744
- response = self.client.request(
3745
- method="GET",
3746
- endpoint=f"{self.endpoint}/versions/{version_id}",
3747
- )
3748
-
3749
- return Version.from_dict(response.json())
3750
-
3751
- def version_exists(self, version_id: str) -> bool:
3752
- """
3753
- Check if a version exists.
3754
-
3755
- This method checks if a specific version of the application exists by
3756
- attempting to retrieve it. It handles HTTP errors for non-existent versions
3757
- and returns a boolean indicating existence.
3758
-
3759
- Parameters
3760
- ----------
3761
- version_id : str
3762
- ID of the version to check for existence.
3763
-
3764
- Returns
3765
- -------
3766
- bool
3767
- True if the version exists, False otherwise.
3768
-
3769
- Raises
3770
- ------
3771
- requests.HTTPError
3772
- If an HTTP error occurs that is not related to the non-existence
3773
- of the version.
3774
-
3775
- Examples
3776
- --------
3777
- >>> # Check if a version exists
3778
- >>> exists = app.version_exists("v1.0.0")
3779
- >>> if exists:
3780
- ... print("Version exists!")
3781
- ... else:
3782
- ... print("Version does not exist.")
3783
- """
3784
-
3785
- try:
3786
- self.version(version_id=version_id)
3787
- return True
3788
- except requests.HTTPError as e:
3789
- if _is_not_exist_error(e):
3790
- return False
3791
- raise e
3792
-
3793
- def __run_result(
3794
- self,
3795
- run_id: str,
3796
- run_information: RunInformation,
3797
- output_dir_path: str | None = ".",
3798
- ) -> RunResult:
3799
- """
3800
- Get the result of a run.
3801
-
3802
- This is a private method that retrieves the complete result of a run,
3803
- including the output data. It handles both small and large outputs,
3804
- automatically using the appropriate API endpoints based on the output
3805
- size. This method serves as the base implementation for retrieving
3806
- run results, regardless of polling strategy.
3807
-
3808
- Parameters
3809
- ----------
3810
- run_id : str
3811
- ID of the run to retrieve the result for.
3812
- run_information : RunInformation
3813
- Information about the run, including metadata such as output size.
3814
- output_dir_path : Optional[str], default="."
3815
- Path to a directory where non-JSON output files will be saved. This is
3816
- required if the output is non-JSON. If the directory does not exist, it
3817
- will be created. Uses the current directory by default.
3818
-
3819
- Returns
3820
- -------
3821
- RunResult
3822
- Result of the run, including all metadata and output data.
3823
- For large outputs, the method will fetch the output from
3824
- a download URL.
3825
-
3826
- Raises
3827
- ------
3828
- requests.HTTPError
3829
- If the response status code is not 2xx.
3830
-
3831
- Notes
3832
- -----
3833
- This method automatically handles large outputs by checking if the
3834
- output size exceeds _MAX_RUN_SIZE. If it does, the method will request
3835
- a download URL and fetch the output data separately.
3836
- """
3837
- query_params = None
3838
- use_presigned_url = False
3839
- if (
3840
- run_information.metadata.format.format_output.output_type != OutputFormat.JSON
3841
- or run_information.metadata.output_size > _MAX_RUN_SIZE
3842
- ):
3843
- query_params = {"format": "url"}
3844
- use_presigned_url = True
3845
-
3846
- response = self.client.request(
3847
- method="GET",
3848
- endpoint=f"{self.endpoint}/runs/{run_id}",
3849
- query_params=query_params,
3850
- )
3851
- result = RunResult.from_dict(response.json())
3852
- result.console_url = self.__console_url(result.id)
3853
-
3854
- if not use_presigned_url or result.metadata.status_v2 != StatusV2.succeeded:
3855
- return result
3856
-
3857
- download_url = DownloadURL.from_dict(response.json()["output"])
3858
- download_response = self.client.request(
3859
- method="GET",
3860
- endpoint=download_url.url,
3861
- headers={"Content-Type": "application/json"},
3862
- )
3863
-
3864
- # See whether we can attach the output directly or need to save to the given
3865
- # directory
3866
- if run_information.metadata.format.format_output.output_type != OutputFormat.JSON:
3867
- if not output_dir_path or output_dir_path == "":
3868
- raise ValueError(
3869
- "If the output format is not JSON, an output_dir_path must be provided.",
3870
- )
3871
- if not os.path.exists(output_dir_path):
3872
- os.makedirs(output_dir_path, exist_ok=True)
3873
- # Save .tar.gz file to a temp directory and extract contents to output_dir_path
3874
- with tempfile.TemporaryDirectory() as tmpdirname:
3875
- temp_tar_path = os.path.join(tmpdirname, f"{run_id}.tar.gz")
3876
- with open(temp_tar_path, "wb") as f:
3877
- f.write(download_response.content)
3878
- shutil.unpack_archive(temp_tar_path, output_dir_path)
3879
- else:
3880
- result.output = download_response.json()
3881
-
3882
- return result
3883
-
3884
- @staticmethod
3885
- def __convert_manifest_to_payload(manifest: Manifest) -> dict[str, Any]: # noqa: C901
3886
- """Converts a manifest to a payload dictionary for the API."""
3887
-
3888
- activation_request = {
3889
- "requirements": {
3890
- "executable_type": manifest.type,
3891
- "runtime": manifest.runtime,
3892
- },
3893
- }
3894
-
3895
- if manifest.configuration is not None and manifest.configuration.content is not None:
3896
- content = manifest.configuration.content
3897
- io_config = {
3898
- "format": content.format,
3899
- }
3900
- if content.multi_file is not None:
3901
- multi_config = io_config["multi_file"] = {}
3902
- if content.multi_file.input is not None:
3903
- multi_config["input_path"] = content.multi_file.input.path
3904
- if content.multi_file.output is not None:
3905
- output_config = multi_config["output_configuration"] = {}
3906
- if content.multi_file.output.statistics:
3907
- output_config["statistics_path"] = content.multi_file.output.statistics
3908
- if content.multi_file.output.assets:
3909
- output_config["assets_path"] = content.multi_file.output.assets
3910
- if content.multi_file.output.solutions:
3911
- output_config["solutions_path"] = content.multi_file.output.solutions
3912
- activation_request["requirements"]["io_configuration"] = io_config
3913
-
3914
- if manifest.configuration is not None and manifest.configuration.options is not None:
3915
- options = manifest.configuration.options.to_dict()
3916
- if "format" in options and isinstance(options["format"], list):
3917
- # the endpoint expects a dictionary with a template key having a list of strings
3918
- # the app.yaml however defines format as a list of strings, so we need to convert it here
3919
- options["format"] = {
3920
- "template": options["format"],
3921
- }
3922
- activation_request["requirements"]["options"] = options
3923
-
3924
- if manifest.execution is not None:
3925
- if manifest.execution.entrypoint:
3926
- activation_request["requirements"]["entrypoint"] = manifest.execution.entrypoint
3927
- if manifest.execution.cwd:
3928
- activation_request["requirements"]["working_directory"] = manifest.execution.cwd
3929
-
3930
- return activation_request
3931
-
3932
- def __update_app_binary(
3933
- self,
3934
- tar_file: str,
3935
- manifest: Manifest,
3936
- verbose: bool = False,
3937
- ) -> None:
3938
- """Updates the application binary in Cloud."""
3939
-
3940
- if verbose:
3941
- log(f'🌟 Pushing to application: "{self.id}".')
3942
-
3943
- endpoint = f"{self.endpoint}/binary"
3944
- response = self.client.request(
3945
- method="GET",
3946
- endpoint=endpoint,
3947
- )
3948
- upload_url = response.json()["upload_url"]
3949
-
3950
- with open(tar_file, "rb") as f:
3951
- response = self.client.request(
3952
- method="PUT",
3953
- endpoint=upload_url,
3954
- data=f,
3955
- headers={"Content-Type": "application/octet-stream"},
3956
- )
3957
-
3958
- response = self.client.request(
3959
- method="PUT",
3960
- endpoint=endpoint,
3961
- payload=Application.__convert_manifest_to_payload(manifest=manifest),
3962
- )
3963
-
3964
- if verbose:
3965
- log(f'💥️ Successfully pushed to application: "{self.id}".')
3966
- log(
3967
- json.dumps(
3968
- {
3969
- "app_id": self.id,
3970
- "endpoint": self.client.url,
3971
- "instance_url": f"{self.endpoint}/runs?instance_id=latest",
3972
- },
3973
- indent=2,
3974
- )
3975
- )
3976
-
3977
- def __console_url(self, run_id: str) -> str:
3978
- """Auxiliary method to get the console URL for a run."""
3979
-
3980
- return f"{self.client.console_url}/app/{self.id}/run/{run_id}?view=details"
3981
-
3982
- def __input_set_for_scenario(self, scenario: Scenario, scenario_id: str) -> InputSet:
3983
- # If working with an input set, there is no need to create one.
3984
- if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT_SET:
3985
- input_set = self.input_set(input_set_id=scenario.scenario_input.scenario_input_data)
3986
- return input_set
3987
-
3988
- # If working with a list of managed inputs, we need to create an
3989
- # input set.
3990
- if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT:
3991
- name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3992
- input_set = self.new_input_set(
3993
- id=id,
3994
- name=name,
3995
- description=f"Automatically created from scenario test: {id}",
3996
- maximum_runs=20,
3997
- inputs=[
3998
- ManagedInput.from_dict(data={"id": input_id})
3999
- for input_id in scenario.scenario_input.scenario_input_data
4000
- ],
4001
- )
4002
- return input_set
4003
-
4004
- # If working with new data, we need to create managed inputs, and then,
4005
- # an input set.
4006
- if scenario.scenario_input.scenario_input_type == ScenarioInputType.NEW:
4007
- managed_inputs = []
4008
- for data in scenario.scenario_input.scenario_input_data:
4009
- upload_url = self.upload_url()
4010
- self.upload_large_input(input=data, upload_url=upload_url)
4011
- name, id = safe_name_and_id(prefix="man-input", entity_id=scenario_id)
4012
- managed_input = self.new_managed_input(
4013
- id=id,
4014
- name=name,
4015
- description=f"Automatically created from scenario test: {id}",
4016
- upload_id=upload_url.upload_id,
4017
- )
4018
- managed_inputs.append(managed_input)
4019
-
4020
- name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
4021
- input_set = self.new_input_set(
4022
- id=id,
4023
- name=name,
4024
- description=f"Automatically created from scenario test: {id}",
4025
- maximum_runs=20,
4026
- inputs=managed_inputs,
4027
- )
4028
- return input_set
4029
-
4030
- raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
4031
-
4032
- def __package_inputs(self, dir_path: str) -> str:
4033
- """
4034
- This is an auxiliary function for packaging the inputs found in the
4035
- provided `dir_path`. All the files found in the directory are tarred and
4036
- g-zipped. This function returns the tar file path that contains the
4037
- packaged inputs.
4038
- """
4039
-
4040
- # Create a temporary directory for the output
4041
- output_dir = tempfile.mkdtemp(prefix="nextmv-inputs-out-")
4042
-
4043
- # Define the output tar file name and path
4044
- tar_filename = "inputs.tar.gz"
4045
- tar_file_path = os.path.join(output_dir, tar_filename)
4046
-
4047
- # Create the tar.gz file
4048
- with tarfile.open(tar_file_path, "w:gz") as tar:
4049
- for root, _, files in os.walk(dir_path):
4050
- for file in files:
4051
- if file == tar_filename:
4052
- continue
4053
-
4054
- file_path = os.path.join(root, file)
4055
-
4056
- # Skip directories, only process files
4057
- if os.path.isdir(file_path):
4058
- continue
4059
-
4060
- # Create relative path for the archive
4061
- arcname = os.path.relpath(file_path, start=dir_path)
4062
- tar.add(file_path, arcname=arcname)
4063
-
4064
- return tar_file_path
4065
-
4066
- def __upload_url_required(
4067
- self,
4068
- upload_id_used: bool,
4069
- input_size: int,
4070
- tar_file: str,
4071
- input: Input | dict[str, Any] | BaseModel | str = None,
4072
- ) -> bool:
4073
- """
4074
- Auxiliary function to determine if an upload URL is required
4075
- based on the input size, type, and configuration.
4076
- """
4077
-
4078
- if upload_id_used:
4079
- return False
4080
-
4081
- non_json_payload = False
4082
- if isinstance(input, str):
4083
- non_json_payload = True
4084
- elif isinstance(input, Input) and input.input_format != InputFormat.JSON:
4085
- non_json_payload = True
4086
- elif tar_file is not None and tar_file != "":
4087
- non_json_payload = True
4088
-
4089
- size_exceeds = input_size > _MAX_RUN_SIZE
4090
-
4091
- return size_exceeds or non_json_payload
4092
-
4093
- def __extract_input_data(
4094
- self,
4095
- input: Input | dict[str, Any] | BaseModel | str = None,
4096
- ) -> dict[str, Any] | str | None:
4097
- """
4098
- Auxiliary function to extract the input data from the input, based on
4099
- its type.
4100
- """
4101
-
4102
- input_data = None
4103
- if isinstance(input, BaseModel):
4104
- input_data = input.to_dict()
4105
- elif isinstance(input, dict) or isinstance(input, str):
4106
- input_data = input
4107
- elif isinstance(input, Input):
4108
- input_data = input.data
4109
-
4110
- return input_data
4111
-
4112
- def __extract_options_dict(
4113
- self,
4114
- options: Options | dict[str, str] | None = None,
4115
- json_configurations: dict[str, Any] | None = None,
4116
- ) -> dict[str, str]:
4117
- """
4118
- Auxiliary function to extract the options that will be sent to the
4119
- application for execution.
4120
- """
4121
-
4122
- options_dict = {}
4123
- if options is not None:
4124
- if isinstance(options, Options):
4125
- options_dict = options.to_dict_cloud()
4126
-
4127
- elif isinstance(options, dict):
4128
- for k, v in options.items():
4129
- if isinstance(v, str):
4130
- options_dict[k] = v
4131
- continue
4132
-
4133
- options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
4134
-
4135
- return options_dict
4136
-
4137
- def __extract_run_config(
4138
- self,
4139
- input: Input | dict[str, Any] | BaseModel | str = None,
4140
- configuration: RunConfiguration | dict[str, Any] | None = None,
4141
- dir_path: str | None = None,
4142
- ) -> dict[str, Any]:
4143
- """
4144
- Auxiliary function to extract the run configuration that will be sent
4145
- to the application for execution.
4146
- """
4147
-
4148
- if configuration is not None:
4149
- configuration_dict = (
4150
- configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
4151
- )
4152
- return configuration_dict
4153
-
4154
- configuration = RunConfiguration()
4155
- configuration.resolve(input=input, dir_path=dir_path)
4156
- configuration_dict = configuration.to_dict()
4157
-
4158
- return configuration_dict
4159
-
4160
-
4161
- def _is_not_exist_error(e: requests.HTTPError) -> bool:
4162
- """
4163
- Check if the error is a known 404 Not Found error.
4164
-
4165
- This is an internal helper function that examines HTTPError objects to determine
4166
- if they represent a "Not Found" (404) condition, either directly or through a
4167
- nested exception.
4168
-
4169
- Parameters
4170
- ----------
4171
- e : requests.HTTPError
4172
- The HTTP error to check.
4173
-
4174
- Returns
4175
- -------
4176
- bool
4177
- True if the error is a 404 Not Found error, False otherwise.
4178
-
4179
- Examples
4180
- --------
4181
- >>> try:
4182
- ... response = requests.get('https://api.example.com/nonexistent')
4183
- ... response.raise_for_status()
4184
- ... except requests.HTTPError as err:
4185
- ... if _is_not_exist_error(err):
4186
- ... print("Resource does not exist")
4187
- ... else:
4188
- ... print("Another error occurred")
4189
- Resource does not exist
4190
- """
4191
- if (
4192
- # Check whether the error is caused by a 404 status code - meaning the app does not exist.
4193
- (hasattr(e, "response") and hasattr(e.response, "status_code") and e.response.status_code == 404)
4194
- or
4195
- # Check a possibly nested exception as well.
4196
- (
4197
- hasattr(e, "__cause__")
4198
- and hasattr(e.__cause__, "response")
4199
- and hasattr(e.__cause__.response, "status_code")
4200
- and e.__cause__.response.status_code == 404
4201
- )
4202
- ):
4203
- return True
4204
- return False