nextmv 0.33.0__tar.gz → 0.34.0.dev0__tar.gz

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.
Files changed (88) hide show
  1. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/PKG-INFO +1 -1
  2. nextmv-0.34.0.dev0/nextmv/__about__.py +1 -0
  3. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/__init__.py +1 -0
  4. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/application.py +50 -49
  5. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/application.py +50 -25
  6. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/executor.py +377 -66
  7. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/local.py +1 -1
  8. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/run.py +175 -13
  9. nextmv-0.33.0/nextmv/__about__.py +0 -1
  10. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/.gitignore +0 -0
  11. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/LICENSE +0 -0
  12. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/README.md +0 -0
  13. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/__entrypoint__.py +0 -0
  14. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/_serialization.py +0 -0
  15. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/base_model.py +0 -0
  16. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/__init__.py +0 -0
  17. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/acceptance_test.py +0 -0
  18. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/account.py +0 -0
  19. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/batch_experiment.py +0 -0
  20. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/client.py +0 -0
  21. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/ensemble.py +0 -0
  22. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/input_set.py +0 -0
  23. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/instance.py +0 -0
  24. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/package.py +0 -0
  25. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/scenario.py +0 -0
  26. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/secrets.py +0 -0
  27. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/url.py +0 -0
  28. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/cloud/version.py +0 -0
  29. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/.gitignore +0 -0
  30. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/README.md +0 -0
  31. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/app.yaml +0 -0
  32. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/input.json +0 -0
  33. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/main.py +0 -0
  34. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/requirements.txt +0 -0
  35. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/src/__init__.py +0 -0
  36. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/src/main.py +0 -0
  37. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/default_app/src/visuals.py +0 -0
  38. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/deprecated.py +0 -0
  39. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/input.py +0 -0
  40. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/__init__.py +0 -0
  41. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/geojson_handler.py +0 -0
  42. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/plotly_handler.py +0 -0
  43. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/local/runner.py +0 -0
  44. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/logger.py +0 -0
  45. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/manifest.py +0 -0
  46. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/model.py +0 -0
  47. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/options.py +0 -0
  48. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/output.py +0 -0
  49. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/polling.py +0 -0
  50. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/safe.py +0 -0
  51. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/nextmv/status.py +0 -0
  52. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/pyproject.toml +0 -0
  53. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/__init__.py +0 -0
  54. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/cloud/__init__.py +0 -0
  55. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/cloud/app.yaml +0 -0
  56. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/cloud/test_client.py +0 -0
  57. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/cloud/test_package.py +0 -0
  58. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/cloud/test_scenario.py +0 -0
  59. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/local/__init__.py +0 -0
  60. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/local/test_application.py +0 -0
  61. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/local/test_executor.py +0 -0
  62. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/local/test_runner.py +0 -0
  63. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/__init__.py +0 -0
  64. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options1.py +0 -0
  65. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options2.py +0 -0
  66. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options3.py +0 -0
  67. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options4.py +0 -0
  68. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options5.py +0 -0
  69. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options6.py +0 -0
  70. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options7.py +0 -0
  71. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/scripts/options_deprecated.py +0 -0
  72. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_base_model.py +0 -0
  73. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_entrypoint/__init__.py +0 -0
  74. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_entrypoint/test_entrypoint.py +0 -0
  75. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_input.py +0 -0
  76. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_inputs/test_data.csv +0 -0
  77. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_inputs/test_data.json +0 -0
  78. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_inputs/test_data.txt +0 -0
  79. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_logger.py +0 -0
  80. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_manifest.py +0 -0
  81. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_model.py +0 -0
  82. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_options.py +0 -0
  83. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_output.py +0 -0
  84. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_polling.py +0 -0
  85. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_run.py +0 -0
  86. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_safe.py +0 -0
  87. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_serialization.py +0 -0
  88. {nextmv-0.33.0 → nextmv-0.34.0.dev0}/tests/test_version.py +0 -0
@@ -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/
@@ -0,0 +1 @@
1
+ __version__ = "v0.34.0.dev0"
@@ -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