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.
@@ -0,0 +1,1173 @@
1
+ """
2
+ Application module for interacting with local Nextmv applications.
3
+
4
+ This module provides functionality to interact with applications in Nextmv,
5
+ including application management, running applications, and managing inputs.
6
+
7
+ Classes
8
+ -------
9
+ Application
10
+ Class for interacting with local Nextmv Applications.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import shutil
16
+ import tempfile
17
+ import webbrowser
18
+ from dataclasses import dataclass
19
+ from datetime import datetime, timezone
20
+ from typing import Any, Optional, Union
21
+
22
+ from nextmv import cloud
23
+ from nextmv._serialization import deflated_serialize_json
24
+ from nextmv.base_model import BaseModel
25
+ from nextmv.input import INPUTS_KEY, Input, InputFormat
26
+ from nextmv.local.local import (
27
+ DEFAULT_INPUT_JSON_FILE,
28
+ DEFAULT_OUTPUT_JSON_FILE,
29
+ LOGS_FILE,
30
+ LOGS_KEY,
31
+ NEXTMV_DIR,
32
+ RUNS_KEY,
33
+ )
34
+ from nextmv.local.runner import run
35
+ from nextmv.logger import log
36
+ from nextmv.manifest import Manifest
37
+ from nextmv.options import Options
38
+ from nextmv.output import OUTPUTS_KEY, SOLUTIONS_KEY, OutputFormat
39
+ from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
40
+ from nextmv.run import ErrorLog, Format, Run, RunConfiguration, RunInformation, RunResult, TrackedRun, TrackedRunStatus
41
+ from nextmv.safe import safe_id
42
+ from nextmv.status import StatusV2
43
+
44
+
45
+ @dataclass
46
+ class Application:
47
+ """
48
+ A decision model that can be executed.
49
+
50
+ You can import the `Application` class directly from `local`:
51
+
52
+ ```python
53
+ from nextmv.local import Application
54
+ ```
55
+
56
+ This class represents an application in Nextmv, providing methods to
57
+ interact with the application, run it with different inputs, manage
58
+ versions, instances, experiments, and more.
59
+
60
+ Parameters
61
+ ----------
62
+ src : str
63
+ Source of the application, when initialized locally. An application's
64
+ source typically refers to the directory containing the `app.yaml`
65
+ manifest.
66
+ description : Optional[str], default=None
67
+ Description of the application.
68
+
69
+ Examples
70
+ --------
71
+ >>> from nextmv.local import Application
72
+ >>> app = Application(src="path/to/app")
73
+ >>> # Retrieve an app's run result
74
+ >>> result = app.run_result("run-id")
75
+ """
76
+
77
+ src: str
78
+ """
79
+ Source of the application, when initialized locally. An application's
80
+ source typically refers to the directory containing the `app.yaml`
81
+ manifest.
82
+ """
83
+
84
+ description: Optional[str] = None
85
+ """Description of the application."""
86
+
87
+ @classmethod
88
+ def initialize(
89
+ cls,
90
+ src: Optional[str] = None,
91
+ description: Optional[str] = None,
92
+ destination: Optional[str] = None,
93
+ ) -> "Application":
94
+ """
95
+ Initialize a sample Nextmv application, locally.
96
+
97
+ This method will create a new application in the local file system. The
98
+ application is a dir with the name given by `src` (it becomes the
99
+ _source_ of the app), under the location given by `destination`. If the
100
+ `destination` parameter is not specified, the current working directory
101
+ is used as default. This method will scaffold the application with the
102
+ necessary files and directories to have an opinionated structure for
103
+ your decision model. Once the application is initialized, you are
104
+ encouraged to complete it with the decision model itself, so that the
105
+ application can be run locally.
106
+
107
+ If the `src` parameter is not provided, a random name will be generated
108
+ for the application.
109
+
110
+ Parameters
111
+ ----------
112
+ src : str, optional
113
+ Source (ID, name) of the application. Will be generated if not
114
+ provided.
115
+ description : str, optional
116
+ Description of the application.
117
+ destination : str, optional
118
+ Destination directory where the application will be initialized. If
119
+ not provided, the current working directory will be used.
120
+
121
+ Returns
122
+ -------
123
+ Application
124
+ The initialized application instance.
125
+ """
126
+
127
+ destination_dir = os.getcwd() if destination is None else destination
128
+ app_id = src if src is not None else safe_id("app")
129
+
130
+ # Create the new directory with the given name.
131
+ app_src = os.path.join(destination_dir, app_id)
132
+ if os.path.exists(app_src):
133
+ raise FileExistsError(f"destination dir for src already exists: {app_src}")
134
+
135
+ os.makedirs(app_src, exist_ok=False)
136
+
137
+ # Get the path to the initial app structure template.
138
+ current_file_dir = os.path.dirname(os.path.abspath(__file__))
139
+ initial_app_structure_path = os.path.join(current_file_dir, "..", "default_app")
140
+ initial_app_structure_path = os.path.normpath(initial_app_structure_path)
141
+
142
+ # Copy everything from initial_app_structure to the new directory.
143
+ if os.path.exists(initial_app_structure_path):
144
+ shutil.copytree(initial_app_structure_path, app_src, dirs_exist_ok=True)
145
+
146
+ return cls(
147
+ src=app_src,
148
+ description=description,
149
+ )
150
+
151
+ def list_runs(self) -> list[Run]:
152
+ """
153
+ List all runs for the application.
154
+
155
+ Returns
156
+ -------
157
+ list[Run]
158
+ A list of all runs associated with the application.
159
+ """
160
+
161
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
162
+ if not os.path.exists(runs_dir):
163
+ raise ValueError(f"`.nextmv/runs` dir does not exist at app source: {self.src}")
164
+
165
+ dirs = os.listdir(runs_dir)
166
+ if not dirs:
167
+ return []
168
+
169
+ run_ids = [d for d in dirs if os.path.isdir(os.path.join(runs_dir, d))]
170
+ if not run_ids:
171
+ return []
172
+
173
+ runs = []
174
+ for run_id in run_ids:
175
+ info = self.run_metadata(run_id=run_id)
176
+ run = info.to_run()
177
+ runs.append(run)
178
+
179
+ return runs
180
+
181
+ def new_run(
182
+ self,
183
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
184
+ name: Optional[str] = None,
185
+ description: Optional[str] = None,
186
+ options: Optional[Union[Options, dict[str, str]]] = None,
187
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
188
+ json_configurations: Optional[dict[str, Any]] = None,
189
+ input_dir_path: Optional[str] = None,
190
+ ) -> str:
191
+ """
192
+ Run the application locally with the provided input.
193
+
194
+ This method is the local equivalent to `cloud.Application.new_run`,
195
+ which submits the input to Nextmv Cloud. This method runs the
196
+ application locally using the `src` of the app.
197
+
198
+ Make sure that the `src` attribute is set on the `Application` class
199
+ before running locally, as it is required by the method.
200
+
201
+ Parameters
202
+ ----------
203
+ input: Union[Input, dict[str, Any], BaseModel, str]
204
+ Input to use for the run. This can be a `nextmv.Input` object,
205
+ `dict`, `BaseModel` or `str`.
206
+
207
+ If `nextmv.Input` is used, and the `input_format` is either
208
+ `nextmv.InputFormat.JSON` or `nextmv.InputFormat.TEXT`, then the
209
+ input data is extracted from the `.data` property.
210
+
211
+ If you want to work with `nextmv.InputFormat.CSV_ARCHIVE` or
212
+ `nextmv.InputFormat.MULTI_FILE`, you should use the
213
+ `input_dir_path` argument instead. This argument takes precedence
214
+ over the `input`. If `input_dir_path` is specified, this function
215
+ looks for files in that directory and tars them, to later be
216
+ uploaded using the `upload_large_input` method. If both the
217
+ `input_dir_path` and `input` arguments are provided, the `input`
218
+ is ignored.
219
+
220
+ When `input_dir_path` is specified, the `configuration` argument
221
+ must also be provided. More specifically, the
222
+ `RunConfiguration.format.format_input.input_type` parameter
223
+ dictates what kind of input is being submitted to the Nextmv Cloud.
224
+ Make sure that this parameter is specified when working with the
225
+ following input formats:
226
+
227
+ - `nextmv.InputFormat.CSV_ARCHIVE`
228
+ - `nextmv.InputFormat.MULTI_FILE`
229
+
230
+ When working with JSON or text data, use the `input` argument
231
+ directly.
232
+
233
+ In general, if an input is too large, it will be uploaded with the
234
+ `upload_large_input` method.
235
+ name: Optional[str]
236
+ Name of the local run.
237
+ description: Optional[str]
238
+ Description of the local run.
239
+ options: Optional[Union[Options, dict[str, str]]]
240
+ Options to use for the run. This can be a `nextmv.Options` object
241
+ or a dict. If a dict is used, the keys must be strings and the
242
+ values must be strings as well. If a `nextmv.Options` object is
243
+ used, the options are extracted from the `.to_cloud_dict()` method.
244
+ Note that specifying `options` overrides the `input.options` (if
245
+ the `input` is of type `nextmv.Input`).
246
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
247
+ Configuration to use for the run. This can be a
248
+ `cloud.RunConfiguration` object or a dict. If the object is used,
249
+ then the `.to_dict()` method is applied to extract the
250
+ configuration.
251
+ json_configurations: Optional[dict[str, Any]]
252
+ Optional configurations for JSON serialization. This is used to
253
+ customize the serialization before data is sent.
254
+ input_dir_path: Optional[str]
255
+ Path to a directory containing input files. This is useful for
256
+ input formats like `nextmv.InputFormat.CSV_ARCHIVE` or
257
+ `nextmv.InputFormat.MULTI_FILE`. If both `input` and
258
+ `input_dir_path` are specified, the `input` is ignored, and the
259
+ files in the directory are used instead.
260
+
261
+ Returns
262
+ -------
263
+ str
264
+ ID (`run_id`) of the local run that was executed.
265
+
266
+ Raises
267
+ ------
268
+ ValueError
269
+ If the `src` property for the `Application` is not specified.
270
+ If neither `input` nor `input_dir_path` is specified.
271
+ If `input_dir_path` is specified but `configuration` is not provided.
272
+ FileNotFoundError
273
+ If the manifest.yaml file cannot be found in the specified `src` directory.
274
+
275
+ Examples
276
+ --------
277
+ >>> from nextmv.local import Application
278
+ >>> app = Application(id="my-app", src="/path/to/app")
279
+ >>> run_id = app.new_run(
280
+ ... input={"vehicles": [{"id": "v1"}]},
281
+ ... options={"duration": "10s"}
282
+ ... )
283
+ >>> print(f"Local run completed with ID: {run_id}")
284
+ """
285
+
286
+ self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
287
+
288
+ if self.src is None:
289
+ raise ValueError("`src` property for the `Application` must be specified to run the application locally")
290
+
291
+ if input is None and input_dir_path is None:
292
+ raise ValueError("Either `input` or `input_directory` must be specified")
293
+
294
+ try:
295
+ manifest = Manifest.from_yaml(self.src)
296
+ except FileNotFoundError as e:
297
+ raise FileNotFoundError(
298
+ f"Could not find manifest.yaml in {self.src}. Maybe specify a different `src` dir?"
299
+ ) from e
300
+
301
+ input_data = None if input_dir_path else self.__extract_input_data(input)
302
+ options_dict = self.__extract_options_dict(options, json_configurations)
303
+ run_config_dict = self.__extract_run_config(input, configuration, input_dir_path)
304
+ run_id = run(
305
+ app_id=self.src,
306
+ src=self.src,
307
+ manifest=manifest,
308
+ run_config=run_config_dict,
309
+ name=name,
310
+ description=description,
311
+ input_data=input_data,
312
+ inputs_dir_path=input_dir_path,
313
+ options=options_dict,
314
+ )
315
+
316
+ return run_id
317
+
318
+ def new_run_with_result(
319
+ self,
320
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
321
+ name: Optional[str] = None,
322
+ description: Optional[str] = None,
323
+ run_options: Optional[Union[Options, dict[str, str]]] = None,
324
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
325
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
326
+ json_configurations: Optional[dict[str, Any]] = None,
327
+ input_dir_path: Optional[str] = None,
328
+ output_dir_path: Optional[str] = ".",
329
+ ) -> RunResult:
330
+ """
331
+ Submit an input to start a new local run of the application and poll
332
+ for the result. This is a convenience method that combines the
333
+ `new_run` and `run_result_with_polling` methods, applying
334
+ polling logic to check when the local run succeeded.
335
+
336
+ This method is the local equivalent to
337
+ `cloud.Application.new_run_with_result`, which submits the input to
338
+ Nextmv Cloud. This method runs the application locally using the `src`
339
+ of the app.
340
+
341
+ Make sure that the `src` attribute is set on the `Application` class
342
+ before running locally, as it is required by the method.
343
+
344
+ Parameters
345
+ ----------
346
+ input: Union[Input, dict[str, Any], BaseModel, str]
347
+ Input to use for the run. This can be a `nextmv.Input` object,
348
+ `dict`, `BaseModel` or `str`.
349
+
350
+ If `nextmv.Input` is used, and the `input_format` is either
351
+ `nextmv.InputFormat.JSON` or `nextmv.InputFormat.TEXT`, then the
352
+ input data is extracted from the `.data` property.
353
+
354
+ If you want to work with `nextmv.InputFormat.CSV_ARCHIVE` or
355
+ `nextmv.InputFormat.MULTI_FILE`, you should use the
356
+ `input_dir_path` argument instead. This argument takes precedence
357
+ over the `input`. If `input_dir_path` is specified, this function
358
+ looks for files in that directory and tars them, to later be
359
+ uploaded using the `upload_large_input` method. If both the
360
+ `input_dir_path` and `input` arguments are provided, the `input` is
361
+ ignored.
362
+
363
+ When `input_dir_path` is specified, the `configuration` argument
364
+ must also be provided. More specifically, the
365
+ `RunConfiguration.format.format_input.input_type` parameter
366
+ dictates what kind of input is being submitted to the Nextmv Cloud.
367
+ Make sure that this parameter is specified when working with the
368
+ following input formats:
369
+
370
+ - `nextmv.InputFormat.CSV_ARCHIVE`
371
+ - `nextmv.InputFormat.MULTI_FILE`
372
+
373
+ When working with JSON or text data, use the `input` argument
374
+ directly.
375
+
376
+ In general, if an input is too large, it will be uploaded with the
377
+ `upload_large_input` method.
378
+ name: Optional[str]
379
+ Name of the local run.
380
+ description: Optional[str]
381
+ Description of the local run.
382
+ run_options: Optional[Union[Options, dict[str, str]]]
383
+ Options to use for the run. This can be a `nextmv.Options` object
384
+ or a dict. If a dict is used, the keys must be strings and the
385
+ values must be strings as well. If a `nextmv.Options` object is
386
+ used, the options are extracted from the `.to_cloud_dict()` method.
387
+ Note that specifying `options` overrides the `input.options` (if
388
+ the `input` is of type `nextmv.Input`).
389
+ polling_options: PollingOptions, default=_DEFAULT_POLLING_OPTIONS
390
+ Options to use when polling for the run result.
391
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]]
392
+ Configuration to use for the run. This can be a
393
+ `cloud.RunConfiguration` object or a dict. If the object is used,
394
+ then the `.to_dict()` method is applied to extract the
395
+ configuration.
396
+ json_configurations: Optional[dict[str, Any]]
397
+ Optional configurations for JSON serialization. This is used to
398
+ customize the serialization before data is sent.
399
+ input_dir_path: Optional[str]
400
+ Path to a directory containing input files. This is useful for
401
+ input formats like `nextmv.InputFormat.CSV_ARCHIVE` or
402
+ `nextmv.InputFormat.MULTI_FILE`. If both `input` and
403
+ `input_dir_path` are specified, the `input` is ignored, and the
404
+ files in the directory are used instead.
405
+ output_dir_path : Optional[str], default="."
406
+ Path to a directory where non-JSON output files will be saved. This
407
+ is required if the output is non-JSON. If the directory does not
408
+ exist, it will be created. Uses the current directory by default.
409
+
410
+ Returns
411
+ -------
412
+ RunResult
413
+ Result of the run, including output.
414
+
415
+ Raises
416
+ ------
417
+ ValueError
418
+ If the `src` property for the `Application` is not specified. If
419
+ neither `input` nor `inputs_dir_path` is specified. If
420
+ `inputs_dir_path` is specified but `configuration` is not provided.
421
+ FileNotFoundError
422
+ If the manifest.yaml file cannot be found in the specified `src`
423
+ directory.
424
+
425
+ Examples
426
+ --------
427
+ >>> from nextmv.local import Application
428
+ >>> app = Application(id="my-app", src="/path/to/app")
429
+ >>> run_result = app.new_run_with_result(
430
+ ... input={"vehicles": [{"id": "v1"}]},
431
+ ... options={"duration": "10s"}
432
+ ... )
433
+ >>> print(f"Local run completed with ID: {run_result.id}")
434
+ """
435
+
436
+ run_id = self.new_run(
437
+ input=input,
438
+ name=name,
439
+ description=description,
440
+ options=run_options,
441
+ configuration=configuration,
442
+ json_configurations=json_configurations,
443
+ input_dir_path=input_dir_path,
444
+ )
445
+
446
+ return self.run_result_with_polling(
447
+ run_id=run_id,
448
+ polling_options=polling_options,
449
+ output_dir_path=output_dir_path,
450
+ )
451
+
452
+ def run_metadata(self, run_id: str) -> RunInformation:
453
+ """
454
+ Get the metadata of a local run.
455
+
456
+ This method is the local equivalent to
457
+ `cloud.Application.run_metadata`, which retrieves the metadata of a
458
+ remote run in Nextmv Cloud. This method is used to get the metadata of
459
+ a run that was executed locally using the `new_run` or
460
+ `new_run_with_result` method.
461
+
462
+ Retrieves information about a run without including the run output.
463
+ This is useful when you only need the run's status and metadata.
464
+
465
+ Parameters
466
+ ----------
467
+ run_id : str
468
+ ID of the run to retrieve metadata for.
469
+
470
+ Returns
471
+ -------
472
+ RunInformation
473
+ Metadata of the run (run information without output).
474
+
475
+ Raises
476
+ ------
477
+ ValueError
478
+ If the `.nextmv/runs` directory does not exist at the application
479
+ source, or if the specified run ID does not exist.
480
+
481
+ Examples
482
+ --------
483
+ >>> metadata = app.run_metadata("run-789")
484
+ >>> print(metadata.metadata.status_v2)
485
+ StatusV2.succeeded
486
+ """
487
+
488
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
489
+ if not os.path.exists(runs_dir):
490
+ raise ValueError(f"`.nextmv/runs` dir does not exist at app source: {self.src}")
491
+
492
+ run_dir = os.path.join(runs_dir, run_id)
493
+ if not os.path.exists(run_dir):
494
+ raise ValueError(f"`{run_id}` run dir does not exist at: {runs_dir}")
495
+
496
+ info_file = os.path.join(run_dir, f"{run_id}.json")
497
+ if not os.path.exists(info_file):
498
+ raise ValueError(f"`{info_file}` file does not exist at: {run_dir}")
499
+
500
+ with open(info_file) as f:
501
+ info_dict = json.load(f)
502
+
503
+ info = RunInformation.from_dict(info_dict)
504
+
505
+ return info
506
+
507
+ def run_result(self, run_id: str, output_dir_path: Optional[str] = ".") -> RunResult:
508
+ """
509
+ Get the local result of a run.
510
+
511
+ This method is the local equivalent to `cloud.Application.run_result`,
512
+ which retrieves the result of a remote run in Nextmv Cloud. This method
513
+ is used to get the result of a run that was executed locally using the
514
+ `new_run` or `new_run_with_result` method.
515
+
516
+ Retrieves the complete result of a run, including the run output.
517
+
518
+ Parameters
519
+ ----------
520
+ run_id : str
521
+ ID of the run to get results for.
522
+ output_dir_path : Optional[str], default="."
523
+ Path to a directory where non-JSON output files will be saved. This
524
+ is required if the output is non-JSON. If the directory does not
525
+ exist, it will be created. Uses the current directory by default.
526
+
527
+ Returns
528
+ -------
529
+ RunResult
530
+ Result of the run, including output.
531
+
532
+ Raises
533
+ ------
534
+ ValueError
535
+ If the `.nextmv/runs` directory does not exist at the application
536
+ source, or if the specified run ID does not exist.
537
+
538
+ Examples
539
+ --------
540
+ >>> result = app.run_result("run-123")
541
+ >>> print(result.metadata.status_v2)
542
+ 'succeeded'
543
+ """
544
+
545
+ run_information = self.run_metadata(run_id=run_id)
546
+
547
+ return self.__run_result(
548
+ run_id=run_id,
549
+ run_information=run_information,
550
+ output_dir_path=output_dir_path,
551
+ )
552
+
553
+ def run_result_with_polling(
554
+ self,
555
+ run_id: str,
556
+ polling_options: PollingOptions = DEFAULT_POLLING_OPTIONS,
557
+ output_dir_path: Optional[str] = ".",
558
+ ) -> RunResult:
559
+ """
560
+ Get the result of a local run with polling.
561
+
562
+ This method is the local equivalent to
563
+ `cloud.Application.run_result_with_polling`, which retrieves the result
564
+ of a remote run in Nextmv Cloud. This method is used to get the result
565
+ of a run that was executed locally using the `new_run` or
566
+ `new_run_with_result` method.
567
+
568
+ Retrieves the result of a run including the run output. This method
569
+ polls for the result until the run finishes executing or the polling
570
+ strategy is exhausted.
571
+
572
+ Parameters
573
+ ----------
574
+ run_id : str
575
+ ID of the run to retrieve the result for.
576
+ polling_options : PollingOptions, default=_DEFAULT_POLLING_OPTIONS
577
+ Options to use when polling for the run result.
578
+ output_dir_path : Optional[str], default="."
579
+ Path to a directory where non-JSON output files will be saved. This
580
+ is required if the output is non-JSON. If the directory does not
581
+ exist, it will be created. Uses the current directory by default.
582
+
583
+ Returns
584
+ -------
585
+ RunResult
586
+ Complete result of the run including output data.
587
+
588
+ Raises
589
+ ------
590
+ requests.HTTPError
591
+ If the response status code is not 2xx.
592
+ TimeoutError
593
+ If the run does not complete after the polling strategy is
594
+ exhausted based on time duration.
595
+ RuntimeError
596
+ If the run does not complete after the polling strategy is
597
+ exhausted based on number of tries.
598
+
599
+ Examples
600
+ --------
601
+ >>> from nextmv.cloud import PollingOptions
602
+ >>> # Create custom polling options
603
+ >>> polling_opts = PollingOptions(max_tries=50, max_duration=600)
604
+ >>> # Get run result with polling
605
+ >>> result = app.run_result_with_polling("run-123", polling_opts)
606
+ >>> print(result.output)
607
+ {'solution': {...}}
608
+ """
609
+
610
+ def polling_func() -> tuple[Any, bool]:
611
+ run_information = self.run_metadata(run_id=run_id)
612
+ if run_information.metadata.status_v2 in {
613
+ StatusV2.succeeded,
614
+ StatusV2.failed,
615
+ StatusV2.canceled,
616
+ }:
617
+ return run_information, True
618
+
619
+ return None, False
620
+
621
+ run_information = poll(polling_options=polling_options, polling_func=polling_func)
622
+
623
+ return self.__run_result(
624
+ run_id=run_id,
625
+ run_information=run_information,
626
+ output_dir_path=output_dir_path,
627
+ )
628
+
629
+ def run_visuals(self, run_id: str) -> None:
630
+ """
631
+ Open the local run visuals in a web browser.
632
+
633
+ This method opens the visual representation of a locally executed run
634
+ in the default web browser. It assumes that the run was executed locally
635
+ using the `new_run` or `new_run_with_result` method and that
636
+ the necessary visualization files are present.
637
+
638
+ If the run was correctly configured to produce visual assets, then the
639
+ run will contain a `visuals` directory with one or more HTML files.
640
+ Each file is opened in a new tab in the default web browser.
641
+
642
+ Parameters
643
+ ----------
644
+ run_id : str
645
+ ID of the local run to visualize.
646
+
647
+ Raises
648
+ ------
649
+ ValueError
650
+ If the `.nextmv/runs` directory does not exist at the application
651
+ source, or if the specified run ID does not exist.
652
+ """
653
+
654
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
655
+ if not os.path.exists(runs_dir):
656
+ raise ValueError(f"`.nextmv/runs` dir does not exist at app source: {self.src}")
657
+
658
+ run_dir = os.path.join(runs_dir, run_id)
659
+ if not os.path.exists(run_dir):
660
+ raise ValueError(f"`{run_id}` run dir does not exist at: {runs_dir}")
661
+
662
+ visuals_dir = os.path.join(run_dir, "visuals")
663
+ if not os.path.exists(visuals_dir):
664
+ raise ValueError(f"`visuals` dir does not exist at: {run_dir}")
665
+
666
+ for file in os.listdir(visuals_dir):
667
+ if file.endswith(".html"):
668
+ file_path = os.path.join(visuals_dir, file)
669
+ webbrowser.open_new_tab(f"file://{os.path.realpath(file_path)}")
670
+
671
+ def sync( # noqa: C901
672
+ self,
673
+ target: cloud.Application,
674
+ run_ids: Optional[list[str]] = None,
675
+ instance_id: Optional[str] = None,
676
+ verbose: Optional[bool] = False,
677
+ ) -> None:
678
+ """
679
+ Sync the local application to a Nextmv Cloud application target.
680
+
681
+ The `Application` class allows you to perform and handle local
682
+ application runs with methods such as:
683
+
684
+ - `new_run`
685
+ - `new_run_with_result`
686
+ - `run_metadata`
687
+ - `run_result`
688
+ - `run_result_with_polling`
689
+
690
+ The runs produced locally live under `self.src/.nextmv/runs`. This
691
+ method syncs those runs to a Nextmv Cloud application target, making
692
+ them available for remote execution and management.
693
+
694
+ Parameters
695
+ ----------
696
+ target : cloud.Application
697
+ Target Nextmv Cloud application where the local application runs
698
+ will be synced to.
699
+ run_ids : Optional[list[str]], default=None
700
+ List of run IDs to sync. If None, all local runs found under
701
+ `self.src/.nextmv/runs` will be synced.
702
+ instance_id : Optional[str], default=None
703
+ Optional instance ID if you want to associate your runs with an
704
+ instance.
705
+ verbose : Optional[bool], default=False
706
+ Whether to print verbose output during the sync process. Useful for
707
+ debugging a large number of runs being synced.
708
+
709
+ Raises
710
+ ------
711
+ ValueError
712
+ If the `src` property is not specified.
713
+ ValueError
714
+ If the `client` property is not specified.
715
+ ValueError
716
+ If the application does not exist in Nextmv Cloud.
717
+ ValueError
718
+ If a run does not exist locally.
719
+ requests.HTTPError
720
+ If the response status code is not 2xx.
721
+ """
722
+ if self.src is None:
723
+ raise ValueError(
724
+ "`src` property for the `Application` must be specified to sync the application to Nextmv Cloud"
725
+ )
726
+
727
+ if target.client is None:
728
+ raise ValueError(
729
+ "`client` property for the target `Application` must be specified to sync the application to Cloud"
730
+ )
731
+
732
+ if not target.exists(target.client, target.id):
733
+ raise ValueError(
734
+ "target Application does not exist in Nextmv Cloud, create it with `cloud.Application.new`"
735
+ )
736
+ if verbose:
737
+ log(f"☁️ Starting sync of local application `{self.src}` to Nextmv Cloud application `{target.id}`.")
738
+
739
+ # Create a temp dir to store the outputs that are written by default to
740
+ # ".". During the sync process, we don't need to keep these outputs, so
741
+ # we can use a temp dir that will be deleted after the sync is done.
742
+ with tempfile.TemporaryDirectory(prefix="nextmv-sync-run-") as temp_results_dir:
743
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
744
+ if run_ids is None:
745
+ # If runs are not specified, by default we sync all local runs that
746
+ # can be found.
747
+ dirs = os.listdir(runs_dir)
748
+ run_ids = [d for d in dirs if os.path.isdir(os.path.join(runs_dir, d))]
749
+
750
+ if verbose:
751
+ log(f"ℹ️ Found {len(run_ids)} local runs to sync from {runs_dir}.")
752
+ else:
753
+ if verbose:
754
+ log(f"ℹ️ Syncing {len(run_ids)} specified local runs from {runs_dir}.")
755
+
756
+ total = 0
757
+ for run_id in run_ids:
758
+ synced = self.__sync_run(
759
+ target=target,
760
+ run_id=run_id,
761
+ runs_dir=runs_dir,
762
+ temp_dir=temp_results_dir,
763
+ instance_id=instance_id,
764
+ verbose=verbose,
765
+ )
766
+ if synced:
767
+ total += 1
768
+
769
+ if verbose:
770
+ log(
771
+ f"🚀 Process completed, synced local application `{self.src}` to "
772
+ f"Nextmv Cloud application `{target.id}`: "
773
+ f"{total}/{len(run_ids)} runs."
774
+ )
775
+
776
+ def __run_result(
777
+ self,
778
+ run_id: str,
779
+ run_information: RunInformation,
780
+ output_dir_path: Optional[str] = ".",
781
+ ) -> RunResult:
782
+ """
783
+ Get the result of a local run.
784
+
785
+ This is a private method that retrieves the complete result of a run,
786
+ including the output data, from a local source. This method serves as
787
+ the base implementation for retrieving run results, regardless of
788
+ polling strategy.
789
+
790
+ Parameters
791
+ ----------
792
+ run_id : str
793
+ ID of the run to retrieve the result for.
794
+ run_information : RunInformation
795
+ Information about the run, including metadata such as output size.
796
+ output_dir_path : Optional[str], default="."
797
+ Path to a directory where non-JSON output files will be saved. This
798
+ is required if the output is non-JSON. If the directory does not
799
+ exist, it will be created. Uses the current directory by default.
800
+
801
+ Returns
802
+ -------
803
+ RunResult
804
+ Result of the run, including all metadata and output data.
805
+
806
+ Raises
807
+ ------
808
+ ValueError
809
+ If the output format is not JSON and no output_dir_path is
810
+ provided.
811
+ If the output format is unknown.
812
+ """
813
+
814
+ result = RunResult.from_dict(run_information.to_dict())
815
+ if result.metadata.error:
816
+ result.error_log = ErrorLog(error=result.metadata.error)
817
+
818
+ if result.metadata.status_v2 != StatusV2.succeeded:
819
+ return result
820
+
821
+ # See whether we can attach the output directly or need to save to the given
822
+ # directory
823
+ output_type = run_information.metadata.format.format_output.output_type
824
+ if output_type != OutputFormat.JSON and (not output_dir_path or output_dir_path == ""):
825
+ raise ValueError(
826
+ "The output format is not JSON: an `output_dir_path` must be provided.",
827
+ )
828
+
829
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
830
+ solutions_dir = os.path.join(runs_dir, run_id, OUTPUTS_KEY, SOLUTIONS_KEY)
831
+
832
+ if output_type == OutputFormat.JSON:
833
+ with open(os.path.join(solutions_dir, DEFAULT_OUTPUT_JSON_FILE)) as f:
834
+ result.output = json.load(f)
835
+ elif output_type in {OutputFormat.CSV_ARCHIVE, OutputFormat.MULTI_FILE}:
836
+ shutil.copytree(solutions_dir, output_dir_path, dirs_exist_ok=True)
837
+ else:
838
+ raise ValueError(f"Unknown output type: {output_type}")
839
+
840
+ return result
841
+
842
+ def __validate_input_dir_path_and_configuration(
843
+ self,
844
+ input_dir_path: Optional[str],
845
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
846
+ ) -> None:
847
+ """
848
+ Auxiliary function to validate the directory path and configuration.
849
+ """
850
+
851
+ if input_dir_path is None or input_dir_path == "":
852
+ return
853
+
854
+ if configuration is None:
855
+ raise ValueError(
856
+ "If dir_path is provided, a RunConfiguration must also be provided.",
857
+ )
858
+
859
+ config_format = self.__extract_config_format(configuration)
860
+
861
+ if config_format is None:
862
+ raise ValueError(
863
+ "If dir_path is provided, RunConfiguration.format must also be provided.",
864
+ )
865
+
866
+ input_type = self.__extract_input_type(config_format)
867
+
868
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
869
+ raise ValueError(
870
+ "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
871
+ f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
872
+ )
873
+
874
+ def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
875
+ """Extract format from configuration, handling both RunConfiguration objects and dicts."""
876
+ if isinstance(configuration, RunConfiguration):
877
+ return configuration.format
878
+
879
+ if isinstance(configuration, dict):
880
+ config_format = configuration.get("format")
881
+ if config_format is not None and isinstance(config_format, dict):
882
+ return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
883
+
884
+ return config_format
885
+
886
+ raise ValueError("Configuration must be a RunConfiguration object or a dict.")
887
+
888
+ def __extract_input_type(self, config_format: Any) -> Any:
889
+ """Extract input type from config format."""
890
+ if isinstance(config_format, dict):
891
+ format_input = config_format.get("format_input") or config_format.get("input")
892
+ if format_input is None:
893
+ raise ValueError(
894
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
895
+ )
896
+
897
+ if isinstance(format_input, dict):
898
+ return format_input.get("input_type") or format_input.get("type")
899
+
900
+ return getattr(format_input, "input_type", None)
901
+
902
+ # Handle Format object
903
+ if config_format.format_input is None:
904
+ raise ValueError(
905
+ "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
906
+ )
907
+
908
+ return config_format.format_input.input_type
909
+
910
+ def __extract_input_data(
911
+ self,
912
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
913
+ ) -> Optional[Union[dict[str, Any], str]]:
914
+ """
915
+ Auxiliary function to extract the input data from the input, based on
916
+ its type.
917
+ """
918
+
919
+ input_data = None
920
+ if isinstance(input, BaseModel):
921
+ input_data = input.to_dict()
922
+ elif isinstance(input, dict) or isinstance(input, str):
923
+ input_data = input
924
+ elif isinstance(input, Input):
925
+ input_data = input.data
926
+
927
+ return input_data
928
+
929
+ def __extract_options_dict(
930
+ self,
931
+ options: Optional[Union[Options, dict[str, str]]] = None,
932
+ json_configurations: Optional[dict[str, Any]] = None,
933
+ ) -> dict[str, str]:
934
+ """
935
+ Auxiliary function to extract the options that will be sent to the
936
+ application for execution.
937
+ """
938
+
939
+ options_dict = {}
940
+ if options is not None:
941
+ if isinstance(options, Options):
942
+ options_dict = options.to_dict_cloud()
943
+ elif isinstance(options, dict):
944
+ for k, v in options.items():
945
+ if isinstance(v, str):
946
+ options_dict[k] = v
947
+ else:
948
+ options_dict[k] = deflated_serialize_json(v, json_configurations=json_configurations)
949
+
950
+ return options_dict
951
+
952
+ def __extract_run_config(
953
+ self,
954
+ input: Union[Input, dict[str, Any], BaseModel, str] = None,
955
+ configuration: Optional[Union[RunConfiguration, dict[str, Any]]] = None,
956
+ dir_path: Optional[str] = None,
957
+ ) -> dict[str, Any]:
958
+ """
959
+ Auxiliary function to extract the run configuration that will be sent
960
+ to the application for execution.
961
+ """
962
+
963
+ if configuration is not None:
964
+ configuration_dict = (
965
+ configuration.to_dict() if isinstance(configuration, RunConfiguration) else configuration
966
+ )
967
+ return configuration_dict
968
+
969
+ configuration = RunConfiguration()
970
+ configuration.resolve(input=input, dir_path=dir_path)
971
+ configuration_dict = configuration.to_dict()
972
+
973
+ return configuration_dict
974
+
975
+ def __sync_run( # noqa: C901
976
+ self,
977
+ target: cloud.Application,
978
+ run_id: str,
979
+ runs_dir: str,
980
+ temp_dir: str,
981
+ instance_id: Optional[str] = None,
982
+ verbose: Optional[bool] = False,
983
+ ) -> bool:
984
+ """
985
+ Syncs a local run to a Nextmv Cloud target application. Returns True if
986
+ the run was synced, False if it was skipped (already synced).
987
+ """
988
+
989
+ if verbose:
990
+ log(f"🔄 Syncing local run `{run_id}`... ")
991
+
992
+ # For files-based runs, the result files are written by default to ".".
993
+ # Avoid this using a dedicated temp dir.
994
+ run_result = self.run_result(run_id, output_dir_path=temp_dir)
995
+ input_type = run_result.metadata.format.format_input.input_type
996
+
997
+ # Skip runs that have already been synced.
998
+ already_synced = run_result.synced_run_id is not None and run_result.synced_at is not None
999
+ if already_synced:
1000
+ if verbose:
1001
+ log(f" ⏭️ Skipping local run `{run_id}`, already synced at {run_result.synced_at.isoformat()}.")
1002
+
1003
+ return False
1004
+
1005
+ # Skip runs that don't have the supported type. TODO: delete this when
1006
+ # external runs support CSV_ARCHIVE and MULTI_FILE. Right now,
1007
+ # submitting an external result with a new run is limited to JSON and
1008
+ # TEXT. After this if statement is removed, the rest of the code should
1009
+ # work with CSV_ARCHIVE and MULTI_FILE as well, as using the input dir
1010
+ # path is already considered.
1011
+ if input_type not in {InputFormat.JSON, InputFormat.TEXT}:
1012
+ if verbose:
1013
+ log(
1014
+ f" ⏭️ Skipping local run `{run_id}`, unsupported input type: {input_type.value}. "
1015
+ f"Supported types are: {[InputFormat.JSON.value, InputFormat.TEXT.value]}",
1016
+ )
1017
+
1018
+ return False
1019
+
1020
+ # Check that it is a valid run with inputs, outputs, logs, etc.
1021
+ if not self.__valid_run_result(run_result, runs_dir, run_id):
1022
+ if verbose:
1023
+ log(f" ❌ Skipping local run `{run_id}`, invalid run (missing inputs, outputs or logs).")
1024
+
1025
+ return False
1026
+
1027
+ status = TrackedRunStatus.SUCCEEDED
1028
+ if run_result.metadata.status_v2 != StatusV2.succeeded:
1029
+ status = TrackedRunStatus.FAILED
1030
+
1031
+ # Read the logs of the run and place each line as an element in a list
1032
+ run_dir = os.path.join(runs_dir, run_id)
1033
+ with open(os.path.join(run_dir, LOGS_KEY, LOGS_FILE)) as f:
1034
+ stderr_logs = f.readlines()
1035
+
1036
+ # Create the tracked run object and start configuring it.
1037
+ tracked_run = TrackedRun(
1038
+ status=status,
1039
+ duration=int(run_result.metadata.duration),
1040
+ error=run_result.metadata.error,
1041
+ logs=stderr_logs,
1042
+ name=run_result.name,
1043
+ description=run_result.description,
1044
+ )
1045
+
1046
+ # Resolve the input according to its type.
1047
+ inputs_path = os.path.join(run_dir, INPUTS_KEY)
1048
+ if input_type == InputFormat.JSON:
1049
+ with open(os.path.join(inputs_path, DEFAULT_INPUT_JSON_FILE)) as f:
1050
+ tracked_run.input = json.load(f)
1051
+ elif input_type == InputFormat.TEXT:
1052
+ with open(os.path.join(inputs_path, "input")) as f:
1053
+ tracked_run.input = f.read()
1054
+ else:
1055
+ tracked_run.input_dir_path = inputs_path
1056
+
1057
+ # Resolve the output according to its type.
1058
+ if run_result.metadata.format.format_output.output_type == OutputFormat.JSON:
1059
+ tracked_run.output = run_result.output
1060
+ else:
1061
+ tracked_run.output_dir_path = os.path.join(run_dir, OUTPUTS_KEY, SOLUTIONS_KEY)
1062
+
1063
+ # Actually sync the run by tracking it remotely on Nextmv Cloud.
1064
+ configuration = RunConfiguration(
1065
+ format=Format(
1066
+ format_input=run_result.metadata.format.format_input,
1067
+ format_output=run_result.metadata.format.format_output,
1068
+ ),
1069
+ )
1070
+ tracked_id = target.track_run(
1071
+ tracked_run=tracked_run,
1072
+ instance_id=instance_id,
1073
+ configuration=configuration,
1074
+ )
1075
+
1076
+ # Mark the local run as synced by updating the local run info.
1077
+ run_result.synced_run_id = tracked_id
1078
+ run_result.synced_at = datetime.now(timezone.utc)
1079
+ with open(os.path.join(run_dir, f"{run_id}.json"), "w") as f:
1080
+ json.dump(run_result.to_dict(), f, indent=2)
1081
+
1082
+ if verbose:
1083
+ log(f"✅ Synced local run `{run_id}` as remote run `{tracked_id}`.")
1084
+
1085
+ return True
1086
+
1087
+ def __valid_run_result(self, run_result: RunResult, runs_dir: str, run_id: str) -> bool:
1088
+ """
1089
+ Validate that a run result has all required files and directories.
1090
+
1091
+ This method checks that a local run has the expected directory structure
1092
+ and files, including inputs, outputs, and logs.
1093
+
1094
+ Parameters
1095
+ ----------
1096
+ run_result : RunResult
1097
+ The run result to validate.
1098
+ runs_dir : str
1099
+ Path to the runs directory.
1100
+ run_id : str
1101
+ ID of the run to validate.
1102
+
1103
+ Returns
1104
+ -------
1105
+ bool
1106
+ True if the run is valid, False otherwise.
1107
+ """
1108
+ run_dir = os.path.join(runs_dir, run_id)
1109
+
1110
+ # Check that the run directory exists
1111
+ if not os.path.exists(run_dir):
1112
+ return False
1113
+
1114
+ # Validate inputs
1115
+ if not self.__validate_inputs(run_dir, run_result.metadata.format.format_input.input_type):
1116
+ return False
1117
+
1118
+ # Validate outputs
1119
+ if not self.__validate_outputs(run_dir, run_result.metadata.format.format_output.output_type):
1120
+ return False
1121
+
1122
+ # Validate logs
1123
+ if not self.__validate_logs(run_dir):
1124
+ return False
1125
+
1126
+ return True
1127
+
1128
+ def __validate_inputs(self, run_dir: str, input_type: InputFormat) -> bool:
1129
+ """Validate that the inputs directory and files exist for the given input type."""
1130
+ inputs_path = os.path.join(run_dir, INPUTS_KEY)
1131
+ if not os.path.exists(inputs_path):
1132
+ return False
1133
+
1134
+ if input_type == InputFormat.JSON:
1135
+ input_file = os.path.join(inputs_path, DEFAULT_INPUT_JSON_FILE)
1136
+
1137
+ return os.path.isfile(input_file)
1138
+
1139
+ if input_type == InputFormat.TEXT:
1140
+ input_file = os.path.join(inputs_path, "input")
1141
+
1142
+ return os.path.isfile(input_file)
1143
+
1144
+ # For CSV_ARCHIVE and MULTI_FILE, inputs_path should be a directory
1145
+ return os.path.isdir(inputs_path)
1146
+
1147
+ def __validate_outputs(self, run_dir: str, output_type: OutputFormat) -> bool:
1148
+ """Validate that the outputs directory and files exist for the given output type."""
1149
+ outputs_dir = os.path.join(run_dir, OUTPUTS_KEY)
1150
+ if not os.path.exists(outputs_dir):
1151
+ return False
1152
+
1153
+ solutions_dir = os.path.join(outputs_dir, SOLUTIONS_KEY)
1154
+ if not os.path.exists(solutions_dir):
1155
+ return False
1156
+
1157
+ if output_type == OutputFormat.JSON:
1158
+ solution_file = os.path.join(solutions_dir, DEFAULT_OUTPUT_JSON_FILE)
1159
+
1160
+ return os.path.isfile(solution_file)
1161
+
1162
+ # For CSV_ARCHIVE and MULTI_FILE, solutions_dir should be a directory
1163
+ return os.path.isdir(solutions_dir)
1164
+
1165
+ def __validate_logs(self, run_dir: str) -> bool:
1166
+ """Validate that the logs directory and file exist."""
1167
+ logs_dir = os.path.join(run_dir, LOGS_KEY)
1168
+ if not os.path.exists(logs_dir):
1169
+ return False
1170
+
1171
+ logs_file = os.path.join(logs_dir, LOGS_FILE)
1172
+
1173
+ return os.path.isfile(logs_file)