nextmv 0.30.0__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
@@ -52,8 +48,18 @@ from nextmv.cloud.batch_experiment import (
52
48
  from nextmv.cloud.client import Client, get_size
53
49
  from nextmv.cloud.input_set import InputSet, ManagedInput
54
50
  from nextmv.cloud.instance import Instance, InstanceConfiguration
55
- from nextmv.cloud.manifest import Manifest
56
- 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 (
57
63
  ExternalRunResult,
58
64
  Format,
59
65
  FormatInput,
@@ -64,16 +70,8 @@ from nextmv.cloud.run import (
64
70
  RunResult,
65
71
  TrackedRun,
66
72
  )
67
- from nextmv.cloud.safe import _name_and_id, _safe_id
68
- from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
69
- from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
70
- from nextmv.cloud.status import StatusV2
71
- from nextmv.cloud.version import Version
72
- from nextmv.input import Input, InputFormat
73
- from nextmv.logger import log
74
- from nextmv.model import Model, ModelConfiguration
75
- from nextmv.options import Options
76
- from nextmv.output import Output, OutputFormat
73
+ from nextmv.safe import safe_id, safe_name_and_id
74
+ from nextmv.status import StatusV2
77
75
 
78
76
  # Maximum size of the run input/output in bytes. This constant defines the
79
77
  # maximum allowed size for run inputs and outputs. When the size exceeds this
@@ -82,180 +80,6 @@ from nextmv.output import Output, OutputFormat
82
80
  _MAX_RUN_SIZE: int = 5 * 1024 * 1024
83
81
 
84
82
 
85
- class DownloadURL(BaseModel):
86
- """
87
- Result of getting a download URL.
88
-
89
- You can import the `DownloadURL` class directly from `cloud`:
90
-
91
- ```python
92
- from nextmv.cloud import DownloadURL
93
- ```
94
-
95
- This class represents a download URL that can be used to fetch content
96
- from Nextmv Cloud, typically used for downloading large run results.
97
-
98
- Attributes
99
- ----------
100
- url : str
101
- URL to use for downloading the file.
102
-
103
- Examples
104
- --------
105
- >>> download_url = DownloadURL(url="https://example.com/download")
106
- >>> response = requests.get(download_url.url)
107
- """
108
-
109
- url: str
110
- """URL to use for downloading the file."""
111
-
112
-
113
- @dataclass
114
- class PollingOptions:
115
- """
116
- Options to use when polling for a run result.
117
-
118
- You can import the `PollingOptions` class directly from `cloud`:
119
-
120
- ```python
121
- from nextmv.cloud import PollingOptions
122
- ```
123
-
124
- The Cloud API will be polled for the result. The polling stops if:
125
-
126
- * The maximum number of polls (tries) are exhausted. This is specified by
127
- the `max_tries` parameter.
128
- * The maximum duration of the polling strategy is reached. This is
129
- specified by the `max_duration` parameter.
130
-
131
- Before conducting the first poll, the `initial_delay` is used to sleep.
132
- After each poll, a sleep duration is calculated using the following
133
- strategy, based on exponential backoff with jitter:
134
-
135
- ```
136
- sleep_duration = min(`max_delay`, `delay` + `backoff` * 2 ** i + Uniform(0, `jitter`))
137
- ```
138
-
139
- Where:
140
- * i is the retry (poll) number.
141
- * Uniform is the uniform distribution.
142
-
143
- Note that the sleep duration is capped by the `max_delay` parameter.
144
-
145
- Parameters
146
- ----------
147
- backoff : float, default=0.9
148
- Exponential backoff factor, in seconds, to use between polls.
149
- delay : float, default=0.1
150
- Base delay to use between polls, in seconds.
151
- initial_delay : float, default=1.0
152
- Initial delay to use before starting the polling strategy, in seconds.
153
- max_delay : float, default=20.0
154
- Maximum delay to use between polls, in seconds.
155
- max_duration : float, default=300.0
156
- Maximum duration of the polling strategy, in seconds.
157
- max_tries : int, default=100
158
- Maximum number of tries to use.
159
- jitter : float, default=1.0
160
- Jitter to use for the polling strategy. A uniform distribution is sampled
161
- between 0 and this number. The resulting random number is added to the
162
- delay for each poll, adding a random noise. Set this to 0 to avoid using
163
- random jitter.
164
- verbose : bool, default=False
165
- Whether to log the polling strategy. This is useful for debugging.
166
- stop : callable, default=None
167
- Function to call to check if the polling should stop. This is useful for
168
- stopping the polling based on external conditions. The function should
169
- return True to stop the polling and False to continue. The function does
170
- not receive any arguments. The function is called before each poll.
171
-
172
- Examples
173
- --------
174
- >>> from nextmv.cloud import PollingOptions
175
- >>> # Create polling options with custom settings
176
- >>> polling_options = PollingOptions(
177
- ... max_tries=50,
178
- ... max_duration=600,
179
- ... verbose=True
180
- ... )
181
- """
182
-
183
- backoff: float = 0.9
184
- """
185
- Exponential backoff factor, in seconds, to use between polls.
186
- """
187
- delay: float = 0.1
188
- """Base delay to use between polls, in seconds."""
189
- initial_delay: float = 1
190
- """
191
- Initial delay to use before starting the polling strategy, in seconds.
192
- """
193
- max_delay: float = 20
194
- """Maximum delay to use between polls, in seconds."""
195
- max_duration: float = -1
196
- """
197
- Maximum duration of the polling strategy, in seconds. A negative value means no limit.
198
- """
199
- max_tries: int = -1
200
- """Maximum number of tries to use. A negative value means no limit."""
201
- jitter: float = 1
202
- """
203
- Jitter to use for the polling strategy. A uniform distribution is sampled
204
- between 0 and this number. The resulting random number is added to the
205
- delay for each poll, adding a random noise. Set this to 0 to avoid using
206
- random jitter.
207
- """
208
- verbose: bool = False
209
- """Whether to log the polling strategy. This is useful for debugging."""
210
- stop: Optional[Callable[[], bool]] = None
211
- """
212
- Function to call to check if the polling should stop. This is useful for
213
- stopping the polling based on external conditions. The function should
214
- return True to stop the polling and False to continue. The function does
215
- not receive any arguments. The function is called before each poll.
216
- """
217
-
218
-
219
- # Default polling options to use when polling for a run result. This constant
220
- # provides the default values for `PollingOptions` used across the module.
221
- # Using these defaults is recommended for most use cases unless specific timing
222
- # needs are required.
223
- _DEFAULT_POLLING_OPTIONS: PollingOptions = PollingOptions()
224
-
225
-
226
- class UploadURL(BaseModel):
227
- """
228
- Result of getting an upload URL.
229
-
230
- You can import the `UploadURL` class directly from `cloud`:
231
-
232
- ```python
233
- from nextmv.cloud import UploadURL
234
- ```
235
-
236
- This class represents an upload URL that can be used to send data to
237
- Nextmv Cloud, typically used for uploading large inputs for runs.
238
-
239
- Attributes
240
- ----------
241
- upload_id : str
242
- ID of the upload, used to reference the uploaded content.
243
- upload_url : str
244
- URL to use for uploading the file.
245
-
246
- Examples
247
- --------
248
- >>> upload_url = UploadURL(upload_id="123", upload_url="https://example.com/upload")
249
- >>> with open("large_input.json", "rb") as f:
250
- ... requests.put(upload_url.upload_url, data=f)
251
- """
252
-
253
- upload_id: str
254
- """ID of the upload."""
255
- upload_url: str
256
- """URL to use for uploading the file."""
257
-
258
-
259
83
  @dataclass
260
84
  class Application:
261
85
  """
@@ -305,15 +129,6 @@ class Application:
305
129
  experiments_endpoint: str = "{base}/experiments"
306
130
  """Base endpoint for the experiments in the application."""
307
131
 
308
- # Local experience parameters.
309
- src: Optional[str] = None
310
- """
311
- Source of the application, if initialized locally. This is the path
312
- to the application's source code.
313
- """
314
- description: Optional[str] = None
315
- """Description of the application."""
316
-
317
132
  def __post_init__(self):
318
133
  """Initialize the endpoint and experiments_endpoint attributes.
319
134
 
@@ -323,92 +138,6 @@ class Application:
323
138
  self.endpoint = self.endpoint.format(id=self.id)
324
139
  self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
325
140
 
326
- @classmethod
327
- def initialize(
328
- cls,
329
- name: str,
330
- id: Optional[str] = None,
331
- description: Optional[str] = None,
332
- destination: Optional[str] = None,
333
- client: Optional[Client] = None,
334
- ) -> "Application":
335
- """
336
- Initialize a Nextmv application, locally.
337
-
338
- This method will create a new application in the local file system. The
339
- application is a folder with the name given by `name`, under the
340
- location given by `destination`. If the `destination` parameter is not
341
- specified, the current working directory is used as default. This
342
- method will scaffold the application with the necessary files and
343
- directories to have an opinionated structure for your decision model.
344
- Once the application is initialized, you are encouraged to complete it
345
- with the decision model itself, so that the application can be run,
346
- locally or remotely.
347
-
348
- This method differs from the `Application.new` method in that it
349
- creates the application locally rather than in the Cloud.
350
-
351
- Although not required, you are encouraged to specify the `client`
352
- parameter, so that the application can be pushed and synced remotely,
353
- with the Nextmv Cloud. If you don't specify the `client`, and intend to
354
- interact with the Nextmv Cloud, you will encounter an error. Make sure
355
- you set the `client` parameter on the `Application` instance after
356
- initialization, if you don't provide it here.
357
-
358
- Use the `destination` parameter to specify where you want the app to be
359
- initialized, using the current working directory by default.
360
-
361
- Parameters
362
- ----------
363
- name : str
364
- Name of the application.
365
- id : str, optional
366
- ID of the application. Will be generated if not provided.
367
- description : str, optional
368
- Description of the application.
369
- destination : str, optional
370
- Destination directory where the application will be initialized. If
371
- not provided, the current working directory will be used.
372
- client : Client, optional
373
- Client to use for interacting with the Nextmv Cloud API.
374
-
375
- Returns
376
- -------
377
- Application
378
- The initialized application instance.
379
- """
380
-
381
- destination_dir = os.getcwd() if destination is None else destination
382
- app_id = id if id is not None else str(uuid.uuid4())
383
-
384
- # Create the new directory with the given name.
385
- src = os.path.join(destination_dir, name)
386
- os.makedirs(src, exist_ok=True)
387
-
388
- # Get the path to the initial app structure template.
389
- current_file_dir = os.path.dirname(os.path.abspath(__file__))
390
- initial_app_structure_path = os.path.join(current_file_dir, "..", "default_app")
391
- initial_app_structure_path = os.path.normpath(initial_app_structure_path)
392
-
393
- # Copy everything from initial_app_structure to the new directory.
394
- if os.path.exists(initial_app_structure_path):
395
- for item in os.listdir(initial_app_structure_path):
396
- source_path = os.path.join(initial_app_structure_path, item)
397
- dest_path = os.path.join(src, item)
398
-
399
- if os.path.isdir(source_path):
400
- shutil.copytree(source_path, dest_path, dirs_exist_ok=True)
401
- continue
402
-
403
- shutil.copy2(source_path, dest_path)
404
-
405
- return cls(
406
- id=app_id,
407
- client=client,
408
- src=src,
409
- description=description,
410
- )
411
-
412
141
  @classmethod
413
142
  def new(
414
143
  cls,
@@ -510,7 +239,7 @@ class Application:
510
239
  def acceptance_test_with_polling(
511
240
  self,
512
241
  acceptance_test_id: str,
513
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
242
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
514
243
  ) -> AcceptanceTest:
515
244
  """
516
245
  Retrieve details of an acceptance test using polling.
@@ -627,7 +356,7 @@ class Application:
627
356
  def batch_experiment_with_polling(
628
357
  self,
629
358
  batch_id: str,
630
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
359
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
631
360
  ) -> BatchExperiment:
632
361
  """
633
362
  Get a batch experiment with polling.
@@ -1327,7 +1056,7 @@ class Application:
1327
1056
  name: str,
1328
1057
  input_set_id: Optional[str] = None,
1329
1058
  description: Optional[str] = None,
1330
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1059
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1331
1060
  ) -> AcceptanceTest:
1332
1061
  """
1333
1062
  Create a new acceptance test and poll for the result.
@@ -1491,7 +1220,7 @@ class Application:
1491
1220
  option_sets: Optional[dict[str, dict[str, str]]] = None,
1492
1221
  runs: Optional[list[Union[BatchExperimentRun, dict[str, Any]]]] = None,
1493
1222
  type: Optional[str] = "batch",
1494
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1223
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1495
1224
  ) -> BatchExperiment:
1496
1225
  """
1497
1226
  Convenience method to create a new batch experiment and poll for the
@@ -1931,13 +1660,7 @@ class Application:
1931
1660
 
1932
1661
  tar_file = self.__package_inputs(input_dir_path)
1933
1662
 
1934
- input_data = None
1935
- if isinstance(input, BaseModel):
1936
- input_data = input.to_dict()
1937
- elif isinstance(input, dict) or isinstance(input, str):
1938
- input_data = input
1939
- elif isinstance(input, Input):
1940
- input_data = input.data
1663
+ input_data = self.__extract_input_data(input)
1941
1664
 
1942
1665
  input_size = 0
1943
1666
  if input_data is not None:
@@ -1950,20 +1673,10 @@ class Application:
1950
1673
  upload_id = upload_url.upload_id
1951
1674
  upload_id_used = True
1952
1675
 
1953
- options_dict = {}
1954
- if isinstance(input, Input) and input.options is not None:
1955
- options_dict = input.options.to_dict_cloud()
1956
-
1957
- if options is not None:
1958
- if isinstance(options, Options):
1959
- options_dict = options.to_dict_cloud()
1960
- elif isinstance(options, dict):
1961
- for k, v in options.items():
1962
- if isinstance(v, str):
1963
- options_dict[k] = v
1964
- else:
1965
- options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
1676
+ options_dict = self.__extract_options_dict(options, json_configurations)
1966
1677
 
1678
+ # Builds the payload progressively based on the different arguments
1679
+ # that must be provided.
1967
1680
  payload = {}
1968
1681
  if upload_id_used:
1969
1682
  payload["upload_id"] = upload_id
@@ -1980,15 +1693,7 @@ class Application:
1980
1693
  raise ValueError(f"options must be dict[str,str], option {k} has type {type(v)} instead.")
1981
1694
  payload["options"] = options_dict
1982
1695
 
1983
- if configuration is not None:
1984
- configuration_dict = (
1985
- configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
1986
- )
1987
- else:
1988
- configuration = RunConfiguration()
1989
- configuration.resolve(input=input, dir_path=input_dir_path)
1990
- configuration_dict = configuration.to_dict()
1991
-
1696
+ configuration_dict = self.__extract_run_config(input, configuration, input_dir_path)
1992
1697
  payload["configuration"] = configuration_dict
1993
1698
 
1994
1699
  if batch_experiment_id is not None:
@@ -2020,7 +1725,7 @@ class Application:
2020
1725
  description: Optional[str] = None,
2021
1726
  upload_id: Optional[str] = None,
2022
1727
  run_options: Optional[Union[Options, dict[str, str]]] = None,
2023
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1728
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2024
1729
  configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
2025
1730
  batch_experiment_id: Optional[str] = None,
2026
1731
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]] = None,
@@ -2289,7 +1994,7 @@ class Application:
2289
1994
  scenarios: list[Scenario],
