nextmv 0.33.0__tar.gz → 0.34.0__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}/PKG-INFO +1 -1
  2. nextmv-0.34.0/nextmv/__about__.py +1 -0
  3. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/__init__.py +2 -0
  4. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/application.py +50 -49
  5. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/application.py +114 -70
  6. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/executor.py +419 -66
  7. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/local.py +1 -1
  8. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/manifest.py +134 -24
  9. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/run.py +175 -13
  10. {nextmv-0.33.0 → nextmv-0.34.0}/tests/local/test_executor.py +44 -12
  11. nextmv-0.33.0/nextmv/__about__.py +0 -1
  12. {nextmv-0.33.0 → nextmv-0.34.0}/.gitignore +0 -0
  13. {nextmv-0.33.0 → nextmv-0.34.0}/LICENSE +0 -0
  14. {nextmv-0.33.0 → nextmv-0.34.0}/README.md +0 -0
  15. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/__entrypoint__.py +0 -0
  16. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/_serialization.py +0 -0
  17. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/base_model.py +0 -0
  18. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/__init__.py +0 -0
  19. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/acceptance_test.py +0 -0
  20. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/account.py +0 -0
  21. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/batch_experiment.py +0 -0
  22. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/client.py +0 -0
  23. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/ensemble.py +0 -0
  24. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/input_set.py +0 -0
  25. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/instance.py +0 -0
  26. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/package.py +0 -0
  27. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/scenario.py +0 -0
  28. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/secrets.py +0 -0
  29. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/url.py +0 -0
  30. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/cloud/version.py +0 -0
  31. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/.gitignore +0 -0
  32. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/README.md +0 -0
  33. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/app.yaml +0 -0
  34. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/input.json +0 -0
  35. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/main.py +0 -0
  36. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/requirements.txt +0 -0
  37. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/src/__init__.py +0 -0
  38. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/src/main.py +0 -0
  39. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/default_app/src/visuals.py +0 -0
  40. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/deprecated.py +0 -0
  41. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/input.py +0 -0
  42. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/__init__.py +0 -0
  43. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/geojson_handler.py +0 -0
  44. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/plotly_handler.py +0 -0
  45. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/local/runner.py +0 -0
  46. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/logger.py +0 -0
  47. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/model.py +0 -0
  48. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/options.py +0 -0
  49. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/output.py +0 -0
  50. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/polling.py +0 -0
  51. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/safe.py +0 -0
  52. {nextmv-0.33.0 → nextmv-0.34.0}/nextmv/status.py +0 -0
  53. {nextmv-0.33.0 → nextmv-0.34.0}/pyproject.toml +0 -0
  54. {nextmv-0.33.0 → nextmv-0.34.0}/tests/__init__.py +0 -0
  55. {nextmv-0.33.0 → nextmv-0.34.0}/tests/cloud/__init__.py +0 -0
  56. {nextmv-0.33.0 → nextmv-0.34.0}/tests/cloud/app.yaml +0 -0
  57. {nextmv-0.33.0 → nextmv-0.34.0}/tests/cloud/test_client.py +0 -0
  58. {nextmv-0.33.0 → nextmv-0.34.0}/tests/cloud/test_package.py +0 -0
  59. {nextmv-0.33.0 → nextmv-0.34.0}/tests/cloud/test_scenario.py +0 -0
  60. {nextmv-0.33.0 → nextmv-0.34.0}/tests/local/__init__.py +0 -0
  61. {nextmv-0.33.0 → nextmv-0.34.0}/tests/local/test_application.py +0 -0
  62. {nextmv-0.33.0 → nextmv-0.34.0}/tests/local/test_runner.py +0 -0
  63. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/__init__.py +0 -0
  64. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options1.py +0 -0
  65. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options2.py +0 -0
  66. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options3.py +0 -0
  67. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options4.py +0 -0
  68. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options5.py +0 -0
  69. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options6.py +0 -0
  70. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options7.py +0 -0
  71. {nextmv-0.33.0 → nextmv-0.34.0}/tests/scripts/options_deprecated.py +0 -0
  72. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_base_model.py +0 -0
  73. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_entrypoint/__init__.py +0 -0
  74. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_entrypoint/test_entrypoint.py +0 -0
  75. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_input.py +0 -0
  76. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_inputs/test_data.csv +0 -0
  77. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_inputs/test_data.json +0 -0
  78. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_inputs/test_data.txt +0 -0
  79. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_logger.py +0 -0
  80. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_manifest.py +0 -0
  81. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_model.py +0 -0
  82. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_options.py +0 -0
  83. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_output.py +0 -0
  84. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_polling.py +0 -0
  85. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_run.py +0 -0
  86. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_safe.py +0 -0
  87. {nextmv-0.33.0 → nextmv-0.34.0}/tests/test_serialization.py +0 -0
  88. {nextmv-0.33.0 → nextmv-0.34.0}/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
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"
@@ -25,6 +25,7 @@ from .manifest import ManifestPythonArch as ManifestPythonArch
25
25
  from .manifest import ManifestPythonModel as ManifestPythonModel
26
26
  from .manifest import ManifestRuntime as ManifestRuntime
27
27
  from .manifest import ManifestType as ManifestType
28
+ from .manifest import default_python_manifest as default_python_manifest
28
29
  from .model import Model as Model
29
30
  from .model import ModelConfiguration as ModelConfiguration
30
31
  from .options import Option as Option
@@ -69,6 +70,7 @@ from .run import RunResult as RunResult
69
70
  from .run import RunType as RunType
70
71
  from .run import RunTypeConfiguration as RunTypeConfiguration
71
72
  from .run import StatisticsIndicator as StatisticsIndicator
73
+ from .run import SyncedRun as SyncedRun
72
74
  from .run import TrackedRun as TrackedRun
73
75
  from .run import TrackedRunStatus as TrackedRunStatus
74
76
  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
@@ -33,11 +33,22 @@ from nextmv.local.local import (
33
33
  )
34
34
  from nextmv.local.runner import run
35
35
  from nextmv.logger import log
36
- from nextmv.manifest import Manifest
36
+ from nextmv.manifest import Manifest, default_python_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
+ FormatInput,
44
+ Run,
45
+ RunConfiguration,
46
+ RunInformation,
47
+ RunResult,
48
+ SyncedRun,
49
+ TrackedRun,
50
+ TrackedRunStatus,
51
+ )
41
52
  from nextmv.safe import safe_id
42
53
  from nextmv.status import StatusV2
43
54
 
@@ -83,6 +94,39 @@ class Application:
83
94
 
84
95
  description: Optional[str] = None
85
96
  """Description of the application."""
97
+ manifest: Optional[Manifest] = None
98
+ """
99
+ Manifest of the application. A manifest is a file named `app.yaml` that
100
+ must be present at the root of the application's `src` directory. If the
101
+ app is initialized, and a manifest is not present, a default Python
102
+ manifest will be created, using the `nextmv.default_python_manifest`
103
+ function. If you specify this argument, and a manifest file is already
104
+ present in the `src` directory, the provided manifest will override the
105
+ existing one.
106
+ """
107
+
108
+ def __post_init__(self):
109
+ """
110
+ Validate the presence of the manifest in the application.
111
+ """
112
+
113
+ if self.manifest is not None:
114
+ self.manifest.to_yaml(self.src)
115
+
116
+ return
117
+
118
+ try:
119
+ manifest = Manifest.from_yaml(self.src)
120
+ self.manifest = manifest
121
+
122
+ return
123
+
124
+ except Exception:
125
+ manifest = default_python_manifest()
126
+ self.manifest = manifest
127
+ manifest.to_yaml(self.src)
128
+
129
+ return
86
130
 
87
131
  @classmethod
