nextmv 0.33.0.dev0__py3-none-any.whl → 0.34.0.dev1__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.dev0"
1
+ __version__ = "v0.34.0.dev1"
nextmv/__init__.py CHANGED
@@ -21,9 +21,11 @@ from .manifest import Manifest as Manifest
21
21
  from .manifest import ManifestBuild as ManifestBuild
22
22
  from .manifest import ManifestOption as ManifestOption
23
23
  from .manifest import ManifestPython as ManifestPython
24
+ from .manifest import ManifestPythonArch as ManifestPythonArch
24
25
  from .manifest import ManifestPythonModel as ManifestPythonModel
25
26
  from .manifest import ManifestRuntime as ManifestRuntime
26
27
  from .manifest import ManifestType as ManifestType
28
+ from .manifest import default_python_manifest as default_python_manifest
27
29
  from .model import Model as Model
28
30
  from .model import ModelConfiguration as ModelConfiguration
29
31
  from .options import Option as Option
@@ -68,6 +70,7 @@ from .run import RunResult as RunResult
68
70
  from .run import RunType as RunType
69
71
  from .run import RunTypeConfiguration as RunTypeConfiguration
70
72
  from .run import StatisticsIndicator as StatisticsIndicator
73
+ from .run import SyncedRun as SyncedRun
71
74
  from .run import TrackedRun as TrackedRun
72
75
  from .run import TrackedRunStatus as TrackedRunStatus
73
76
  from .run import run_duration as run_duration
@@ -256,16 +256,25 @@ class ToleranceType(str, Enum):
256
256
  """ToleranceType is deprecated, please use MetricToleranceType instead.
257
257
  Relative tolerance type."""
258
258
 
259
- def __new__(cls, value: str):
260
- """Create a new ToleranceType instance and emit deprecation warning."""
259
+
260
+ # Override __getattribute__ to emit deprecation warnings when enum values are accessed
261
+ _original_getattribute = ToleranceType.__class__.__getattribute__
262
+
263
+
264
+ def _deprecated_getattribute(cls, name: str):
265
+ # Only emit deprecation warning if this is specifically the ToleranceType class
266
+ if cls is ToleranceType and name in ("undefined", "absolute", "relative"):
261
267
  deprecated(
262
- "ToleranceType",
268
+ f"ToleranceType.{name}",
263
269
  "ToleranceType is deprecated and will be removed in a future version. "
264
270
  "Please use MetricToleranceType instead",
265
271
  )
266
- obj = str.__new__(cls, value)
267
- obj._value_ = value
268
- return obj
272
+
273
+ return _original_getattribute(cls, name)
274
+
275
+
276
+ ToleranceType.__class__.__getattribute__ = _deprecated_getattribute
277
+
269
278
 
270
279
  class MetricToleranceType(str, Enum):
271
280
  """
@@ -304,6 +313,7 @@ class MetricToleranceType(str, Enum):
304
313
  relative = "relative"
305
314
  """Relative tolerance type."""
306
315
 
316
+
307
317
  class MetricTolerance(BaseModel):
308
318
  """
309
319
  Tolerance used for a metric in an acceptance test.
@@ -46,11 +46,7 @@ from nextmv.cloud.batch_experiment import (
46
46
  to_runs,
47
47
  )
48
48
  from nextmv.cloud.client import Client, get_size
49
- from nextmv.cloud.ensemble import (
50
- EnsembleDefinition,
51
- EvaluationRule,
52
- RunGroup,
53
- )
49
+ from nextmv.cloud.ensemble import EnsembleDefinition, EvaluationRule, RunGroup
54
50
  from nextmv.cloud.input_set import InputSet, ManagedInput
55
51
  from nextmv.cloud.instance import Instance, InstanceConfiguration
56
52
  from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
@@ -62,7 +58,7 @@ from nextmv.logger import log
62
58
  from nextmv.manifest import Manifest
63
59
  from nextmv.model import Model, ModelConfiguration
64
60
  from nextmv.options import Options
65
- from nextmv.output import Output, OutputFormat
61
+ from nextmv.output import ASSETS_KEY, STATISTICS_KEY, Asset, Output, OutputFormat, Statistics
66
62
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
67
63
  from nextmv.run import (
68
64
  ExternalRunResult,
@@ -191,17 +187,20 @@ class Application:
191
187
  >>> app = Application.new(client=client, name="My New App", id="my-app")
192
188
  """
193
189
 
190
+ if id is None:
191
+ id = safe_id("app")
192
+
194
193
  if exist_ok and cls.exists(client=client, id=id):
195
194
  return Application(client=client, id=id)
196
195
 
197
196
  payload = {
198
197
  "name": name,
198
+ "id": id,
199
199
  }
200
200
 
201
201
  if description is not None:
202
202
  payload["description"] = description
203
- if id is not None:
204
- payload["id"] = id
203
+
205
204
  if is_workflow is not None:
206
205
  payload["is_pipeline"] = is_workflow
207
206
 
@@ -1794,7 +1793,7 @@ class Application:
1794
1793
  when the run is part of a batch experiment.
1795
1794
  external_result: Optional[Union[ExternalRunResult, dict[str, Any]]]
1796
1795
  External result to use for the run. This can be a
1797
- `cloud.ExternalRunResult` object or a dict. If the object is used,
1796
+ `nextmv.ExternalRunResult` object or a dict. If the object is used,
1798
1797
  then the `.to_dict()` method is applied to extract the
1799
1798
  configuration. This is used when the run is an external run. We
1800
1799
  suggest that instead of specifying this parameter, you use the
@@ -1824,8 +1823,6 @@ class Application:
1824
1823
  not `JSON`. If the final `options` are not of type `dict[str,str]`.
1825
1824
  """
1826
1825
 
1827
- self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
1828
-
1829
1826
  tar_file = ""
1830
1827
  if input_dir_path is not None and input_dir_path != "":
1831
1828
  if not os.path.exists(input_dir_path):
@@ -1883,6 +1880,7 @@ class Application:
1883
1880
  query_params = {}
1884
1881
  if instance_id is not None or self.default_instance_id is not None:
1885
1882
  query_params["instance_id"] = instance_id if instance_id is not None else self.default_instance_id
1883
+
1886
1884
  response = self.client.request(
1887
1885
  method="POST",
1888
1886
  endpoint=f"{self.endpoint}/runs",
@@ -2997,6 +2995,7 @@ class Application:
2997
2995
  execution_duration=tracked_run.duration,
2998
2996
  )
2999
2997
 
2998
+ # Handle the stderr logs if provided.
3000
2999
  if tracked_run.logs is not None:
3001
3000
  url_stderr = self.upload_url()
3002
3001
  self.upload_large_input(input=tracked_run.logs_text(), upload_url=url_stderr)
@@ -3005,6 +3004,47 @@ class Application:
3005
3004
  if tracked_run.error is not None and tracked_run.error != "":
3006
3005
  external_result.error_message = tracked_run.error
3007
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
+
3008
3048
  return self.new_run(
3009
3049
  upload_id=url_input.upload_id,
3010
3050
  external_result=external_result,
@@ -3864,74 +3904,6 @@ class Application:
3864
3904
 
3865
3905
  raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
3866
3906
 
3867
- def __validate_input_dir_path_and_configuration(
3868
- self,
3869
- input_dir_path: Optional[str],
3870
- configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
3871
- ) -> None:
3872
- """
3873
- Auxiliary function to validate the directory path and configuration.
3874
- """
3875
-
3876
- if input_dir_path is None or input_dir_path == "":
3877
- return
3878
-
3879
- if configuration is None:
3880
- raise ValueError(
3881
- "If dir_path is provided, a RunConfiguration must also be provided.",
3882
- )
3883
-
3884
- config_format = self.__extract_config_format(configuration)
3885
-
3886
- if config_format is None:
3887
- raise ValueError(
3888
- "If dir_path is provided, RunConfiguration.format must also be provided.",
3889
- )
3890
-
3891
- input_type = self.__extract_input_type(config_format)
3892
-
3893
- if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
3894
- raise ValueError(
3895
- "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
3896
- f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
3897
- )
3898
-
3899
- def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
3900
- """Extract format from configuration, handling both RunConfiguration objects and dicts."""
3901
- if isinstance(configuration, RunConfiguration):
3902
- return configuration.format
3903
-
3904
- if isinstance(configuration, dict):
3905
- config_format = configuration.get("format")
3906
- if config_format is not None and isinstance(config_format, dict):
3907
- return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
3908
-
3909
- return config_format
3910
-
3911
- raise ValueError("Configuration must be a RunConfiguration object or a dict.")
3912
-
3913
- def __extract_input_type(self, config_format: Any) -> Any:
3914
- """Extract input type from config format."""
3915
- if isinstance(config_format, dict):
3916
- format_input = config_format.get("format_input") or config_format.get("input")
3917
- if format_input is None:
3918
- raise ValueError(
3919
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3920
- )
3921
-
3922
- if isinstance(format_input, dict):
3923
- return format_input.get("input_type") or format_input.get("type")
3924
-
3925
- return getattr(format_input, "input_type", None)
3926
-
3927
- # Handle Format object
3928
- if config_format.format_input is None:
3929
- raise ValueError(
3930
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
3931
- )
3932
-
3933
- return config_format.format_input.input_type
3934
-
3935
3907
  def __package_inputs(self, dir_path: str) -> str:
3936
3908
  """
3937
3909
  This is an auxiliary function for packaging the inputs found in the
nextmv/cloud/package.py CHANGED
@@ -222,7 +222,7 @@ def __handle_python(
222
222
  __install_dependencies(manifest, app_dir, temp_dir)
223
223
 
224
224
 
225
- def __install_dependencies(
225
+ def __install_dependencies( # noqa: C901 # complexity
226
226
  manifest: Manifest,
227
227
  app_dir: str,
228
228
  temp_dir: str,
@@ -253,31 +253,58 @@ def __install_dependencies(
253
253
  if not os.path.isfile(os.path.join(app_dir, pip_requirements)):
254
254
  raise FileNotFoundError(f"pip requirements file '{pip_requirements}' not found in '{app_dir}'")
255
255
 
256
+ platform_filter = []
257
+ if not manifest.python.arch or manifest.python.arch == "arm64":
258
+ platform_filter.extend(
259
+ [
260
+ "--platform=manylinux2014_aarch64",
261
+ "--platform=manylinux_2_17_aarch64",
262
+ "--platform=manylinux_2_24_aarch64",
263
+ "--platform=manylinux_2_28_aarch64",
264
+ "--platform=linux_aarch64",
265
+ ]
266
+ )
267
+ elif manifest.python.arch == "amd64":
268
+ platform_filter.extend(
269
+ [
270
+ "--platform=manylinux2014_x86_64",
271
+ "--platform=manylinux_2_17_x86_64",
272
+ "--platform=manylinux_2_24_x86_64",
273
+ "--platform=manylinux_2_28_x86_64",
274
+ "--platform=linux_x86_64",
275
+ ]
276
+ )
277
+ else:
278
+ raise Exception(f"unknown architecture '{manifest.python.arch}' specified in manifest")
279
+
280
+ version_filter = ["--python-version=3.11"]
281
+ if manifest.python.version:
282
+ __confirm_python_bundling_version(manifest.python.version)
283
+ version_filter = [f"--python-version={manifest.python.version}"]
284
+
256
285
  py_cmd = __get_python_command()
257
286
  dep_dir = os.path.join(".nextmv", "python", "deps")
258
- command = [
259
- py_cmd,
260
- "-m",
261
- "pip",
262
- "install",
263
- "-r",
264
- pip_requirements,
265
- "--platform=manylinux2014_aarch64",
266
- "--platform=manylinux_2_17_aarch64",
267
- "--platform=manylinux_2_24_aarch64",
268
- "--platform=manylinux_2_28_aarch64",
269
- "--platform=linux_aarch64",
270
- "--only-binary=:all:",
271
- "--python-version=3.11",
272
- "--implementation=cp",
273
- "--upgrade",
274
- "--no-warn-conflicts",
275
- "--target",
276
- os.path.join(temp_dir, dep_dir),
277
- "--no-user", # We explicitly avoid user mode (mainly to fix issues with Windows store Python installations)
278
- "--no-input",
279
- "--quiet",
280
- ]
287
+ command = (
288
+ [
289
+ py_cmd,
290
+ "-m",
291
+ "pip",
292
+ "install",
293
+ "-r",
294
+ pip_requirements,
295
+ "--only-binary=:all:",
296
+ "--implementation=cp",
297
+ "--upgrade",
298
+ "--no-warn-conflicts",
299
+ "--target",
300
+ os.path.join(temp_dir, dep_dir),
301
+ "--no-user", # We explicitly avoid user mode (mainly to fix issues with Windows store Python installations)
302
+ "--no-input",
303
+ "--quiet",
304
+ ]
305
+ + platform_filter
306
+ + version_filter
307
+ )
281
308
  result = subprocess.run(
282
309
  command,
283
310
  cwd=app_dir,
@@ -381,6 +408,17 @@ def __confirm_python_version(output: str) -> None:
381
408
  raise Exception("python version 3.9 or higher is required")
382
409
 
383
410
 
411
+ def __confirm_python_bundling_version(version: str) -> None:
412
+ # Only accept versions in the form "major.minor" where both are integers
413
+ re_version = re.compile(r"^(\d+)\.(\d+)$")
414
+ match = re_version.fullmatch(version)
415
+ if match:
416
+ major, minor = int(match.group(1)), int(match.group(2))
417
+ if major == 3 and minor >= 9:
418
+ return
419
+ raise Exception(f"python version 3.9 or higher is required for bundling, got {version}")
420
+
421
+
384
422
  def __compress_tar(source: str, target: str) -> tuple[str, int]:
385
423
  """Compress the source directory into a tar.gz file in the target"""
386
424
 
@@ -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,7 +887,7 @@ 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
  """
@@ -852,60 +896,45 @@ class Application:
852
896
  return
853
897
 
854
898
  if configuration is None:
855
- raise ValueError(
856
- "If dir_path is provided, a RunConfiguration must also be provided.",
857
- )
899
+ if self.manifest.configuration is not None and self.manifest.configuration.content is not None:
900
+ configuration = RunConfiguration(
901
+ format=Format(
902
+ format_input=FormatInput(
903
+ input_type=self.manifest.configuration.content.format,
904
+ ),
905
+ ),
906
+ )
907
+ else:
908
+ raise ValueError(
909
+ "If `dir_path` is provided, either a `RunConfiguration` must also be provided or "
910
+ "the application's manifest (app.yaml) must include the format under "
911
+ "`configuration.content.format`.",
912
+ )
858
913
 
859
- config_format = self.__extract_config_format(configuration)
914
+ # Forcefully turn the configuration into a RunConfiguration object to
915
+ # make it easier to deal with in the other functions.
916
+ if isinstance(configuration, dict):
917
+ configuration = RunConfiguration.from_dict(configuration)
860
918
 
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 = [line.rstrip("\n") for line in f.readlines()]
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