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.
- nextmv/__about__.py +1 -1
- nextmv/__entrypoint__.py +39 -0
- nextmv/__init__.py +57 -0
- nextmv/_serialization.py +96 -0
- nextmv/base_model.py +79 -9
- nextmv/cloud/__init__.py +71 -10
- nextmv/cloud/acceptance_test.py +888 -17
- nextmv/cloud/account.py +154 -10
- nextmv/cloud/application.py +3644 -437
- nextmv/cloud/batch_experiment.py +292 -33
- nextmv/cloud/client.py +354 -53
- nextmv/cloud/ensemble.py +247 -0
- nextmv/cloud/input_set.py +121 -4
- nextmv/cloud/instance.py +125 -0
- nextmv/cloud/package.py +474 -0
- nextmv/cloud/scenario.py +410 -0
- nextmv/cloud/secrets.py +234 -0
- nextmv/cloud/url.py +73 -0
- nextmv/cloud/version.py +174 -0
- nextmv/default_app/.gitignore +1 -0
- nextmv/default_app/README.md +32 -0
- nextmv/default_app/app.yaml +12 -0
- nextmv/default_app/input.json +5 -0
- nextmv/default_app/main.py +37 -0
- nextmv/default_app/requirements.txt +2 -0
- nextmv/default_app/src/__init__.py +0 -0
- nextmv/default_app/src/main.py +37 -0
- nextmv/default_app/src/visuals.py +36 -0
- nextmv/deprecated.py +47 -0
- nextmv/input.py +883 -78
- nextmv/local/__init__.py +5 -0
- nextmv/local/application.py +1263 -0
- nextmv/local/executor.py +1040 -0
- nextmv/local/geojson_handler.py +323 -0
- nextmv/local/local.py +97 -0
- nextmv/local/plotly_handler.py +61 -0
- nextmv/local/runner.py +274 -0
- nextmv/logger.py +80 -9
- nextmv/manifest.py +1472 -0
- nextmv/model.py +431 -0
- nextmv/options.py +968 -78
- nextmv/output.py +1363 -231
- nextmv/polling.py +287 -0
- nextmv/run.py +1623 -0
- nextmv/safe.py +145 -0
- nextmv/status.py +122 -0
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/METADATA +51 -288
- nextmv-0.35.0.dist-info/RECORD +50 -0
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/WHEEL +1 -1
- nextmv/cloud/status.py +0 -29
- nextmv/nextroute/__init__.py +0 -2
- nextmv/nextroute/check/__init__.py +0 -26
- nextmv/nextroute/check/schema.py +0 -141
- nextmv/nextroute/schema/__init__.py +0 -19
- nextmv/nextroute/schema/input.py +0 -52
- nextmv/nextroute/schema/location.py +0 -13
- nextmv/nextroute/schema/output.py +0 -136
- nextmv/nextroute/schema/stop.py +0 -61
- nextmv/nextroute/schema/vehicle.py +0 -68
- nextmv-0.10.3.dev0.dist-info/RECORD +0 -28
- {nextmv-0.10.3.dev0.dist-info → nextmv-0.35.0.dist-info}/licenses/LICENSE +0 -0
nextmv/cloud/application.py
CHANGED
|
@@ -1,175 +1,242 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
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
|
|
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
|
|
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.
|
|
15
|
-
from nextmv.cloud.
|
|
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
|
-
"""
|
|
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 =
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
190
|
+
if id is None:
|
|
191
|
+
id = safe_id("app")
|
|
165
192
|
|
|
166
|
-
|
|
167
|
-
|
|
193
|
+
if exist_ok and cls.exists(client=client, id=id):
|
|
194
|
+
return Application(client=client, id=id)
|
|
168
195
|
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
Batch experiment.
|
|
292
|
+
return None, False
|
|
189
293
|
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
450
|
+
def delete(self) -> None:
|
|
218
451
|
"""
|
|
219
|
-
|
|
220
|
-
such as its runs.
|
|
452
|
+
Delete the application.
|
|
221
453
|
|
|
222
|
-
|
|
223
|
-
batch_id: ID of the batch experiment.
|
|
454
|
+
Permanently removes the application from Nextmv Cloud.
|
|
224
455
|
|
|
225
|
-
Raises
|
|
226
|
-
|
|
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=
|
|
468
|
+
endpoint=self.endpoint,
|
|
232
469
|
)
|
|
233
470
|
|
|
234
471
|
def delete_acceptance_test(self, acceptance_test_id: str) -> None:
|
|
235
472
|
"""
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
498
|
+
def delete_batch_experiment(self, batch_id: str) -> None:
|
|
252
499
|
"""
|
|
253
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
259
|
-
|
|
668
|
+
def input_set(self, input_set_id: str) -> InputSet:
|
|
669
|
+
"""
|
|
670
|
+
Get an input set.
|
|
260
671
|
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
701
|
+
def instance(self, instance_id: str) -> Instance:
|
|
273
702
|
"""
|
|
274
|
-
|
|
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
|
-
|
|
277
|
-
|
|
762
|
+
def list_acceptance_tests(self) -> list[AcceptanceTest]:
|
|
763
|
+
"""
|
|
764
|
+
List all acceptance tests.
|
|
278
765
|
|
|
279
|
-
|
|
280
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
|
815
|
+
def list_ensemble_definitions(self) -> list[EnsembleDefinition]:
|
|
309
816
|
"""
|
|
310
|
-
List all
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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:
|
|
1066
|
+
metrics: list[Metric | dict[str, Any]],
|
|
332
1067
|
name: str,
|
|
333
|
-
input_set_id:
|
|
334
|
-
description:
|
|
1068
|
+
input_set_id: str | None = None,
|
|
1069
|
+
description: str | None = None,
|
|
335
1070
|
) -> AcceptanceTest:
|
|
336
1071
|
"""
|
|
337
|
-
Create a new acceptance test.
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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:
|
|
415
|
-
description:
|
|
416
|
-
id:
|
|
417
|
-
option_sets:
|
|
418
|
-
runs:
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
|
1341
|
+
def new_batch_experiment_with_result(
|
|
465
1342
|
self,
|
|
466
|
-
id: str,
|
|
467
1343
|
name: str,
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
Input set.
|
|
1409
|
+
return self.batch_experiment_with_polling(batch_id=batch_id, polling_options=polling_options)
|
|
491
1410
|
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
1551
|
+
def new_instance(
|
|
522
1552
|
self,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
name:
|
|
526
|
-
description:
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
547
|
-
|
|
1831
|
+
if not os.path.isdir(input_dir_path):
|
|
1832
|
+
raise ValueError(f"Path {input_dir_path} is not a directory.")
|
|
548
1833
|
|
|
549
|
-
|
|
550
|
-
requests.HTTPError: If the response status code is not 2xx.
|
|
551
|
-
"""
|
|
1834
|
+
tar_file = self.__package_inputs(input_dir_path)
|
|
552
1835
|
|
|
553
|
-
|
|
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
|
-
|
|
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
|
|
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=
|
|
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"] =
|
|
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
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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:
|
|
598
|
-
instance_id:
|
|
599
|
-
name:
|
|
600
|
-
description:
|
|
601
|
-
upload_id:
|
|
602
|
-
run_options:
|
|
603
|
-
polling_options: PollingOptions =
|
|
604
|
-
configuration:
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
|
|
2295
|
+
payload = {
|
|
2296
|
+
"secrets": [secret.to_dict() for secret in secrets],
|
|
2297
|
+
}
|
|
655
2298
|
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
711
|
-
run_id: ID of the run.
|
|
2699
|
+
run_information = self.run_metadata(run_id=run_id)
|
|
712
2700
|
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
run_id: ID of the run.
|
|
2843
|
+
return self.batch_experiment_metadata(batch_id=scenario_test_id)
|
|
733
2844
|
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
738
|
-
|
|
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
|
-
|
|
2943
|
+
# Get the URL to upload the input to.
|
|
2944
|
+
url_input = self.upload_url()
|
|
742
2945
|
|
|
743
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
polling_options: PollingOptions =
|
|
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
|
-
|
|
752
|
-
method
|
|
753
|
-
polling
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
760
|
-
|
|
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
|
-
|
|
763
|
-
|
|
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
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
3151
|
+
if name is not None:
|
|
3152
|
+
payload["name"] = name
|
|
3153
|
+
if description is not None:
|
|
3154
|
+
payload["description"] = description
|
|
783
3155
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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
|
|
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.
|
|
843
|
-
|
|
844
|
-
of
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
858
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|