nextmv 0.33.0__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 +1 -1
- nextmv/__init__.py +1 -0
- nextmv/cloud/application.py +50 -49
- nextmv/local/application.py +50 -25
- nextmv/local/executor.py +377 -66
- nextmv/local/local.py +1 -1
- nextmv/run.py +175 -13
- {nextmv-0.33.0.dist-info → nextmv-0.34.0.dev0.dist-info}/METADATA +1 -1
- {nextmv-0.33.0.dist-info → nextmv-0.34.0.dev0.dist-info}/RECORD +11 -11
- {nextmv-0.33.0.dist-info → nextmv-0.34.0.dev0.dist-info}/WHEEL +0 -0
- {nextmv-0.33.0.dist-info → nextmv-0.34.0.dev0.dist-info}/licenses/LICENSE +0 -0
nextmv/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "v0.
|
|
1
|
+
__version__ = "v0.34.0.dev0"
|
nextmv/__init__.py
CHANGED
|
@@ -69,6 +69,7 @@ from .run import RunResult as RunResult
|
|
|
69
69
|
from .run import RunType as RunType
|
|
70
70
|
from .run import RunTypeConfiguration as RunTypeConfiguration
|
|
71
71
|
from .run import StatisticsIndicator as StatisticsIndicator
|
|
72
|
+
from .run import SyncedRun as SyncedRun
|
|
72
73
|
from .run import TrackedRun as TrackedRun
|
|
73
74
|
from .run import TrackedRunStatus as TrackedRunStatus
|
|
74
75
|
from .run import run_duration as run_duration
|
nextmv/cloud/application.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`
|
|
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
|
nextmv/local/application.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
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 `{
|
|
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
|
-
|
|
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
|
nextmv/local/executor.py
CHANGED
|
@@ -16,6 +16,10 @@ process_run_input
|
|
|
16
16
|
Function to process the run input based on the format.
|
|
17
17
|
process_run_output
|
|
18
18
|
Function to process the run output and handle results.
|
|
19
|
+
resolve_output_format
|
|
20
|
+
Function to determine the output format from manifest or directory structure.
|
|
21
|
+
process_run_information
|
|
22
|
+
Function to update run metadata including duration and status.
|
|
19
23
|
process_run_logs
|
|
20
24
|
Function to process and save run logs.
|
|
21
25
|
process_run_statistics
|
|
@@ -26,8 +30,13 @@ process_run_solutions
|
|
|
26
30
|
Function to process and save run solutions.
|
|
27
31
|
process_run_visuals
|
|
28
32
|
Function to process and save run visuals.
|
|
33
|
+
resolve_stdout
|
|
34
|
+
Function to parse subprocess stdout output.
|
|
35
|
+
ignore_patterns
|
|
36
|
+
Function to filter files and directories during source code copying.
|
|
29
37
|
"""
|
|
30
38
|
|
|
39
|
+
import hashlib
|
|
31
40
|
import json
|
|
32
41
|
import os
|
|
33
42
|
import shutil
|
|
@@ -84,25 +93,26 @@ def execute_run(
|
|
|
84
93
|
input_data: Optional[Union[dict[str, Any], str]] = None,
|
|
85
94
|
) -> None:
|
|
86
95
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
options.
|
|
96
|
+
Executes the decision model run using a subprocess to call the entrypoint
|
|
97
|
+
script with the appropriate input and options.
|
|
90
98
|
|
|
91
99
|
Parameters
|
|
92
100
|
----------
|
|
101
|
+
run_id : str
|
|
102
|
+
The unique identifier for the run.
|
|
93
103
|
src : str
|
|
94
104
|
The path to the application source code.
|
|
95
|
-
|
|
96
|
-
The
|
|
105
|
+
manifest_dict : dict[str, Any]
|
|
106
|
+
The manifest dictionary containing application configuration.
|
|
97
107
|
run_dir : str
|
|
98
|
-
The path to the run directory.
|
|
108
|
+
The path to the run directory where outputs will be stored.
|
|
99
109
|
run_config : dict[str, Any]
|
|
100
|
-
The run configuration.
|
|
110
|
+
The run configuration containing format and other settings.
|
|
101
111
|
inputs_dir_path : Optional[str], optional
|
|
102
112
|
The path to the directory containing input files, by default None. If
|
|
103
113
|
provided, this parameter takes precedence over `input_data`.
|
|
104
114
|
options : Optional[dict[str, Any]], optional
|
|
105
|
-
Additional options for the run, by default None.
|
|
115
|
+
Additional command-line options for the run, by default None.
|
|
106
116
|
input_data : Optional[Union[dict[str, Any], str]], optional
|
|
107
117
|
The input data for the run, by default None. If `inputs_dir_path` is
|
|
108
118
|
provided, this parameter is ignored.
|
|
@@ -119,7 +129,7 @@ def execute_run(
|
|
|
119
129
|
# place to work from, and be cleaned up afterwards.
|
|
120
130
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
121
131
|
temp_src = os.path.join(temp_dir, "src")
|
|
122
|
-
shutil.copytree(src, temp_src, ignore=
|
|
132
|
+
shutil.copytree(src, temp_src, ignore=ignore_patterns)
|
|
123
133
|
|
|
124
134
|
manifest = Manifest.from_dict(manifest_dict)
|
|
125
135
|
|
|
@@ -162,6 +172,7 @@ def execute_run(
|
|
|
162
172
|
temp_src=temp_src,
|
|
163
173
|
result=result,
|
|
164
174
|
run_dir=run_dir,
|
|
175
|
+
src=src,
|
|
165
176
|
)
|
|
166
177
|
|
|
167
178
|
except Exception as e:
|
|
@@ -290,29 +301,30 @@ def process_run_output(
|
|
|
290
301
|
temp_src: str,
|
|
291
302
|
result: subprocess.CompletedProcess[str],
|
|
292
303
|
run_dir: str,
|
|
304
|
+
src: str,
|
|
293
305
|
) -> None:
|
|
294
306
|
"""
|
|
295
307
|
Processes the result of the subprocess run. This function is in charge of
|
|
296
308
|
handling the run results, including solutions, statistics, logs, assets,
|
|
297
|
-
|
|
309
|
+
and visuals.
|
|
298
310
|
|
|
299
311
|
Parameters
|
|
300
312
|
----------
|
|
301
313
|
manifest : Manifest
|
|
302
|
-
The application manifest.
|
|
314
|
+
The application manifest containing configuration details.
|
|
315
|
+
run_id : str
|
|
316
|
+
The unique identifier for the run.
|
|
303
317
|
temp_src : str
|
|
304
318
|
The path to the temporary source directory.
|
|
305
319
|
result : subprocess.CompletedProcess[str]
|
|
306
|
-
The result of the subprocess run.
|
|
320
|
+
The result of the subprocess run containing stdout, stderr, and return code.
|
|
307
321
|
run_dir : str
|
|
308
|
-
The path to the run directory.
|
|
322
|
+
The path to the run directory where outputs will be stored.
|
|
323
|
+
src : str
|
|
324
|
+
The path to the application source code.
|
|
309
325
|
"""
|
|
310
326
|
|
|
311
|
-
|
|
312
|
-
stdout_output = {}
|
|
313
|
-
raw_output = result.stdout
|
|
314
|
-
if raw_output.strip() != "":
|
|
315
|
-
stdout_output = json.loads(raw_output)
|
|
327
|
+
stdout_output = resolve_stdout(result)
|
|
316
328
|
|
|
317
329
|
# Create outputs directory.
|
|
318
330
|
outputs_dir = os.path.join(run_dir, OUTPUTS_KEY)
|
|
@@ -324,7 +336,6 @@ def process_run_output(
|
|
|
324
336
|
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
325
337
|
temp_src=temp_src,
|
|
326
338
|
)
|
|
327
|
-
|
|
328
339
|
process_run_information(
|
|
329
340
|
run_id=run_id,
|
|
330
341
|
run_dir=run_dir,
|
|
@@ -342,6 +353,7 @@ def process_run_output(
|
|
|
342
353
|
stdout_output=stdout_output,
|
|
343
354
|
temp_src=temp_src,
|
|
344
355
|
manifest=manifest,
|
|
356
|
+
src=src,
|
|
345
357
|
)
|
|
346
358
|
process_run_assets(
|
|
347
359
|
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
@@ -349,6 +361,7 @@ def process_run_output(
|
|
|
349
361
|
stdout_output=stdout_output,
|
|
350
362
|
temp_src=temp_src,
|
|
351
363
|
manifest=manifest,
|
|
364
|
+
src=src,
|
|
352
365
|
)
|
|
353
366
|
process_run_solutions(
|
|
354
367
|
run_id=run_id,
|
|
@@ -359,6 +372,7 @@ def process_run_output(
|
|
|
359
372
|
stdout_output=stdout_output,
|
|
360
373
|
output_format=output_format,
|
|
361
374
|
manifest=manifest,
|
|
375
|
+
src=src,
|
|
362
376
|
)
|
|
363
377
|
process_run_visuals(
|
|
364
378
|
run_dir=run_dir,
|
|
@@ -381,11 +395,16 @@ def resolve_output_format(
|
|
|
381
395
|
Parameters
|
|
382
396
|
----------
|
|
383
397
|
manifest : Manifest
|
|
384
|
-
The application manifest.
|
|
398
|
+
The application manifest containing configuration details.
|
|
385
399
|
temp_run_outputs_dir : str
|
|
386
400
|
The path to the temporary outputs directory.
|
|
387
401
|
temp_src : str
|
|
388
402
|
The path to the temporary source directory.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
OutputFormat
|
|
407
|
+
The determined output format (JSON, CSV_ARCHIVE, or MULTI_FILE).
|
|
389
408
|
"""
|
|
390
409
|
|
|
391
410
|
if manifest.configuration is not None and manifest.configuration.content is not None:
|
|
@@ -433,7 +452,8 @@ def process_run_information(run_id: str, run_dir: str, result: subprocess.Comple
|
|
|
433
452
|
error = ""
|
|
434
453
|
if result.returncode != 0:
|
|
435
454
|
status = StatusV2.failed.value
|
|
436
|
-
error
|
|
455
|
+
# Truncate error message so that Cloud does not complain.
|
|
456
|
+
error = (result.stderr.strip().replace("\n", " ") if result.stderr else "unknown error")[:60]
|
|
437
457
|
|
|
438
458
|
# Update the run info file.
|
|
439
459
|
info["metadata"]["duration"] = duration
|
|
@@ -448,29 +468,34 @@ def process_run_logs(
|
|
|
448
468
|
output_format: OutputFormat,
|
|
449
469
|
run_dir: str,
|
|
450
470
|
result: subprocess.CompletedProcess[str],
|
|
451
|
-
stdout_output: dict[str, Any],
|
|
471
|
+
stdout_output: Union[str, dict[str, Any]],
|
|
452
472
|
) -> None:
|
|
453
473
|
"""
|
|
454
474
|
Processes the logs of the run. Writes the logs to a logs directory.
|
|
475
|
+
For multi-file format, stdout is written to logs if present.
|
|
455
476
|
|
|
456
477
|
Parameters
|
|
457
478
|
----------
|
|
458
479
|
output_format : OutputFormat
|
|
459
|
-
The output format of the run.
|
|
480
|
+
The output format of the run (JSON, CSV_ARCHIVE, or MULTI_FILE).
|
|
460
481
|
run_dir : str
|
|
461
|
-
The path to the run directory.
|
|
482
|
+
The path to the run directory where logs will be stored.
|
|
462
483
|
result : subprocess.CompletedProcess[str]
|
|
463
|
-
The result of the subprocess run.
|
|
464
|
-
stdout_output : dict[str, Any]
|
|
465
|
-
The stdout output of the run,
|
|
484
|
+
The result of the subprocess run containing stderr output.
|
|
485
|
+
stdout_output : Union[str, dict[str, Any]]
|
|
486
|
+
The stdout output of the run, either as raw string or parsed dictionary.
|
|
466
487
|
"""
|
|
467
488
|
|
|
468
489
|
logs_dir = os.path.join(run_dir, LOGS_KEY)
|
|
469
490
|
os.makedirs(logs_dir, exist_ok=True)
|
|
470
491
|
std_err = result.stderr
|
|
471
492
|
with open(os.path.join(logs_dir, LOGS_FILE), "w") as f:
|
|
472
|
-
if output_format == OutputFormat.MULTI_FILE and stdout_output
|
|
473
|
-
|
|
493
|
+
if output_format == OutputFormat.MULTI_FILE and bool(stdout_output):
|
|
494
|
+
if isinstance(stdout_output, dict):
|
|
495
|
+
f.write(json.dumps(stdout_output))
|
|
496
|
+
elif isinstance(stdout_output, str):
|
|
497
|
+
f.write(stdout_output)
|
|
498
|
+
|
|
474
499
|
if std_err:
|
|
475
500
|
f.write("\n")
|
|
476
501
|
|
|
@@ -480,14 +505,15 @@ def process_run_logs(
|
|
|
480
505
|
def process_run_statistics(
|
|
481
506
|
temp_run_outputs_dir: str,
|
|
482
507
|
outputs_dir: str,
|
|
483
|
-
stdout_output: dict[str, Any],
|
|
508
|
+
stdout_output: Union[str, dict[str, Any]],
|
|
484
509
|
temp_src: str,
|
|
485
510
|
manifest: Manifest,
|
|
511
|
+
src: str,
|
|
486
512
|
) -> None:
|
|
487
513
|
"""
|
|
488
|
-
Processes the statistics of the run.
|
|
489
|
-
|
|
490
|
-
|
|
514
|
+
Processes the statistics of the run. Checks for an outputs/statistics folder
|
|
515
|
+
or custom statistics file location from manifest. If found, copies to run
|
|
516
|
+
directory. Otherwise, attempts to extract statistics from stdout.
|
|
491
517
|
|
|
492
518
|
Parameters
|
|
493
519
|
----------
|
|
@@ -495,12 +521,15 @@ def process_run_statistics(
|
|
|
495
521
|
The path to the temporary outputs directory.
|
|
496
522
|
outputs_dir : str
|
|
497
523
|
The path to the outputs directory in the run directory.
|
|
498
|
-
stdout_output : dict[str, Any]
|
|
499
|
-
The stdout output of the run,
|
|
524
|
+
stdout_output : Union[str, dict[str, Any]]
|
|
525
|
+
The stdout output of the run, either as raw string or parsed dictionary.
|
|
500
526
|
temp_src : str
|
|
501
527
|
The path to the temporary source directory.
|
|
502
528
|
manifest : Manifest
|
|
503
|
-
The application manifest.
|
|
529
|
+
The application manifest containing configuration and custom paths.
|
|
530
|
+
src : str
|
|
531
|
+
The path to the original application source code, used to avoid copying
|
|
532
|
+
files that are already part of the source.
|
|
504
533
|
"""
|
|
505
534
|
|
|
506
535
|
stats_dst = os.path.join(outputs_dir, STATISTICS_KEY)
|
|
@@ -524,7 +553,10 @@ def process_run_statistics(
|
|
|
524
553
|
|
|
525
554
|
stats_src = os.path.join(temp_run_outputs_dir, STATISTICS_KEY)
|
|
526
555
|
if os.path.exists(stats_src) and os.path.isdir(stats_src):
|
|
527
|
-
|
|
556
|
+
_copy_new_or_modified_files(stats_src, stats_dst, src)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
if not isinstance(stdout_output, dict):
|
|
528
560
|
return
|
|
529
561
|
|
|
530
562
|
if STATISTICS_KEY not in stdout_output:
|
|
@@ -538,14 +570,15 @@ def process_run_statistics(
|
|
|
538
570
|
def process_run_assets(
|
|
539
571
|
temp_run_outputs_dir: str,
|
|
540
572
|
outputs_dir: str,
|
|
541
|
-
stdout_output: dict[str, Any],
|
|
573
|
+
stdout_output: Union[str, dict[str, Any]],
|
|
542
574
|
temp_src: str,
|
|
543
575
|
manifest: Manifest,
|
|
576
|
+
src: str,
|
|
544
577
|
) -> None:
|
|
545
578
|
"""
|
|
546
|
-
Processes the assets of the run.
|
|
547
|
-
|
|
548
|
-
|
|
579
|
+
Processes the assets of the run. Checks for an outputs/assets folder or
|
|
580
|
+
custom assets file location from manifest. If found, copies to run directory.
|
|
581
|
+
Otherwise, attempts to extract assets from stdout.
|
|
549
582
|
|
|
550
583
|
Parameters
|
|
551
584
|
----------
|
|
@@ -553,12 +586,15 @@ def process_run_assets(
|
|
|
553
586
|
The path to the temporary outputs directory.
|
|
554
587
|
outputs_dir : str
|
|
555
588
|
The path to the outputs directory in the run directory.
|
|
556
|
-
stdout_output : dict[str, Any]
|
|
557
|
-
The stdout output of the run,
|
|
589
|
+
stdout_output : Union[str, dict[str, Any]]
|
|
590
|
+
The stdout output of the run, either as raw string or parsed dictionary.
|
|
558
591
|
temp_src : str
|
|
559
592
|
The path to the temporary source directory.
|
|
560
593
|
manifest : Manifest
|
|
561
|
-
The application manifest.
|
|
594
|
+
The application manifest containing configuration and custom paths.
|
|
595
|
+
src : str
|
|
596
|
+
The path to the original application source code, used to avoid copying
|
|
597
|
+
files that are already part of the source.
|
|
562
598
|
"""
|
|
563
599
|
|
|
564
600
|
assets_dst = os.path.join(outputs_dir, ASSETS_KEY)
|
|
@@ -582,7 +618,10 @@ def process_run_assets(
|
|
|
582
618
|
|
|
583
619
|
assets_src = os.path.join(temp_run_outputs_dir, ASSETS_KEY)
|
|
584
620
|
if os.path.exists(assets_src) and os.path.isdir(assets_src):
|
|
585
|
-
|
|
621
|
+
_copy_new_or_modified_files(assets_src, assets_dst, src)
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
if not isinstance(stdout_output, dict):
|
|
586
625
|
return
|
|
587
626
|
|
|
588
627
|
if ASSETS_KEY not in stdout_output:
|
|
@@ -599,37 +638,42 @@ def process_run_solutions(
|
|
|
599
638
|
temp_run_outputs_dir: str,
|
|
600
639
|
temp_src: str,
|
|
601
640
|
outputs_dir: str,
|
|
602
|
-
stdout_output: dict[str, Any],
|
|
641
|
+
stdout_output: Union[str, dict[str, Any]],
|
|
603
642
|
output_format: OutputFormat,
|
|
604
643
|
manifest: Manifest,
|
|
644
|
+
src: str,
|
|
605
645
|
) -> None:
|
|
606
646
|
"""
|
|
607
|
-
Processes the solutions (output) of the run.
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
647
|
+
Processes the solutions (output) of the run. Handles all different output
|
|
648
|
+
formats including CSV-archive, multi-file, JSON, and text. Looks for
|
|
649
|
+
`output` directory (csv-archive), `outputs/solutions` directory (multi-file),
|
|
650
|
+
or custom solutions path from manifest. Falls back to stdout for JSON/text.
|
|
651
|
+
Updates run metadata with output size and format information.
|
|
652
|
+
|
|
653
|
+
Only copies files that are truly new outputs, excluding files that already
|
|
654
|
+
exist in the original source code, inputs, statistics, or assets directories
|
|
655
|
+
to prevent copying application data as solutions.
|
|
614
656
|
|
|
615
657
|
Parameters
|
|
616
658
|
----------
|
|
617
659
|
run_id : str
|
|
618
|
-
The
|
|
660
|
+
The unique identifier of the run.
|
|
619
661
|
run_dir : str
|
|
620
|
-
The path to the run directory.
|
|
662
|
+
The path to the run directory where outputs are stored.
|
|
621
663
|
temp_run_outputs_dir : str
|
|
622
664
|
The path to the temporary outputs directory.
|
|
623
665
|
temp_src : str
|
|
624
666
|
The path to the temporary source directory.
|
|
625
667
|
outputs_dir : str
|
|
626
668
|
The path to the outputs directory in the run directory.
|
|
627
|
-
stdout_output : dict[str, Any]
|
|
628
|
-
The stdout output of the run,
|
|
669
|
+
stdout_output : Union[str, dict[str, Any]]
|
|
670
|
+
The stdout output of the run, either as raw string or parsed dictionary.
|
|
629
671
|
output_format : OutputFormat
|
|
630
|
-
The output format
|
|
672
|
+
The determined output format (JSON, CSV_ARCHIVE, MULTI_FILE, or TEXT).
|
|
631
673
|
manifest : Manifest
|
|
632
|
-
The application manifest.
|
|
674
|
+
The application manifest containing configuration and custom paths.
|
|
675
|
+
src : str
|
|
676
|
+
The path to the application source code.
|
|
633
677
|
"""
|
|
634
678
|
|
|
635
679
|
info_file = os.path.join(run_dir, f"{run_id}.json")
|
|
@@ -640,9 +684,12 @@ def process_run_solutions(
|
|
|
640
684
|
solutions_dst = os.path.join(outputs_dir, SOLUTIONS_KEY)
|
|
641
685
|
os.makedirs(solutions_dst, exist_ok=True)
|
|
642
686
|
|
|
687
|
+
# Build list of directories to exclude from copying
|
|
688
|
+
exclusion_dirs = _build_exclusion_directories(src, manifest, outputs_dir, run_dir)
|
|
689
|
+
|
|
643
690
|
if output_format == OutputFormat.CSV_ARCHIVE:
|
|
644
691
|
output_src = os.path.join(temp_src, OUTPUT_KEY)
|
|
645
|
-
|
|
692
|
+
_copy_new_or_modified_files(output_src, solutions_dst, src, exclusion_dirs)
|
|
646
693
|
elif output_format == OutputFormat.MULTI_FILE:
|
|
647
694
|
solutions_src = os.path.join(temp_run_outputs_dir, SOLUTIONS_KEY)
|
|
648
695
|
if (
|
|
@@ -653,11 +700,14 @@ def process_run_solutions(
|
|
|
653
700
|
):
|
|
654
701
|
solutions_src = os.path.join(temp_src, manifest.configuration.content.multi_file.output.solutions)
|
|
655
702
|
|
|
656
|
-
|
|
703
|
+
_copy_new_or_modified_files(solutions_src, solutions_dst, src, exclusion_dirs)
|
|
657
704
|
else:
|
|
658
|
-
if stdout_output:
|
|
705
|
+
if bool(stdout_output):
|
|
659
706
|
with open(os.path.join(solutions_dst, DEFAULT_OUTPUT_JSON_FILE), "w") as f:
|
|
660
|
-
|
|
707
|
+
if isinstance(stdout_output, dict):
|
|
708
|
+
json.dump(stdout_output, f, indent=2)
|
|
709
|
+
elif isinstance(stdout_output, str):
|
|
710
|
+
f.write(stdout_output)
|
|
661
711
|
|
|
662
712
|
# Update the run information file with the output size and type.
|
|
663
713
|
calculate_files_size(run_dir, run_id, solutions_dst, metadata_key="output_size")
|
|
@@ -669,14 +719,15 @@ def process_run_solutions(
|
|
|
669
719
|
def process_run_visuals(run_dir: str, outputs_dir: str) -> None:
|
|
670
720
|
"""
|
|
671
721
|
Processes the visuals from the assets in the run output. This function looks
|
|
672
|
-
for
|
|
722
|
+
for visual assets (Plotly and GeoJSON) in the assets.json file and generates
|
|
723
|
+
HTML files for each visual. ChartJS visuals are ignored for local runs.
|
|
673
724
|
|
|
674
725
|
Parameters
|
|
675
726
|
----------
|
|
676
727
|
run_dir : str
|
|
677
|
-
The path to the run directory.
|
|
728
|
+
The path to the run directory where visuals will be stored.
|
|
678
729
|
outputs_dir : str
|
|
679
|
-
The path to the outputs directory in the run directory.
|
|
730
|
+
The path to the outputs directory in the run directory containing assets.
|
|
680
731
|
"""
|
|
681
732
|
|
|
682
733
|
# Get the assets.
|
|
@@ -710,5 +761,265 @@ def process_run_visuals(run_dir: str, outputs_dir: str) -> None:
|
|
|
710
761
|
# so we ignore it for now.
|
|
711
762
|
|
|
712
763
|
|
|
764
|
+
def resolve_stdout(result: subprocess.CompletedProcess[str]) -> Union[str, dict[str, Any]]:
|
|
765
|
+
"""
|
|
766
|
+
Resolves the stdout output of the subprocess run. If the stdout is valid
|
|
767
|
+
JSON, it returns the parsed dictionary. Otherwise, it returns the raw
|
|
768
|
+
string output.
|
|
769
|
+
|
|
770
|
+
Parameters
|
|
771
|
+
----------
|
|
772
|
+
result : subprocess.CompletedProcess[str]
|
|
773
|
+
The result of the subprocess run.
|
|
774
|
+
|
|
775
|
+
Returns
|
|
776
|
+
-------
|
|
777
|
+
Union[str, dict[str, Any]]
|
|
778
|
+
The parsed stdout output as a dictionary if valid JSON, otherwise the
|
|
779
|
+
raw string output.
|
|
780
|
+
"""
|
|
781
|
+
raw_output = result.stdout
|
|
782
|
+
if raw_output.strip() == "":
|
|
783
|
+
return ""
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
return json.loads(raw_output)
|
|
787
|
+
except json.JSONDecodeError:
|
|
788
|
+
return raw_output
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
|
|
792
|
+
"""
|
|
793
|
+
Custom ignore function for copytree that filters files and directories
|
|
794
|
+
during source code copying. Excludes virtual environments, cache files,
|
|
795
|
+
the nextmv directory, and non-essential files while preserving Python
|
|
796
|
+
source files and application manifests.
|
|
797
|
+
|
|
798
|
+
Parameters
|
|
799
|
+
----------
|
|
800
|
+
dir_path : str
|
|
801
|
+
The path to the directory being processed.
|
|
802
|
+
names : list[str]
|
|
803
|
+
A list of file and directory names in the current directory.
|
|
804
|
+
|
|
805
|
+
Returns
|
|
806
|
+
-------
|
|
807
|
+
list[str]
|
|
808
|
+
A list of names to ignore during the copy operation.
|
|
809
|
+
"""
|
|
810
|
+
ignored = []
|
|
811
|
+
for name in names:
|
|
812
|
+
full_path = os.path.join(dir_path, name)
|
|
813
|
+
|
|
814
|
+
# Ignore nextmv directory
|
|
815
|
+
if name == NEXTMV_DIR:
|
|
816
|
+
ignored.append(name)
|
|
817
|
+
continue
|
|
818
|
+
|
|
819
|
+
# Ignore virtual environment directories
|
|
820
|
+
if name in ("venv", ".venv", "env", ".env", "virtualenv", ".virtualenv"):
|
|
821
|
+
ignored.append(name)
|
|
822
|
+
continue
|
|
823
|
+
|
|
824
|
+
# Ignore __pycache__ directories
|
|
825
|
+
if name == "__pycache__":
|
|
826
|
+
ignored.append(name)
|
|
827
|
+
continue
|
|
828
|
+
|
|
829
|
+
# If it's a file, only keep Python files and app.yaml
|
|
830
|
+
if os.path.isfile(full_path):
|
|
831
|
+
if not (name.endswith(".py") or name == "app.yaml"):
|
|
832
|
+
ignored.append(name)
|
|
833
|
+
continue
|
|
834
|
+
|
|
835
|
+
# Ignore .pyc files explicitly
|
|
836
|
+
if name.endswith(".pyc"):
|
|
837
|
+
ignored.append(name)
|
|
838
|
+
continue
|
|
839
|
+
|
|
840
|
+
return ignored
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _build_exclusion_directories(src: str, manifest: Manifest, outputs_dir: str, run_dir: str) -> list[str]:
|
|
844
|
+
"""
|
|
845
|
+
Build a list of directories to exclude when copying solution files.
|
|
846
|
+
|
|
847
|
+
Parameters
|
|
848
|
+
----------
|
|
849
|
+
src : str
|
|
850
|
+
The path to the original application source code.
|
|
851
|
+
manifest : Manifest
|
|
852
|
+
The application manifest containing configuration.
|
|
853
|
+
outputs_dir : str
|
|
854
|
+
The path to the outputs directory in the run directory.
|
|
855
|
+
run_dir : str
|
|
856
|
+
The path to the run directory.
|
|
857
|
+
|
|
858
|
+
Returns
|
|
859
|
+
-------
|
|
860
|
+
list[str]
|
|
861
|
+
List of directory paths to exclude from copying.
|
|
862
|
+
"""
|
|
863
|
+
exclusion_dirs = []
|
|
864
|
+
|
|
865
|
+
# Add inputs directory from original source
|
|
866
|
+
inputs_dir_original = os.path.join(src, INPUTS_KEY)
|
|
867
|
+
if os.path.exists(inputs_dir_original):
|
|
868
|
+
exclusion_dirs.append(inputs_dir_original)
|
|
869
|
+
|
|
870
|
+
# Add custom inputs directory if specified in manifest
|
|
871
|
+
if (
|
|
872
|
+
manifest.configuration is not None
|
|
873
|
+
and manifest.configuration.content is not None
|
|
874
|
+
and manifest.configuration.content.format == InputFormat.MULTI_FILE
|
|
875
|
+
and manifest.configuration.content.multi_file is not None
|
|
876
|
+
):
|
|
877
|
+
custom_inputs_dir = os.path.join(src, manifest.configuration.content.multi_file.input.path)
|
|
878
|
+
if os.path.exists(custom_inputs_dir):
|
|
879
|
+
exclusion_dirs.append(custom_inputs_dir)
|
|
880
|
+
|
|
881
|
+
# Add inputs directory from run directory
|
|
882
|
+
inputs_dir_run = os.path.join(run_dir, INPUTS_KEY)
|
|
883
|
+
if os.path.exists(inputs_dir_run):
|
|
884
|
+
exclusion_dirs.append(inputs_dir_run)
|
|
885
|
+
|
|
886
|
+
# Add statistics and assets directories from run outputs
|
|
887
|
+
stats_dir = os.path.join(outputs_dir, STATISTICS_KEY)
|
|
888
|
+
if os.path.exists(stats_dir):
|
|
889
|
+
exclusion_dirs.append(stats_dir)
|
|
890
|
+
|
|
891
|
+
assets_dir = os.path.join(outputs_dir, ASSETS_KEY)
|
|
892
|
+
if os.path.exists(assets_dir):
|
|
893
|
+
exclusion_dirs.append(assets_dir)
|
|
894
|
+
|
|
895
|
+
return exclusion_dirs
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _copy_new_or_modified_files(
|
|
899
|
+
src_dir: str, dst_dir: str, original_src_dir: Optional[str] = None, exclusion_dirs: Optional[list[str]] = None
|
|
900
|
+
) -> None:
|
|
901
|
+
"""
|
|
902
|
+
Copy files from source to destination only if they meet specific criteria.
|
|
903
|
+
|
|
904
|
+
This function ensures that only files that are either:
|
|
905
|
+
1. New files (not present in destination)
|
|
906
|
+
2. Existing files with different content (based on checksum comparison)
|
|
907
|
+
3. Files that are NOT present in the original source directory (if provided)
|
|
908
|
+
4. Files that are NOT present in any of the exclusion directories (if provided)
|
|
909
|
+
|
|
910
|
+
Parameters
|
|
911
|
+
----------
|
|
912
|
+
src_dir : str
|
|
913
|
+
The source directory path to copy from.
|
|
914
|
+
dst_dir : str
|
|
915
|
+
The destination directory path to copy to.
|
|
916
|
+
original_src_dir : Optional[str], optional
|
|
917
|
+
The original source directory to check against. Files present in this
|
|
918
|
+
directory will NOT be copied, by default None.
|
|
919
|
+
exclusion_dirs : Optional[list[str]], optional
|
|
920
|
+
Additional directories to check against. Files present in any of these
|
|
921
|
+
directories will NOT be copied, by default None.
|
|
922
|
+
"""
|
|
923
|
+
# Build list of all exclusion directories
|
|
924
|
+
exclusion_directories = []
|
|
925
|
+
if original_src_dir is not None:
|
|
926
|
+
exclusion_directories.append(original_src_dir)
|
|
927
|
+
if exclusion_dirs is not None:
|
|
928
|
+
exclusion_directories.extend(exclusion_dirs)
|
|
929
|
+
|
|
930
|
+
for root, _dirs, files in os.walk(src_dir):
|
|
931
|
+
rel_root = os.path.relpath(root, src_dir)
|
|
932
|
+
dst_root = dst_dir if rel_root == "." else os.path.join(dst_dir, rel_root)
|
|
933
|
+
os.makedirs(dst_root, exist_ok=True)
|
|
934
|
+
|
|
935
|
+
for file in files:
|
|
936
|
+
# Skip if file exists in any exclusion directory
|
|
937
|
+
if exclusion_directories and _file_exists_in_exclusion_dirs(file, rel_root, exclusion_directories):
|
|
938
|
+
continue
|
|
939
|
+
|
|
940
|
+
src_file = os.path.join(root, file)
|
|
941
|
+
dst_file = os.path.join(dst_root, file)
|
|
942
|
+
|
|
943
|
+
if _should_copy_file(src_file, dst_file):
|
|
944
|
+
shutil.copy2(src_file, dst_file)
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _should_copy_file(src_file: str, dst_file: str) -> bool:
|
|
948
|
+
"""
|
|
949
|
+
Determine if a file should be copied based on existence and content.
|
|
950
|
+
|
|
951
|
+
Parameters
|
|
952
|
+
----------
|
|
953
|
+
src_file : str
|
|
954
|
+
Path to the source file.
|
|
955
|
+
dst_file : str
|
|
956
|
+
Path to the destination file.
|
|
957
|
+
|
|
958
|
+
Returns
|
|
959
|
+
-------
|
|
960
|
+
bool
|
|
961
|
+
True if the file should be copied, False otherwise.
|
|
962
|
+
"""
|
|
963
|
+
if not os.path.exists(dst_file):
|
|
964
|
+
return True
|
|
965
|
+
|
|
966
|
+
try:
|
|
967
|
+
src_checksum = _calculate_file_checksum(src_file)
|
|
968
|
+
dst_checksum = _calculate_file_checksum(dst_file)
|
|
969
|
+
return src_checksum != dst_checksum
|
|
970
|
+
except OSError:
|
|
971
|
+
return True
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _calculate_file_checksum(file_path: str) -> str:
|
|
975
|
+
"""
|
|
976
|
+
Calculate MD5 checksum of a file.
|
|
977
|
+
|
|
978
|
+
Parameters
|
|
979
|
+
----------
|
|
980
|
+
file_path : str
|
|
981
|
+
The path to the file.
|
|
982
|
+
|
|
983
|
+
Returns
|
|
984
|
+
-------
|
|
985
|
+
str
|
|
986
|
+
The MD5 checksum of the file.
|
|
987
|
+
"""
|
|
988
|
+
hash_md5 = hashlib.md5()
|
|
989
|
+
with open(file_path, "rb") as f:
|
|
990
|
+
for chunk in iter(lambda: f.read(4096), b""):
|
|
991
|
+
hash_md5.update(chunk)
|
|
992
|
+
return hash_md5.hexdigest()
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _file_exists_in_exclusion_dirs(file_name: str, rel_root: str, exclusion_dirs: list[str]) -> bool:
|
|
996
|
+
"""
|
|
997
|
+
Check if a file exists in any of the exclusion directories.
|
|
998
|
+
|
|
999
|
+
Parameters
|
|
1000
|
+
----------
|
|
1001
|
+
file_name : str
|
|
1002
|
+
The name of the file to check.
|
|
1003
|
+
rel_root : str
|
|
1004
|
+
The relative root path from the source directory.
|
|
1005
|
+
exclusion_dirs : list[str]
|
|
1006
|
+
List of directories to check against.
|
|
1007
|
+
|
|
1008
|
+
Returns
|
|
1009
|
+
-------
|
|
1010
|
+
bool
|
|
1011
|
+
True if the file exists in any exclusion directory, False otherwise.
|
|
1012
|
+
"""
|
|
1013
|
+
for exclusion_dir in exclusion_dirs:
|
|
1014
|
+
if rel_root != ".":
|
|
1015
|
+
exclusion_file = os.path.join(exclusion_dir, rel_root, file_name)
|
|
1016
|
+
else:
|
|
1017
|
+
exclusion_file = os.path.join(exclusion_dir, file_name)
|
|
1018
|
+
|
|
1019
|
+
if os.path.exists(exclusion_file):
|
|
1020
|
+
return True
|
|
1021
|
+
return False
|
|
1022
|
+
|
|
1023
|
+
|
|
713
1024
|
if __name__ == "__main__":
|
|
714
1025
|
main()
|
nextmv/local/local.py
CHANGED
nextmv/run.py
CHANGED
|
@@ -52,7 +52,7 @@ from pydantic import AliasChoices, Field, field_validator
|
|
|
52
52
|
from nextmv._serialization import serialize_json
|
|
53
53
|
from nextmv.base_model import BaseModel
|
|
54
54
|
from nextmv.input import Input, InputFormat
|
|
55
|
-
from nextmv.output import Output, OutputFormat
|
|
55
|
+
from nextmv.output import Asset, Output, OutputFormat, Statistics
|
|
56
56
|
from nextmv.status import Status, StatusV2
|
|
57
57
|
|
|
58
58
|
|
|
@@ -687,6 +687,56 @@ class Metadata(BaseModel):
|
|
|
687
687
|
"""Deprecated: use status_v2."""
|
|
688
688
|
|
|
689
689
|
|
|
690
|
+
class SyncedRun(BaseModel):
|
|
691
|
+
"""
|
|
692
|
+
Information about a run that has been synced to a remote application.
|
|
693
|
+
|
|
694
|
+
You can import the `SyncedRun` class directly from `nextmv`:
|
|
695
|
+
|
|
696
|
+
```python
|
|
697
|
+
from nextmv import SyncedRun
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Parameters
|
|
701
|
+
----------
|
|
702
|
+
run_id : str
|
|
703
|
+
ID of the synced remote run. When the `Application.sync` method is
|
|
704
|
+
used, this field marks the association between the local run (`id`) and
|
|
705
|
+
the remote run (`synced_run.id`).
|
|
706
|
+
synced_at : datetime
|
|
707
|
+
Timestamp when the run was synced with the remote run.
|
|
708
|
+
app_id : str
|
|
709
|
+
The ID of the remote application that the local run was synced to.
|
|
710
|
+
instance_id : Optional[str], optional
|
|
711
|
+
The instance of the remote application that the local run was synced
|
|
712
|
+
to. This field is optional and may be None. If it is not specified, it
|
|
713
|
+
indicates that the run was synced against the default instance of the
|
|
714
|
+
app. Defaults to None.
|
|
715
|
+
"""
|
|
716
|
+
|
|
717
|
+
run_id: str
|
|
718
|
+
"""
|
|
719
|
+
ID of the synced remote run. When the `Application.sync` method is used,
|
|
720
|
+
this field marks the association between the local run (`id`) and the
|
|
721
|
+
remote run (`synced_run.id`)
|
|
722
|
+
"""
|
|
723
|
+
synced_at: datetime
|
|
724
|
+
"""
|
|
725
|
+
Timestamp when the run was synced with the remote run.
|
|
726
|
+
"""
|
|
727
|
+
app_id: str
|
|
728
|
+
"""
|
|
729
|
+
The ID of the remote application that the local run was synced to.
|
|
730
|
+
"""
|
|
731
|
+
|
|
732
|
+
instance_id: Optional[str] = None
|
|
733
|
+
"""
|
|
734
|
+
The instance of the remote application that the local run was synced to.
|
|
735
|
+
This field is optional and may be None. If it is not specified, it
|
|
736
|
+
indicates that the run was synced against the default instance of the app.
|
|
737
|
+
"""
|
|
738
|
+
|
|
739
|
+
|
|
690
740
|
class RunInformation(BaseModel):
|
|
691
741
|
"""
|
|
692
742
|
Information of a run.
|
|
@@ -727,19 +777,18 @@ class RunInformation(BaseModel):
|
|
|
727
777
|
"""
|
|
728
778
|
URL to the run in the Nextmv console.
|
|
729
779
|
"""
|
|
730
|
-
|
|
731
|
-
"""
|
|
732
|
-
ID of the synced remote run, if applicable. When the `Application.sync`
|
|
733
|
-
method is used, this field marks the association between the local run
|
|
734
|
-
(`id`) and the remote run (`synced_run_id`). This field is None if the run
|
|
735
|
-
was not created using `Application.sync` or if the run has not been synced
|
|
736
|
-
yet.
|
|
780
|
+
synced_runs: Optional[list[SyncedRun]] = None
|
|
737
781
|
"""
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
None if the run was not created using `Application.sync` or if the
|
|
742
|
-
has not been synced yet.
|
|
782
|
+
List of synced runs associated with this run, if applicable. When the
|
|
783
|
+
`Application.sync` method is used, this field contains the associations
|
|
784
|
+
between the local run (`id`) and the remote runs (`synced_run.id`). This
|
|
785
|
+
field is None if the run was not created using `Application.sync` or if the
|
|
786
|
+
run has not been synced yet. It is possible to sync a single local run to
|
|
787
|
+
multiple remote runs. A remote run is identified by its application ID and
|
|
788
|
+
instance (if applicable). A local run cannot be synced to a remote run if
|
|
789
|
+
it is already present, this is, if there exists a record in the list with
|
|
790
|
+
the same application ID and instance. If there is not a repeated remote
|
|
791
|
+
run, a new record is added to the list.
|
|
743
792
|
"""
|
|
744
793
|
|
|
745
794
|
def to_run(self) -> Run:
|
|
@@ -817,6 +866,83 @@ class RunInformation(BaseModel):
|
|
|
817
866
|
input_set_id=None,
|
|
818
867
|
)
|
|
819
868
|
|
|
869
|
+
def add_synced_run(self, synced_run: SyncedRun) -> bool:
|
|
870
|
+
"""
|
|
871
|
+
Add a synced run to the RunInformation.
|
|
872
|
+
|
|
873
|
+
This method adds a `SyncedRun` instance to the list of synced runs
|
|
874
|
+
associated with this `RunInformation`. If the list is None, it
|
|
875
|
+
initializes it first. If the run has already been synced, then it is
|
|
876
|
+
not added to the list. A run is already synced if there exists a record
|
|
877
|
+
in the list with the same application ID. This method returns True if
|
|
878
|
+
the synced run was added, and False otherwise.
|
|
879
|
+
|
|
880
|
+
Parameters
|
|
881
|
+
----------
|
|
882
|
+
synced_run : SyncedRun
|
|
883
|
+
The SyncedRun instance to add.
|
|
884
|
+
|
|
885
|
+
Returns
|
|
886
|
+
-------
|
|
887
|
+
bool
|
|
888
|
+
True if the synced run was added, False if it was already present.
|
|
889
|
+
"""
|
|
890
|
+
|
|
891
|
+
if self.synced_runs is None:
|
|
892
|
+
self.synced_runs = [synced_run]
|
|
893
|
+
|
|
894
|
+
return True
|
|
895
|
+
|
|
896
|
+
if synced_run.instance_id is None:
|
|
897
|
+
for existing_run in self.synced_runs:
|
|
898
|
+
if existing_run.app_id == synced_run.app_id:
|
|
899
|
+
return False
|
|
900
|
+
else:
|
|
901
|
+
for existing_run in self.synced_runs:
|
|
902
|
+
if existing_run.app_id == synced_run.app_id and existing_run.instance_id == synced_run.instance_id:
|
|
903
|
+
return False
|
|
904
|
+
|
|
905
|
+
self.synced_runs.append(synced_run)
|
|
906
|
+
|
|
907
|
+
return True
|
|
908
|
+
|
|
909
|
+
def is_synced(self, app_id: str, instance_id: Optional[str] = None) -> tuple[SyncedRun, bool]:
|
|
910
|
+
"""
|
|
911
|
+
Check if the run has been synced to a specific application and instance.
|
|
912
|
+
|
|
913
|
+
This method checks if there exists a `SyncedRun` in the list of synced
|
|
914
|
+
runs that matches the given application ID and optional instance ID.
|
|
915
|
+
|
|
916
|
+
Parameters
|
|
917
|
+
----------
|
|
918
|
+
app_id : str
|
|
919
|
+
The application ID to check.
|
|
920
|
+
instance_id : Optional[str], optional
|
|
921
|
+
The instance ID to check. If None, only the application ID is
|
|
922
|
+
considered. Defaults to None.
|
|
923
|
+
|
|
924
|
+
Returns
|
|
925
|
+
-------
|
|
926
|
+
tuple[SyncedRun, bool]
|
|
927
|
+
A tuple containing the SyncedRun instance if found, and a boolean
|
|
928
|
+
indicating whether the run has been synced to the specified
|
|
929
|
+
application and instance.
|
|
930
|
+
"""
|
|
931
|
+
|
|
932
|
+
if self.synced_runs is None:
|
|
933
|
+
return None, False
|
|
934
|
+
|
|
935
|
+
if instance_id is None:
|
|
936
|
+
for existing_run in self.synced_runs:
|
|
937
|
+
if existing_run.app_id == app_id:
|
|
938
|
+
return existing_run, True
|
|
939
|
+
else:
|
|
940
|
+
for existing_run in self.synced_runs:
|
|
941
|
+
if existing_run.app_id == app_id and existing_run.instance_id == instance_id:
|
|
942
|
+
return existing_run, True
|
|
943
|
+
|
|
944
|
+
return None, False
|
|
945
|
+
|
|
820
946
|
|
|
821
947
|
class ErrorLog(BaseModel):
|
|
822
948
|
"""
|
|
@@ -1154,6 +1280,16 @@ class ExternalRunResult(BaseModel):
|
|
|
1154
1280
|
"""Error message of the run."""
|
|
1155
1281
|
execution_duration: Optional[int] = None
|
|
1156
1282
|
"""Duration of the run, in milliseconds."""
|
|
1283
|
+
statistics_upload_id: Optional[str] = None
|
|
1284
|
+
"""
|
|
1285
|
+
ID of the statistics upload. Use this field when working with `CSV_ARCHIVE`
|
|
1286
|
+
or `MULTI_FILE` output formats.
|
|
1287
|
+
"""
|
|
1288
|
+
assets_upload_id: Optional[str] = None
|
|
1289
|
+
"""
|
|
1290
|
+
ID of the assets upload. Use this field when working with `CSV_ARCHIVE`
|
|
1291
|
+
or `MULTI_FILE` output formats.
|
|
1292
|
+
"""
|
|
1157
1293
|
|
|
1158
1294
|
def __post_init_post_parse__(self):
|
|
1159
1295
|
"""
|
|
@@ -1265,6 +1401,18 @@ class TrackedRun:
|
|
|
1265
1401
|
when working with `CSV_ARCHIVE` or `MULTI_FILE`. If both `output` and
|
|
1266
1402
|
`output_dir_path` are specified, the `output` is ignored, and the files
|
|
1267
1403
|
are saved in the directory instead. Defaults to None.
|
|
1404
|
+
statistics : Statistics or dict[str, Any], optional
|
|
1405
|
+
Statistics of the run being tracked. Only use this field if you want to
|
|
1406
|
+
track statistics for `CSV_ARCHIVE` or `MULTI_FILE` output formats. If you
|
|
1407
|
+
are working with `JSON` or `TEXT` output formats, this field will be
|
|
1408
|
+
ignored, as the statistics are extracted directly from the `output`.
|
|
1409
|
+
This field is optional. Defaults to None.
|
|
1410
|
+
assets : list[Asset or dict[str, Any]], optional
|
|
1411
|
+
Assets associated with the run being tracked. Only use this field if you
|
|
1412
|
+
want to track assets for `CSV_ARCHIVE` or `MULTI_FILE` output formats.
|
|
1413
|
+
If you are working with `JSON` or `TEXT` output formats, this field will
|
|
1414
|
+
be ignored, as the assets are extracted directly from the `output`.
|
|
1415
|
+
This field is optional. Defaults to None.
|
|
1268
1416
|
|
|
1269
1417
|
Examples
|
|
1270
1418
|
--------
|
|
@@ -1365,6 +1513,20 @@ class TrackedRun:
|
|
|
1365
1513
|
`output_dir_path` are specified, the `output` is ignored, and the files
|
|
1366
1514
|
are saved in the directory instead.
|
|
1367
1515
|
"""
|
|
1516
|
+
statistics: Optional[Union[Statistics, dict[str, Any]]] = None
|
|
1517
|
+
"""
|
|
1518
|
+
Statistics of the run being tracked. Only use this field if you want to
|
|
1519
|
+
track statistics for `CSV_ARCHIVE` or `MULTI_FILE` output formats. If you
|
|
1520
|
+
are working with `JSON` or `TEXT` output formats, this field will be
|
|
1521
|
+
ignored, as the statistics are extracted directly from the `output`.
|
|
1522
|
+
"""
|
|
1523
|
+
assets: Optional[list[Union[Asset, dict[str, Any]]]] = None
|
|
1524
|
+
"""
|
|
1525
|
+
Assets associated with the run being tracked. Only use this field if you
|
|
1526
|
+
want to track assets for `CSV_ARCHIVE` or `MULTI_FILE` output formats.
|
|
1527
|
+
If you are working with `JSON` or `TEXT` output formats, this field will
|
|
1528
|
+
be ignored, as the assets are extracted directly from the `output`.
|
|
1529
|
+
"""
|
|
1368
1530
|
|
|
1369
1531
|
def __post_init__(self): # noqa: C901
|
|
1370
1532
|
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
nextmv/__about__.py,sha256=
|
|
1
|
+
nextmv/__about__.py,sha256=IEH4q3YZ3NJ8xLBOPvbdmzjvZQSBu6mlVa0ZYNf6FZ0,29
|
|
2
2
|
nextmv/__entrypoint__.py,sha256=dA0iwwHtrq6Z9w9FxmxKLoBGLyhe7jWtUAU-Y3PEgHg,1094
|
|
3
|
-
nextmv/__init__.py,sha256=
|
|
3
|
+
nextmv/__init__.py,sha256=GYzXp81KEteniXZkNZ9IgLYnJNOBOQckOeGpTkxjjD4,3723
|
|
4
4
|
nextmv/_serialization.py,sha256=JlSl6BL0M2Esf7F89GsGIZ__Pp8RnFRNM0UxYhuuYU4,2853
|
|
5
5
|
nextmv/base_model.py,sha256=qmJ4AsYr9Yv01HQX_BERrn3229gyoZrYyP9tcyqNfeU,2311
|
|
6
6
|
nextmv/deprecated.py,sha256=kEVfyQ-nT0v2ePXTNldjQG9uH5IlfQVy3L4tztIxwmU,1638
|
|
@@ -11,13 +11,13 @@ nextmv/model.py,sha256=vI3pSV3iTwjRPflar7nAg-6h98XRUyi9II5O2J06-Kc,15018
|
|
|
11
11
|
nextmv/options.py,sha256=yPJu5lYMbV6YioMwAXv7ctpZUggLXKlZc9CqIbUFvE4,37895
|
|
12
12
|
nextmv/output.py,sha256=HdvWYG3gIzwoXquulaEVI4LLchXJDjkbag0BkBPM0vQ,55128
|
|
13
13
|
nextmv/polling.py,sha256=nfefvWI1smm-lIzaXE-4DMlojp6KXIvVi88XLJYUmo8,9724
|
|
14
|
-
nextmv/run.py,sha256=
|
|
14
|
+
nextmv/run.py,sha256=8B9hh-jWJpoMSJiDmgmXaqvmzTicWCn75UTJu7Nf7Cs,52401
|
|
15
15
|
nextmv/safe.py,sha256=VAK4fGEurbLNji4Pg5Okga5XQSbI4aI9JJf95_68Z20,3867
|
|
16
16
|
nextmv/status.py,sha256=SCDLhh2om3yeO5FxO0x-_RShQsZNXEpjHNdCGdb3VUI,2787
|
|
17
17
|
nextmv/cloud/__init__.py,sha256=2wI72lhWq81BYv1OpS0OOTT5-3sivpX0H4z5ANPoLMc,5051
|
|
18
18
|
nextmv/cloud/acceptance_test.py,sha256=ZEzCMrfJF-nUFr1nEr4IDgcoyavPhnanjFuPBJ79tAk,27731
|
|
19
19
|
nextmv/cloud/account.py,sha256=jIdGNyI3l3dVh2PuriAwAOrEuWRM150WgzxcBMVBNRw,6058
|
|
20
|
-
nextmv/cloud/application.py,sha256=
|
|
20
|
+
nextmv/cloud/application.py,sha256=oUpw6I1r4u9Cn0pnlylWlm23hPgZKsg_vnBXbqTtU-w,139693
|
|
21
21
|
nextmv/cloud/batch_experiment.py,sha256=13ciRpgBabMMTyazfdfEAymD3rTPrTAAorECsANxxuA,10397
|
|
22
22
|
nextmv/cloud/client.py,sha256=E0DiUb377jvEnpXlRnfT1PGCI0Jm0lTUoX5VqeU91lk,18165
|
|
23
23
|
nextmv/cloud/ensemble.py,sha256=glrRgyRFcEH12fNUhEl1FOo6xOTDEaF478dxfX0wj2Y,8604
|
|
@@ -38,13 +38,13 @@ nextmv/default_app/src/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
|
|
|
38
38
|
nextmv/default_app/src/main.py,sha256=WWeN_xl_mcPhICl3rSCvdEjRkFXGmAnej88FhS-fAmc,884
|
|
39
39
|
nextmv/default_app/src/visuals.py,sha256=WYK_YBnLmYo3TpVev1CpoNCuW5R7hk9QIkeCmvMn1Fs,1014
|
|
40
40
|
nextmv/local/__init__.py,sha256=6BsoqlK4dw6X11_uKzz9gBPfxKpdiol2FYO8R3X73SE,116
|
|
41
|
-
nextmv/local/application.py,sha256=
|
|
42
|
-
nextmv/local/executor.py,sha256=
|
|
41
|
+
nextmv/local/application.py,sha256=dupLPPJ0JkMsTI-7-OoPz9JCRboJyxWy6iO3SPKdA1o,46514
|
|
42
|
+
nextmv/local/executor.py,sha256=Num-VdI5rRecI1XDz__YtW7U2q02ulbwMfWxlhhzwU8,35202
|
|
43
43
|
nextmv/local/geojson_handler.py,sha256=7FavJdkUonop-yskjis0x3qFGB8A5wZyoBUblw-bVhw,12540
|
|
44
|
-
nextmv/local/local.py,sha256=
|
|
44
|
+
nextmv/local/local.py,sha256=cp56UpI8h19Ob6Jvb_Ni0ceXH5Vv3ET_iPTDe6ftq3Y,2617
|
|
45
45
|
nextmv/local/plotly_handler.py,sha256=bLb50e3AkVr_W-F6S7lXfeRdN60mG2jk3UElNmhoMWU,1930
|
|
46
46
|
nextmv/local/runner.py,sha256=hwkITHrQG_J9TzxufnaP1mjLWG-iSsNQD66UFZY4pp4,8602
|
|
47
|
-
nextmv-0.
|
|
48
|
-
nextmv-0.
|
|
49
|
-
nextmv-0.
|
|
50
|
-
nextmv-0.
|
|
47
|
+
nextmv-0.34.0.dev0.dist-info/METADATA,sha256=lqIIWxNBN6AfvZ_Nw8hmkTyxM1lOhVQ2nvm--xoPML8,16013
|
|
48
|
+
nextmv-0.34.0.dev0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
49
|
+
nextmv-0.34.0.dev0.dist-info/licenses/LICENSE,sha256=ZIbK-sSWA-OZprjNbmJAglYRtl5_K4l9UwAV3PGJAPc,11349
|
|
50
|
+
nextmv-0.34.0.dev0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|