2290
1995
  description: Optional[str] = None,
2291
1996
  repetitions: Optional[int] = 0,
2292
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1997
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2293
1998
  ) -> BatchExperiment:
2294
1999
  """
2295
2000
  Convenience method to create a new scenario test and poll for the
@@ -2496,7 +2201,7 @@ class Application:
2496
2201
  return self.version(version_id=id)
2497
2202
 
2498
2203
  if id is None:
2499
- id = _safe_id(prefix="version")
2204
+ id = safe_id(prefix="version")
2500
2205
 
2501
2206
  payload = {
2502
2207
  "id": id,
@@ -2827,7 +2532,7 @@ class Application:
2827
2532
  def run_result_with_polling(
2828
2533
  self,
2829
2534
  run_id: str,
2830
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2535
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2831
2536
  output_dir_path: Optional[str] = ".",
2832
2537
  ) -> RunResult:
2833
2538
  """
@@ -2965,7 +2670,7 @@ class Application:
2965
2670
  def scenario_test_with_polling(
2966
2671
  self,
2967
2672
  scenario_test_id: str,
2968
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2673
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2969
2674
  ) -> BatchExperiment:
2970
2675
  """
2971
2676
  Get a scenario test with polling.
@@ -3004,7 +2709,12 @@ class Application:
3004
2709
 
3005
2710
  return self.batch_experiment_with_polling(batch_id=scenario_test_id, polling_options=polling_options)
3006
2711
 
3007
- def track_run(self, tracked_run: TrackedRun, instance_id: Optional[str] = None) -> str:
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:
3008
2718
  """
