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 +1 -1
- nextmv/__init__.py +3 -0
- nextmv/cloud/acceptance_test.py +16 -6
- nextmv/cloud/application.py +51 -79
- nextmv/cloud/package.py +62 -24
- nextmv/local/application.py +114 -70
- nextmv/local/executor.py +419 -66
- nextmv/local/local.py +1 -1
- nextmv/manifest.py +93 -4
- nextmv/run.py +175 -13
- {nextmv-0.33.0.dev0.dist-info → nextmv-0.34.0.dev1.dist-info}/METADATA +1 -1
- {nextmv-0.33.0.dev0.dist-info → nextmv-0.34.0.dev1.dist-info}/RECORD +14 -14
- {nextmv-0.33.0.dev0.dist-info → nextmv-0.34.0.dev1.dist-info}/WHEEL +0 -0
- {nextmv-0.33.0.dev0.dist-info → nextmv-0.34.0.dev1.dist-info}/licenses/LICENSE +0 -0
nextmv/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "v0.
|
|
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
|
nextmv/cloud/acceptance_test.py
CHANGED
|
@@ -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
|
-
|
|
260
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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.
|
nextmv/cloud/application.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
nextmv/local/application.py
CHANGED
|
@@ -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
|
|
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
|
-
) ->
|
|
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
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
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 `{
|
|
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
|
-
|
|
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
|