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