nextmv 0.30.0__py3-none-any.whl → 0.32.0__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 +35 -0
- nextmv/cloud/__init__.py +38 -33
- nextmv/cloud/account.py +1 -1
- nextmv/cloud/application.py +249 -484
- nextmv/cloud/batch_experiment.py +7 -1
- nextmv/cloud/input_set.py +1 -1
- nextmv/cloud/package.py +1 -1
- nextmv/cloud/url.py +73 -0
- nextmv/default_app/.gitignore +1 -0
- nextmv/default_app/README.md +17 -2
- nextmv/default_app/app.yaml +1 -0
- nextmv/default_app/input.json +5 -0
- nextmv/default_app/main.py +37 -0
- nextmv/default_app/src/main.py +2 -1
- nextmv/input.py +11 -1
- nextmv/local/__init__.py +5 -0
- nextmv/local/application.py +1173 -0
- nextmv/local/executor.py +713 -0
- nextmv/local/geojson_handler.py +323 -0
- nextmv/local/local.py +97 -0
- nextmv/local/plotly_handler.py +61 -0
- nextmv/local/runner.py +274 -0
- nextmv/{cloud/manifest.py → manifest.py} +110 -69
- nextmv/output.py +41 -8
- nextmv/polling.py +287 -0
- nextmv/run.py +1460 -0
- nextmv/{cloud/safe.py → safe.py} +35 -3
- nextmv/{cloud/status.py → status.py} +9 -9
- {nextmv-0.30.0.dist-info → nextmv-0.32.0.dist-info}/METADATA +5 -1
- nextmv-0.32.0.dist-info/RECORD +49 -0
- nextmv/cloud/run.py +0 -755
- nextmv-0.30.0.dist-info/RECORD +0 -37
- {nextmv-0.30.0.dist-info → nextmv-0.32.0.dist-info}/WHEEL +0 -0
- {nextmv-0.30.0.dist-info → nextmv-0.32.0.dist-info}/licenses/LICENSE +0 -0
nextmv/local/executor.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Executor module for executing local runs.
|
|
3
|
+
|
|
4
|
+
This module provides functionality to execute local runs. The `main` function
|
|
5
|
+
is summoned from the `run` function in the `runner` module.
|
|
6
|
+
|
|
7
|
+
Functions
|
|
8
|
+
---------
|
|
9
|
+
main
|
|
10
|
+
Main function to execute a local run.
|
|
11
|
+
execute_run
|
|
12
|
+
Function to execute the decision model run.
|
|
13
|
+
options_args
|
|
14
|
+
Function to convert options dictionary to command-line arguments.
|
|
15
|
+
process_run_input
|
|
16
|
+
Function to process the run input based on the format.
|
|
17
|
+
process_run_output
|
|
18
|
+
Function to process the run output and handle results.
|
|
19
|
+
process_run_logs
|
|
20
|
+
Function to process and save run logs.
|
|
21
|
+
process_run_statistics
|
|
22
|
+
Function to process and save run statistics.
|
|
23
|
+
process_run_assets
|
|
24
|
+
Function to process and save run assets.
|
|
25
|
+
process_run_solutions
|
|
26
|
+
Function to process and save run solutions.
|
|
27
|
+
process_run_visuals
|
|
28
|
+
Function to process and save run visuals.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import tempfile
|
|
36
|
+
from datetime import datetime, timezone
|
|
37
|
+
from typing import Any, Optional, Union
|
|
38
|
+
|
|
39
|
+
from nextmv.input import INPUTS_KEY, InputFormat, load
|
|
40
|
+
from nextmv.local.geojson_handler import handle_geojson_visual
|
|
41
|
+
from nextmv.local.local import (
|
|
42
|
+
DEFAULT_OUTPUT_JSON_FILE,
|
|
43
|
+
LOGS_FILE,
|
|
44
|
+
LOGS_KEY,
|
|
45
|
+
NEXTMV_DIR,
|
|
46
|
+
OUTPUT_KEY,
|
|
47
|
+
calculate_files_size,
|
|
48
|
+
)
|
|
49
|
+
from nextmv.local.plotly_handler import handle_plotly_visual
|
|
50
|
+
from nextmv.manifest import Manifest
|
|
51
|
+
from nextmv.output import ASSETS_KEY, OUTPUTS_KEY, SOLUTIONS_KEY, STATISTICS_KEY, Asset, OutputFormat, VisualSchema
|
|
52
|
+
from nextmv.status import StatusV2
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main() -> None:
|
|
56
|
+
"""
|
|
57
|
+
Main function to execute a local run. This function is called when
|
|
58
|
+
executing the script directly. It loads input data (arguments) from stdin
|
|
59
|
+
and orders the execution of the run.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
input = load()
|
|
63
|
+
execute_run(
|
|
64
|
+
run_id=input.data["run_id"],
|
|
65
|
+
src=input.data["src"],
|
|
66
|
+
manifest_dict=input.data["manifest_dict"],
|
|
67
|
+
run_dir=input.data["run_dir"],
|
|
68
|
+
run_config=input.data["run_config"],
|
|
69
|
+
inputs_dir_path=input.data["inputs_dir_path"],
|
|
70
|
+
options=input.data["options"],
|
|
71
|
+
input_data=input.data["input_data"],
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def execute_run(
|
|
76
|
+
run_id: str,
|
|
77
|
+
src: str,
|
|
78
|
+
manifest_dict: dict[str, Any],
|
|
79
|
+
run_dir: str,
|
|
80
|
+
run_config: dict[str, Any],
|
|
81
|
+
inputs_dir_path: Optional[str] = None,
|
|
82
|
+
options: Optional[dict[str, Any]] = None,
|
|
83
|
+
input_data: Optional[Union[dict[str, Any], str]] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
This function actually executes the decision model run, using a
|
|
87
|
+
subprocess to call the entrypoint script with the appropriate input and
|
|
88
|
+
options.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
src : str
|
|
93
|
+
The path to the application source code.
|
|
94
|
+
manifest_entrypoint : str
|
|
95
|
+
The entrypoint script as defined in the application manifest.
|
|
96
|
+
run_dir : str
|
|
97
|
+
The path to the run directory.
|
|
98
|
+
run_config : dict[str, Any]
|
|
99
|
+
The run configuration.
|
|
100
|
+
inputs_dir_path : Optional[str], optional
|
|
101
|
+
The path to the directory containing input files, by default None. If
|
|
102
|
+
provided, this parameter takes precedence over `input_data`.
|
|
103
|
+
options : Optional[dict[str, Any]], optional
|
|
104
|
+
Additional options for the run, by default None.
|
|
105
|
+
input_data : Optional[Union[dict[str, Any], str]], optional
|
|
106
|
+
The input data for the run, by default None. If `inputs_dir_path` is
|
|
107
|
+
provided, this parameter is ignored.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Create the logs dir to register whatever failure might happen during the
|
|
111
|
+
# execution process.
|
|
112
|
+
logs_dir = os.path.join(run_dir, LOGS_KEY)
|
|
113
|
+
os.makedirs(logs_dir, exist_ok=True)
|
|
114
|
+
|
|
115
|
+
# The complete execution is wrapped to capture any errors.
|
|
116
|
+
try:
|
|
117
|
+
# Create a temp dir, and copy the entire src there, to have a transient
|
|
118
|
+
# place to work from, and be cleaned up afterwards.
|
|
119
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
120
|
+
temp_src = os.path.join(temp_dir, "src")
|
|
121
|
+
shutil.copytree(src, temp_src, ignore=shutil.ignore_patterns(NEXTMV_DIR))
|
|
122
|
+
|
|
123
|
+
manifest = Manifest.from_dict(manifest_dict)
|
|
124
|
+
|
|
125
|
+
stdin_input = process_run_input(
|
|
126
|
+
temp_src=temp_src,
|
|
127
|
+
run_format=run_config["format"]["input"]["type"],
|
|
128
|
+
manifest=manifest,
|
|
129
|
+
input_data=input_data,
|
|
130
|
+
inputs_dir_path=inputs_dir_path,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Set the run status to running.
|
|
134
|
+
info_file = os.path.join(run_dir, f"{run_id}.json")
|
|
135
|
+
with open(info_file, "r+") as f:
|
|
136
|
+
info = json.load(f)
|
|
137
|
+
info["metadata"]["status_v2"] = "running"
|
|
138
|
+
f.seek(0)
|
|
139
|
+
json.dump(info, f, indent=2)
|
|
140
|
+
f.truncate()
|
|
141
|
+
|
|
142
|
+
# Start a Python subprocess to execute the entrypoint. For now, we are
|
|
143
|
+
# supporting a Python-first experience, so we are not summoning
|
|
144
|
+
# applications that are not Python-based.
|
|
145
|
+
entrypoint = os.path.join(temp_src, manifest.entrypoint)
|
|
146
|
+
args = ["python", entrypoint] + options_args(options)
|
|
147
|
+
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
args,
|
|
150
|
+
env=os.environ,
|
|
151
|
+
check=False,
|
|
152
|
+
text=True,
|
|
153
|
+
capture_output=True,
|
|
154
|
+
input=stdin_input,
|
|
155
|
+
cwd=temp_src,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
process_run_output(
|
|
159
|
+
manifest=manifest,
|
|
160
|
+
run_id=run_id,
|
|
161
|
+
temp_src=temp_src,
|
|
162
|
+
result=result,
|
|
163
|
+
run_dir=run_dir,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
# If we encounter an exception, we log it to the stderr log file.
|
|
168
|
+
with open(os.path.join(logs_dir, LOGS_FILE), "a") as f:
|
|
169
|
+
f.write(f"\nException during run execution: {str(e)}\n")
|
|
170
|
+
|
|
171
|
+
# Also, we update the run information file to set the status to failed.
|
|
172
|
+
info_file = os.path.join(run_dir, f"{run_id}.json")
|
|
173
|
+
with open(info_file, "r+") as f:
|
|
174
|
+
info = json.load(f)
|
|
175
|
+
info["metadata"]["status_v2"] = "failed"
|
|
176
|
+
info["metadata"]["error"] = str(e)
|
|
177
|
+
f.seek(0)
|
|
178
|
+
json.dump(info, f, indent=2)
|
|
179
|
+
f.truncate()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def options_args(options: Optional[dict[str, Any]] = None) -> list[str]:
|
|
183
|
+
"""
|
|
184
|
+
Converts options dictionary to a list of command-line arguments.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
options : Optional[dict[str, Any]], optional
|
|
189
|
+
Additional options for the run, by default None.
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
list[str]
|
|
194
|
+
A list of command-line arguments derived from the options.
|
|
195
|
+
"""
|
|
196
|
+
option_args = []
|
|
197
|
+
|
|
198
|
+
if options is not None:
|
|
199
|
+
for key, value in options.items():
|
|
200
|
+
option_args.append(f"-{key}")
|
|
201
|
+
option_args.append(str(value))
|
|
202
|
+
|
|
203
|
+
return option_args
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def process_run_input(
|
|
207
|
+
temp_src: str,
|
|
208
|
+
run_format: str,
|
|
209
|
+
manifest: Manifest,
|
|
210
|
+
input_data: Optional[Union[dict[str, Any], str]] = None,
|
|
211
|
+
inputs_dir_path: Optional[str] = None,
|
|
212
|
+
) -> str:
|
|
213
|
+
"""
|
|
214
|
+
In the temp source, writes the run input according to the run format. If
|
|
215
|
+
the format is `json` or `text`, then the input is not written anywhere,
|
|
216
|
+
rather, it is returned as a string in this function. If the format is
|
|
217
|
+
`csv-archive`, then the input files are written to an `input` directory. If
|
|
218
|
+
the format is `multi-file`, then the input files are written to an `inputs`
|
|
219
|
+
directory or to a custom location specified in the manifest.
|
|
220
|
+
|
|
221
|
+
Parameters
|
|
222
|
+
----------
|
|
223
|
+
temp_src : str
|
|
224
|
+
The path to the temporary source directory.
|
|
225
|
+
run_format : str
|
|
226
|
+
The run format, one of `json`, `text`, `csv-archive`, or `multi-file`.
|
|
227
|
+
manifest : Manifest
|
|
228
|
+
The application manifest.
|
|
229
|
+
input_data : Optional[Union[dict[str, Any], str]], optional
|
|
230
|
+
The input data for the run, by default None. If `inputs_dir_path` is
|
|
231
|
+
provided, this parameter is ignored.
|
|
232
|
+
inputs_dir_path : Optional[str], optional
|
|
233
|
+
The path to the directory containing input files, by default None. If
|
|
234
|
+
provided, this parameter takes precedence over `input_data`.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
str
|
|
239
|
+
The input data as a string, if the format is `json` or `text`. Otherwise,
|
|
240
|
+
returns an empty string.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
# For JSON and TEXT formats, we return the input data as a string.
|
|
244
|
+
if run_format in (InputFormat.JSON.value, InputFormat.TEXT.value):
|
|
245
|
+
if isinstance(input_data, dict) and run_format == InputFormat.JSON.value:
|
|
246
|
+
return json.dumps(input_data)
|
|
247
|
+
|
|
248
|
+
if isinstance(input_data, str) and run_format == InputFormat.TEXT.value:
|
|
249
|
+
return input_data
|
|
250
|
+
|
|
251
|
+
raise ValueError(f"invalid input data for format {run_format}")
|
|
252
|
+
|
|
253
|
+
if input_data is not None:
|
|
254
|
+
raise ValueError("input data must be None for csv-archive or multi-file format")
|
|
255
|
+
|
|
256
|
+
# For CSV-ARCHIVE format, we write the input files to an `input` directory.
|
|
257
|
+
if run_format == InputFormat.CSV_ARCHIVE.value:
|
|
258
|
+
input_dir = os.path.join(temp_src, "input")
|
|
259
|
+
os.makedirs(input_dir, exist_ok=True)
|
|
260
|
+
|
|
261
|
+
if inputs_dir_path is not None and inputs_dir_path != "":
|
|
262
|
+
shutil.copytree(inputs_dir_path, input_dir, dirs_exist_ok=True)
|
|
263
|
+
|
|
264
|
+
return ""
|
|
265
|
+
|
|
266
|
+
# For MULTI-FILE format, we write the input files to an `inputs` directory,
|
|
267
|
+
# or to a custom location specified in the manifest.
|
|
268
|
+
if run_format == InputFormat.MULTI_FILE.value:
|
|
269
|
+
inputs_dir = os.path.join(temp_src, INPUTS_KEY)
|
|
270
|
+
if (
|
|
271
|
+
manifest.configuration is not None
|
|
272
|
+
and manifest.configuration.content is not None
|
|
273
|
+
and manifest.configuration.content.format == InputFormat.MULTI_FILE
|
|
274
|
+
and manifest.configuration.content.multi_file is not None
|
|
275
|
+
):
|
|
276
|
+
inputs_dir = os.path.join(temp_src, manifest.configuration.content.multi_file.input.path)
|
|
277
|
+
|
|
278
|
+
os.makedirs(inputs_dir, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
if inputs_dir_path is not None and inputs_dir_path != "":
|
|
281
|
+
shutil.copytree(inputs_dir_path, inputs_dir, dirs_exist_ok=True)
|
|
282
|
+
|
|
283
|
+
return ""
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def process_run_output(
|
|
287
|
+
manifest: Manifest,
|
|
288
|
+
run_id: str,
|
|
289
|
+
temp_src: str,
|
|
290
|
+
result: subprocess.CompletedProcess[str],
|
|
291
|
+
run_dir: str,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""
|
|
294
|
+
Processes the result of the subprocess run. This function is in charge of
|
|
295
|
+
handling the run results, including solutions, statistics, logs, assets,
|
|
296
|
+
etc.
|
|
297
|
+
|
|
298
|
+
Parameters
|
|
299
|
+
----------
|
|
300
|
+
manifest : Manifest
|
|
301
|
+
The application manifest.
|
|
302
|
+
temp_src : str
|
|
303
|
+
The path to the temporary source directory.
|
|
304
|
+
result : subprocess.CompletedProcess[str]
|
|
305
|
+
The result of the subprocess run.
|
|
306
|
+
run_dir : str
|
|
307
|
+
The path to the run directory.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
# Parse stdout as JSON, if possible.
|
|
311
|
+
stdout_output = {}
|
|
312
|
+
raw_output = result.stdout
|
|
313
|
+
if raw_output.strip() != "":
|
|
314
|
+
stdout_output = json.loads(raw_output)
|
|
315
|
+
|
|
316
|
+
# Create outputs directory.
|
|
317
|
+
outputs_dir = os.path.join(run_dir, OUTPUTS_KEY)
|
|
318
|
+
os.makedirs(outputs_dir, exist_ok=True)
|
|
319
|
+
temp_run_outputs_dir = os.path.join(temp_src, OUTPUTS_KEY)
|
|
320
|
+
|
|
321
|
+
output_format = resolve_output_format(
|
|
322
|
+
manifest=manifest,
|
|
323
|
+
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
324
|
+
temp_src=temp_src,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
process_run_information(
|
|
328
|
+
run_id=run_id,
|
|
329
|
+
run_dir=run_dir,
|
|
330
|
+
result=result,
|
|
331
|
+
)
|
|
332
|
+
process_run_logs(
|
|
333
|
+
output_format=output_format,
|
|
334
|
+
run_dir=run_dir,
|
|
335
|
+
result=result,
|
|
336
|
+
stdout_output=stdout_output,
|
|
337
|
+
)
|
|
338
|
+
process_run_statistics(
|
|
339
|
+
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
340
|
+
outputs_dir=outputs_dir,
|
|
341
|
+
stdout_output=stdout_output,
|
|
342
|
+
temp_src=temp_src,
|
|
343
|
+
manifest=manifest,
|
|
344
|
+
)
|
|
345
|
+
process_run_assets(
|
|
346
|
+
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
347
|
+
outputs_dir=outputs_dir,
|
|
348
|
+
stdout_output=stdout_output,
|
|
349
|
+
temp_src=temp_src,
|
|
350
|
+
manifest=manifest,
|
|
351
|
+
)
|
|
352
|
+
process_run_solutions(
|
|
353
|
+
run_id=run_id,
|
|
354
|
+
run_dir=run_dir,
|
|
355
|
+
temp_run_outputs_dir=temp_run_outputs_dir,
|
|
356
|
+
temp_src=temp_src,
|
|
357
|
+
outputs_dir=outputs_dir,
|
|
358
|
+
stdout_output=stdout_output,
|
|
359
|
+
output_format=output_format,
|
|
360
|
+
manifest=manifest,
|
|
361
|
+
)
|
|
362
|
+
process_run_visuals(
|
|
363
|
+
run_dir=run_dir,
|
|
364
|
+
outputs_dir=outputs_dir,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def resolve_output_format(
|
|
369
|
+
manifest: Manifest,
|
|
370
|
+
temp_run_outputs_dir: str,
|
|
371
|
+
temp_src: str,
|
|
372
|
+
) -> OutputFormat:
|
|
373
|
+
"""
|
|
374
|
+
Resolves the output format of the run. This function checks the manifest
|
|
375
|
+
configuration for the output format. If not specified, it checks for the
|
|
376
|
+
presence of an `output` directory (for `csv-archive`), or an
|
|
377
|
+
`outputs/solutions` directory (for `multi-file`). If neither exist, it
|
|
378
|
+
defaults to `json`.
|
|
379
|
+
|
|
380
|
+
Parameters
|
|
381
|
+
----------
|
|
382
|
+
manifest : Manifest
|
|
383
|
+
The application manifest.
|
|
384
|
+
temp_run_outputs_dir : str
|
|
385
|
+
The path to the temporary outputs directory.
|
|
386
|
+
temp_src : str
|
|
387
|
+
The path to the temporary source directory.
|
|
388
|
+
"""
|
|
389
|
+
|
|
390
|
+
if manifest.configuration is not None and manifest.configuration.content is not None:
|
|
391
|
+
return manifest.configuration.content.format
|
|
392
|
+
|
|
393
|
+
output_dir = os.path.join(temp_src, OUTPUT_KEY)
|
|
394
|
+
if os.path.exists(output_dir) and os.path.isdir(output_dir):
|
|
395
|
+
return OutputFormat.CSV_ARCHIVE
|
|
396
|
+
|
|
397
|
+
solutions_dir = os.path.join(temp_run_outputs_dir, SOLUTIONS_KEY)
|
|
398
|
+
if os.path.exists(solutions_dir) and os.path.isdir(solutions_dir):
|
|
399
|
+
return OutputFormat.MULTI_FILE
|
|
400
|
+
|
|
401
|
+
return OutputFormat.JSON
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def process_run_information(run_id: str, run_dir: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
405
|
+
"""
|
|
406
|
+
Processes the run information, updating properties such as duration and
|
|
407
|
+
status.
|
|
408
|
+
|
|
409
|
+
Parameters
|
|
410
|
+
----------
|
|
411
|
+
run_id : str
|
|
412
|
+
The ID of the run.
|
|
413
|
+
run_dir : str
|
|
414
|
+
The path to the run directory.
|
|
415
|
+
result : subprocess.CompletedProcess[str]
|
|
416
|
+
The result of the subprocess run.
|
|
417
|
+
"""
|
|
418
|
+
|
|
419
|
+
info_file = os.path.join(run_dir, f"{run_id}.json")
|
|
420
|
+
|
|
421
|
+
with open(info_file) as f:
|
|
422
|
+
info = json.load(f)
|
|
423
|
+
|
|
424
|
+
# Calculate duration.
|
|
425
|
+
created_at_str = info["metadata"]["created_at"]
|
|
426
|
+
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
|
427
|
+
now = datetime.now(timezone.utc)
|
|
428
|
+
duration = round((now - created_at).total_seconds() * 1000, 1)
|
|
429
|
+
|
|
430
|
+
# Update the status
|
|
431
|
+
status = StatusV2.succeeded.value
|
|
432
|
+
error = ""
|
|
433
|
+
if result.returncode != 0:
|
|
434
|
+
status = StatusV2.failed.value
|
|
435
|
+
error = result.stderr if result.stderr else "unknown error"
|
|
436
|
+
|
|
437
|
+
# Update the run info file.
|
|
438
|
+
info["metadata"]["duration"] = duration
|
|
439
|
+
info["metadata"]["status_v2"] = status
|
|
440
|
+
info["metadata"]["error"] = error
|
|
441
|
+
|
|
442
|
+
with open(info_file, "w") as f:
|
|
443
|
+
json.dump(info, f, indent=2)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def process_run_logs(
|
|
447
|
+
output_format: OutputFormat,
|
|
448
|
+
run_dir: str,
|
|
449
|
+
result: subprocess.CompletedProcess[str],
|
|
450
|
+
stdout_output: dict[str, Any],
|
|
451
|
+
) -> None:
|
|
452
|
+
"""
|
|
453
|
+
Processes the logs of the run. Writes the logs to a logs directory.
|
|
454
|
+
|
|
455
|
+
Parameters
|
|
456
|
+
----------
|
|
457
|
+
output_format : OutputFormat
|
|
458
|
+
The output format of the run.
|
|
459
|
+
run_dir : str
|
|
460
|
+
The path to the run directory.
|
|
461
|
+
result : subprocess.CompletedProcess[str]
|
|
462
|
+
The result of the subprocess run.
|
|
463
|
+
stdout_output : dict[str, Any]
|
|
464
|
+
The stdout output of the run, parsed as a dictionary.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
logs_dir = os.path.join(run_dir, LOGS_KEY)
|
|
468
|
+
os.makedirs(logs_dir, exist_ok=True)
|
|
469
|
+
std_err = result.stderr
|
|
470
|
+
with open(os.path.join(logs_dir, LOGS_FILE), "w") as f:
|
|
471
|
+
if output_format == OutputFormat.MULTI_FILE and stdout_output != {}:
|
|
472
|
+
f.write(json.dumps(stdout_output))
|
|
473
|
+
if std_err:
|
|
474
|
+
f.write("\n")
|
|
475
|
+
|
|
476
|
+
f.write(std_err)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def process_run_statistics(
|
|
480
|
+
temp_run_outputs_dir: str,
|
|
481
|
+
outputs_dir: str,
|
|
482
|
+
stdout_output: dict[str, Any],
|
|
483
|
+
temp_src: str,
|
|
484
|
+
manifest: Manifest,
|
|
485
|
+
) -> None:
|
|
486
|
+
"""
|
|
487
|
+
Processes the statistics of the run. Check for an outputs/statistics folder
|
|
488
|
+
being created by the run. If it exists, copy it to the run directory. If it
|
|
489
|
+
doesn't exist, attempt to get the stats from stdout.
|
|
490
|
+
|
|
491
|
+
Parameters
|
|
492
|
+
----------
|
|
493
|
+
temp_run_outputs_dir : str
|
|
494
|
+
The path to the temporary outputs directory.
|
|
495
|
+
outputs_dir : str
|
|
496
|
+
The path to the outputs directory in the run directory.
|
|
497
|
+
stdout_output : dict[str, Any]
|
|
498
|
+
The stdout output of the run, parsed as a dictionary.
|
|
499
|
+
temp_src : str
|
|
500
|
+
The path to the temporary source directory.
|
|
501
|
+
manifest : Manifest
|
|
502
|
+
The application manifest.
|
|
503
|
+
"""
|
|
504
|
+
|
|
505
|
+
stats_dst = os.path.join(outputs_dir, STATISTICS_KEY)
|
|
506
|
+
os.makedirs(stats_dst, exist_ok=True)
|
|
507
|
+
statistics_file = f"{STATISTICS_KEY}.json"
|
|
508
|
+
|
|
509
|
+
# Check for custom location in manifest and override stats_src if needed.
|
|
510
|
+
if (
|
|
511
|
+
manifest.configuration is not None
|
|
512
|
+
and manifest.configuration.content is not None
|
|
513
|
+
and manifest.configuration.content.format == OutputFormat.MULTI_FILE
|
|
514
|
+
and manifest.configuration.content.multi_file is not None
|
|
515
|
+
):
|
|
516
|
+
stats_src_file = os.path.join(temp_src, manifest.configuration.content.multi_file.output.statistics)
|
|
517
|
+
|
|
518
|
+
# If the custom statistics file exists, copy it to the stats destination
|
|
519
|
+
if os.path.exists(stats_src_file) and os.path.isfile(stats_src_file):
|
|
520
|
+
stats_dst_file = os.path.join(stats_dst, statistics_file)
|
|
521
|
+
shutil.copy2(stats_src_file, stats_dst_file)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
stats_src = os.path.join(temp_run_outputs_dir, STATISTICS_KEY)
|
|
525
|
+
if os.path.exists(stats_src) and os.path.isdir(stats_src):
|
|
526
|
+
shutil.copytree(stats_src, stats_dst, dirs_exist_ok=True)
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
if STATISTICS_KEY not in stdout_output:
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
with open(os.path.join(stats_dst, statistics_file), "w") as f:
|
|
533
|
+
statistics = {STATISTICS_KEY: stdout_output[STATISTICS_KEY]}
|
|
534
|
+
json.dump(statistics, f, indent=2)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def process_run_assets(
|
|
538
|
+
temp_run_outputs_dir: str,
|
|
539
|
+
outputs_dir: str,
|
|
540
|
+
stdout_output: dict[str, Any],
|
|
541
|
+
temp_src: str,
|
|
542
|
+
manifest: Manifest,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""
|
|
545
|
+
Processes the assets of the run. Check for an outputs/assets folder being
|
|
546
|
+
created by the run. If it exists, copy it to the run directory. If it
|
|
547
|
+
doesn't exist, attempt to get the assets from stdout.
|
|
548
|
+
|
|
549
|
+
Parameters
|
|
550
|
+
----------
|
|
551
|
+
temp_run_outputs_dir : str
|
|
552
|
+
The path to the temporary outputs directory.
|
|
553
|
+
outputs_dir : str
|
|
554
|
+
The path to the outputs directory in the run directory.
|
|
555
|
+
stdout_output : dict[str, Any]
|
|
556
|
+
The stdout output of the run, parsed as a dictionary.
|
|
557
|
+
temp_src : str
|
|
558
|
+
The path to the temporary source directory.
|
|
559
|
+
manifest : Manifest
|
|
560
|
+
The application manifest.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
assets_dst = os.path.join(outputs_dir, ASSETS_KEY)
|
|
564
|
+
os.makedirs(assets_dst, exist_ok=True)
|
|
565
|
+
assets_file = f"{ASSETS_KEY}.json"
|
|
566
|
+
|
|
567
|
+
# Check for custom location in manifest and override assets_src if needed.
|
|
568
|
+
if (
|
|
569
|
+
manifest.configuration is not None
|
|
570
|
+
and manifest.configuration.content is not None
|
|
571
|
+
and manifest.configuration.content.format == OutputFormat.MULTI_FILE
|
|
572
|
+
and manifest.configuration.content.multi_file is not None
|
|
573
|
+
):
|
|
574
|
+
assets_src_file = os.path.join(temp_src, manifest.configuration.content.multi_file.output.assets)
|
|
575
|
+
|
|
576
|
+
# If the custom assets file exists, copy it to the assets destination
|
|
577
|
+
if os.path.exists(assets_src_file) and os.path.isfile(assets_src_file):
|
|
578
|
+
assets_dst_file = os.path.join(assets_dst, assets_file)
|
|
579
|
+
shutil.copy2(assets_src_file, assets_dst_file)
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
assets_src = os.path.join(temp_run_outputs_dir, ASSETS_KEY)
|
|
583
|
+
if os.path.exists(assets_src) and os.path.isdir(assets_src):
|
|
584
|
+
shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
|
|
585
|
+
return
|
|
586
|
+
|
|
587
|
+
if ASSETS_KEY not in stdout_output:
|
|
588
|
+
return
|
|
589
|
+
|
|
590
|
+
with open(os.path.join(assets_dst, assets_file), "w") as f:
|
|
591
|
+
assets = {ASSETS_KEY: stdout_output[ASSETS_KEY]}
|
|
592
|
+
json.dump(assets, f, indent=2)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def process_run_solutions(
|
|
596
|
+
run_id: str,
|
|
597
|
+
run_dir: str,
|
|
598
|
+
temp_run_outputs_dir: str,
|
|
599
|
+
temp_src: str,
|
|
600
|
+
outputs_dir: str,
|
|
601
|
+
stdout_output: dict[str, Any],
|
|
602
|
+
output_format: OutputFormat,
|
|
603
|
+
manifest: Manifest,
|
|
604
|
+
) -> None:
|
|
605
|
+
"""
|
|
606
|
+
Processes the solutions (output) of the run. This method has the handle all
|
|
607
|
+
the different formats for processing solutions. This includes looking for
|
|
608
|
+
an `output` directory (`csv-archive`), an `outputs/solutions` directory
|
|
609
|
+
(`multi-file`), or looking for solutions in the stdout output (`json` or
|
|
610
|
+
`text`). For flexibility, we copy whatever is in the `output` and
|
|
611
|
+
`outputs/solutions` directories, if they exist. If neither exist, we
|
|
612
|
+
attempt to get the solution from stdout.
|
|
613
|
+
|
|
614
|
+
Parameters
|
|
615
|
+
----------
|
|
616
|
+
run_id : str
|
|
617
|
+
The ID of the run.
|
|
618
|
+
run_dir : str
|
|
619
|
+
The path to the run directory.
|
|
620
|
+
temp_run_outputs_dir : str
|
|
621
|
+
The path to the temporary outputs directory.
|
|
622
|
+
temp_src : str
|
|
623
|
+
The path to the temporary source directory.
|
|
624
|
+
outputs_dir : str
|
|
625
|
+
The path to the outputs directory in the run directory.
|
|
626
|
+
stdout_output : dict[str, Any]
|
|
627
|
+
The stdout output of the run, parsed as a dictionary.
|
|
628
|
+
output_format : OutputFormat
|
|
629
|
+
The output format of the run.
|
|
630
|
+
manifest : Manifest
|
|
631
|
+
The application manifest.
|
|
632
|
+
"""
|
|
633
|
+
|
|
634
|
+
info_file = os.path.join(run_dir, f"{run_id}.json")
|
|
635
|
+
|
|
636
|
+
with open(info_file) as f:
|
|
637
|
+
info = json.load(f)
|
|
638
|
+
|
|
639
|
+
solutions_dst = os.path.join(outputs_dir, SOLUTIONS_KEY)
|
|
640
|
+
os.makedirs(solutions_dst, exist_ok=True)
|
|
641
|
+
|
|
642
|
+
if output_format == OutputFormat.CSV_ARCHIVE:
|
|
643
|
+
output_src = os.path.join(temp_src, OUTPUT_KEY)
|
|
644
|
+
shutil.copytree(output_src, solutions_dst, dirs_exist_ok=True)
|
|
645
|
+
elif output_format == OutputFormat.MULTI_FILE:
|
|
646
|
+
solutions_src = os.path.join(temp_run_outputs_dir, SOLUTIONS_KEY)
|
|
647
|
+
if (
|
|
648
|
+
manifest.configuration is not None
|
|
649
|
+
and manifest.configuration.content is not None
|
|
650
|
+
and manifest.configuration.content.format == OutputFormat.MULTI_FILE
|
|
651
|
+
and manifest.configuration.content.multi_file is not None
|
|
652
|
+
):
|
|
653
|
+
solutions_src = os.path.join(temp_src, manifest.configuration.content.multi_file.output.solutions)
|
|
654
|
+
|
|
655
|
+
shutil.copytree(solutions_src, solutions_dst, dirs_exist_ok=True)
|
|
656
|
+
else:
|
|
657
|
+
if stdout_output:
|
|
658
|
+
with open(os.path.join(solutions_dst, DEFAULT_OUTPUT_JSON_FILE), "w") as f:
|
|
659
|
+
json.dump(stdout_output, f, indent=2)
|
|
660
|
+
|
|
661
|
+
# Update the run information file with the output size and type.
|
|
662
|
+
calculate_files_size(run_dir, run_id, solutions_dst, metadata_key="output_size")
|
|
663
|
+
info["metadata"]["format"]["output"] = {"type": output_format.value}
|
|
664
|
+
with open(info_file, "w") as f:
|
|
665
|
+
json.dump(info, f, indent=2)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def process_run_visuals(run_dir: str, outputs_dir: str) -> None:
|
|
669
|
+
"""
|
|
670
|
+
Processes the visuals from the assets in the run output. This function looks
|
|
671
|
+
for Plotly assets and generates HTML files for each visual.
|
|
672
|
+
|
|
673
|
+
Parameters
|
|
674
|
+
----------
|
|
675
|
+
run_dir : str
|
|
676
|
+
The path to the run directory.
|
|
677
|
+
outputs_dir : str
|
|
678
|
+
The path to the outputs directory in the run directory.
|
|
679
|
+
"""
|
|
680
|
+
|
|
681
|
+
# Get the assets.
|
|
682
|
+
assets_dir = os.path.join(outputs_dir, ASSETS_KEY)
|
|
683
|
+
if not os.path.exists(assets_dir):
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
assets_file = os.path.join(assets_dir, f"{ASSETS_KEY}.json")
|
|
687
|
+
if not os.path.exists(assets_file):
|
|
688
|
+
return
|
|
689
|
+
|
|
690
|
+
with open(assets_file) as f:
|
|
691
|
+
assets = json.load(f)
|
|
692
|
+
|
|
693
|
+
# Create visuals directory.
|
|
694
|
+
visuals_dir = os.path.join(run_dir, "visuals")
|
|
695
|
+
os.makedirs(visuals_dir, exist_ok=True)
|
|
696
|
+
|
|
697
|
+
# Loop over all the assets to find visual assets.
|
|
698
|
+
for asset_dict in assets.get(ASSETS_KEY, []):
|
|
699
|
+
asset = Asset.from_dict(asset_dict)
|
|
700
|
+
if asset.visual is None:
|
|
701
|
+
continue
|
|
702
|
+
|
|
703
|
+
if asset.visual.visual_schema == VisualSchema.PLOTLY:
|
|
704
|
+
handle_plotly_visual(asset, visuals_dir)
|
|
705
|
+
elif asset.visual.visual_schema == VisualSchema.GEOJSON:
|
|
706
|
+
handle_geojson_visual(asset, visuals_dir)
|
|
707
|
+
|
|
708
|
+
# ChartJS is not easily supported directly from Python in local runs,
|
|
709
|
+
# so we ignore it for now.
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
if __name__ == "__main__":
|
|
713
|
+
main()
|