3009
2719
  Track an external run.
3010
2720
 
@@ -3013,6 +2723,14 @@ class Application:
3013
2723
  information about a run in Nextmv is useful for things like
3014
2724
  experimenting and testing.
3015
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
+
3016
2734
  Parameters
3017
2735
  ----------
3018
2736
  tracked_run : TrackedRun
@@ -3020,6 +2738,11 @@ class Application:
3020
2738
  instance_id : Optional[str], default=None
3021
2739
  Optional instance ID if you want to associate your tracked run with
3022
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.
3023
2746
 
3024
2747
  Returns
3025
2748
  -------
@@ -3042,22 +2765,55 @@ class Application:
3042
2765
  >>> run_id = app.track_run(tracked_run)
3043
2766
  """
3044
2767
 
2768
+ # Get the URL to upload the input to.
3045
2769
  url_input = self.upload_url()
3046
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.
3047
2785
  upload_input = tracked_run.input
3048
- if isinstance(tracked_run.input, Input):
2786
+ if upload_input is not None and isinstance(tracked_run.input, Input):
3049
2787
  upload_input = tracked_run.input.data
3050
2788
 
3051
- 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)
3052
2791
 
2792
+ # Get the URL to upload the output to.
3053
2793
  url_output = self.upload_url()
3054
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.
3055
2809
  upload_output = tracked_run.output
3056
- if isinstance(tracked_run.output, Output):
2810
+ if upload_output is not None and isinstance(tracked_run.output, Output):
3057
2811
  upload_output = tracked_run.output.to_dict()
3058
2812
 
3059
- 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)
3060
2815
 
2816
+ # Create the external run result and appends logs if required.
3061
2817
  external_result = ExternalRunResult(
3062
2818
  output_upload_id=url_output.upload_id,
3063
2819
  status=tracked_run.status.value,
@@ -3076,14 +2832,18 @@ class Application:
3076
2832
  upload_id=url_input.upload_id,
3077
2833
  external_result=external_result,
3078
2834
  instance_id=instance_id,
2835
+ name=tracked_run.name,
2836
+ description=tracked_run.description,
2837
+ configuration=configuration,
3079
2838
  )
3080
2839
 
3081
2840
  def track_run_with_result(
3082
2841
  self,
3083
2842
  tracked_run: TrackedRun,
3084
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2843
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
3085
2844
  instance_id: Optional[str] = None,
3086
2845
  output_dir_path: Optional[str] = ".",
2846
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
3087
2847
  ) -> RunResult:
3088
2848
  """
3089
2849
  Track an external run and poll for the result. This is a convenience
@@ -3104,6 +2864,11 @@ class Application:
3104
2864
  Path to a directory where non-JSON output files will be saved. This is
3105
2865
  required if the output is non-JSON. If the directory does not exist, it
3106
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.
3107
2872
 
3108
2873
  Returns
3109
2874
  -------
@@ -3123,7 +2888,11 @@ class Application:
3123
2888
  If the run does not succeed after the polling strategy is
3124
2889
  exhausted based on number of tries.
3125
2890
  """
3126
- 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
+ )
3127
2896
 
3128
2897
  return self.run_result_with_polling(
3129
2898
  run_id=run_id,
@@ -3829,7 +3598,7 @@ class Application:
3829
3598
  # If working with a list of managed inputs, we need to create an
3830
3599
  # input set.
3831
3600
  if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT:
3832
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3601
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3833
3602
  input_set = self.new_input_set(
3834
3603
  id=id,
3835
3604
  name=name,
@@ -3849,7 +3618,7 @@ class Application:
3849
3618
  for data in scenario.scenario_input.scenario_input_data:
3850
3619
  upload_url = self.upload_url()
3851
3620
  self.upload_large_input(input=data, upload_url=upload_url)
3852
- 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)
3853
3622
  managed_input = self.new_managed_input(
3854
3623
  id=id,
3855
3624
  name=name,
@@ -3858,7 +3627,7 @@ class Application:
3858
3627
  )
3859
3628
  managed_inputs.append(managed_input)
3860
3629
 
3861
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3630
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3862
3631
  input_set = self.new_input_set(
3863
3632
  id=id,
3864
3633
  name=name,
@@ -3873,28 +3642,71 @@ class Application:
3873
3642
  def __validate_input_dir_path_and_configuration(
3874
3643
  self,
3875
3644
  input_dir_path: Optional[str],
3876
- configuration: Optional[RunConfiguration],
3645
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
3877
3646
  ) -> None:
3878
3647
  """
