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.
nextmv/output.py CHANGED
@@ -40,6 +40,21 @@ Functions
40
40
  ---------
41
41
  write
42
42
  Write the output to the specified destination.
43
+
44
+ Constants
45
+ ---------
46
+ ASSETS_KEY
47
+ Assets key constant used for identifying assets in the run output.
48
+ STATISTICS_KEY
49
+ Statistics key constant used for identifying statistics in the run output.
50
+ SOLUTIONS_KEY
51
+ Solutions key constant used for identifying solutions in the run output.
52
+ OUTPUTS_KEY
53
+ Outputs key constant used for identifying outputs in the run output.
54
+ LOGS_FILE
55
+ Constant used for identifying the file used for logging.
56
+ DEFAULT_OUTPUT_JSON_FILE
57
+ Constant for the default output JSON file name.
43
58
  """
44
59
 
45
60
  import copy
@@ -59,6 +74,39 @@ from nextmv.deprecated import deprecated
59
74
  from nextmv.logger import reset_stdout
60
75
  from nextmv.options import Options
61
76
 
77
+ ASSETS_KEY = "assets"
78
+ """
79
+ Assets key constant used for identifying assets in the run output.
80
+ """
81
+ STATISTICS_KEY = "statistics"
82
+ """
83
+ Statistics key constant used for identifying statistics in the run output.
84
+ """
85
+ SOLUTIONS_KEY = "solutions"
86
+ """
87
+ Solutions key constant used for identifying solutions in the run output.
88
+ """
89
+ OUTPUTS_KEY = "outputs"
90
+ """
91
+ Outputs key constant used for identifying outputs in the run output.
92
+ """
93
+ OUTPUT_KEY = "output"
94
+ """
95
+ Output key constant used for identifying output in the run output.
96
+ """
97
+ LOGS_KEY = "logs"
98
+ """
99
+ Logs key constant used for identifying logs in the run output.
100
+ """
101
+ LOGS_FILE = "stderr.log"
102
+ """
103
+ Constant used for identifying the file used for logging.
104
+ """
105
+ DEFAULT_OUTPUT_JSON_FILE = "solution.json"
106
+ """
107
+ Constant for the default output JSON file name.
108
+ """
109
+
62
110
 
63
111
  class RunStatistics(BaseModel):
64
112
  """
@@ -495,6 +543,8 @@ class OutputFormat(str, Enum):
495
543
  CSV archive format: multiple CSV files.
496
544
  MULTI_FILE : str
497
545
  Multi-file format: multiple files in a directory.
546
+ TEXT : str
547
+ Text format, utf-8 encoded.
498
548
  """
499
549
 
500
550
  JSON = "json"
@@ -503,6 +553,8 @@ class OutputFormat(str, Enum):
503
553
  """CSV archive format: multiple CSV files."""
504
554
  MULTI_FILE = "multi-file"
505
555
  """Multi-file format: multiple files in a directory."""
556
+ TEXT = "text"
557
+ """Text format, utf-8 encoded."""
506
558
 
507
559
 
508
560
  @dataclass
@@ -1072,8 +1124,8 @@ class Output:
1072
1124
  output_dict = {
1073
1125
  "options": options,
1074
1126
  "solution": self.solution if self.solution is not None else {},
1075
- "statistics": statistics,
1076
- "assets": assets,
1127
+ STATISTICS_KEY: statistics,
1128
+ ASSETS_KEY: assets,
1077
1129
  }
1078
1130
 
1079
1131
  # Add the auxiliary configurations to the output dictionary if they are
@@ -1232,8 +1284,8 @@ class LocalOutputWriter(OutputWriter):
1232
1284
  serialized = serialize_json(
1233
1285
  {
1234
1286
  "options": output_dict.get("options", {}),
1235
- "statistics": output_dict.get("statistics", {}),
1236
- "assets": output_dict.get("assets", []),
1287
+ STATISTICS_KEY: output_dict.get(STATISTICS_KEY, {}),
1288
+ ASSETS_KEY: output_dict.get(ASSETS_KEY, []),
1237
1289
  },
1238
1290
  json_configurations=json_configurations,
1239
1291
  )
@@ -1281,7 +1333,7 @@ class LocalOutputWriter(OutputWriter):
1281
1333
  ValueError
1282
1334
  If the path is an existing file instead of a directory.
1283
1335
  """
