nextmv 0.30.0__py3-none-any.whl → 0.32.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,28 +48,31 @@ 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,
60
66
  FormatOutput,
67
+ Run,
61
68
  RunConfiguration,
62
69
  RunInformation,
63
70
  RunLog,
64
71
  RunResult,
65
72
  TrackedRun,
66
73
  )
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
74
+ from nextmv.safe import safe_id, safe_name_and_id
75
+ from nextmv.status import StatusV2
77
76
 
78
77
  # Maximum size of the run input/output in bytes. This constant defines the
79
78
  # maximum allowed size for run inputs and outputs. When the size exceeds this
@@ -82,180 +81,6 @@ from nextmv.output import Output, OutputFormat
82
81
  _MAX_RUN_SIZE: int = 5 * 1024 * 1024
83
82
 
84
83
 
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
84
  @dataclass
260
85
  class Application:
261
86
  """
@@ -305,15 +130,6 @@ class Application:
305
130
  experiments_endpoint: str = "{base}/experiments"
306
131
  """Base endpoint for the experiments in the application."""
307
132
 
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
133
  def __post_init__(self):
318
134
  """Initialize the endpoint and experiments_endpoint attributes.
319
135
 
@@ -323,92 +139,6 @@ class Application:
323
139
  self.endpoint = self.endpoint.format(id=self.id)
324
140
  self.experiments_endpoint = self.experiments_endpoint.format(base=self.endpoint)
325
141
 
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
142
  @classmethod
413
143
  def new(
414
144
  cls,
@@ -510,7 +240,7 @@ class Application:
510
240
  def acceptance_test_with_polling(
511
241
  self,
512
242
  acceptance_test_id: str,
513
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
243
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
514
244
  ) -> AcceptanceTest:
515
245
  """
516
246
  Retrieve details of an acceptance test using polling.
@@ -560,7 +290,8 @@ class Application:
560
290
 
561
291
  def batch_experiment(self, batch_id: str) -> BatchExperiment:
562
292
  """
563
- Get a batch experiment.
293
+ Get a batch experiment. This method also returns the runs of the batch
294
+ experiment under the `.runs` attribute.
564
295
 
565
296
  Parameters
566
297
  ----------
@@ -589,7 +320,17 @@ class Application:
589
320
  endpoint=f"{self.experiments_endpoint}/batch/{batch_id}",
590
321
  )
591
322
 
592
- return BatchExperiment.from_dict(response.json())
323
+ exp = BatchExperiment.from_dict(response.json())
324
+
325
+ runs_response = self.client.request(
326
+ method="GET",
327
+ endpoint=f"{self.experiments_endpoint}/batch/{batch_id}/runs",
328
+ )
329
+
330
+ runs = [Run.from_dict(run) for run in runs_response.json().get("runs", [])]
331
+ exp.runs = runs
332
+
333
+ return exp
593
334
 
594
335
  def batch_experiment_metadata(self, batch_id: str) -> BatchExperimentMetadata:
595
336
  """
@@ -627,7 +368,7 @@ class Application:
627
368
  def batch_experiment_with_polling(
628
369
  self,
629
370
  batch_id: str,
630
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
371
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
631
372
  ) -> BatchExperiment:
632
373
  """
633
374
  Get a batch experiment with polling.
@@ -1089,6 +830,28 @@ class Application:
1089
830
 
1090
831
  return [ManagedInput.from_dict(managed_input) for managed_input in response.json()]
1091
832
 
833
+ def list_runs(self) -> list[Run]:
834
+ """
835
+ List all runs.
836
+
837
+ Returns
838
+ -------
839
+ list[Run]
840
+ List of runs.
841
+
842
+ Raises
843
+ ------
844
+ requests.HTTPError
845
+ If the response status code is not 2xx.
846
+ """
847
+
848
+ response = self.client.request(
849
+ method="GET",
850
+ endpoint=f"{self.endpoint}/runs",
851
+ )
852
+
853
+ return [Run.from_dict(run) for run in response.json().get("runs", [])]
854
+
1092
855
  def list_scenario_tests(self) -> list[BatchExperimentMetadata]:
1093
856
  """
1094
857
  List all batch scenario tests. Scenario tests are based on the batch
@@ -1327,7 +1090,7 @@ class Application:
1327
1090
  name: str,
1328
1091
  input_set_id: Optional[str] = None,
1329
1092
  description: Optional[str] = None,
1330
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1093
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1331
1094
  ) -> AcceptanceTest:
1332
1095
  """
1333
1096
  Create a new acceptance test and poll for the result.
@@ -1491,7 +1254,7 @@ class Application:
1491
1254
  option_sets: Optional[dict[str, dict[str, str]]] = None,
1492
1255
  runs: Optional[list[Union[BatchExperimentRun, dict[str, Any]]]] = None,
1493
1256
  type: Optional[str] = "batch",
1494
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1257
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
1495
1258
  ) -> BatchExperiment:
1496
1259
  """
1497
1260
  Convenience method to create a new batch experiment and poll for the
@@ -1931,13 +1694,7 @@ class Application:
1931
1694
 
1932
1695
  tar_file = self.__package_inputs(input_dir_path)
1933
1696
 
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
1697
+ input_data = self.__extract_input_data(input)
1941
1698
 
1942
1699
  input_size = 0
1943
1700
  if input_data is not None:
@@ -1950,20 +1707,10 @@ class Application:
1950
1707
  upload_id = upload_url.upload_id
1951
1708
  upload_id_used = True
1952
1709
 
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)
1710
+ options_dict = self.__extract_options_dict(options, json_configurations)
1966
1711
 
1712
+ # Builds the payload progressively based on the different arguments
1713
+ # that must be provided.
1967
1714
  payload = {}
1968
1715
  if upload_id_used:
1969
1716
  payload["upload_id"] = upload_id
@@ -1980,15 +1727,7 @@ class Application:
1980
1727
  raise ValueError(f"options must be dict[str,str], option {k} has type {type(v)} instead.")
1981
1728
  payload["options"] = options_dict
1982
1729
 
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
-
1730
+ configuration_dict = self.__extract_run_config(input, configuration, input_dir_path)
1992
1731
  payload["configuration"] = configuration_dict
1993
1732
 
1994
1733
  if batch_experiment_id is not None:
@@ -2020,7 +1759,7 @@ class Application:
2020
1759
  description: Optional[str] = None,
2021
1760
  upload_id: Optional[str] = None,
2022
1761
  run_options: Optional[Union[Options, dict[str, str]]] = None,
2023
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1762
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2024
1763
  configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
2025
1764
  batch_experiment_id: Optional[str] = None,
2026
1765
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]] = None,
@@ -2289,7 +2028,7 @@ class Application:
2289
2028
  scenarios: list[Scenario],
2290
2029
  description: Optional[str] = None,
2291
2030
  repetitions: Optional[int] = 0,
2292
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2031
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2293
2032
  ) -> BatchExperiment:
2294
2033
  """
2295
2034
  Convenience method to create a new scenario test and poll for the
@@ -2496,7 +2235,7 @@ class Application:
2496
2235
  return self.version(version_id=id)
2497
2236
 
2498
2237
  if id is None:
2499
- id = _safe_id(prefix="version")
2238
+ id = safe_id(prefix="version")
2500
2239
 
2501
2240
  payload = {
2502
2241
  "id": id,
@@ -2827,7 +2566,7 @@ class Application:
2827
2566
  def run_result_with_polling(
2828
2567
  self,
2829
2568
  run_id: str,
2830
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2569
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2831
2570
  output_dir_path: Optional[str] = ".",
2832
2571
  ) -> RunResult:
2833
2572
  """
@@ -2965,7 +2704,7 @@ class Application:
2965
2704
  def scenario_test_with_polling(
2966
2705
  self,
2967
2706
  scenario_test_id: str,
2968
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2707
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
2969
2708
  ) -> BatchExperiment:
2970
2709
  """
2971
2710
  Get a scenario test with polling.
@@ -3004,7 +2743,12 @@ class Application:
3004
2743
 
3005
2744
  return self.batch_experiment_with_polling(batch_id=scenario_test_id, polling_options=polling_options)
3006
2745
 
3007
- def track_run(self, tracked_run: TrackedRun, instance_id: Optional[str] = None) -> str:
2746
+ def track_run( # noqa: C901
2747
+ self,
2748
+ tracked_run: TrackedRun,
2749
+ instance_id: Optional[str] = None,
2750
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
2751
+ ) -> str:
3008
2752
  """
3009
2753
  Track an external run.
3010
2754
 
@@ -3013,6 +2757,14 @@ class Application:
3013
2757
  information about a run in Nextmv is useful for things like
3014
2758
  experimenting and testing.
3015
2759
 
2760
+ Please read the documentation on the `TrackedRun` class carefully, as
2761
+ there are important considerations to take into account when using this
2762
+ method. For example, if you intend to upload JSON input/output, use the
2763
+ `input`/`output` attributes of the `TrackedRun` class. On the other
2764
+ hand, if you intend to track files-based input/output, use the
2765
+ `input_dir_path`/`output_dir_path` attributes of the `TrackedRun`
2766
+ class.
2767
+
3016
2768
  Parameters
3017
2769
  ----------
3018
2770
  tracked_run : TrackedRun
@@ -3020,6 +2772,11 @@ class Application:
3020
2772
  instance_id : Optional[str], default=None
3021
2773
  Optional instance ID if you want to associate your tracked run with
3022
2774
  an instance.
2775
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
2776
+ Configuration to use for the run. This can be a
2777
+ `cloud.RunConfiguration` object or a dict. If the object is used,
2778
+ then the `.to_dict()` method is applied to extract the
2779
+ configuration.
3023
2780
 
3024
2781
  Returns
3025
2782
  -------
@@ -3036,28 +2793,61 @@ class Application:
3036
2793
  Examples
3037
2794
  --------
3038
2795
  >>> from nextmv.cloud import Application
3039
- >>> from nextmv.cloud.run import TrackedRun
2796
+ >>> from nextmv import TrackedRun
3040
2797
  >>> app = Application(id="app_123")
3041
2798
  >>> tracked_run = TrackedRun(input={"data": [...]}, output={"solution": [...]})
3042
2799
  >>> run_id = app.track_run(tracked_run)
3043
2800
  """
3044
2801
 
2802
+ # Get the URL to upload the input to.
3045
2803
  url_input = self.upload_url()
3046
2804
 
2805
+ # Handle the case where the input is being uploaded as files. We need
2806
+ # to tar them.
2807
+ input_tar_file = ""
2808
+ input_dir_path = tracked_run.input_dir_path
2809
+ if input_dir_path is not None and input_dir_path != "":
2810
+ if not os.path.exists(input_dir_path):
2811
+ raise ValueError(f"Directory {input_dir_path} does not exist.")
2812
+
2813
+ if not os.path.isdir(input_dir_path):
2814
+ raise ValueError(f"Path {input_dir_path} is not a directory.")
2815
+
2816
+ input_tar_file = self.__package_inputs(input_dir_path)
2817
+
2818
+ # Handle the case where the input is uploaded as Input or a dict.
3047
2819
  upload_input = tracked_run.input
3048
- if isinstance(tracked_run.input, Input):
2820
+ if upload_input is not None and isinstance(tracked_run.input, Input):
3049
2821
  upload_input = tracked_run.input.data
3050
2822
 
3051
- self.upload_large_input(input=upload_input, upload_url=url_input)
2823
+ # Actually uploads de input.
2824
+ self.upload_large_input(input=upload_input, upload_url=url_input, tar_file=input_tar_file)
3052
2825
 
2826
+ # Get the URL to upload the output to.
3053
2827
  url_output = self.upload_url()
3054
2828
 
2829
+ # Handle the case where the output is being uploaded as files. We need
2830
+ # to tar them.
2831
+ output_tar_file = ""
2832
+ output_dir_path = tracked_run.output_dir_path
2833
+ if output_dir_path is not None and output_dir_path != "":
2834
+ if not os.path.exists(output_dir_path):
2835
+ raise ValueError(f"Directory {output_dir_path} does not exist.")
2836
+
2837
+ if not os.path.isdir(output_dir_path):
2838
+ raise ValueError(f"Path {output_dir_path} is not a directory.")
2839
+
2840
+ output_tar_file = self.__package_inputs(output_dir_path)
2841
+
2842
+ # Handle the case where the output is uploaded as Output or a dict.
3055
2843
  upload_output = tracked_run.output
3056
- if isinstance(tracked_run.output, Output):
2844
+ if upload_output is not None and isinstance(tracked_run.output, Output):
3057
2845
  upload_output = tracked_run.output.to_dict()
3058
2846
 
3059
- self.upload_large_input(input=upload_output, upload_url=url_output)
2847
+ # Actually uploads the output.
2848
+ self.upload_large_input(input=upload_output, upload_url=url_output, tar_file=output_tar_file)
3060
2849
 
2850
+ # Create the external run result and appends logs if required.
3061
2851
  external_result = ExternalRunResult(
3062
2852
  output_upload_id=url_output.upload_id,
3063
2853
  status=tracked_run.status.value,
@@ -3076,14 +2866,18 @@ class Application:
3076
2866
  upload_id=url_input.upload_id,
3077
2867
  external_result=external_result,
3078
2868
  instance_id=instance_id,
2869
+ name=tracked_run.name,
2870
+ description=tracked_run.description,
2871
+ configuration=configuration,
3079
2872
  )
3080
2873
 
3081
2874
  def track_run_with_result(
3082
2875
  self,
3083
2876
  tracked_run: TrackedRun,
3084
- polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
2877
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
3085
2878
  instance_id: Optional[str] = None,
3086
2879
  output_dir_path: Optional[str] = ".",
2880
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
3087
2881
  ) -> RunResult:
3088
2882
  """
3089
2883
  Track an external run and poll for the result. This is a convenience
@@ -3104,6 +2898,11 @@ class Application:
3104
2898
  Path to a directory where non-JSON output files will be saved. This is
3105
2899
  required if the output is non-JSON. If the directory does not exist, it
3106
2900
  will be created. Uses the current directory by default.
2901
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
2902
+ Configuration to use for the run. This can be a
2903
+ `cloud.RunConfiguration` object or a dict. If the object is used,
2904
+ then the `.to_dict()` method is applied to extract the
2905
+ configuration.
3107
2906
 
3108
2907
  Returns
3109
2908
  -------
@@ -3123,7 +2922,11 @@ class Application:
3123
2922
  If the run does not succeed after the polling strategy is
3124
2923
  exhausted based on number of tries.
3125
2924
  """
3126
- run_id = self.track_run(tracked_run=tracked_run, instance_id=instance_id)
2925
+ run_id = self.track_run(
2926
+ tracked_run=tracked_run,
2927
+ instance_id=instance_id,
2928
+ configuration=configuration,
2929
+ )
3127
2930
 
3128
2931
  return self.run_result_with_polling(
3129
2932
  run_id=run_id,
@@ -3829,7 +3632,7 @@ class Application:
3829
3632
  # If working with a list of managed inputs, we need to create an
3830
3633
  # input set.
3831
3634
  if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT:
3832
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3635
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3833
3636
  input_set = self.new_input_set(
3834
3637
  id=id,
3835
3638
  name=name,
@@ -3849,7 +3652,7 @@ class Application:
3849
3652
  for data in scenario.scenario_input.scenario_input_data:
3850
3653
  upload_url = self.upload_url()
3851
3654
  self.upload_large_input(input=data, upload_url=upload_url)
3852
- name, id = _name_and_id(prefix="man-input", entity_id=scenario_id)
3655
+ name, id = safe_name_and_id(prefix="man-input", entity_id=scenario_id)
3853
3656
  managed_input = self.new_managed_input(
3854
3657
  id=id,
3855
3658
  name=name,
@@ -3858,7 +3661,7 @@ class Application:
3858
3661
  )
3859
3662
  managed_inputs.append(managed_input)
3860
3663
 
3861
- name, id = _name_and_id(prefix="inpset", entity_id=scenario_id)
3664
+ name, id = safe_name_and_id(prefix="inpset", entity_id=scenario_id)
3862
3665
  input_set = self.new_input_set(
3863
3666
  id=id,
3864
3667
  name=name,
@@ -3873,28 +3676,71 @@ class Application:
3873
3676
  def __validate_input_dir_path_and_configuration(
3874
3677
  self,
3875
3678
  input_dir_path: Optional[str],
3876
- configuration: Optional[RunConfiguration],
3679
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
3877
3680
  ) -> None:
3878
3681
  """
3879
3682
  Auxiliary function to validate the directory path and configuration.
3880
3683
  """
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.
3684
+
3685
+ if input_dir_path is None or input_dir_path == "":
3888
3686
  return
3889
3687
 
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:
3688
+ if configuration is None:
3689
+ raise ValueError(
3690
+ "If dir_path is provided, a RunConfiguration must also be provided.",
3691
+ )
3692
+
3693
+ config_format = self.__extract_config_format(configuration)
3694
+
3695
+ if config_format is None:
3696
+ raise ValueError(
3697
+ "If dir_path is provided, RunConfiguration.format must also be provided.",
3698
+ )
3699
+
3700
+ input_type = self.__extract_input_type(config_format)
3701
+
3702
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3703
+ raise ValueError(
3704
+ "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
3705
+ f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3706
+ )
3707
+
3708
+ def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
3709
+ """Extract format from configuration, handling both RunConfiguration objects and dicts."""
3710
+ if isinstance(configuration, RunConfiguration):
3711
+ return configuration.format
3712
+
3713
+ if isinstance(configuration, dict):
3714
+ config_format = configuration.get("format")
3715
+ if config_format is not None and isinstance(config_format, dict):
3716
+ return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
3717
+
3718
+ return config_format
3719
+
3720
+ raise ValueError("Configuration must be a RunConfiguration object or a dict.")
3721
+
3722
+ def __extract_input_type(self, config_format: Any) -> Any:
3723
+ """Extract input type from config format."""
3724
+ if isinstance(config_format, dict):
3725
+ format_input = config_format.get("format_input") or config_format.get("input")
3726
+ if format_input is None:
3727
+ raise ValueError(
3728
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3729
+ )
3730
+
3731
+ if isinstance(format_input, dict):
3732
+ return format_input.get("input_type") or format_input.get("type")
3733
+
3734
+ return getattr(format_input, "input_type", None)
3735
+
3736
+ # Handle Format object
3737
+ if config_format.format_input is None:
3893
3738
  raise ValueError(
3894
- f"If RunConfiguration.format.format_input.input_type is set to {input_type}, "
3895
- "then input_dir_path must be provided.",
3739
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3896
3740
  )
3897
3741
 
3742
+ return config_format.format_input.input_type
3743
+
3898
3744
  def __package_inputs(self, dir_path: str) -> str:
3899
3745
  """
3900
3746
  This is an auxiliary function for packaging the inputs found in the
@@ -3956,153 +3802,72 @@ class Application:
3956
3802
 
3957
3803
  return size_exceeds or non_json_payload
3958
3804
 
3805
+ def __extract_input_data(
3806
+ self,
3807
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
3808
+ ) -> Optional[Union[dict[str, Any], str]]:
3809
+ """
3810
+ Auxiliary function to extract the input data from the input, based on
3811
+ its type.
3812
+ """
3959
3813
 
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)
3814
+ input_data = None
3815
+ if isinstance(input, BaseModel):
3816
+ input_data = input.to_dict()
3817
+ elif isinstance(input, dict) or isinstance(input, str):
3818
+ input_data = input
3819
+ elif isinstance(input, Input):
3820
+ input_data = input.data
4038
3821
 
4039
- start_time = time.time()
4040
- stopped = False
3822
+ return input_data
4041
3823
 
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
3824
+ def __extract_options_dict(
3825
+ self,
3826
+ options: Optional[Union[Options, dict[str, str]]] = None,
3827
+ json_configurations: Optional[dict[str, Any]] = None,
3828
+ ) -> dict[str, str]:
3829
+ """
3830
+ Auxiliary function to extract the options that will be sent to the
3831
+ application for execution.
3832
+ """
4050
3833
 
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
3834
+ options_dict = {}
3835
+ if options is not None:
3836
+ if isinstance(options, Options):
3837
+ options_dict = options.to_dict_cloud()
4054
3838
 
4055
- break
3839
+ elif isinstance(options, dict):
3840
+ for k, v in options.items():
3841
+ if isinstance(v, str):
3842
+ options_dict[k] = v
3843
+ continue
4056
3844
 
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}")
3845
+ options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
4061
3846
 
4062
- if ok:
4063
- return result
3847
+ return options_dict
4064
3848
 
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}")
3849
+ def __extract_run_config(
3850
+ self,
3851
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
3852
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
3853
+ dir_path: Optional[str] = None,
3854
+ ) -> dict[str, Any]:
3855
+ """
3856
+ Auxiliary function to extract the run configuration that will be sent
3857
+ to the application for execution.
3858
+ """
4069
3859
 
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}",
3860
+ if configuration is not None:
3861
+ configuration_dict = (
3862
+ configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
4073
3863
  )
3864
+ return configuration_dict
4074
3865
 
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
3866
+ configuration = RunConfiguration()
3867
+ configuration.resolve(input=input, dir_path=dir_path)
3868
+ configuration_dict = configuration.to_dict()
4102
3869
 
4103
- raise RuntimeError(
4104
- f"polling did not succeed after {polling_options.max_tries} tries",
4105
- )
3870
+ return configuration_dict
4106
3871
 
4107
3872
 
4108
3873
  def _is_not_exist_error(e: requests.HTTPError) -> bool: