nextmv 0.29.5.dev1__py3-none-any.whl → 0.31.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.
@@ -24,13 +24,9 @@ poll
24
24
 
25
25
  import json
26
26
  import os
27
- import random
28
27
  import shutil
29
28
  import tarfile
30
29
  import tempfile
31
- import time
32
- import uuid
33
- from collections.abc import Callable
34
30
  from dataclasses import dataclass
35
31
  from datetime import datetime
36
32
  from typing import Any, Optional, Union
@@ -40,19 +36,30 @@ import requests
40
36
  from nextmv._serialization import deflated_serialize_json
41
37
  from nextmv.base_model import BaseModel
42
38
  from nextmv.cloud import package
43
- from nextmv.cloud.acceptance_test import AcceptanceTest, ExperimentStatus, Metric
39
+ from nextmv.cloud.acceptance_test import AcceptanceTest, Metric
44
40
  from nextmv.cloud.batch_experiment import (
45
41
  BatchExperiment,
46
42
  BatchExperimentInformation,
47
43
  BatchExperimentMetadata,
48
44
  BatchExperimentRun,
45
+ ExperimentStatus,
49
46
  to_runs,
50
47
  )
51
48
  from nextmv.cloud.client import Client, get_size
52
49
  from nextmv.cloud.input_set import InputSet, ManagedInput
53
50
  from nextmv.cloud.instance import Instance, InstanceConfiguration
54
- from nextmv.cloud.manifest import Manifest
55
- from nextmv.cloud.run import (
51
+ from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
52
+ from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
53
+ from nextmv.cloud.url import DownloadURL, UploadURL
54
+ from nextmv.cloud.version import Version
55
+ from nextmv.input import Input, InputFormat
56
+ from nextmv.logger import log
57
+ from nextmv.manifest import Manifest
58
+ from nextmv.model import Model, ModelConfiguration
59
+ from nextmv.options import Options
60
+ from nextmv.output import Output, OutputFormat
61
+ from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
62
+ from nextmv.run import (
56
63
  ExternalRunResult,
57
64
  Format,
58
65
  FormatInput,
@@ -63,16 +70,8 @@ from nextmv.cloud.run import (
63
70
  RunResult,
64
71
  TrackedRun,
65
72
  )
66
- from nextmv.cloud.safe import _name_and_id, _safe_id
67
- from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
68
- from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
69
- from nextmv.cloud.status import StatusV2
70
- from nextmv.cloud.version import Version
71
- from nextmv.input import Input, InputFormat
72
- from nextmv.logger import log
73
- from nextmv.model import Model, ModelConfiguration
74
- from nextmv.options import Options
75
- from nextmv.output import Output, OutputFormat
73
+ from nextmv.safe import safe_id, safe_name_and_id
74
+ from nextmv.status import StatusV2
76
75
 
77
76
  # Maximum size of the run input/output in bytes. This constant defines the
78
77
  # maximum allowed size for run inputs and outputs. When the size exceeds this
@@ -81,180 +80,6 @@ from nextmv.output import Output, OutputFormat
81
80
  _MAX_RUN_SIZE: int = 5 * 1024 * 1024
82
81
 
83
82
 
84
- class DownloadURL(BaseModel):
85
- """
86
- Result of getting a download URL.
87
-
88
- You can import the `DownloadURL` class directly from `cloud`:
89
-
90
- ```python
91
- from nextmv.cloud import DownloadURL
92
- ```
93
-
94
- This class represents a download URL that can be used to fetch content
95
- from Nextmv Cloud, typically used for downloading large run results.
96
-
97
- Attributes
98
- ----------
99
- url : str
100
- URL to use for downloading the file.
101
-
102
- Examples
103
- --------
104
- >>> download_url = DownloadURL(url="https://example.com/download")
105
- >>> response = requests.get(download_url.url)
106
- """
107
-
108
- url: str
109
- """URL to use for downloading the file."""
110
-
111
-
112
- @dataclass
113
- class PollingOptions:
114
- """
115
- Options to use when polling for a run result.
116
-
117
- You can import the `PollingOptions` class directly from `cloud`:
118
-
119
- ```python
120
- from nextmv.cloud import PollingOptions
121
- ```
122
-
123
- The Cloud API will be polled for the result. The polling stops if:
124
-
125
- * The maximum number of polls (tries) are exhausted. This is specified by
126
- the `max_tries` parameter.
127
- * The maximum duration of the polling strategy is reached. This is
128
- specified by the `max_duration` parameter.
129
-
130
- Before conducting the first poll, the `initial_delay` is used to sleep.
131
- After each poll, a sleep duration is calculated using the following
132
- strategy, based on exponential backoff with jitter:
133
-
134
- ```
135
- sleep_duration = min(`max_delay`, `delay` + `backoff` * 2 ** i + Uniform(0, `jitter`))
136
- ```
137
-
138
- Where:
139
- * i is the retry (poll) number.
140
- * Uniform is the uniform distribution.
141
-
142
- Note that the sleep duration is capped by the `max_delay` parameter.
143
-
144
- Parameters
145
- ----------
146
- backoff : float, default=0.9
147
- Exponential backoff factor, in seconds, to use between polls.
148
- delay : float, default=0.1
149
- Base delay to use between polls, in seconds.
150
- initial_delay : float, default=1.0
151
- Initial delay to use before starting the polling strategy, in seconds.
152
- max_delay : float, default=20.0
153
- Maximum delay to use between polls, in seconds.
154
- max_duration : float, default=300.0
155
- Maximum duration of the polling strategy, in seconds.
156
- max_tries : int, default=100
157
- Maximum number of tries to use.
158
- jitter : float, default=1.0
159
- Jitter to use for the polling strategy. A uniform distribution is sampled
160
- between 0 and this number. The resulting random number is added to the
161
- delay for each poll, adding a random noise. Set this to 0 to avoid using
162
- random jitter.
163
- verbose : bool, default=False
164
- Whether to log the polling strategy. This is useful for debugging.
165
- stop : callable, default=None
166
- Function to call to check if the polling should stop. This is useful for
167
- stopping the polling based on external conditions. The function should
168
- return True to stop the polling and False to continue. The function does
169
- not receive any arguments. The function is called before each poll.
170
-
171
- Examples
172
- --------
173
- >>> from nextmv.cloud import PollingOptions
174
- >>> # Create polling options with custom settings
175
- >>> polling_options = PollingOptions(
176
- ... max_tries=50,
177
- ... max_duration=600,
178
- ... verbose=True
179
- ... )
180
- """
181
-
182
- backoff: float = 0.9
183
- """
184
- Exponential backoff factor, in seconds, to use between polls.
185
- """
186
- delay: float = 0.1
187
- """Base delay to use between polls, in seconds."""
188
- initial_delay: float = 1
189
- """
190
- Initial delay to use before starting the polling strategy, in seconds.
191
- """
192
- max_delay: float = 20
193
- """Maximum delay to use between polls, in seconds."""
194
- max_duration: float = -1
195
- """
196
- Maximum duration of the polling strategy, in seconds. A negative value means no limit.
197
- """
198
- max_tries: int = -1
199
- """Maximum number of tries to use. A negative value means no limit."""
200
- jitter: float = 1
201
- """
202
- Jitter to use for the polling strategy. A uniform distribution is sampled
203
- between 0 and this number. The resulting random number is added to the
204
- delay for each poll, adding a random noise. Set this to 0 to avoid using
205
- random jitter.
206
- """
207
- verbose: bool = False
208
- """Whether to log the polling strategy. This is useful for debugging."""
209
- stop: Optional[Callable[[], bool]] = None
210
- """
211
- Function to call to check if the polling should stop. This is useful for
212
- stopping the polling based on external conditions. The function should
213
- return True to stop the polling and False to continue. The function does
214
- not receive any arguments. The function is called before each poll.
215
- """
216
-
217
-
218
- # Default polling options to use when polling for a run result. This constant
219
- # provides the default values for `PollingOptions` used across the module.
220
- # Using these defaults is recommended for most use cases unless specific timing
221
- # needs are required.
222
- _DEFAULT_POLLING_OPTIONS: PollingOptions = PollingOptions()
223
-
224
-
225
- class UploadURL(BaseModel):
226
- """
227
- Result of getting an upload URL.
228
-
229
- You can import the `UploadURL` class directly from `cloud`:
230
-
231
- ```python
232
- from nextmv.cloud import UploadURL
233
- ```
234
-
235
- This class represents an upload URL that can be used to send data to
236
- Nextmv Cloud, typically used for uploading large inputs for runs.
237
-
238
- Attributes
239
- ----------
240
- upload_id : str
241
- ID of the upload, used to reference the uploaded content.
242
- upload_url : str
243
- URL to use for uploading the file.
244
-
245
- Examples
246
- --------
247
- >>> upload_url = UploadURL(upload_id="123", upload_url="https://example.com/upload")
248
- >>> with open("large_input.json", "rb") as f:
249
- ... requests.put(upload_url.upload_url, data=f)
250
- """
251
-
252
- upload_id: str
253
- """ID of the upload."""
254
- upload_url: str
255
- """URL to use for uploading the file."""
256
-
257
-
258
83
  @dataclass
259
84
  class Application:
260
85
  """
@@ -304,15 +129,6 @@ class Application:
304
129
  experiments_endpoint: str = "{base}/experiments"
305
130
  """Base endpoint for the experiments in the application."""
306
131
 
307
- # Local experience parameters.
308
- src: Optional[str] = None
309
- """
310
- Source of the application, if initialized locally. This is the path
311
- to the application's source code.
312
- """
313
- description: Optional[str] = None
314
- """Description of the application."""
315
-
316
132
  def __post_init__(self):
317
133
  """Initialize the endpoint and experiments_endpoint attributes.
318
134
 
@@ -322,92 +138,6 @@ class Application:
322
138
  self.endpoint = self.endpoint.format(id=self.id)
323
139
  self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
324
140
 
325
- @classmethod
326
- def initialize(
327
- cls,
328
- name: str,
329
- id: Optional[str] = None,
330
- description: Optional[str] = None,
331
- destination: Optional[str] = None,
332
- client: Optional[Client] = None,
333
- ) -> "Application":
334
- """
335
- Initialize a Nextmv application, locally.
336
-
337
- This method will create a new application in the local file system. The
338
- application is a folder with the name given by `name`, under the
339
- location given by `destination`. If the `destination` parameter is not
340
- specified, the current working directory is used as default. This
341
- method will scaffold the application with the necessary files and
342
- directories to have an opinionated structure for your decision model.
343
- Once the application is initialized, you are encouraged to complete it
344
- with the decision model itself, so that the application can be run,
345
- locally or remotely.
346
-
347
- This method differs from the `Application.new` method in that it
348
- creates the application locally rather than in the Cloud.
349
-
350
- Although not required, you are encouraged to specify the `client`
351
- parameter, so that the application can be pushed and synced remotely,
352
- with the Nextmv Cloud. If you don't specify the `client`, and intend to
353
- interact with the Nextmv Cloud, you will encounter an error. Make sure
354
- you set the `client` parameter on the `Application` instance after
355
- initialization, if you don't provide it here.
356
-
357
- Use the `destination` parameter to specify where you want the app to be
358
- initialized, using the current working directory by default.
359
-
360
- Parameters
361
- ----------
362
- name : str
363
- Name of the application.
364
- id : str, optional
365
- ID of the application. Will be generated if not provided.
366
- description : str, optional
367
- Description of the application.
368
- destination : str, optional
369
- Destination directory where the application will be initialized. If
370
- not provided, the current working directory will be used.
371
- client : Client, optional
372
- Client to use for interacting with the Nextmv Cloud API.
373
-
374
- Returns
375
- -------
376
- Application
377
- The initialized application instance.
378
- """
379
-
380
- destination_dir = os.getcwd() if destination is None else destination
381
- app_id = id if id is not None else str(uuid.uuid4())
382
-
383
- # Create the new directory with the given name.
384
- src = os.path.join(destination_dir, name)
385
- os.makedirs(src, exist_ok=True)
386
-
387
- # Get the path to the initial app structure template.
388
- current_file_dir = os.path.dirname(os.path.abspath(__file__))
389
- initial_app_structure_path = os.path.join(current_file_dir, "..", "default_app")
390
- initial_app_structure_path = os.path.normpath(initial_app_structure_path)
391
-
392
- # Copy everything from initial_app_structure to the new directory.
393
- if os.path.exists(initial_app_structure_path):
394
- for item in os.listdir(initial_app_structure_path):
395
- source_path = os.path.join(initial_app_structure_path, item)
396
- dest_path = os.path.join(src, item)
397
-
398
- if os.path.isdir(source_path):
399
- shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
400
- continue
401
-
402
- shutil.copy2(source_path, dest_path)
403
-
404
- return cls(
405
- id=app_id,
406
- client=client,
407
- src=src,
408
- description=description,
409
- )
410
-
411
141
  @classmethod
412
142
  def new(
413
143
  cls,
@@ -506,6 +236,57 @@ class Application:
506
236
 
507
237
  return AcceptanceTest.from_dict(response.json())
508
238
 
239
+ def acceptance_test_with_polling(
240
+ self,
241
+ acceptance_test_id: str,
242
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
243
+ ) -> AcceptanceTest:
244
+ """
245
+ Retrieve details of an acceptance test using polling.
246
+
247
+ Retrieves the result of an acceptance test. This method polls for the
248
+ result until the test finishes executing or the polling strategy is
249
+ exhausted.
250
+
251
+ Parameters
252
+ ----------
253
+ acceptance_test_id : str
254
+ ID of the acceptance test to retrieve.
255
+
256
+ Returns
257
+ -------
258
+ AcceptanceTest
259
+ The requested acceptance test details.
260
+
261
+ Raises
262
+ ------
263
+ requests.HTTPError
264
+ If the response status code is not 2xx.
265
+
266
+ Examples
267
+ --------
268
+ >>> test = app.acceptance_test_with_polling("test-123")
269
+ >>> print(test.name)
270
+ 'My Test'
271
+ """
272
+
273
+ def polling_func() -> tuple[Any, bool]:
274
+ acceptance_test_result = self.acceptance_test(acceptance_test_id=acceptance_test_id)
275
+ if acceptance_test_result.status in {
276
+ ExperimentStatus.COMPLETED,
277
+ ExperimentStatus.FAILED,
278
+ ExperimentStatus.DRAFT,
279
+ ExperimentStatus.CANCELED,
280
+ ExperimentStatus.DELETE_FAILED,
281
+ }:
282
+ return acceptance_test_result, True
283
+
284
+ return None, False
285
+
286
+ acceptance_test = poll(polling_options=polling_options, polling_func=polling_func)
287
+
288
+ return self.acceptance_test(acceptance_test_id=acceptance_test.id)
289
+
509
290
  def batch_experiment(self, batch_id: str) -> BatchExperiment:
510
291
  """
511
292
  Get a batch experiment.
@@ -539,6 +320,90 @@ class Application:
539
320
 
540
321
  return BatchExperiment.from_dict(response.json())
541
322
 
323
+ def batch_experiment_metadata(self, batch_id: str) -> BatchExperimentMetadata:
324
+ """
325
+ Get metadata for a batch experiment.
326
+
327
+ Parameters
328
+ ----------
329
+ batch_id : str
330
+ ID of the batch experiment.
331
+
332
+ Returns
333
+ -------
334
+ BatchExperimentMetadata
335
+ The requested batch experiment metadata.
336
+
337
+ Raises
338
+ ------
339
+ requests.HTTPError
340
+ If the response status code is not 2xx.
341
+
342
+ Examples
343
+ --------
344
+ >>> metadata = app.batch_experiment_metadata("batch-123")
345
+ >>> print(metadata.name)
346
+ 'My Batch Experiment'
347
+ """
348
+
349
+ response = self.client.request(
350
+ method="GET",
351
+ endpoint=f"{self.experiments_endpoint}/batch/{batch_id}/metadata",
352
+ )
353
+
354
+ return BatchExperimentMetadata.from_dict(response.json())
355
+
356
+ def batch_experiment_with_polling(
357
+ self,
358
+ batch_id: str,
359
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
360
+ ) -> BatchExperiment:
361
+ """
362
+ Get a batch experiment with polling.
363
+
364
+ Retrieves the result of an experiment. This method polls for the result
365
+ until the experiment finishes executing or the polling strategy is
366
+ exhausted.
367
+
368
+ Parameters
369
+ ----------
370
+ batch_id : str
371
+ ID of the batch experiment.
372
+
373
+ Returns
374
+ -------
375
+ BatchExperiment
376
+ The requested batch experiment details.
377
+
378
+ Raises
379
+ ------
380
+ requests.HTTPError
381
+ If the response status code is not 2xx.
382
+
383
+ Examples
384
+ --------
385
+ >>> batch_exp = app.batch_experiment_with_polling("batch-123")
386
+ >>> print(batch_exp.name)
387
+ 'My Batch Experiment'
388
+ """
389
+
390
+ def polling_func() -> tuple[Any, bool]:
391
+ batch_metadata = self.batch_experiment_metadata(batch_id=batch_id)
392
+ if batch_metadata.status in {
393
+ ExperimentStatus.COMPLETED,
394
+ ExperimentStatus.FAILED,
395
+ ExperimentStatus.DRAFT,
396
+ ExperimentStatus.CANCELED,
397
+ ExperimentStatus.DELETE_FAILED,
398
+ }:
399
+ return batch_metadata, True
400
+
401
+ return None, False
402
+
403
+ batch_information = poll(polling_options=polling_options, polling_func=polling_func)
404
+
405
+ return self.batch_experiment(batch_id=batch_information.id)
406
+
542
407
  def cancel_run(self, run_id: str) -> None:
543
408
  """
544
409
  Cancel a run.
@@ -1130,7 +995,7 @@ class Application:
1130
995
  else:
1131
996
  # Get all input IDs from the input set.
1132
997
  input_set = self.input_set(input_set_id=input_set_id)
1133
- if len(input_set.input_ids) == 0:
998
+ if not input_set.input_ids:
1134
999
  raise ValueError(f"input set {input_set_id} does not contain any inputs")
1135
1000
  runs = []
1136
1001
  for input_id in input_set.input_ids:
@@ -1191,7 +1056,7 @@ class Application:
1191
1056
  name: str,
1192
1057
  input_set_id: Optional[str] = None,
1193
1058
  description: Optional[str] = None,
1194
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1059
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1195
1060
  ) -> AcceptanceTest:
1196
1061
  """
1197
1062
  Create a new acceptance test and poll for the result.
@@ -1248,7 +1113,8 @@ class Application:
1248
1113
  >>> print(test.status)
1249
1114
  'completed'
1250
1115
  """
1251
- _ = self.new_acceptance_test(
1116
+
1117
+ acceptance_test = self.new_acceptance_test(
1252
1118
  candidate_instance_id=candidate_instance_id,
1253
1119
  baseline_instance_id=baseline_instance_id,
1254
1120
  id=id,
@@ -1258,20 +1124,10 @@ class Application:
1258
1124
  description=description,
1259
1125
  )
1260
1126
 
1261
- def polling_func() -> tuple[AcceptanceTest, bool]:
1262
- test_information = self.acceptance_test(acceptance_test_id=id)
1263
- if test_information.status in [
1264
- ExperimentStatus.completed,
1265
- ExperimentStatus.failed,
1266
- ExperimentStatus.canceled,
1267
- ]:
1268
- return test_information, True
1269
-
1270
- return None, False
1271
-
1272
- test_information = poll(polling_options=polling_options, polling_func=polling_func)
1273
-
1274
- return test_information
1127
+ return self.acceptance_test_with_polling(
1128
+ acceptance_test_id=acceptance_test.id,
1129
+ polling_options=polling_options,
1130
+ )
1275
1131
 
1276
1132
  def new_batch_experiment(
1277
1133
  self,
@@ -1354,6 +1210,76 @@ class Application:
1354
1210
 
1355
1211
  return response.json()["id"]
1356
1212
 
1213
+ def new_batch_experiment_with_result(
1214
+ self,
1215
+ name: str,
1216
+ input_set_id: Optional[str] = None,
1217
+ instance_ids: Optional[list[str]] = None,
1218
+ description: Optional[str] = None,
1219
+ id: Optional[str] = None,
1220
+ option_sets: Optional[dict[str, dict[str, str]]] = None,
1221
+ runs: Optional[list[Union[BatchExperimentRun, dict[str, Any]]]] = None,
1222
+ type: Optional[str] = "batch",
1223
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1224
+ ) -> BatchExperiment:
1225
+ """
1226
+ Convenience method to create a new batch experiment and poll for the
1227
+ result.
1228
+
1229
+ This method combines the `new_batch_experiment` and
1230
+ `batch_experiment_with_polling` methods, applying polling logic to
1231
+ check when the experiment succeeded.
1232
+
1233
+ Parameters
1234
+ ----------
1235
+ name: str
1236
+ Name of the batch experiment.
1237
+ input_set_id: str
1238
+ ID of the input set to use for the batch experiment.
1239
+ instance_ids: list[str]
1240
+ List of instance IDs to use for the batch experiment. This argument
1241
+ is deprecated, use `runs` instead.
1242
+ description: Optional[str]
1243
+ Optional description of the batch experiment.
1244
+ id: Optional[str]
1245
+ ID of the batch experiment. Will be generated if not provided.
1246
+ option_sets: Optional[dict[str, dict[str, str]]]
1247
+ Option sets to use for the batch experiment. This is a dictionary
1248
+ where the keys are option set IDs and the values are dictionaries
1249
+ with the actual options.
1250
+ runs: Optional[list[BatchExperimentRun]]
1251
+ List of runs to use for the batch experiment.
1252
+ type: Optional[str]
1253
+ Type of the batch experiment. This is used to determine the
1254
+ experiment type. The default value is "batch". If you want to
1255
+ create a scenario test, set this to "scenario".
1256
+ polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
1257
+ Options to use when polling for the batch experiment result.
1258
+
1259
+ Returns
1260
+ -------
1261
+ BatchExperiment
1262
+ The completed batch experiment with results.
1263
+
1264
+ Raises
1265
+ ------
1266
+ requests.HTTPError
1267
+ If the response status code is not 2xx.
1268
+ """
1269
+
1270
+ batch_id = self.new_batch_experiment(
1271
+ name=name,
1272
+ input_set_id=input_set_id,
1273
+ instance_ids=instance_ids,
1274
+ description=description,
1275
+ id=id,
1276
+ option_sets=option_sets,
1277
+ runs=runs,
1278
+ type=type,
1279
+ )
1280
+
1281
+ return self.batch_experiment_with_polling(batch_id=batch_id, polling_options=polling_options)
1282
+
1357
1283
  def new_input_set(
1358
1284
  self,
1359
1285
  id: str,
@@ -1722,7 +1648,7 @@ class Application:
1722
1648
  not `JSON`. If the final `options` are not of type `dict[str,str]`.
1723
1649
  """
1724
1650
 
1725
- self.__validate_dir_path_and_configuration(input_dir_path, configuration)
1651
+ self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
1726
1652
 
1727
1653
  tar_file = ""
1728
1654
  if input_dir_path is not None and input_dir_path != "":
@@ -1734,13 +1660,7 @@ class Application:
1734
1660
 
1735
1661
  tar_file = self.__package_inputs(input_dir_path)
1736
1662
 
1737
- input_data = None
1738
- if isinstance(input, BaseModel):
1739
- input_data = input.to_dict()
1740
- elif isinstance(input, dict) or isinstance(input, str):
1741
- input_data = input
1742
- elif isinstance(input, Input):
1743
- input_data = input.data
1663
+ input_data = self.__extract_input_data(input)
1744
1664
 
1745
1665
  input_size = 0
1746
1666
  if input_data is not None:
@@ -1753,20 +1673,10 @@ class Application:
1753
1673
  upload_id = upload_url.upload_id
1754
1674
  upload_id_used = True
1755
1675
 
1756
- options_dict = {}
1757
- if isinstance(input, Input) and input.options is not None:
1758
- options_dict = input.options.to_dict_cloud()
1759
-
1760
- if options is not None:
1761
- if isinstance(options, Options):
1762
- options_dict = options.to_dict_cloud()
1763
- elif isinstance(options, dict):
1764
- for k, v in options.items():
1765
- if isinstance(v, str):
1766
- options_dict[k] = v
1767
- else:
1768
- options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
1676
+ options_dict = self.__extract_options_dict(options, json_configurations)
1769
1677
 
1678
+ # Builds the payload progressively based on the different arguments
1679
+ # that must be provided.
1770
1680
  payload = {}
1771
1681
  if upload_id_used:
1772
1682
  payload["upload_id"] = upload_id
@@ -1779,19 +1689,11 @@ class Application:
1779
1689
  payload["description"] = description
1780
1690
  if len(options_dict) > 0:
1781
1691
  for k, v in options_dict.items():
1782
- if not isinstance(v, str):
1783
- raise ValueError(f"options must be dict[str,str], option {k} has type {type(v)} instead.")
1784
- payload["options"] = options_dict
1785
-
1786
- if configuration is not None:
1787
- configuration_dict = (
1788
- configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
1789
- )
1790
- else:
1791
- configuration = RunConfiguration()
1792
- configuration.resolve(input=input, dir_path=input_dir_path)
1793
- configuration_dict = configuration.to_dict()
1692
+ if not isinstance(v, str):
1693
+ raise ValueError(f"options must be dict[str,str], option {k} has type {type(v)} instead.")
1694
+ payload["options"] = options_dict
1794
1695
 
1696
+ configuration_dict = self.__extract_run_config(input, configuration, input_dir_path)
1795
1697
  payload["configuration"] = configuration_dict
1796
1698
 
1797
1699
  if batch_experiment_id is not None:
@@ -1823,7 +1725,7 @@ class Application:
1823
1725
  description: Optional[str] = None,
1824
1726
  upload_id: Optional[str] = None,
1825
1727
  run_options: Optional[Union[Options, dict[str, str]]] = None,
1826
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1728
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1827
1729
  configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
1828
1730
  batch_experiment_id: Optional[str] = None,
1829
1731
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]] = None,
@@ -2085,6 +1987,69 @@ class Application:
2085
1987
  runs=runs,
2086
1988
  )
2087
1989
 
1990
+ def new_scenario_test_with_result(
1991
+ self,
1992
+ id: str,
1993
+ name: str,
1994
+ scenarios: list[Scenario],
1995
+ description: Optional[str] = None,
1996
+ repetitions: Optional[int] = 0,
1997
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1998
+ ) -> BatchExperiment:
1999
+ """
2000
+ Convenience method to create a new scenario test and poll for the
2001
+ result.
2002
+
2003
+ This method combines the `new_scenario_test` and
2004
+ `scenario_test_with_polling` methods, applying polling logic to
2005
+ check when the test succeeded.
2006
+
2007
+ The scenario tests uses the batch experiments API under the hood.
2008
+
2009
+ Parameters
2010
+ ----------
2011
+ id: str
2012
+ ID of the scenario test.
2013
+ name: str
2014
+ Name of the scenario test.
2015
+ scenarios: list[Scenario]
2016
+ List of scenarios to use for the scenario test. At least one
2017
+ scenario should be provided.
2018
+ description: Optional[str]
2019
+ Optional description of the scenario test.
2020
+ repetitions: Optional[int]
2021
+ Number of repetitions to use for the scenario test. 0
2022
+ repetitions means that the tests will be executed once. 1
2023
+ repetition means that the test will be repeated once, i.e.: it
2024
+ will be executed twice. 2 repetitions equals 3 executions, so on,
2025
+ and so forth.
2026
+
2027
+ Returns
2028
+ -------
2029
+ BatchExperiment
2030
+ The completed scenario test as a BatchExperiment.
2031
+
2032
+ Raises
2033
+ ------
2034
+ requests.HTTPError
2035
+ If the response status code is not 2xx.
2036
+ ValueError
2037
+ If no scenarios are provided.
2038
+ """
2039
+
2040
+ test_id = self.new_scenario_test(
2041
+ id=id,
2042
+ name=name,
2043
+ scenarios=scenarios,
2044
+ description=description,
2045
+ repetitions=repetitions,
2046
+ )
2047
+
2048
+ return self.scenario_test_with_polling(
2049
+ scenario_test_id=test_id,
2050
+ polling_options=polling_options,
2051
+ )
2052
+
2088
2053
  def new_secrets_collection(
2089
2054
  self,
2090
2055
  secrets: list[Secret],
@@ -2236,7 +2201,7 @@ class Application:
2236
2201
  return self.version(version_id=id)
2237
2202
 
2238
2203
  if id is None:
2239
- id = _safe_id(prefix="version")
2204
+ id = safe_id(prefix="version")
2240
2205
 
2241
2206
  payload = {
2242
2207
  "id": id,
@@ -2567,7 +2532,7 @@ class Application:
2567
2532
  def run_result_with_polling(
2568
2533
  self,
2569
2534
  run_id: str,
2570
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2535
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2571
2536
  output_dir_path: Optional[str] = ".",
2572
2537
  ) -> RunResult:
2573
2538
  """
@@ -2638,9 +2603,9 @@ class Application:
2638
2603
  """
2639
2604
  Get a scenario test.
2640
2605
 
2641
- Retrieves a scenario test by ID. Scenario tests are based on batch experiments,
2642
- so this function returns the corresponding batch experiment associated with
2643
- the scenario test.
2606
+ Retrieves a scenario test by ID. Scenario tests are based on batch
2607
+ experiments, so this function returns the corresponding batch
2608
+ experiment associated with the scenario test.
2644
2609
 
2645
2610
  Parameters
2646
2611
  ----------
@@ -2668,7 +2633,88 @@ class Application:
2668
2633
 
2669
2634
  return self.batch_experiment(batch_id=scenario_test_id)
2670
2635
 
2671
- def track_run(self, tracked_run: TrackedRun, instance_id: Optional[str] = None) -> str:
2636
+ def scenario_test_metadata(self, scenario_test_id: str) -> BatchExperimentMetadata:
2637
+ """
2638
+ Get the metadata for a scenario test, given its ID.
2639
+
2640
+ Scenario tests are based on batch experiments, so this function returns
2641
+ the corresponding batch experiment metadata associated with the
2642
+ scenario test.
2643
+
2644
+ Parameters
2645
+ ----------
2646
+ scenario_test_id : str
2647
+ ID of the scenario test to retrieve.
2648
+
2649
+ Returns
2650
+ -------
2651
+ BatchExperimentMetadata
2652
+ The scenario test metadata as a batch experiment.
2653
+
2654
+ Raises
2655
+ ------
2656
+ requests.HTTPError
2657
+ If the response status code is not 2xx.
2658
+
2659
+ Examples
2660
+ --------
2661
+ >>> metadata = app.scenario_test_metadata("scenario-123")
2662
+ >>> print(metadata.name)
2663
+ 'My Scenario Test'
2664
+ >>> print(metadata.type)
2665
+ 'scenario'
2666
+ """
2667
+
2668
+ return self.batch_experiment_metadata(batch_id=scenario_test_id)
2669
+
2670
+ def scenario_test_with_polling(
2671
+ self,
2672
+ scenario_test_id: str,
2673
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2674
+ ) -> BatchExperiment:
2675
+ """
2676
+ Get a scenario test with polling.
2677
+
2678
+ Retrieves the result of a scenario test. This method polls for the
2679
+ result until the test finishes executing or the polling strategy is
2680
+ exhausted.
2681
+
2682
+ The scenario tests uses the batch experiments API under the hood.
2683
+
2684
+ Parameters
2685
+ ----------
2686
+ scenario_test_id : str
2687
+ ID of the scenario test to retrieve.
2688
+ polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
2689
+ Options to use when polling for the scenario test result.
2690
+
2691
+ Returns
2692
+ -------
2693
+ BatchExperiment
2694
+ The scenario test details as a batch experiment.
2695
+
2696
+ Raises
2697
+ ------
2698
+ requests.HTTPError
2699
+ If the response status code is not 2xx.
2700
+
2701
+ Examples
2702
+ --------
2703
+ >>> test = app.scenario_test_with_polling("scenario-123")
2704
+ >>> print(test.name)
2705
+ 'My Scenario Test'
2706
+ >>> print(test.type)
2707
+ 'scenario'
2708
+ """
2709
+
2710
+ return self.batch_experiment_with_polling(batch_id=scenario_test_id, polling_options=polling_options)
2711
+
2712
+ def track_run( # noqa: C901
2713
+ self,
2714
+ tracked_run: TrackedRun,
2715
+ instance_id: Optional[str] = None,
2716
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
2717
+ ) -> str:
2672
2718
  """
2673
2719
  Track an external run.
2674
2720
 
@@ -2677,6 +2723,14 @@ class Application:
2677
2723
  information about a run in Nextmv is useful for things like
2678
2724
  experimenting and testing.
2679
2725
 
2726
+ Please read the documentation on the `TrackedRun` class carefully, as
2727
+ there are important considerations to take into account when using this
2728
+ method. For example, if you intend to upload JSON input/output, use the
2729
+ `input`/`output` attributes of the `TrackedRun` class. On the other
2730
+ hand, if you intend to track files-based input/output, use the
2731
+ `input_dir_path`/`output_dir_path` attributes of the `TrackedRun`
2732
+ class.
2733
+
2680
2734
  Parameters
2681
2735
  ----------
2682
2736
  tracked_run : TrackedRun
@@ -2684,6 +2738,11 @@ class Application:
2684
2738
  instance_id : Optional[str], default=None
2685
2739
  Optional instance ID if you want to associate your tracked run with
2686
2740
  an instance.
2741
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
2742
+ Configuration to use for the run. This can be a
2743
+ `cloud.RunConfiguration` object or a dict. If the object is used,
2744
+ then the `.to_dict()` method is applied to extract the
2745
+ configuration.
2687
2746
 
2688
2747
  Returns
2689
2748
  -------
@@ -2706,22 +2765,55 @@ class Application:
2706
2765
  >>> run_id = app.track_run(tracked_run)
2707
2766
  """
2708
2767
 
2768
+ # Get the URL to upload the input to.
2709
2769
  url_input = self.upload_url()
2710
2770
 
2771
+ # Handle the case where the input is being uploaded as files. We need
2772
+ # to tar them.
2773
+ input_tar_file = ""
2774
+ input_dir_path = tracked_run.input_dir_path
2775
+ if input_dir_path is not None and input_dir_path != "":
2776
+ if not os.path.exists(input_dir_path):
2777
+ raise ValueError(f"Directory {input_dir_path} does not exist.")
2778
+
2779
+ if not os.path.isdir(input_dir_path):
2780
+ raise ValueError(f"Path {input_dir_path} is not a directory.")
2781
+
2782
+ input_tar_file = self.__package_inputs(input_dir_path)
2783
+
2784
+ # Handle the case where the input is uploaded as Input or a dict.
2711
2785
  upload_input = tracked_run.input
2712
- if isinstance(tracked_run.input, Input):
2786
+ if upload_input is not None and isinstance(tracked_run.input, Input):
2713
2787
  upload_input = tracked_run.input.data
2714
2788
 
2715
- self.upload_large_input(input=upload_input, upload_url=url_input)
2789
+ # Actually uploads de input.
2790
+ self.upload_large_input(input=upload_input, upload_url=url_input, tar_file=input_tar_file)
2716
2791
 
2792
+ # Get the URL to upload the output to.
2717
2793
  url_output = self.upload_url()
2718
2794
 
2795
+ # Handle the case where the output is being uploaded as files. We need
2796
+ # to tar them.
2797
+ output_tar_file = ""
2798
+ output_dir_path = tracked_run.output_dir_path
2799
+ if output_dir_path is not None and output_dir_path != "":
2800
+ if not os.path.exists(output_dir_path):
2801
+ raise ValueError(f"Directory {output_dir_path} does not exist.")
2802
+
2803
+ if not os.path.isdir(output_dir_path):
2804
+ raise ValueError(f"Path {output_dir_path} is not a directory.")
2805
+
2806
+ output_tar_file = self.__package_inputs(output_dir_path)
2807
+
2808
+ # Handle the case where the output is uploaded as Output or a dict.
2719
2809
  upload_output = tracked_run.output
2720
- if isinstance(tracked_run.output, Output):
2810
+ if upload_output is not None and isinstance(tracked_run.output, Output):
2721
2811
  upload_output = tracked_run.output.to_dict()
2722
2812
 
2723
- self.upload_large_input(input=upload_output, upload_url=url_output)
2813
+ # Actually uploads the output.
2814
+ self.upload_large_input(input=upload_output, upload_url=url_output, tar_file=output_tar_file)
2724
2815
 
2816
+ # Create the external run result and appends logs if required.
2725
2817
  external_result = ExternalRunResult(
2726
2818
  output_upload_id=url_output.upload_id,
2727
2819
  status=tracked_run.status.value,
@@ -2740,14 +2832,18 @@ class Application:
2740
2832
  upload_id=url_input.upload_id,
2741
2833
  external_result=external_result,
2742
2834
  instance_id=instance_id,
2835
+ name=tracked_run.name,
2836
+ description=tracked_run.description,
2837
+ configuration=configuration,
2743
2838
  )
2744
2839
 
2745
2840
  def track_run_with_result(
2746
2841
  self,
2747
2842
  tracked_run: TrackedRun,
2748
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2843
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2749
2844
  instance_id: Optional[str] = None,
2750
2845
  output_dir_path: Optional[str] = ".",
2846
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
2751
2847
  ) -> RunResult:
2752
2848
  """
2753
2849
  Track an external run and poll for the result. This is a convenience
@@ -2768,6 +2864,11 @@ class Application:
2768
2864
  Path to a directory where non-JSON output files will be saved. This is
2769
2865
  required if the output is non-JSON. If the directory does not exist, it
2770
2866
  will be created. Uses the current directory by default.
2867
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
2868
+ Configuration to use for the run. This can be a
2869
+ `cloud.RunConfiguration` object or a dict. If the object is used,
2870
+ then the `.to_dict()` method is applied to extract the
2871
+ configuration.
2771
2872
 
2772
2873
  Returns
2773
2874
  -------
@@ -2787,7 +2888,11 @@ class Application:
2787
2888
  If the run does not succeed after the polling strategy is
2788
2889
  exhausted based on number of tries.
2789
2890
  """
2790
- run_id = self.track_run(tracked_run=tracked_run, instance_id=instance_id)
2891
+ run_id = self.track_run(
2892
+ tracked_run=tracked_run,
2893
+ instance_id=instance_id,
2894
+ configuration=configuration,
2895
+ )
2791
2896
 
2792
2897
  return self.run_result_with_polling(
2793
2898
  run_id=run_id,
@@ -3393,6 +3498,47 @@ class Application:
3393
3498
 
3394
3499
  return result
3395
3500
 
3501
+ @staticmethod
3502
+ def __convert_manifest_to_payload(manifest: Manifest) -> dict[str, Any]:
3503
+ """Converts a manifest to a payload dictionary for the API."""
3504
+
3505
+ activation_request = {
3506
+ "requirements": {
3507
+ "executable_type": manifest.type,
3508
+ "runtime": manifest.runtime,
3509
+ },
3510
+ }
3511
+
3512
+ if manifest.configuration is not None and manifest.configuration.content is not None:
3513
+ content = manifest.configuration.content
3514
+ io_config = {
3515
+ "format": content.format,
3516
+ }
3517
+ if content.multi_file is not None:
3518
+ multi_config = io_config["multi_file"] = {}
3519
+ if content.multi_file.input is not None:
3520
+ multi_config["input_path"] = content.multi_file.input.path
3521
+ if content.multi_file.output is not None:
3522
+ output_config = multi_config["output_configuration"] = {}
3523
+ if content.multi_file.output.statistics:
3524
+ output_config["statistics_path"] = content.multi_file.output.statistics
3525
+ if content.multi_file.output.assets:
3526
+ output_config["assets_path"] = content.multi_file.output.assets
3527
+ if content.multi_file.output.solutions:
3528
+ output_config["solutions_path"] = content.multi_file.output.solutions
3529
+ activation_request["requirements"]["io_configuration"] = io_config
3530
+
3531
+ if manifest.configuration is not None and manifest.configuration.options is not None:
3532
+ options = manifest.configuration.options.to_dict()
3533
+ if "format" in options and isinstance(options["format"], list):
3534
+ # the endpoint expects a dictionary with a template key having a list of strings
3535
+ # the app.yaml however defines format as a list of strings, so we need to convert it here
3536
+ options["format"] = {
3537
+ "template": options["format"],
3538
+ }
3539
+ activation_request["requirements"]["options"] = options
3540
+ return activation_request
3541
+
3396
3542
  def __update_app_binary(
3397
3543
  self,
3398
3544
  tar_file: str,
@@ -3419,27 +3565,10 @@ class Application:
3419
3565
  headers={"Content-Type": "application/octet-stream"},
3420
3566
  )
3421
3567
 
3422
- activation_request = {
3423
- "requirements": {
3424
- "executable_type": manifest.type,
3425
- "runtime": manifest.runtime,
3426
- },
3427
- }
3428
-
3429
- if manifest.configuration is not None and manifest.configuration.options is not None:
3430
- options = manifest.configuration.options.to_dict()
3431
- if "format" in options and isinstance(options["format"], list):
3432
- # the endpoint expects a dictionary with a template key having a list of strings
3433
- # the app.yaml however defines format as a list of strings, so we need to convert it here
3434
- options["format"] = {
3435
- "template": options["format"],
3436
- }
3437
- activation_request["requirements"]["options"] = options
3438
-
3439
3568
  response = self.client.request(
3440
3569
  method="PUT",
3441
3570
  endpoint=endpoint,
3442
- payload=activation_request,
3571
+ payload=Application.__convert_manifest_to_payload(manifest=manifest),
3443
3572
  )
3444
3573
 
3445
3574
  if verbose:
@@ -3469,7 +3598,7 @@ class Application:
3469
3598
  # If working with a list of managed inputs, we need to create an
3470
3599
  # input set.
3471
3600
  if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT:
3472
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3601
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3473
3602
  input_set = self.new_input_set(
3474
3603
  id=id,
3475
3604
  name=name,
@@ -3489,7 +3618,7 @@ class Application:
3489
3618
  for data in scenario.scenario_input.scenario_input_data:
3490
3619
  upload_url = self.upload_url()
3491
3620
  self.upload_large_input(input=data, upload_url=upload_url)
3492
- name, id = _name_and_id(prefix="man-input", entity_id=scenario_id)
3621
+ name, id = safe_name_and_id(prefix="man-input", entity_id=scenario_id)
3493
3622
  managed_input = self.new_managed_input(
3494
3623
  id=id,
3495
3624
  name=name,
@@ -3498,7 +3627,7 @@ class Application:
3498
3627
  )
3499
3628
  managed_inputs.append(managed_input)
3500
3629
 
3501
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3630
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3502
3631
  input_set = self.new_input_set(
3503
3632
  id=id,
3504
3633
  name=name,
@@ -3510,15 +3639,16 @@ class Application:
3510
3639
 
3511
3640
  raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
3512
3641
 
3513
- def __validate_dir_path_and_configuration(
3642
+ def __validate_input_dir_path_and_configuration(
3514
3643
  self,
3515
- dir_path: Optional[str],
3516
- configuration: Optional[RunConfiguration],
3644
+ input_dir_path: Optional[str],
3645
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
3517
3646
  ) -> None:
3518
3647
  """
3519
3648
  Auxiliary function to validate the directory path and configuration.
3520
3649
  """
3521
- if dir_path is None or dir_path == "":
3650
+
3651
+ if input_dir_path is None or input_dir_path == "":
3522
3652
  return
3523
3653
 
3524
3654
  if configuration is None:
@@ -3526,23 +3656,57 @@ class Application:
3526
3656
  "If dir_path is provided, a RunConfiguration must also be provided.",
3527
3657
  )
3528
3658
 
3529
- if configuration.format is None:
3659
+ config_format = self.__extract_config_format(configuration)
3660
+
3661
+ if config_format is None:
3530
3662
  raise ValueError(
3531
3663
  "If dir_path is provided, RunConfiguration.format must also be provided.",
3532
3664
  )
3533
3665
 
3534
- if configuration.format.format_input is None:
3535
- raise ValueError(
3536
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3537
- )
3666
+ input_type = self.__extract_input_type(config_format)
3538
3667
 
3539
- input_type = configuration.format.format_input.input_type
3540
3668
  if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3541
3669
  raise ValueError(
3542
- "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type."
3670
+ "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
3543
3671
  f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3544
3672
  )
3545
3673
 
3674
+ def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
3675
+ """Extract format from configuration, handling both RunConfiguration objects and dicts."""
3676
+ if isinstance(configuration, RunConfiguration):
3677
+ return configuration.format
3678
+
3679
+ if isinstance(configuration, dict):
3680
+ config_format = configuration.get("format")
3681
+ if config_format is not None and isinstance(config_format, dict):
3682
+ return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
3683
+
3684
+ return config_format
3685
+
3686
+ raise ValueError("Configuration must be a RunConfiguration object or a dict.")
3687
+
3688
+ def __extract_input_type(self, config_format: Any) -> Any:
3689
+ """Extract input type from config format."""
3690
+ if isinstance(config_format, dict):
3691
+ format_input = config_format.get("format_input") or config_format.get("input")
3692
+ if format_input is None:
3693
+ raise ValueError(
3694
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3695
+ )
3696
+
3697
+ if isinstance(format_input, dict):
3698
+ return format_input.get("input_type") or format_input.get("type")
3699
+
3700
+ return getattr(format_input, "input_type", None)
3701
+
3702
+ # Handle Format object
3703
+ if config_format.format_input is None:
3704
+ raise ValueError(
3705
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3706
+ )
3707
+
3708
+ return config_format.format_input.input_type
3709
+
3546
3710
  def __package_inputs(self, dir_path: str) -> str:
3547
3711
  """
3548
3712
  This is an auxiliary function for packaging the inputs found in the
@@ -3604,153 +3768,72 @@ class Application:
3604
3768
 
3605
3769
  return size_exceeds or non_json_payload
3606
3770
 
3771
+ def __extract_input_data(
3772
+ self,
3773
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
3774
+ ) -> Optional[Union[dict[str, Any], str]]:
3775
+ """
3776
+ Auxiliary function to extract the input data from the input, based on
3777
+ its type.
3778
+ """
3607
3779
 
3608
- def poll( # noqa: C901
3609
- polling_options: PollingOptions,
3610
- polling_func: Callable[[], tuple[Any, bool]],
3611
- __sleep_func: Callable[[float], None] = time.sleep,
3612
- ) -> Any:
3613
- """
3614
- Poll a function until it succeeds or the polling strategy is exhausted.
3615
-
3616
- You can import the `poll` function directly from `cloud`:
3617
-
3618
- ```python
3619
- from nextmv.cloud import poll
3620
- ```
3621
-
3622
- This function implements a flexible polling strategy with exponential backoff
3623
- and jitter. It calls the provided polling function repeatedly until it indicates
3624
- success, the maximum number of tries is reached, or the maximum duration is exceeded.
3625
-
3626
- The `polling_func` is a callable that must return a `tuple[Any, bool]`
3627
- where the first element is the result of the polling and the second
3628
- element is a boolean indicating if the polling was successful or should be
3629
- retried.
3630
-
3631
- Parameters
3632
- ----------
3633
- polling_options : PollingOptions
3634
- Options for configuring the polling behavior, including retry counts,
3635
- delays, timeouts, and verbosity settings.
3636
- polling_func : callable
3637
- Function to call to check if the polling was successful. Must return a tuple
3638
- where the first element is the result value and the second is a boolean
3639
- indicating success (True) or need to retry (False).
3640
-
3641
- Returns
3642
- -------
3643
- Any
3644
- Result value from the polling function when successful.
3645
-
3646
- Raises
3647
- ------
3648
- TimeoutError
3649
- If the polling exceeds the maximum duration specified in polling_options.
3650
- RuntimeError
3651
- If the maximum number of tries is exhausted without success.
3652
-
3653
- Examples
3654
- --------
3655
- >>> from nextmv.cloud import PollingOptions, poll
3656
- >>> import time
3657
- >>>
3658
- >>> # Define a polling function that succeeds after 3 tries
3659
- >>> counter = 0
3660
- >>> def check_completion() -> tuple[str, bool]:
3661
- ... global counter
3662
- ... counter += 1
3663
- ... if counter >= 3:
3664
- ... return "Success", True
3665
- ... return None, False
3666
- ...
3667
- >>> # Configure polling options
3668
- >>> options = PollingOptions(
3669
- ... max_tries=5,
3670
- ... delay=0.1,
3671
- ... backoff=0.2,
3672
- ... verbose=True
3673
- ... )
3674
- >>>
3675
- >>> # Poll until the function succeeds
3676
- >>> result = poll(options, check_completion)
3677
- >>> print(result)
3678
- 'Success'
3679
- """
3680
-
3681
- # Start by sleeping for the duration specified as initial delay.
3682
- if polling_options.verbose:
3683
- log(f"polling | sleeping for initial delay: {polling_options.initial_delay}")
3684
-
3685
- __sleep_func(polling_options.initial_delay)
3780
+ input_data = None
3781
+ if isinstance(input, BaseModel):
3782
+ input_data = input.to_dict()
3783
+ elif isinstance(input, dict) or isinstance(input, str):
3784
+ input_data = input
3785
+ elif isinstance(input, Input):
3786
+ input_data = input.data
3686
3787
 
3687
- start_time = time.time()
3688
- stopped = False
3788
+ return input_data
3689
3789
 
3690
- # Begin the polling process.
3691
- max_reached = False
3692
- ix = 0
3693
- while True:
3694
- # Check if we reached the maximum number of tries. Break if so.
3695
- if ix >= polling_options.max_tries and polling_options.max_tries >= 0:
3696
- break
3697
- ix += 1
3790
+ def __extract_options_dict(
3791
+ self,
3792
+ options: Optional[Union[Options, dict[str, str]]] = None,
3793
+ json_configurations: Optional[dict[str, Any]] = None,
3794
+ ) -> dict[str, str]:
3795
+ """
3796
+ Auxiliary function to extract the options that will be sent to the
3797
+ application for execution.
3798
+ """
3698
3799
 
3699
- # Check is we should stop polling according to the stop callback.
3700
- if polling_options.stop is not None and polling_options.stop():
3701
- stopped = True
3800
+ options_dict = {}
3801
+ if options is not None:
3802
+ if isinstance(options, Options):
3803
+ options_dict = options.to_dict_cloud()
3702
3804
 
3703
- break
3805
+ elif isinstance(options, dict):
3806
+ for k, v in options.items():
3807
+ if isinstance(v, str):
3808
+ options_dict[k] = v
3809
+ continue
3704
3810
 
3705
- # We check if we can stop polling.
3706
- result, ok = polling_func()
3707
- if polling_options.verbose:
3708
- log(f"polling | try # {ix + 1}, ok: {ok}")
3811
+ options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
3709
3812
 
3710
- if ok:
3711
- return result
3813
+ return options_dict
3712
3814
 
3713
- # An exit condition happens if we exceed the allowed duration.
3714
- passed = time.time() - start_time
3715
- if polling_options.verbose:
3716
- log(f"polling | elapsed time: {passed}")
3815
+ def __extract_run_config(
3816
+ self,
3817
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
3818
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
3819
+ dir_path: Optional[str] = None,
3820
+ ) -> dict[str, Any]:
3821
+ """
3822
+ Auxiliary function to extract the run configuration that will be sent
3823
+ to the application for execution.
3824
+ """
3717
3825
 
3718
- if passed >= polling_options.max_duration and polling_options.max_duration >= 0:
3719
- raise TimeoutError(
3720
- f"polling did not succeed after {passed} seconds, exceeds max duration: {polling_options.max_duration}",
3826
+ if configuration is not None:
3827
+ configuration_dict = (
3828
+ configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
3721
3829
  )
3830
+ return configuration_dict
3722
3831
 
3723
- # Calculate the delay.
3724
- if max_reached:
3725
- # If we already reached the maximum, we don't want to further calculate the
3726
- # delay to avoid overflows.
3727
- delay = polling_options.max_delay
3728
- delay += random.uniform(0, polling_options.jitter) # Add jitter.
3729
- else:
3730
- delay = polling_options.delay # Base
3731
- delay += polling_options.backoff * (2**ix) # Add exponential backoff.
3732
- delay += random.uniform(0, polling_options.jitter) # Add jitter.
3733
-
3734
- # We cannot exceed the max delay.
3735
- if delay >= polling_options.max_delay:
3736
- max_reached = True
3737
- delay = polling_options.max_delay
3738
-
3739
- # Sleep for the calculated delay.
3740
- sleep_duration = delay
3741
- if polling_options.verbose:
3742
- log(f"polling | sleeping for duration: {sleep_duration}")
3743
-
3744
- __sleep_func(sleep_duration)
3745
-
3746
- if stopped:
3747
- log("polling | stop condition met, stopping polling")
3748
-
3749
- return None
3832
+ configuration = RunConfiguration()
3833
+ configuration.resolve(input=input, dir_path=dir_path)
3834
+ configuration_dict = configuration.to_dict()
3750
3835
 
3751
- raise RuntimeError(
3752
- f"polling did not succeed after {polling_options.max_tries} tries",
3753
- )
3836
+ return configuration_dict
3754
3837
 
3755
3838
 
3756
3839
  def _is_not_exist_error(e: requests.HTTPError) -> bool: