nextmv 0.33.0.dev0__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.dev0"
1
+ __version__ = "v0.34.0.dev0"
nextmv/__init__.py CHANGED
@@ -21,6 +21,7 @@ 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
@@ -68,6 +69,7 @@ from .run import RunResult as RunResult
68
69
  from .run import RunType as RunType
69
70
  from .run import RunTypeConfiguration as RunTypeConfiguration
70
71
  from .run import StatisticsIndicator as StatisticsIndicator
72
+ from .run import SyncedRun as SyncedRun
71
73
  from .run import TrackedRun as TrackedRun
72
74
  from .run import TrackedRunStatus as TrackedRunStatus
73
75
  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
 
@@ -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