3879
3648
  Auxiliary function to validate the directory path and configuration.
3880
3649
  """
3881
- if (
3882
- configuration is None
3883
- or configuration.format is None
3884
- or configuration.format.format_input is None
3885
- or configuration.format.format_input.input_type is None
3886
- ):
3887
- # No explicit input type set, so we cannot confirm it.
3650
+
3651
+ if input_dir_path is None or input_dir_path == "":
3888
3652
  return
3889
3653
 
3890
- input_type = configuration.format.format_input.input_type
3891
- dir_types = (InputFormat.MULTI_FILE, InputFormat.CSV_ARCHIVE)
3892
- if input_type in dir_types and not input_dir_path:
3654
+ if configuration is None:
3893
3655
  raise ValueError(
3894
- f"If RunConfiguration.format.format_input.input_type is set to {input_type}, "
3895
- "then input_dir_path must be provided.",
3656
+ "If dir_path is provided, a RunConfiguration must also be provided.",
3896
3657
  )
3897
3658
 
3659
+ config_format = self.__extract_config_format(configuration)
3660
+
3661
+ if config_format is None:
3662
+ raise ValueError(
3663
+ "If dir_path is provided, RunConfiguration.format must also be provided.",
3664
+ )
3665
+
3666
+ input_type = self.__extract_input_type(config_format)
3667
+
3668
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3669
+ raise ValueError(
3670
+ "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
3671
+ f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3672
+ )
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
+
3898
3710
  def __package_inputs(self, dir_path: str) -> str:
3899
3711
  """
3900
3712
  This is an auxiliary function for packaging the inputs found in the
@@ -3956,153 +3768,72 @@ class Application:
3956
3768
 
3957
3769
  return size_exceeds or non_json_payload
3958
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
+ """
3959
3779
 
3960
- def poll( # noqa: C901
3961
- polling_options: PollingOptions,
3962
- polling_func: Callable[[], tuple[Any, bool]],
3963
- __sleep_func: Callable[[float], None] = time.sleep,
3964
- ) -> Any:
3965
- """
3966
- Poll a function until it succeeds or the polling strategy is exhausted.
3967
-
3968
- You can import the `poll` function directly from `cloud`:
3969
-
3970
- ```python
3971
- from nextmv.cloud import poll
3972
- ```
3973
-
3974
- This function implements a flexible polling strategy with exponential backoff
3975
- and jitter. It calls the provided polling function repeatedly until it indicates
3976
- success, the maximum number of tries is reached, or the maximum duration is exceeded.
3977
-
3978
- The `polling_func` is a callable that must return a `tuple[Any, bool]`
3979
- where the first element is the result of the polling and the second
3980
- element is a boolean indicating if the polling was successful or should be
3981
- retried.
3982
-
3983
- Parameters
3984
- ----------
3985
- polling_options : PollingOptions
3986
- Options for configuring the polling behavior, including retry counts,
3987
- delays, timeouts, and verbosity settings.
3988
- polling_func : callable
3989
- Function to call to check if the polling was successful. Must return a tuple
3990
- where the first element is the result value and the second is a boolean
3991
- indicating success (True) or need to retry (False).
3992
-
3993
- Returns
3994
- -------
3995
- Any
3996
- Result value from the polling function when successful.
3997
-
3998
- Raises
3999
- ------
4000
- TimeoutError
4001
- If the polling exceeds the maximum duration specified in polling_options.
4002
- RuntimeError
4003
- If the maximum number of tries is exhausted without success.
4004
-
4005
- Examples
4006
- --------
4007
- >>> from nextmv.cloud import PollingOptions, poll
4008
- >>> import time
4009
- >>>
4010
- >>> # Define a polling function that succeeds after 3 tries
4011
- >>> counter = 0
4012
- >>> def check_completion() -> tuple[str, bool]:
4013
- ... global counter
4014
- ... counter += 1
4015
- ... if counter >= 3:
4016
- ... return "Success", True
4017
- ... return None, False
4018
- ...
4019
- >>> # Configure polling options
4020
- >>> options = PollingOptions(
4021
- ... max_tries=5,
4022
- ... delay=0.1,
4023
- ... backoff=0.2,
4024
- ... verbose=True
4025
- ... )
4026
- >>>
4027
- >>> # Poll until the function succeeds
4028
- >>> result = poll(options, check_completion)
4029
- >>> print(result)
4030
- 'Success'
4031
- """
4032
-
4033
- # Start by sleeping for the duration specified as initial delay.
4034
- if polling_options.verbose:
4035
- log(f"polling | sleeping for initial delay: {polling_options.initial_delay}")
4036
-
4037
- __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
4038
3787
 
4039
- start_time = time.time()
4040
- stopped = False
3788
+ return input_data
4041
3789
 
4042
- # Begin the polling process.
4043
- max_reached = False
4044
- ix = 0
4045
- while True:
4046
- # Check if we reached the maximum number of tries. Break if so.
4047
- if ix >= polling_options.max_tries and polling_options.max_tries >= 0:
4048
- break
4049
- 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
+ """
4050
3799
 