1284
- dir_path = "outputs"
1336
+ dir_path = OUTPUTS_KEY
1285
1337
  if path is not None and path != "":
1286
1338
  if os.path.isfile(path):
1287
1339
  raise ValueError(f"The path refers to an existing file: {path}")
@@ -1299,13 +1351,13 @@ class LocalOutputWriter(OutputWriter):
1299
1351
  parent_dir=dir_path,
1300
1352
  json_configurations=json_configurations,
1301
1353
  output_dict=output_dict,
1302
- element_key="statistics",
1354
+ element_key=STATISTICS_KEY,
1303
1355
  )
1304
1356
  self._write_multi_file_element(
1305
1357
  parent_dir=dir_path,
1306
1358
  json_configurations=json_configurations,
1307
1359
  output_dict=output_dict,
1308
- element_key="assets",
1360
+ element_key=ASSETS_KEY,
1309
1361
  )
1310
1362
  self._write_multi_file_solution(dir_path=dir_path, output=output)
1311
1363
 
@@ -1350,7 +1402,7 @@ class LocalOutputWriter(OutputWriter):
1350
1402
  if output.solution_files is None:
1351
1403
  return
1352
1404
 
1353
- solutions_dir = os.path.join(dir_path, "solutions")
1405
+ solutions_dir = os.path.join(dir_path, SOLUTIONS_KEY)
1354
1406
 
1355
1407
  if not os.path.exists(solutions_dir):
1356
1408
  os.makedirs(solutions_dir)
@@ -1381,6 +1433,7 @@ class LocalOutputWriter(OutputWriter):
1381
1433
  OutputFormat.JSON: _write_json,
1382
1434
  OutputFormat.CSV_ARCHIVE: _write_archive,
1383
1435
  OutputFormat.MULTI_FILE: _write_multi_file,
1436
+ OutputFormat.TEXT: _write_json,
1384
1437
  }
1385
1438
  """Dictionary mapping output formats to writer functions."""
1386
1439
 
nextmv/polling.py ADDED
@@ -0,0 +1,287 @@
1
+ """
2
+ Polling module containing logic to poll for a run result.
3
+
4
+ Polling can be used with both Cloud and local applications.
5
+
6
+ Classes
7
+ -------
8
+ PollingOptions
9
+ Options to use when polling for a run result.
10
+
11
+ Functions
12
+ ---------
13
+ poll
14
+ Function to poll a function until it succeeds or the polling strategy is
15
+ exhausted.
16
+ """
17
+
18
+ import random
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from typing import Any, Optional
23
+
24
+ from nextmv.logger import log
25
+
26
+
27
+ @dataclass
28
+ class PollingOptions:
29
+ """
30
+ Options to use when polling for a run result.
31
+
32
+ You can import the `PollingOptions` class directly from `nextmv`:
33
+
34
+ ```python
35
+ from nextmv import PollingOptions
36
+ ```
37
+
38
+ The Cloud API will be polled for the result. The polling stops if:
39
+
40
+ * The maximum number of polls (tries) are exhausted. This is specified by
41
+ the `max_tries` parameter.
42
+ * The maximum duration of the polling strategy is reached. This is
43
+ specified by the `max_duration` parameter.
44
+
45
+ Before conducting the first poll, the `initial_delay` is used to sleep.
46
+ After each poll, a sleep duration is calculated using the following
47
+ strategy, based on exponential backoff with jitter:
48
+
49
+ ```
50
+ sleep_duration = min(`max_delay`, `delay` + `backoff` * 2 ** i + Uniform(0, `jitter`))
51
+ ```
52
+
53
+ Where:
54
+ * i is the retry (poll) number.
55
+ * Uniform is the uniform distribution.
56
+
57
+ Note that the sleep duration is capped by the `max_delay` parameter.
58
+
59
+ Parameters
60
+ ----------
61
+ backoff : float, default=0.9
62
+ Exponential backoff factor, in seconds, to use between polls.
63
+ delay : float, default=0.1
64
+ Base delay to use between polls, in seconds.
65
+ initial_delay : float, default=1.0
66
+ Initial delay to use before starting the polling strategy, in seconds.
67
+ max_delay : float, default=20.0
68
+ Maximum delay to use between polls, in seconds.
69
+ max_duration : float, default=300.0
70
+ Maximum duration of the polling strategy, in seconds.
71
+ max_tries : int, default=100
72
+ Maximum number of tries to use.
73
+ jitter : float, default=1.0
74
+ Jitter to use for the polling strategy. A uniform distribution is sampled
75
+ between 0 and this number. The resulting random number is added to the
76
+ delay for each poll, adding a random noise. Set this to 0 to avoid using
77
+ random jitter.
78
+ verbose : bool, default=False
79
+ Whether to log the polling strategy. This is useful for debugging.
80
+ stop : callable, default=None
81
+ Function to call to check if the polling should stop. This is useful for
82
+ stopping the polling based on external conditions. The function should
83
+ return True to stop the polling and False to continue. The function does
84
+ not receive any arguments. The function is called before each poll.
85
+
86
+ Examples
87
+ --------
88
+ >>> from nextmv.cloud import PollingOptions
89
+ >>> # Create polling options with custom settings
90
+ >>> polling_options = PollingOptions(
91
+ ... max_tries=50,
92
+ ... max_duration=600,
93
+ ... verbose=True
94
+ ... )
95
+ """
96
+
97
+ backoff: float = 0.9
98
+ """
99
+ Exponential backoff factor, in seconds, to use between polls.
100
+ """
101
+ delay: float = 0.1
102
+ """Base delay to use between polls, in seconds."""
103
+ initial_delay: float = 1
104
+ """
105
+ Initial delay to use before starting the polling strategy, in seconds.
106
+ """
107
+ max_delay: float = 20
108
+ """Maximum delay to use between polls, in seconds."""
109
+ max_duration: float = -1
110
+ """
111
+ Maximum duration of the polling strategy, in seconds. A negative value means no limit.
112
+ """
113
+ max_tries: int = -1
114
+ """Maximum number of tries to use. A negative value means no limit."""
115
+ jitter: float = 1
116
+ """
117
+ Jitter to use for the polling strategy. A uniform distribution is sampled
118
+ between 0 and this number. The resulting random number is added to the
119
+ delay for each poll, adding a random noise. Set this to 0 to avoid using
120
+ random jitter.
121
+ """
122
+ verbose: bool = False
123
+ """Whether to log the polling strategy. This is useful for debugging."""
124
+ stop: Optional[Callable[[], bool]] = None
125
+ """
126
+ Function to call to check if the polling should stop. This is useful for
127
+ stopping the polling based on external conditions. The function should
128
+ return True to stop the polling and False to continue. The function does
129
+ not receive any arguments. The function is called before each poll.
130
+ """
131
+
132
+
133
+ DEFAULT_POLLING_OPTIONS: PollingOptions = PollingOptions()
134
+ """
135
+ Default polling options to use when polling for a run result. This constant
136
+ provides the default values for `PollingOptions` used across the module.
137
+ Using these defaults is recommended for most use cases unless specific timing
138
+ needs are required.
139
+ """
140
+
141
+
142
+ def poll( # noqa: C901
143
+ polling_options: PollingOptions,
144
+ polling_func: Callable[[], tuple[Any, bool]],
145
+ __sleep_func: Callable[[float], None] = time.sleep,
146
+ ) -> Any:
147
+ """
148
+ Poll a function until it succeeds or the polling strategy is exhausted.
149
+
150
+ You can import the `poll` function directly from `nextmv`:
151
+
152
+ ```python
153
+ from nextmv import poll
154
+ ```
155
+
156
+ This function implements a flexible polling strategy with exponential backoff
157
+ and jitter. It calls the provided polling function repeatedly until it indicates
158
+ success, the maximum number of tries is reached, or the maximum duration is exceeded.
159
+
160
+ The `polling_func` is a callable that must return a `tuple[Any, bool]`
161
+ where the first element is the result of the polling and the second
162
+ element is a boolean indicating if the polling was successful or should be
163
+ retried.
164
+
165
+ Parameters
166
+ ----------
167
+ polling_options : PollingOptions
168
+ Options for configuring the polling behavior, including retry counts,
169
+ delays, timeouts, and verbosity settings.
170
+ polling_func : callable
171
+ Function to call to check if the polling was successful. Must return a tuple
172
+ where the first element is the result value and the second is a boolean
173
+ indicating success (True) or need to retry (False).
174
+
175
+ Returns
176
+ -------
177
+ Any
178
+ Result value from the polling function when successful.
179
+
180
+ Raises
181
+ ------
182
+ TimeoutError
183
+ If the polling exceeds the maximum duration specified in polling_options.
184
+ RuntimeError
185
+ If the maximum number of tries is exhausted without success.
186
+
187
+ Examples
188
+ --------
189
+ >>> from nextmv.cloud import PollingOptions, poll
190
+ >>> import time
191
+ >>>
192
+ >>> # Define a polling function that succeeds after 3 tries
193
+ >>> counter = 0
194
+ >>> def check_completion() -> tuple[str, bool]:
195
+ ... global counter
196
+ ... counter += 1
197
+ ... if counter >= 3:
198
+ ... return "Success", True
199
+ ... return None, False
200
+ ...
201
+ >>> # Configure polling options
202
+ >>> options = PollingOptions(
203
+ ... max_tries=5,
204
+ ... delay=0.1,
205
+ ... backoff=0.2,
206
+ ... verbose=True
207
+ ... )
208
+ >>>
209
+ >>> # Poll until the function succeeds
210
+ >>> result = poll(options, check_completion)
211
+ >>> print(result)
212
+ 'Success'
213
+ """
214
+
215
+ # Start by sleeping for the duration specified as initial delay.
216
+ if polling_options.verbose:
217
+ log(f"polling | sleeping for initial delay: {polling_options.initial_delay}")
218
+
219
+ __sleep_func(polling_options.initial_delay)
220
+
221
+ start_time = time.time()
222
+ stopped = False
223
+
224
+ # Begin the polling process.
225
+ max_reached = False
226
+ ix = 0
227
+ while True:
228
+ # Check if we reached the maximum number of tries. Break if so.
229
+ if ix >= polling_options.max_tries and polling_options.max_tries >= 0:
230
+ break
231
+ ix += 1
232
+
233
+ # Check is we should stop polling according to the stop callback.
234
+ if polling_options.stop is not None and polling_options.stop():
235
+ stopped = True
236
+
237
+ break
238
+
239
+ # We check if we can stop polling.
240
+ result, ok = polling_func()
241
+ if polling_options.verbose:
242
+ log(f"polling | try # {ix + 1}, ok: {ok}")
243
+
244
+ if ok:
245
+ return result
246
+
247
+ # An exit condition happens if we exceed the allowed duration.
248
+ passed = time.time() - start_time
249
+ if polling_options.verbose:
250
+ log(f"polling | elapsed time: {passed}")
251
+
252
+ if passed >= polling_options.max_duration and polling_options.max_duration >= 0:
253
+ raise TimeoutError(
254
+ f"polling did not succeed after {passed} seconds, exceeds max duration: {polling_options.max_duration}",
255
+ )
256
+
257
+ # Calculate the delay.
258
+ if max_reached:
259
+ # If we already reached the maximum, we don't want to further calculate the
260
+ # delay to avoid overflows.
261
+ delay = polling_options.max_delay
262
+ delay += random.uniform(0, polling_options.jitter) # Add jitter.
263
+ else:
264
+ delay = polling_options.delay # Base
265
+ delay += polling_options.backoff * (2**ix) # Add exponential backoff.
266
+ delay += random.uniform(0, polling_options.jitter) # Add jitter.
267
+
268
+ # We cannot exceed the max delay.
269
+ if delay >= polling_options.max_delay:
270
+ max_reached = True
271
+ delay = polling_options.max_delay
272
+
273
+ # Sleep for the calculated delay.
274
+ sleep_duration = delay
275
+ if polling_options.verbose:
276
+ log(f"polling | sleeping for duration: {sleep_duration}")
277
+
278
+ __sleep_func(sleep_duration)
279
+
280
+ if stopped:
281
+ log("polling | stop condition met, stopping polling")
282
+
283
+ return None
284
+
285
+ raise RuntimeError(
286
+ f"polling did not succeed after {polling_options.max_tries} tries",
287
+ )