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