88
132
  def initialize(
@@ -283,7 +327,7 @@ class Application:
283
327
  >>> print(f"Local run completed with ID: {run_id}")
284
328
  """
285
329
 
286
- self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
330
+ configuration = self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
287
331
 
288
332
  if self.src is None:
289
333
  raise ValueError("`src` property for the `Application` must be specified to run the application locally")
@@ -843,69 +887,54 @@ class Application:
843
887
  self,
844
888
  input_dir_path: Optional[str],
845
889
  configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
846
- ) -> None:
890
+ ) -> RunConfiguration:
847
891
  """
848
892
  Auxiliary function to validate the directory path and configuration.
849
893
  """
850
894
 
895
+ if configuration is None:
896
+ if self.manifest.configuration is not None and self.manifest.configuration.content is not None:
897
+ configuration = RunConfiguration(
898
+ format=Format(
899
+ format_input=FormatInput(
900
+ input_type=self.manifest.configuration.content.format,
901
+ ),
902
+ ),
903
+ )
904
+ elif isinstance(configuration, dict):
905
+ # Forcefully turn the configuration into a RunConfiguration object to
906
+ # make it easier to deal with in the other functions.
907
+ configuration = RunConfiguration.from_dict(configuration)
908
+
851
909
  if input_dir_path is None or input_dir_path == "":
852
- return
910
+ return configuration
853
911
 
854
912
  if configuration is None:
855
913
  raise ValueError(
856
- "If dir_path is provided, a RunConfiguration must also be provided.",
914
+ "If `dir_path` is provided, either a `RunConfiguration` must also be provided or "
915
+ "the application's manifest (app.yaml) must include the format under "
916
+ "`configuration.content.format`.",
857
917
  )
858
918
 
859
- config_format = self.__extract_config_format(configuration)
860
-
919
+ config_format = configuration.format
861
920
  if config_format is None:
862
921
  raise ValueError(
863
- "If dir_path is provided, RunConfiguration.format must also be provided.",
922
+ "If `dir_path` is provided, `RunConfiguration.format` must also be provided.",
864
923
  )
865
924
 
866
- input_type = self.__extract_input_type(config_format)
867
-
868
- if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
925
+ input_type = config_format.format_input
926
+ if input_type is None:
869
927
  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]}",
928
+ "If `dir_path` is provided, `RunConfiguration.format.format_input` must also be provided.",
872
929
  )
873
930
 
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:
931
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
904
932
  raise ValueError(
905
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
933
+ "If `dir_path` is provided, `RunConfiguration.format.format_input.input_type` must be set to "
934
+ f"a valid type. Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
906
935
  )
907
936
 
908
- return config_format.format_input.input_type
937
+ return configuration
909
938
 
910
939
  def __extract_input_data(
911
940
  self,
@@ -995,25 +1024,10 @@ class Application:
995
1024
  input_type = run_result.metadata.format.format_input.input_type
996
1025
 
997
1026
  # 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
1027
+ synced_run, already_synced = run_result.is_synced(app_id=target.id, instance_id=instance_id)
999
1028
  if already_synced:
1000
1029
  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
- )
1030
+ log(f" ⏭️ Skipping local run `{run_id}`, already synced with {synced_run.to_dict()}.")
1017
1031
 
1018
1032
  return False
1019
1033
 
@@ -1031,7 +1045,7 @@ class Application:
1031
1045
  # Read the logs of the run and place each line as an element in a list
1032
1046
  run_dir = os.path.join(runs_dir, run_id)
1033
1047
  with open(os.path.join(run_dir, LOGS_KEY, LOGS_FILE)) as f:
1034
- stderr_logs = f.readlines()
1048
+ stderr_logs = f.read()
1035
1049
 
1036
1050
  # Create the tracked run object and start configuring it.
1037
1051
  tracked_run = TrackedRun(
@@ -1055,11 +1069,28 @@ class Application:
1055
1069
  tracked_run.input_dir_path = inputs_path
1056
1070
 
1057
1071
  # Resolve the output according to its type.
1058
- if run_result.metadata.format.format_output.output_type == OutputFormat.JSON:
1072
+ output_type = run_result.metadata.format.format_output.output_type
1073
+ if output_type == OutputFormat.JSON:
1059
1074
  tracked_run.output = run_result.output
1060
1075
  else:
1061
1076
  tracked_run.output_dir_path = os.path.join(run_dir, OUTPUTS_KEY, SOLUTIONS_KEY)
1062
1077
 
1078
+ # Resolve the statistics according to their type and presence. If
1079
+ # working with JSON, the statistics should be resolved from the output.
1080
+ if output_type in {OutputFormat.CSV_ARCHIVE, OutputFormat.MULTI_FILE}:
1081
+ stats_file_path = os.path.join(run_dir, OUTPUTS_KEY, STATISTICS_KEY, f"{STATISTICS_KEY}.json")
1082
+ if os.path.exists(stats_file_path):
1083
+ with open(stats_file_path) as f:
1084
+ tracked_run.statistics = json.load(f)
1085
+
1086
+ # Resolve the assets according to their type and presence. If working
1087
+ # with JSON, the assets should be resolved from the output.
1088
+ if output_type in {OutputFormat.CSV_ARCHIVE, OutputFormat.MULTI_FILE}:
1089
+ assets_file_path = os.path.join(run_dir, OUTPUTS_KEY, ASSETS_KEY, f"{ASSETS_KEY}.json")
1090
+ if os.path.exists(assets_file_path):
1091
+ with open(assets_file_path) as f:
1092
+ tracked_run.assets = json.load(f)
1093
+
1063
1094
  # Actually sync the run by tracking it remotely on Nextmv Cloud.
1064
1095
  configuration = RunConfiguration(
1065
1096
  format=Format(
@@ -1074,13 +1105,18 @@ class Application:
1074
1105
  )
1075
1106
 
1076
1107
  # 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)
1108
+ synced_run = SyncedRun(
1109
+ run_id=tracked_id,
1110
+ synced_at=datetime.now(timezone.utc),
1111
+ app_id=target.id,
1112
+ instance_id=instance_id,
1113
+ )
1114
+ run_result.add_synced_run(synced_run)
1079
1115
  with open(os.path.join(run_dir, f"{run_id}.json"), "w") as f:
1080
1116
  json.dump(run_result.to_dict(), f, indent=2)
1081
1117
 
1082
1118
  if verbose:
1083
- log(f"✅ Synced local run `{run_id}` as remote run `{tracked_id}`.")
1119
+ log(f"✅ Synced local run `{run_id}` as remote run `{synced_run.to_dict()}`.")
1084
1120
 
1085
1121
  return True
1086
1122
 
@@ -1116,7 +1152,15 @@ class Application:
1116
1152
  return False
1117
1153
 
1118
1154
  # Validate outputs
1119
- if not self.__validate_outputs(run_dir, run_result.metadata.format.format_output.output_type):
1155
+ format_output = run_result.metadata.format.format_output
1156
+ if format_output is None or not format_output:
1157
+ return False
1158
+
1159
+ output_type = format_output.output_type
1160
+ if output_type is None or output_type == "":
1161
+ return False
1162
+
1163
+ if not self.__validate_outputs(run_dir, output_type):
1120
1164
  return False
1121
1165
 
1122
1166
  # Validate logs