4051
- # Check is we should stop polling according to the stop callback.
4052
- if polling_options.stop is not None and polling_options.stop():
4053
- stopped = True
3800
+ options_dict = {}
3801
+ if options is not None:
3802
+ if isinstance(options, Options):
3803
+ options_dict = options.to_dict_cloud()
4054
3804
 
4055
- 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
4056
3810
 
4057
- # We check if we can stop polling.
4058
- result, ok = polling_func()
4059
- if polling_options.verbose:
4060
- log(f"polling | try # {ix + 1}, ok: {ok}")
3811
+ options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
4061
3812
 
4062
- if ok:
4063
- return result
3813
+ return options_dict
4064
3814
 
4065
- # An exit condition happens if we exceed the allowed duration.
4066
- passed = time.time() - start_time
4067
- if polling_options.verbose:
4068
- 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
+ """
4069
3825
 
4070
- if passed >= polling_options.max_duration and polling_options.max_duration >= 0:
4071
- raise TimeoutError(
4072
- 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
4073
3829
  )
3830
+ return configuration_dict
4074
3831
 
4075
- # Calculate the delay.
4076
- if max_reached:
4077
- # If we already reached the maximum, we don't want to further calculate the
4078
- # delay to avoid overflows.
4079
- delay = polling_options.max_delay
4080
- delay += random.uniform(0, polling_options.jitter) # Add jitter.
4081
- else:
4082
- delay = polling_options.delay # Base
4083
- delay += polling_options.backoff * (2**ix) # Add exponential backoff.
4084
- delay += random.uniform(0, polling_options.jitter) # Add jitter.
4085
-
4086
- # We cannot exceed the max delay.
4087
- if delay >= polling_options.max_delay:
4088
- max_reached = True
4089
- delay = polling_options.max_delay
4090
-
4091
- # Sleep for the calculated delay.
4092
- sleep_duration = delay
4093
- if polling_options.verbose:
4094
- log(f"polling | sleeping for duration: {sleep_duration}")
4095
-
4096
- __sleep_func(sleep_duration)
4097
-
4098
- if stopped:
4099
- log("polling | stop condition met, stopping polling")
4100
-
4101
- return None
3832
+ configuration = RunConfiguration()
3833
+ configuration.resolve(input=input, dir_path=dir_path)
3834
+ configuration_dict = configuration.to_dict()
4102
3835
 
4103
- raise RuntimeError(
4104
- f"polling did not succeed after {polling_options.max_tries} tries",
4105
- )
3836
+ return configuration_dict
4106
3837
 
4107
3838
 
4108
3839
  def _is_not_exist_error(e: requests.HTTPError) -> bool: