nextmv 0.33.0__py3-none-any.whl → 0.34.0.dev0__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/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "v0.33.0"
1
+ __version__ = "v0.34.0.dev0"
nextmv/__init__.py CHANGED
@@ -69,6 +69,7 @@ from .run import RunResult as RunResult
69
69
  from .run import RunType as RunType
70
70
  from .run import RunTypeConfiguration as RunTypeConfiguration
71
71
  from .run import StatisticsIndicator as StatisticsIndicator
72
+ from .run import SyncedRun as SyncedRun
72
73
  from .run import TrackedRun as TrackedRun
73
74
  from .run import TrackedRunStatus as TrackedRunStatus
74
75
  from .run import run_duration as run_duration
@@ -58,7 +58,7 @@ from nextmv.logger import log
58
58
  from nextmv.manifest import Manifest
59
59
  from nextmv.model import Model, ModelConfiguration
60
60
  from nextmv.options import Options
61
- from nextmv.output import Output, OutputFormat
61
+ from nextmv.output import ASSETS_KEY, STATISTICS_KEY, Asset, Output, OutputFormat, Statistics
62
62
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
63
63
  from nextmv.run import (
64
64
  ExternalRunResult,
@@ -187,17 +187,20 @@ class Application:
187
187
  >>> app = Application.new(client=client, name="My New App", id="my-app")
188
188
  """
189
189
 
190
+ if id is None:
191
+ id = safe_id("app")
192
+
190
193
  if exist_ok and cls.exists(client=client, id=id):
191
194
  return Application(client=client, id=id)
192
195
 
193
196
  payload = {
194
197
  "name": name,
198
+ "id": id,
195
199
  }
196
200
 
197
201
  if description is not None:
198
202
  payload["description"] = description
199
- if id is not None:
200
- payload["id"] = id
203
+
201
204
  if is_workflow is not None:
202
205
  payload["is_pipeline"] = is_workflow
203
206
 
@@ -1790,7 +1793,7 @@ class Application:
1790
1793
  when the run is part of a batch experiment.
1791
1794
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]]
1792
1795
  External result to use for the run. This can be a
1793
- `cloud.ExternalRunResult` object or a dict. If the object is used,
1796
+ `nextmv.ExternalRunResult` object or a dict. If the object is used,
1794
1797
  then the `.to_dict()` method is applied to extract the
1795
1798
  configuration. This is used when the run is an external run. We
1796
1799
  suggest that instead of specifying this parameter, you use the
@@ -1820,8 +1823,6 @@ class Application:
1820
1823
  not `JSON`. If the final `options` are not of type `dict[str,str]`.
1821
1824
  """
1822
1825
 
1823
- self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
1824
-
1825
1826
  tar_file = ""
1826
1827
  if input_dir_path is not None and input_dir_path != "":
1827
1828
  if not os.path.exists(input_dir_path):
@@ -1879,6 +1880,7 @@ class Application:
1879
1880
  query_params = {}
1880
1881
  if instance_id is not None or self.default_instance_id is not None:
1881
1882
  query_params["instance_id"] = instance_id if instance_id is not None else self.default_instance_id
1883
+
1882
1884
  response = self.client.request(
1883
1885
  method="POST",
1884
1886
  endpoint=f"{self.endpoint}/runs",
@@ -2993,6 +2995,7 @@ class Application:
2993
2995
  execution_duration=tracked_run.duration,
2994
2996
  )
2995
2997
 
2998
+ # Handle the stderr logs if provided.
2996
2999
  if tracked_run.logs is not None:
2997
3000
  url_stderr = self.upload_url()
2998
3001
  self.upload_large_input(input=tracked_run.logs_text(), upload_url=url_stderr)
@@ -3001,6 +3004,47 @@ class Application:
3001
3004
  if tracked_run.error is not None and tracked_run.error != "":
3002
3005
  external_result.error_message = tracked_run.error
3003
3006
 
3007
+ # Handle the statistics upload if provided.
3008
+ stats = tracked_run.statistics
3009
+ if stats is not None:
3010
+ if isinstance(stats, Statistics):
3011
+ stats_dict = stats.to_dict()
3012
+ stats_dict = {STATISTICS_KEY: stats_dict}
3013
+ elif isinstance(stats, dict):
3014
+ stats_dict = stats
3015
+ if STATISTICS_KEY not in stats_dict:
3016
+ stats_dict = {STATISTICS_KEY: stats_dict}
3017
+ else:
3018
+ raise ValueError("tracked_run.statistics must be either a `Statistics` or `dict` object")
3019
+
3020
+ url_stats = self.upload_url()
3021
+ self.upload_large_input(input=stats_dict, upload_url=url_stats)
3022
+ external_result.statistics_upload_id = url_stats.upload_id
3023
+
3024
+ # Handle the assets upload if provided.
3025
+ assets = tracked_run.assets
3026
+ if assets is not None:
3027
+ if isinstance(assets, list):
3028
+ assets_list = []
3029
+ for ix, asset in enumerate(assets):
3030
+ if isinstance(asset, Asset):
3031
+ assets_list.append(asset.to_dict())
3032
+ elif isinstance(asset, dict):
3033
+ assets_list.append(asset)
3034
+ else:
3035
+ raise ValueError(f"tracked_run.assets, index {ix} must be an `Asset` or `dict` object")
3036
+ assets_dict = {ASSETS_KEY: assets_list}
3037
+ elif isinstance(assets, dict):
3038
+ assets_dict = assets
3039
+ if ASSETS_KEY not in assets_dict:
3040
+ assets_dict = {ASSETS_KEY: assets_dict}
3041
+ else:
3042
+ raise ValueError("tracked_run.assets must be either a `list[Asset]`, `list[dict]`, or `dict` object")
3043
+
3044
+ url_assets = self.upload_url()
3045
+ self.upload_large_input(input=assets_dict, upload_url=url_assets)
3046
+ external_result.assets_upload_id = url_assets.upload_id
3047
+
3004
3048
  return self.new_run(
3005
3049
  upload_id=url_input.upload_id,
3006
3050
  external_result=external_result,
@@ -3860,49 +3904,6 @@ class Application:
3860
3904
 
3861
3905
  raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
3862
3906
 
3863
- def __validate_input_dir_path_and_configuration(
3864
- self,
3865
- input_dir_path: Optional[str],
3866
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
3867
- ) -> None:
3868
- """
3869
- Auxiliary function to validate the directory path and configuration.
3870
- """
3871
- input_type = self.__get_input_type(configuration)
3872
-
3873
- # If no explicit input type is defined, there is nothing to validate.
3874
- if input_type is None:
3875
- return
3876
-
3877
- # Validate that the input directory path is provided when explicitly required.
3878
- dir_types = (InputFormat.MULTI_FILE, InputFormat.CSV_ARCHIVE)
3879
- if input_type in dir_types and not input_dir_path:
3880
- raise ValueError(
3881
- f"If RunConfiguration.format.format_input.input_type is set to {input_type}, "
3882
- "then input_dir_path must be provided.",
3883
- )
3884
-
3885
- def __get_input_type(self, config: Union[RunConfiguration, dict[str, Any]]) -> Optional[InputFormat]:
3886
- """
3887
- Auxiliary function to extract the input type from the run configuration.
3888
- """
3889
-
3890
- if config is None:
3891
- return None
3892
-
3893
- if isinstance(config, dict):
3894
- config = RunConfiguration.from_dict(config)
3895
-
3896
- if (
3897
- isinstance(config, RunConfiguration)
3898
- and config.format is not None
3899
- and config.format.format_input is not None
3900
- and config.format.format_input.input_type is not None
3901
- ):
3902
- return config.format.format_input.input_type
3903
-
3904
- return None
3905
-
3906
3907
  def __package_inputs(self, dir_path: str) -> str:
3907
3908
  """
3908
3909
  This is an auxiliary function for packaging the inputs found in the
@@ -35,9 +35,19 @@ from nextmv.local.runner import run
35
35
  from nextmv.logger import log
36
36
  from nextmv.manifest import Manifest
37
37
  from nextmv.options import Options
38
- from nextmv.output import OUTPUTS_KEY, SOLUTIONS_KEY, OutputFormat
38
+ from nextmv.output import ASSETS_KEY, OUTPUTS_KEY, SOLUTIONS_KEY, STATISTICS_KEY, OutputFormat
39
39
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
40
- from nextmv.run import ErrorLog, Format, Run, RunConfiguration, RunInformation, RunResult, TrackedRun, TrackedRunStatus
40
+ from nextmv.run import (
41
+ ErrorLog,
42
+ Format,
43
+ Run,
44
+ RunConfiguration,
45
+ RunInformation,
46
+ RunResult,
47
+ SyncedRun,
48
+ TrackedRun,
49
+ TrackedRunStatus,
50
+ )
41
51
  from nextmv.safe import safe_id
42
52
  from nextmv.status import StatusV2
43
53
 
@@ -995,25 +1005,10 @@ class Application:
995
1005
  input_type = run_result.metadata.format.format_input.input_type
996
1006
 
997
1007
  # 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
1008
+ synced_run, already_synced = run_result.is_synced(app_id=target.id, instance_id=instance_id)
999
1009
  if already_synced:
1000
1010
  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
- )
1011
+ log(f" ⏭️ Skipping local run `{run_id}`, already synced with {synced_run.to_dict()}.")
1017
1012
 
1018
1013
  return False
1019
1014
 
@@ -1031,7 +1026,7 @@ class Application:
1031
1026
  # Read the logs of the run and place each line as an element in a list
1032
1027
  run_dir = os.path.join(runs_dir, run_id)
1033
1028
  with open(os.path.join(run_dir, LOGS_KEY, LOGS_FILE)) as f:
1034
- stderr_logs = f.readlines()
1029
+ stderr_logs = [line.rstrip("\n") for line in f.readlines()]
1035
1030
 
1036
1031
  # Create the tracked run object and start configuring it.
1037
1032
  tracked_run = TrackedRun(
@@ -1055,11 +1050,28 @@ class Application:
1055
1050
  tracked_run.input_dir_path = inputs_path
1056
1051
 
1057
1052
  # Resolve the output according to its type.
1058
- if run_result.metadata.format.format_output.output_type == OutputFormat.JSON:
1053
+ output_type = run_result.metadata.format.format_output.output_type
1054
+ if output_type == OutputFormat.JSON:
1059
1055
  tracked_run.output = run_result.output
1060
1056
  else:
1061
1057
  tracked_run.output_dir_path = os.path.join(run_dir, OUTPUTS_KEY, SOLUTIONS_KEY)
1062
1058
 
1059
+ # Resolve the statistics according to their type and presence. If
1060
+ # working with JSON, the statistics should be resolved from the output.
1061
+ if output_type in {OutputFormat.CSV_ARCHIVE, OutputFormat.MULTI_FILE}:
1062
+ stats_file_path = os.path.join(run_dir, OUTPUTS_KEY, STATISTICS_KEY, f"{STATISTICS_KEY}.json")
1063
+ if os.path.exists(stats_file_path):
1064
+ with open(stats_file_path) as f:
1065
+ tracked_run.statistics = json.load(f)
1066
+
1067
+ # Resolve the assets according to their type and presence. If working
1068
+ # with JSON, the assets should be resolved from the output.
1069
+ if output_type in {OutputFormat.CSV_ARCHIVE, OutputFormat.MULTI_FILE}:
1070
+ assets_file_path = os.path.join(run_dir, OUTPUTS_KEY, ASSETS_KEY, f"{ASSETS_KEY}.json")
1071
+ if os.path.exists(assets_file_path):
1072
+ with open(assets_file_path) as f:
1073
+ tracked_run.assets = json.load(f)
1074
+
1063
1075
  # Actually sync the run by tracking it remotely on Nextmv Cloud.
1064
1076
  configuration = RunConfiguration(
1065
1077
  format=Format(
@@ -1074,13 +1086,18 @@ class Application:
1074
1086
  )
1075
1087
 
1076
1088
  # 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)
1089
+ synced_run = SyncedRun(
1090
+ run_id=tracked_id,
1091
+ synced_at=datetime.now(timezone.utc),
1092
+ app_id=target.id,
1093
+ instance_id=instance_id,
1094
+ )
1095
+ run_result.add_synced_run(synced_run)
1079
1096
  with open(os.path.join(run_dir, f"{run_id}.json"), "w") as f:
1080
1097
  json.dump(run_result.to_dict(), f, indent=2)
1081
1098
 
1082
1099
  if verbose:
1083
- log(f"✅ Synced local run `{run_id}` as remote run `{tracked_id}`.")
1100
+ log(f"✅ Synced local run `{run_id}` as remote run `{synced_run.to_dict()}`.")
1084
1101
 
1085
1102
  return True
1086
1103
 
@@ -1116,7 +1133,15 @@ class Application:
1116
1133
  return False
1117
1134
 
1118
1135
  # Validate outputs
1119
- if not self.__validate_outputs(run_dir, run_result.metadata.format.format_output.output_type):
1136
+ format_output = run_result.metadata.format.format_output
1137
+ if format_output is None or not format_output:
1138
+ return False
1139
+
1140
+ output_type = format_output.output_type
1141
+ if output_type is None or output_type == "":
1142
+ return False
1143
+
1144
+ if not self.__validate_outputs(run_dir, output_type):
1120
1145
  return False
1121
1146
 
1122
1147
  # Validate logs
nextmv/local/executor.py CHANGED
@@ -16,6 +16,10 @@ process_run_input
16
16
  Function to process the run input based on the format.
17
17
  process_run_output
18
18
  Function to process the run output and handle results.
19
+ resolve_output_format
20
+ Function to determine the output format from manifest or directory structure.
21
+ process_run_information
22
+ Function to update run metadata including duration and status.
19
23
  process_run_logs
20
24
  Function to process and save run logs.
21
25
  process_run_statistics
@@ -26,8 +30,13 @@ process_run_solutions
26
30
  Function to process and save run solutions.
27
31
  process_run_visuals
28
32
  Function to process and save run visuals.
33
+ resolve_stdout
34
+ Function to parse subprocess stdout output.
35
+ ignore_patterns
36
+ Function to filter files and directories during source code copying.
29
37
  """
30
38
 
39
+ import hashlib
31
40
  import json
32
41
  import os
33
42
  import shutil
@@ -84,25 +93,26 @@ def execute_run(
84
93
  input_data: Optional[Union[dict[str, Any], str]] = None,
85
94
  ) -> None:
86
95
  """
87
- This function actually executes the decision model run, using a
88
- subprocess to call the entrypoint script with the appropriate input and
89
- options.
96
+ Executes the decision model run using a subprocess to call the entrypoint
97
+ script with the appropriate input and options.
90
98
 
91
99
  Parameters
92
100
  ----------
101
+ run_id : str
102
+ The unique identifier for the run.
93
103
  src : str
94
104
  The path to the application source code.
95
- manifest_entrypoint : str
96
- The entrypoint script as defined in the application manifest.
105
+ manifest_dict : dict[str, Any]
106
+ The manifest dictionary containing application configuration.
97
107
  run_dir : str
98
- The path to the run directory.
108
+ The path to the run directory where outputs will be stored.
99
109
  run_config : dict[str, Any]
100
- The run configuration.
110
+ The run configuration containing format and other settings.
101
111
  inputs_dir_path : Optional[str], optional
102
112
  The path to the directory containing input files, by default None. If
103
113
  provided, this parameter takes precedence over `input_data`.
104
114
  options : Optional[dict[str, Any]], optional
105
- Additional options for the run, by default None.
115
+ Additional command-line options for the run, by default None.
106
116
  input_data : Optional[Union[dict[str, Any], str]], optional
107
117
  The input data for the run, by default None. If `inputs_dir_path` is
108
118
  provided, this parameter is ignored.
@@ -119,7 +129,7 @@ def execute_run(
119
129
  # place to work from, and be cleaned up afterwards.
120
130
  with tempfile.TemporaryDirectory() as temp_dir:
121
131
  temp_src = os.path.join(temp_dir, "src")
122
- shutil.copytree(src, temp_src, ignore=shutil.ignore_patterns(NEXTMV_DIR))
132
+ shutil.copytree(src, temp_src, ignore=ignore_patterns)
123
133
 
124
134
  manifest = Manifest.from_dict(manifest_dict)
125
135
 
@@ -162,6 +172,7 @@ def execute_run(
162
172
  temp_src=temp_src,
163
173
  result=result,
164
174
  run_dir=run_dir,
175
+ src=src,
165
176
  )
166
177
 
167
178
  except Exception as e:
@@ -290,29 +301,30 @@ def process_run_output(
290
301
  temp_src: str,
291
302
  result: subprocess.CompletedProcess[str],
292
303
  run_dir: str,
304
+ src: str,
293
305
  ) -> None:
294
306
  """
295
307
  Processes the result of the subprocess run. This function is in charge of
296
308
  handling the run results, including solutions, statistics, logs, assets,
297
- etc.
309
+ and visuals.
298
310
 
299
311
  Parameters
300
312
  ----------
301
313
  manifest : Manifest
302
- The application manifest.
314
+ The application manifest containing configuration details.
315
+ run_id : str
316
+ The unique identifier for the run.
303
317
  temp_src : str
304
318
  The path to the temporary source directory.
305
319
  result : subprocess.CompletedProcess[str]
306
- The result of the subprocess run.
320
+ The result of the subprocess run containing stdout, stderr, and return code.
307
321
  run_dir : str
308
- The path to the run directory.
322
+ The path to the run directory where outputs will be stored.
323
+ src : str
324
+ The path to the application source code.
309
325
  """
310
326
 
311
- # Parse stdout as JSON, if possible.
312
- stdout_output = {}
313
- raw_output = result.stdout
314
- if raw_output.strip() != "":
315
- stdout_output = json.loads(raw_output)
327
+ stdout_output = resolve_stdout(result)
316
328
 
317
329
  # Create outputs directory.
318
330
  outputs_dir = os.path.join(run_dir, OUTPUTS_KEY)
@@ -324,7 +336,6 @@ def process_run_output(
324
336
  temp_run_outputs_dir=temp_run_outputs_dir,
325
337
  temp_src=temp_src,
326
338
  )
327
-
328
339
  process_run_information(
329
340
  run_id=run_id,
330
341
  run_dir=run_dir,
@@ -342,6 +353,7 @@ def process_run_output(
342
353
  stdout_output=stdout_output,
343
354
  temp_src=temp_src,
344
355
  manifest=manifest,
356
+ src=src,
345
357
  )
346
358
  process_run_assets(
347
359
  temp_run_outputs_dir=temp_run_outputs_dir,
@@ -349,6 +361,7 @@ def process_run_output(
349
361
  stdout_output=stdout_output,
350
362
  temp_src=temp_src,
351
363
  manifest=manifest,
364
+ src=src,
352
365
  )
353
366
  process_run_solutions(
354
367
  run_id=run_id,
@@ -359,6 +372,7 @@ def process_run_output(
359
372
  stdout_output=stdout_output,
360
373
  output_format=output_format,
361
374
  manifest=manifest,
375
+ src=src,
362
376
  )
363
377
  process_run_visuals(
364
378
  run_dir=run_dir,
@@ -381,11 +395,16 @@ def resolve_output_format(
381
395
  Parameters
382
396
  ----------
383
397
  manifest : Manifest
384
- The application manifest.
398
+ The application manifest containing configuration details.
385
399
  temp_run_outputs_dir : str
386
400
  The path to the temporary outputs directory.
387
401
  temp_src : str
388
402
  The path to the temporary source directory.
403
+
404
+ Returns
405
+ -------
406
+ OutputFormat
407
+ The determined output format (JSON, CSV_ARCHIVE, or MULTI_FILE).
389
408
  """
390
409
 
391
410
  if manifest.configuration is not None and manifest.configuration.content is not None:
@@ -433,7 +452,8 @@ def process_run_information(run_id: str, run_dir: str, result: subprocess.Comple
433
452
  error = ""
434
453
  if result.returncode != 0:
435
454
  status = StatusV2.failed.value
436
- error = result.stderr if result.stderr else "unknown error"
455
+ # Truncate error message so that Cloud does not complain.
456
+ error = (result.stderr.strip().replace("\n", " ") if result.stderr else "unknown error")[:60]
437
457
 
438
458
  # Update the run info file.
439
459
  info["metadata"]["duration"] = duration
@@ -448,29 +468,34 @@ def process_run_logs(
448
468
  output_format: OutputFormat,
449
469
  run_dir: str,
450
470
  result: subprocess.CompletedProcess[str],
451
- stdout_output: dict[str, Any],
471
+ stdout_output: Union[str, dict[str, Any]],
452
472
  ) -> None:
453
473
  """
454
474
  Processes the logs of the run. Writes the logs to a logs directory.
475
+ For multi-file format, stdout is written to logs if present.
455
476
 
456
477
  Parameters
457
478
  ----------
458
479
  output_format : OutputFormat
459
- The output format of the run.
480
+ The output format of the run (JSON, CSV_ARCHIVE, or MULTI_FILE).
460
481
  run_dir : str
461
- The path to the run directory.
482
+ The path to the run directory where logs will be stored.
462
483
  result : subprocess.CompletedProcess[str]
463
- The result of the subprocess run.
464
- stdout_output : dict[str, Any]
465
- The stdout output of the run, parsed as a dictionary.
484
+ The result of the subprocess run containing stderr output.
485
+ stdout_output : Union[str, dict[str, Any]]
486
+ The stdout output of the run, either as raw string or parsed dictionary.
466
487
  """
467
488
 
468
489
  logs_dir = os.path.join(run_dir, LOGS_KEY)
469
490
  os.makedirs(logs_dir, exist_ok=True)
470
491
  std_err = result.stderr
471
492
  with open(os.path.join(logs_dir, LOGS_FILE), "w") as f:
472
- if output_format == OutputFormat.MULTI_FILE and stdout_output != {}:
473
- f.write(json.dumps(stdout_output))
493
+ if output_format == OutputFormat.MULTI_FILE and bool(stdout_output):
494
+ if isinstance(stdout_output, dict):
495
+ f.write(json.dumps(stdout_output))
496
+ elif isinstance(stdout_output, str):
497
+ f.write(stdout_output)
498
+
474
499
  if std_err:
475
500
  f.write("\n")
476
501
 
@@ -480,14 +505,15 @@ def process_run_logs(
480
505
  def process_run_statistics(
481
506
  temp_run_outputs_dir: str,
482
507
  outputs_dir: str,
483
- stdout_output: dict[str, Any],
508
+ stdout_output: Union[str, dict[str, Any]],
484
509
  temp_src: str,
485
510
  manifest: Manifest,
511
+ src: str,
486
512
  ) -> None:
487
513
  """
488
- Processes the statistics of the run. Check for an outputs/statistics folder
489
- being created by the run. If it exists, copy it to the run directory. If it
490
- doesn't exist, attempt to get the stats from stdout.
514
+ Processes the statistics of the run. Checks for an outputs/statistics folder
515
+ or custom statistics file location from manifest. If found, copies to run
516
+ directory. Otherwise, attempts to extract statistics from stdout.
491
517
 
492
518
  Parameters
493
519
  ----------
@@ -495,12 +521,15 @@ def process_run_statistics(
495
521
  The path to the temporary outputs directory.
496
522
  outputs_dir : str
497
523
  The path to the outputs directory in the run directory.
498
- stdout_output : dict[str, Any]
499
- The stdout output of the run, parsed as a dictionary.
524
+ stdout_output : Union[str, dict[str, Any]]
525
+ The stdout output of the run, either as raw string or parsed dictionary.
500
526
  temp_src : str
501
527
  The path to the temporary source directory.
502
528
  manifest : Manifest
503
- The application manifest.
529
+ The application manifest containing configuration and custom paths.
530
+ src : str
531
+ The path to the original application source code, used to avoid copying
532
+ files that are already part of the source.
504
533
  """
505
534
 
506
535
  stats_dst = os.path.join(outputs_dir, STATISTICS_KEY)
@@ -524,7 +553,10 @@ def process_run_statistics(
524
553
 
525
554
  stats_src = os.path.join(temp_run_outputs_dir, STATISTICS_KEY)
526
555
  if os.path.exists(stats_src) and os.path.isdir(stats_src):
527
- shutil.copytree(stats_src, stats_dst, dirs_exist_ok=True)
556
+ _copy_new_or_modified_files(stats_src, stats_dst, src)
557
+ return
558
+
559
+ if not isinstance(stdout_output, dict):
528
560
  return
529
561
 
530
562
  if STATISTICS_KEY not in stdout_output:
@@ -538,14 +570,15 @@ def process_run_statistics(
538
570
  def process_run_assets(
539
571
  temp_run_outputs_dir: str,
540
572
  outputs_dir: str,
541
- stdout_output: dict[str, Any],
573
+ stdout_output: Union[str, dict[str, Any]],
542
574
  temp_src: str,
543
575
  manifest: Manifest,
576
+ src: str,
544
577
  ) -> None:
545
578
  """
546
- Processes the assets of the run. Check for an outputs/assets folder being
547
- created by the run. If it exists, copy it to the run directory. If it
548
- doesn't exist, attempt to get the assets from stdout.
579
+ Processes the assets of the run. Checks for an outputs/assets folder or
580
+ custom assets file location from manifest. If found, copies to run directory.
581
+ Otherwise, attempts to extract assets from stdout.
549
582
 
550
583
  Parameters
551
584
  ----------
@@ -553,12 +586,15 @@ def process_run_assets(
553
586
  The path to the temporary outputs directory.
554
587
  outputs_dir : str
555
588
  The path to the outputs directory in the run directory.
556
- stdout_output : dict[str, Any]
557
- The stdout output of the run, parsed as a dictionary.
589
+ stdout_output : Union[str, dict[str, Any]]
590
+ The stdout output of the run, either as raw string or parsed dictionary.
558
591
  temp_src : str
559
592
  The path to the temporary source directory.
560
593
  manifest : Manifest
561
- The application manifest.
594
+ The application manifest containing configuration and custom paths.
595
+ src : str
596
+ The path to the original application source code, used to avoid copying
597
+ files that are already part of the source.
562
598
  """
563
599
 
564
600
  assets_dst = os.path.join(outputs_dir, ASSETS_KEY)
@@ -582,7 +618,10 @@ def process_run_assets(
582
618
 
583
619
  assets_src = os.path.join(temp_run_outputs_dir, ASSETS_KEY)
584
620
  if os.path.exists(assets_src) and os.path.isdir(assets_src):
585
- shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
621
+ _copy_new_or_modified_files(assets_src, assets_dst, src)
622
+ return
623
+
624
+ if not isinstance(stdout_output, dict):
586
625
  return
587
626
 
588
627
  if ASSETS_KEY not in stdout_output:
@@ -599,37 +638,42 @@ def process_run_solutions(
599
638
  temp_run_outputs_dir: str,
600
639
  temp_src: str,
601
640
  outputs_dir: str,
602
- stdout_output: dict[str, Any],
641
+ stdout_output: Union[str, dict[str, Any]],
603
642
  output_format: OutputFormat,
604
643
  manifest: Manifest,
644
+ src: str,
605
645
  ) -> None:
606
646
  """
607
- Processes the solutions (output) of the run. This method has the handle all
608
- the different formats for processing solutions. This includes looking for
609
- an `output` directory (`csv-archive`), an `outputs/solutions` directory
610
- (`multi-file`), or looking for solutions in the stdout output (`json` or
611
- `text`). For flexibility, we copy whatever is in the `output` and
612
- `outputs/solutions` directories, if they exist. If neither exist, we
613
- attempt to get the solution from stdout.
647
+ Processes the solutions (output) of the run. Handles all different output
648
+ formats including CSV-archive, multi-file, JSON, and text. Looks for
649
+ `output` directory (csv-archive), `outputs/solutions` directory (multi-file),
650
+ or custom solutions path from manifest. Falls back to stdout for JSON/text.
651
+ Updates run metadata with output size and format information.
652
+
653
+ Only copies files that are truly new outputs, excluding files that already
654
+ exist in the original source code, inputs, statistics, or assets directories
655
+ to prevent copying application data as solutions.
614
656
 
615
657
  Parameters
616
658
  ----------
617
659
  run_id : str
618
- The ID of the run.
660
+ The unique identifier of the run.
619
661
  run_dir : str
620
- The path to the run directory.
662
+ The path to the run directory where outputs are stored.
621
663
  temp_run_outputs_dir : str
622
664
  The path to the temporary outputs directory.
623
665
  temp_src : str
624
666
  The path to the temporary source directory.
625
667
  outputs_dir : str
626
668
  The path to the outputs directory in the run directory.
627
- stdout_output : dict[str, Any]
628
- The stdout output of the run, parsed as a dictionary.
669
+ stdout_output : Union[str, dict[str, Any]]
670
+ The stdout output of the run, either as raw string or parsed dictionary.
629
671
  output_format : OutputFormat
630
- The output format of the run.
672
+ The determined output format (JSON, CSV_ARCHIVE, MULTI_FILE, or TEXT).
631
673
  manifest : Manifest
632
- The application manifest.
674
+ The application manifest containing configuration and custom paths.
675
+ src : str
676
+ The path to the application source code.
633
677
  """
634
678
 
635
679
  info_file = os.path.join(run_dir, f"{run_id}.json")
@@ -640,9 +684,12 @@ def process_run_solutions(
640
684
  solutions_dst = os.path.join(outputs_dir, SOLUTIONS_KEY)
641
685
  os.makedirs(solutions_dst, exist_ok=True)
642
686
 
687
+ # Build list of directories to exclude from copying
688
+ exclusion_dirs = _build_exclusion_directories(src, manifest, outputs_dir, run_dir)
689
+
643
690
  if output_format == OutputFormat.CSV_ARCHIVE:
644
691
  output_src = os.path.join(temp_src, OUTPUT_KEY)
645
- shutil.copytree(output_src, solutions_dst, dirs_exist_ok=True)
692
+ _copy_new_or_modified_files(output_src, solutions_dst, src, exclusion_dirs)
646
693
  elif output_format == OutputFormat.MULTI_FILE:
647
694
  solutions_src = os.path.join(temp_run_outputs_dir, SOLUTIONS_KEY)
648
695
  if (
@@ -653,11 +700,14 @@ def process_run_solutions(
653
700
  ):
654
701
  solutions_src = os.path.join(temp_src, manifest.configuration.content.multi_file.output.solutions)
655
702
 
656
- shutil.copytree(solutions_src, solutions_dst, dirs_exist_ok=True)
703
+ _copy_new_or_modified_files(solutions_src, solutions_dst, src, exclusion_dirs)
657
704
  else:
658
- if stdout_output:
705
+ if bool(stdout_output):
659
706
  with open(os.path.join(solutions_dst, DEFAULT_OUTPUT_JSON_FILE), "w") as f:
660
- json.dump(stdout_output, f, indent=2)
707
+ if isinstance(stdout_output, dict):
708
+ json.dump(stdout_output, f, indent=2)
709
+ elif isinstance(stdout_output, str):
710
+ f.write(stdout_output)
661
711
 
662
712
  # Update the run information file with the output size and type.
663
713
  calculate_files_size(run_dir, run_id, solutions_dst, metadata_key="output_size")
@@ -669,14 +719,15 @@ def process_run_solutions(
669
719
  def process_run_visuals(run_dir: str, outputs_dir: str) -> None:
670
720
  """
671
721
  Processes the visuals from the assets in the run output. This function looks
672
- for Plotly assets and generates HTML files for each visual.
722
+ for visual assets (Plotly and GeoJSON) in the assets.json file and generates
723
+ HTML files for each visual. ChartJS visuals are ignored for local runs.
673
724
 
674
725
  Parameters
675
726
  ----------
676
727
  run_dir : str
677
- The path to the run directory.
728
+ The path to the run directory where visuals will be stored.
678
729
  outputs_dir : str
679
- The path to the outputs directory in the run directory.
730
+ The path to the outputs directory in the run directory containing assets.
680
731
  """
681
732
 
682
733
  # Get the assets.
@@ -710,5 +761,265 @@ def process_run_visuals(run_dir: str, outputs_dir: str) -> None:
710
761
  # so we ignore it for now.
711
762
 
712
763
 
764
+ def resolve_stdout(result: subprocess.CompletedProcess[str]) -> Union[str, dict[str, Any]]:
765
+ """
766
+ Resolves the stdout output of the subprocess run. If the stdout is valid
767
+ JSON, it returns the parsed dictionary. Otherwise, it returns the raw
768
+ string output.
769
+
770
+ Parameters
771
+ ----------
772
+ result : subprocess.CompletedProcess[str]
773
+ The result of the subprocess run.
774
+
775
+ Returns
776
+ -------
777
+ Union[str, dict[str, Any]]
778
+ The parsed stdout output as a dictionary if valid JSON, otherwise the
779
+ raw string output.
780
+ """
781
+ raw_output = result.stdout
782
+ if raw_output.strip() == "":
783
+ return ""
784
+
785
+ try:
786
+ return json.loads(raw_output)
787
+ except json.JSONDecodeError:
788
+ return raw_output
789
+
790
+
791
+ def ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
792
+ """
793
+ Custom ignore function for copytree that filters files and directories
794
+ during source code copying. Excludes virtual environments, cache files,
795
+ the nextmv directory, and non-essential files while preserving Python
796
+ source files and application manifests.
797
+
798
+ Parameters
799
+ ----------
800
+ dir_path : str
801
+ The path to the directory being processed.
802
+ names : list[str]
803
+ A list of file and directory names in the current directory.
804
+
805
+ Returns
806
+ -------
807
+ list[str]
808
+ A list of names to ignore during the copy operation.
809
+ """
810
+ ignored = []
811
+ for name in names:
812
+ full_path = os.path.join(dir_path, name)
813
+
814
+ # Ignore nextmv directory
815
+ if name == NEXTMV_DIR:
816
+ ignored.append(name)
817
+ continue
818
+
819
+ # Ignore virtual environment directories
820
+ if name in ("venv", ".venv", "env", ".env", "virtualenv", ".virtualenv"):
821
+ ignored.append(name)
822
+ continue
823
+
824
+ # Ignore __pycache__ directories
825
+ if name == "__pycache__":
826
+ ignored.append(name)
827
+ continue
828
+
829
+ # If it's a file, only keep Python files and app.yaml
830
+ if os.path.isfile(full_path):
831
+ if not (name.endswith(".py") or name == "app.yaml"):
832
+ ignored.append(name)
833
+ continue
834
+
835
+ # Ignore .pyc files explicitly
836
+ if name.endswith(".pyc"):
837
+ ignored.append(name)
838
+ continue
839
+
840
+ return ignored
841
+
842
+
843
+ def _build_exclusion_directories(src: str, manifest: Manifest, outputs_dir: str, run_dir: str) -> list[str]:
844
+ """
845
+ Build a list of directories to exclude when copying solution files.
846
+
847
+ Parameters
848
+ ----------
849
+ src : str
850
+ The path to the original application source code.
851
+ manifest : Manifest
852
+ The application manifest containing configuration.
853
+ outputs_dir : str
854
+ The path to the outputs directory in the run directory.
855
+ run_dir : str
856
+ The path to the run directory.
857
+
858
+ Returns
859
+ -------
860
+ list[str]
861
+ List of directory paths to exclude from copying.
862
+ """
863
+ exclusion_dirs = []
864
+
865
+ # Add inputs directory from original source
866
+ inputs_dir_original = os.path.join(src, INPUTS_KEY)
867
+ if os.path.exists(inputs_dir_original):
868
+ exclusion_dirs.append(inputs_dir_original)
869
+
870
+ # Add custom inputs directory if specified in manifest
871
+ if (
872
+ manifest.configuration is not None
873
+ and manifest.configuration.content is not None
874
+ and manifest.configuration.content.format == InputFormat.MULTI_FILE
875
+ and manifest.configuration.content.multi_file is not None
876
+ ):
877
+ custom_inputs_dir = os.path.join(src, manifest.configuration.content.multi_file.input.path)
878
+ if os.path.exists(custom_inputs_dir):
879
+ exclusion_dirs.append(custom_inputs_dir)
880
+
881
+ # Add inputs directory from run directory
882
+ inputs_dir_run = os.path.join(run_dir, INPUTS_KEY)
883
+ if os.path.exists(inputs_dir_run):
884
+ exclusion_dirs.append(inputs_dir_run)
885
+
886
+ # Add statistics and assets directories from run outputs
887
+ stats_dir = os.path.join(outputs_dir, STATISTICS_KEY)
888
+ if os.path.exists(stats_dir):
889
+ exclusion_dirs.append(stats_dir)
890
+
891
+ assets_dir = os.path.join(outputs_dir, ASSETS_KEY)
892
+ if os.path.exists(assets_dir):
893
+ exclusion_dirs.append(assets_dir)
894
+
895
+ return exclusion_dirs
896
+
897
+
898
+ def _copy_new_or_modified_files(
899
+ src_dir: str, dst_dir: str, original_src_dir: Optional[str] = None, exclusion_dirs: Optional[list[str]] = None
900
+ ) -> None:
901
+ """
902
+ Copy files from source to destination only if they meet specific criteria.
903
+
904
+ This function ensures that only files that are either:
905
+ 1. New files (not present in destination)
906
+ 2. Existing files with different content (based on checksum comparison)
907
+ 3. Files that are NOT present in the original source directory (if provided)
908
+ 4. Files that are NOT present in any of the exclusion directories (if provided)
909
+
910
+ Parameters
911
+ ----------
912
+ src_dir : str
913
+ The source directory path to copy from.
914
+ dst_dir : str
915
+ The destination directory path to copy to.
916
+ original_src_dir : Optional[str], optional
917
+ The original source directory to check against. Files present in this
918
+ directory will NOT be copied, by default None.
919
+ exclusion_dirs : Optional[list[str]], optional
920
+ Additional directories to check against. Files present in any of these
921
+ directories will NOT be copied, by default None.
922
+ """
923
+ # Build list of all exclusion directories
924
+ exclusion_directories = []
925
+ if original_src_dir is not None:
926
+ exclusion_directories.append(original_src_dir)
927
+ if exclusion_dirs is not None:
928
+ exclusion_directories.extend(exclusion_dirs)
929
+
930
+ for root, _dirs, files in os.walk(src_dir):
931
+ rel_root = os.path.relpath(root, src_dir)
932
+ dst_root = dst_dir if rel_root == "." else os.path.join(dst_dir, rel_root)
933
+ os.makedirs(dst_root, exist_ok=True)
934
+
935
+ for file in files:
936
+ # Skip if file exists in any exclusion directory
937
+ if exclusion_directories and _file_exists_in_exclusion_dirs(file, rel_root, exclusion_directories):
938
+ continue
939
+
940
+ src_file = os.path.join(root, file)
941
+ dst_file = os.path.join(dst_root, file)
942
+
943
+ if _should_copy_file(src_file, dst_file):
944
+ shutil.copy2(src_file, dst_file)
945
+
946
+
947
+ def _should_copy_file(src_file: str, dst_file: str) -> bool:
948
+ """
949
+ Determine if a file should be copied based on existence and content.
950
+
951
+ Parameters
952
+ ----------
953
+ src_file : str
954
+ Path to the source file.
955
+ dst_file : str
956
+ Path to the destination file.
957
+
958
+ Returns
959
+ -------
960
+ bool
961
+ True if the file should be copied, False otherwise.
962
+ """
963
+ if not os.path.exists(dst_file):
964
+ return True
965
+
966
+ try:
967
+ src_checksum = _calculate_file_checksum(src_file)
968
+ dst_checksum = _calculate_file_checksum(dst_file)
969
+ return src_checksum != dst_checksum
970
+ except OSError:
971
+ return True
972
+
973
+
974
+ def _calculate_file_checksum(file_path: str) -> str:
975
+ """
976
+ Calculate MD5 checksum of a file.
977
+
978
+ Parameters
979
+ ----------
980
+ file_path : str
981
+ The path to the file.
982
+
983
+ Returns
984
+ -------
985
+ str
986
+ The MD5 checksum of the file.
987
+ """
988
+ hash_md5 = hashlib.md5()
989
+ with open(file_path, "rb") as f:
990
+ for chunk in iter(lambda: f.read(4096), b""):
991
+ hash_md5.update(chunk)
992
+ return hash_md5.hexdigest()
993
+
994
+
995
+ def _file_exists_in_exclusion_dirs(file_name: str, rel_root: str, exclusion_dirs: list[str]) -> bool:
996
+ """
997
+ Check if a file exists in any of the exclusion directories.
998
+
999
+ Parameters
1000
+ ----------
1001
+ file_name : str
1002
+ The name of the file to check.
1003
+ rel_root : str
1004
+ The relative root path from the source directory.
1005
+ exclusion_dirs : list[str]
1006
+ List of directories to check against.
1007
+
1008
+ Returns
1009
+ -------
1010
+ bool
1011
+ True if the file exists in any exclusion directory, False otherwise.
1012
+ """
1013
+ for exclusion_dir in exclusion_dirs:
1014
+ if rel_root != ".":
1015
+ exclusion_file = os.path.join(exclusion_dir, rel_root, file_name)
1016
+ else:
1017
+ exclusion_file = os.path.join(exclusion_dir, file_name)
1018
+
1019
+ if os.path.exists(exclusion_file):
1020
+ return True
1021
+ return False
1022
+
1023
+
713
1024
  if __name__ == "__main__":
714
1025
  main()
nextmv/local/local.py CHANGED
@@ -36,7 +36,7 @@ LOGS_KEY = "logs"
36
36
  """
37
37
  Logs key constant used for identifying logs in the run output.
38
38
  """
39
- LOGS_FILE = "stderr.log"
39
+ LOGS_FILE = "logs.log"
40
40
  """
41
41
  Constant used for identifying the file used for logging.
42
42
  """
nextmv/run.py CHANGED
@@ -52,7 +52,7 @@ from pydantic import AliasChoices, Field, field_validator
52
52
  from nextmv._serialization import serialize_json
53
53
  from nextmv.base_model import BaseModel
54
54
  from nextmv.input import Input, InputFormat
55
- from nextmv.output import Output, OutputFormat
55
+ from nextmv.output import Asset, Output, OutputFormat, Statistics
56
56
  from nextmv.status import Status, StatusV2
57
57
 
58
58
 
@@ -687,6 +687,56 @@ class Metadata(BaseModel):
687
687
  """Deprecated: use status_v2."""
688
688
 
689
689
 
690
+ class SyncedRun(BaseModel):
691
+ """
692
+ Information about a run that has been synced to a remote application.
693
+
694
+ You can import the `SyncedRun` class directly from `nextmv`:
695
+
696
+ ```python
697
+ from nextmv import SyncedRun
698
+ ```
699
+
700
+ Parameters
701
+ ----------
702
+ run_id : str
703
+ ID of the synced remote run. When the `Application.sync` method is
704
+ used, this field marks the association between the local run (`id`) and
705
+ the remote run (`synced_run.id`).
706
+ synced_at : datetime
707
+ Timestamp when the run was synced with the remote run.
708
+ app_id : str
709
+ The ID of the remote application that the local run was synced to.
710
+ instance_id : Optional[str], optional
711
+ The instance of the remote application that the local run was synced
712
+ to. This field is optional and may be None. If it is not specified, it
713
+ indicates that the run was synced against the default instance of the
714
+ app. Defaults to None.
715
+ """
716
+
717
+ run_id: str
718
+ """
719
+ ID of the synced remote run. When the `Application.sync` method is used,
720
+ this field marks the association between the local run (`id`) and the
721
+ remote run (`synced_run.id`)
722
+ """
723
+ synced_at: datetime
724
+ """
725
+ Timestamp when the run was synced with the remote run.
726
+ """
727
+ app_id: str
728
+ """
729
+ The ID of the remote application that the local run was synced to.
730
+ """
731
+
732
+ instance_id: Optional[str] = None
733
+ """
734
+ The instance of the remote application that the local run was synced to.
735
+ This field is optional and may be None. If it is not specified, it
736
+ indicates that the run was synced against the default instance of the app.
737
+ """
738
+
739
+
690
740
  class RunInformation(BaseModel):
691
741
  """
692
742
  Information of a run.
@@ -727,19 +777,18 @@ class RunInformation(BaseModel):
727
777
  """
728
778
  URL to the run in the Nextmv console.
729
779
  """
730
- synced_run_id: Optional[str] = None
731
- """
732
- ID of the synced remote run, if applicable. When the `Application.sync`
733
- method is used, this field marks the association between the local run
734
- (`id`) and the remote run (`synced_run_id`). This field is None if the run
735
- was not created using `Application.sync` or if the run has not been synced
736
- yet.
780
+ synced_runs: Optional[list[SyncedRun]] = None
737
781
  """
738
- synced_at: Optional[datetime] = None
739
- """
740
- Timestamp when the run was synced with the remote run. This field is
741
- None if the run was not created using `Application.sync` or if the run
742
- has not been synced yet.
782
+ List of synced runs associated with this run, if applicable. When the
783
+ `Application.sync` method is used, this field contains the associations
784
+ between the local run (`id`) and the remote runs (`synced_run.id`). This
785
+ field is None if the run was not created using `Application.sync` or if the
786
+ run has not been synced yet. It is possible to sync a single local run to
787
+ multiple remote runs. A remote run is identified by its application ID and
788
+ instance (if applicable). A local run cannot be synced to a remote run if
789
+ it is already present, this is, if there exists a record in the list with
790
+ the same application ID and instance. If there is not a repeated remote
791
+ run, a new record is added to the list.
743
792
  """
744
793
 
745
794
  def to_run(self) -> Run:
@@ -817,6 +866,83 @@ class RunInformation(BaseModel):
817
866
  input_set_id=None,
818
867
  )
819
868
 
869
+ def add_synced_run(self, synced_run: SyncedRun) -> bool:
870
+ """
871
+ Add a synced run to the RunInformation.
872
+
873
+ This method adds a `SyncedRun` instance to the list of synced runs
874
+ associated with this `RunInformation`. If the list is None, it
875
+ initializes it first. If the run has already been synced, then it is
876
+ not added to the list. A run is already synced if there exists a record
877
+ in the list with the same application ID. This method returns True if
878
+ the synced run was added, and False otherwise.
879
+
880
+ Parameters
881
+ ----------
882
+ synced_run : SyncedRun
883
+ The SyncedRun instance to add.
884
+
885
+ Returns
886
+ -------
887
+ bool
888
+ True if the synced run was added, False if it was already present.
889
+ """
890
+
891
+ if self.synced_runs is None:
892
+ self.synced_runs = [synced_run]
893
+
894
+ return True
895
+
896
+ if synced_run.instance_id is None:
897
+ for existing_run in self.synced_runs:
898
+ if existing_run.app_id == synced_run.app_id:
899
+ return False
900
+ else:
901
+ for existing_run in self.synced_runs:
902
+ if existing_run.app_id == synced_run.app_id and existing_run.instance_id == synced_run.instance_id:
903
+ return False
904
+
905
+ self.synced_runs.append(synced_run)
906
+
907
+ return True
908
+
909
+ def is_synced(self, app_id: str, instance_id: Optional[str] = None) -> tuple[SyncedRun, bool]:
910
+ """
911
+ Check if the run has been synced to a specific application and instance.
912
+
913
+ This method checks if there exists a `SyncedRun` in the list of synced
914
+ runs that matches the given application ID and optional instance ID.
915
+
916
+ Parameters
917
+ ----------
918
+ app_id : str
919
+ The application ID to check.
920
+ instance_id : Optional[str], optional
921
+ The instance ID to check. If None, only the application ID is
922
+ considered. Defaults to None.
923
+
924
+ Returns
925
+ -------
926
+ tuple[SyncedRun, bool]
927
+ A tuple containing the SyncedRun instance if found, and a boolean
928
+ indicating whether the run has been synced to the specified
929
+ application and instance.
930
+ """
931
+
932
+ if self.synced_runs is None:
933
+ return None, False
934
+
935
+ if instance_id is None:
936
+ for existing_run in self.synced_runs:
937
+ if existing_run.app_id == app_id:
938
+ return existing_run, True
939
+ else:
940
+ for existing_run in self.synced_runs:
941
+ if existing_run.app_id == app_id and existing_run.instance_id == instance_id:
942
+ return existing_run, True
943
+
944
+ return None, False
945
+
820
946
 
821
947
  class ErrorLog(BaseModel):
822
948
  """
@@ -1154,6 +1280,16 @@ class ExternalRunResult(BaseModel):
1154
1280
  """Error message of the run."""
1155
1281
  execution_duration: Optional[int] = None
1156
1282
  """Duration of the run, in milliseconds."""
1283
+ statistics_upload_id: Optional[str] = None
1284
+ """
1285
+ ID of the statistics upload. Use this field when working with `CSV_ARCHIVE`
1286
+ or `MULTI_FILE` output formats.
1287
+ """
1288
+ assets_upload_id: Optional[str] = None
1289
+ """
1290
+ ID of the assets upload. Use this field when working with `CSV_ARCHIVE`
1291
+ or `MULTI_FILE` output formats.
1292
+ """
1157
1293
 
1158
1294
  def __post_init_post_parse__(self):
1159
1295
  """
@@ -1265,6 +1401,18 @@ class TrackedRun:
1265
1401
  when working with `CSV_ARCHIVE` or `MULTI_FILE`. If both `output` and
1266
1402
  `output_dir_path` are specified, the `output` is ignored, and the files
1267
1403
  are saved in the directory instead. Defaults to None.
1404
+ statistics : Statistics or dict[str, Any], optional
1405
+ Statistics of the run being tracked. Only use this field if you want to
1406
+ track statistics for `CSV_ARCHIVE` or `MULTI_FILE` output formats. If you
1407
+ are working with `JSON` or `TEXT` output formats, this field will be
1408
+ ignored, as the statistics are extracted directly from the `output`.
1409
+ This field is optional. Defaults to None.
1410
+ assets : list[Asset or dict[str, Any]], optional
1411
+ Assets associated with the run being tracked. Only use this field if you
1412
+ want to track assets for `CSV_ARCHIVE` or `MULTI_FILE` output formats.
1413
+ If you are working with `JSON` or `TEXT` output formats, this field will
1414
+ be ignored, as the assets are extracted directly from the `output`.
1415
+ This field is optional. Defaults to None.
1268
1416
 
1269
1417
  Examples
1270
1418
  --------
@@ -1365,6 +1513,20 @@ class TrackedRun:
1365
1513
  `output_dir_path` are specified, the `output` is ignored, and the files
1366
1514
  are saved in the directory instead.
1367
1515
  """
1516
+ statistics: Optional[Union[Statistics, dict[str, Any]]] = None
1517
+ """
1518
+ Statistics of the run being tracked. Only use this field if you want to
1519
+ track statistics for `CSV_ARCHIVE` or `MULTI_FILE` output formats. If you
1520
+ are working with `JSON` or `TEXT` output formats, this field will be
1521
+ ignored, as the statistics are extracted directly from the `output`.
1522
+ """
1523
+ assets: Optional[list[Union[Asset, dict[str, Any]]]] = None
1524
+ """
1525
+ Assets associated with the run being tracked. Only use this field if you
1526
+ want to track assets for `CSV_ARCHIVE` or `MULTI_FILE` output formats.
1527
+ If you are working with `JSON` or `TEXT` output formats, this field will
1528
+ be ignored, as the assets are extracted directly from the `output`.
1529
+ """
1368
1530
 
1369
1531
  def __post_init__(self): # noqa: C901
1370
1532
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.33.0
3
+ Version: 0.34.0.dev0
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
@@ -1,6 +1,6 @@
1
- nextmv/__about__.py,sha256=VyKoqs7qvl1UtyON_wE484wOoQ1-D2LWfsRhOrha9gg,24
1
+ nextmv/__about__.py,sha256=IEH4q3YZ3NJ8xLBOPvbdmzjvZQSBu6mlVa0ZYNf6FZ0,29
2
2
  nextmv/__entrypoint__.py,sha256=dA0iwwHtrq6Z9w9FxmxKLoBGLyhe7jWtUAU-Y3PEgHg,1094
3
- nextmv/__init__.py,sha256=uM80mRRs2Ht2dP60DvxR11HmunFJ7EL7XUJHtKQi2qI,3683
3
+ nextmv/__init__.py,sha256=GYzXp81KEteniXZkNZ9IgLYnJNOBOQckOeGpTkxjjD4,3723
4
4
  nextmv/_serialization.py,sha256=JlSl6BL0M2Esf7F89GsGIZ__Pp8RnFRNM0UxYhuuYU4,2853
5
5
  nextmv/base_model.py,sha256=qmJ4AsYr9Yv01HQX_BERrn3229gyoZrYyP9tcyqNfeU,2311
6
6
  nextmv/deprecated.py,sha256=kEVfyQ-nT0v2ePXTNldjQG9uH5IlfQVy3L4tztIxwmU,1638
@@ -11,13 +11,13 @@ nextmv/model.py,sha256=vI3pSV3iTwjRPflar7nAg-6h98XRUyi9II5O2J06-Kc,15018
11
11
  nextmv/options.py,sha256=yPJu5lYMbV6YioMwAXv7ctpZUggLXKlZc9CqIbUFvE4,37895
12
12
  nextmv/output.py,sha256=HdvWYG3gIzwoXquulaEVI4LLchXJDjkbag0BkBPM0vQ,55128
13
13
  nextmv/polling.py,sha256=nfefvWI1smm-lIzaXE-4DMlojp6KXIvVi88XLJYUmo8,9724
14
- nextmv/run.py,sha256=lAWWkLml1C7wkXSvN8orBjudoz72Qv5ZCHCWY9e7xaY,45813
14
+ nextmv/run.py,sha256=8B9hh-jWJpoMSJiDmgmXaqvmzTicWCn75UTJu7Nf7Cs,52401
15
15
  nextmv/safe.py,sha256=VAK4fGEurbLNji4Pg5Okga5XQSbI4aI9JJf95_68Z20,3867
16
16
  nextmv/status.py,sha256=SCDLhh2om3yeO5FxO0x-_RShQsZNXEpjHNdCGdb3VUI,2787
17
17
  nextmv/cloud/__init__.py,sha256=2wI72lhWq81BYv1OpS0OOTT5-3sivpX0H4z5ANPoLMc,5051
18
18
  nextmv/cloud/acceptance_test.py,sha256=ZEzCMrfJF-nUFr1nEr4IDgcoyavPhnanjFuPBJ79tAk,27731
19
19
  nextmv/cloud/account.py,sha256=jIdGNyI3l3dVh2PuriAwAOrEuWRM150WgzxcBMVBNRw,6058
20
- nextmv/cloud/application.py,sha256=zbirm_bbihnzhKsO8gAO_dJRAJV4FtXsQsSaismgt-I,139298
20
+ nextmv/cloud/application.py,sha256=oUpw6I1r4u9Cn0pnlylWlm23hPgZKsg_vnBXbqTtU-w,139693
21
21
  nextmv/cloud/batch_experiment.py,sha256=13ciRpgBabMMTyazfdfEAymD3rTPrTAAorECsANxxuA,10397
22
22
  nextmv/cloud/client.py,sha256=E0DiUb377jvEnpXlRnfT1PGCI0Jm0lTUoX5VqeU91lk,18165
23
23
  nextmv/cloud/ensemble.py,sha256=glrRgyRFcEH12fNUhEl1FOo6xOTDEaF478dxfX0wj2Y,8604
@@ -38,13 +38,13 @@ nextmv/default_app/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
38
38
  nextmv/default_app/src/main.py,sha256=WWeN_xl_mcPhICl3rSCvdEjRkFXGmAnej88FhS-fAmc,884
39
39
  nextmv/default_app/src/visuals.py,sha256=WYK_YBnLmYo3TpVev1CpoNCuW5R7hk9QIkeCmvMn1Fs,1014
40
40
  nextmv/local/__init__.py,sha256=6BsoqlK4dw6X11_uKzz9gBPfxKpdiol2FYO8R3X73SE,116
41
- nextmv/local/application.py,sha256=qq14ihKxymg5NHqhU56qTu6lVmMYWLGYUJC4MrhWB68,45815
42
- nextmv/local/executor.py,sha256=ohAUrIRohcH_qGglK1hSFR0W6bPBSuMAcMwVkW5G4vM,24338
41
+ nextmv/local/application.py,sha256=dupLPPJ0JkMsTI-7-OoPz9JCRboJyxWy6iO3SPKdA1o,46514
42
+ nextmv/local/executor.py,sha256=Num-VdI5rRecI1XDz__YtW7U2q02ulbwMfWxlhhzwU8,35202
43
43
  nextmv/local/geojson_handler.py,sha256=7FavJdkUonop-yskjis0x3qFGB8A5wZyoBUblw-bVhw,12540
44
- nextmv/local/local.py,sha256=wUHuoAXqJIZpTJBh056hmQuiPEjlZvLTR2BC6Ino-WI,2619
44
+ nextmv/local/local.py,sha256=cp56UpI8h19Ob6Jvb_Ni0ceXH5Vv3ET_iPTDe6ftq3Y,2617
45
45
  nextmv/local/plotly_handler.py,sha256=bLb50e3AkVr_W-F6S7lXfeRdN60mG2jk3UElNmhoMWU,1930
46
46
  nextmv/local/runner.py,sha256=hwkITHrQG_J9TzxufnaP1mjLWG-iSsNQD66UFZY4pp4,8602
47
- nextmv-0.33.0.dist-info/METADATA,sha256=JRt0lALKQ1i0E_J2AhgozeUtC7kv_YGHsqK_-VDePsw,16008
48
- nextmv-0.33.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
49
- nextmv-0.33.0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
50
- nextmv-0.33.0.dist-info/RECORD,,
47
+ nextmv-0.34.0.dev0.dist-info/METADATA,sha256=lqIIWxNBN6AfvZ_Nw8hmkTyxM1lOhVQ2nvm--xoPML8,16013
48
+ nextmv-0.34.0.dev0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
49
+ nextmv-0.34.0.dev0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
50
+ nextmv-0.34.0.dev0.dist-info/RECORD,,