nextmv 0.34.1.dev1__tar.gz → 0.35.0.dev0__tar.gz

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.
Files changed (88) hide show
  1. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/PKG-INFO +1 -1
  2. nextmv-0.35.0.dev0/nextmv/__about__.py +1 -0
  3. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/package.py +14 -3
  4. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/application.py +46 -0
  5. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/executor.py +120 -177
  6. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/manifest.py +21 -3
  7. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/output.py +21 -0
  8. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/cloud/test_package.py +11 -1
  9. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/local/test_executor.py +414 -5
  10. nextmv-0.34.1.dev1/nextmv/__about__.py +0 -1
  11. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/.gitignore +0 -0
  12. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/LICENSE +0 -0
  13. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/README.md +0 -0
  14. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/__entrypoint__.py +0 -0
  15. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/__init__.py +0 -0
  16. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/_serialization.py +0 -0
  17. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/base_model.py +0 -0
  18. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/__init__.py +0 -0
  19. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/acceptance_test.py +0 -0
  20. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/account.py +0 -0
  21. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/application.py +0 -0
  22. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/batch_experiment.py +0 -0
  23. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/client.py +0 -0
  24. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/ensemble.py +0 -0
  25. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/input_set.py +0 -0
  26. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/instance.py +0 -0
  27. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/scenario.py +0 -0
  28. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/secrets.py +0 -0
  29. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/url.py +0 -0
  30. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/cloud/version.py +0 -0
  31. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/.gitignore +0 -0
  32. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/README.md +0 -0
  33. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/app.yaml +0 -0
  34. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/input.json +0 -0
  35. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/main.py +0 -0
  36. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/requirements.txt +0 -0
  37. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/src/__init__.py +0 -0
  38. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/src/main.py +0 -0
  39. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/default_app/src/visuals.py +0 -0
  40. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/deprecated.py +0 -0
  41. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/input.py +0 -0
  42. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/__init__.py +0 -0
  43. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/geojson_handler.py +0 -0
  44. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/local.py +0 -0
  45. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/plotly_handler.py +0 -0
  46. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/local/runner.py +0 -0
  47. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/logger.py +0 -0
  48. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/model.py +0 -0
  49. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/options.py +0 -0
  50. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/polling.py +0 -0
  51. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/run.py +0 -0
  52. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/safe.py +0 -0
  53. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/nextmv/status.py +0 -0
  54. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/pyproject.toml +0 -0
  55. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/__init__.py +0 -0
  56. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/cloud/__init__.py +0 -0
  57. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/cloud/app.yaml +0 -0
  58. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/cloud/test_client.py +0 -0
  59. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/cloud/test_scenario.py +0 -0
  60. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/local/__init__.py +0 -0
  61. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/local/test_application.py +0 -0
  62. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/local/test_runner.py +0 -0
  63. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/__init__.py +0 -0
  64. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options1.py +0 -0
  65. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options2.py +0 -0
  66. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options3.py +0 -0
  67. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options4.py +0 -0
  68. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options5.py +0 -0
  69. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options6.py +0 -0
  70. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options7.py +0 -0
  71. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/scripts/options_deprecated.py +0 -0
  72. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_base_model.py +0 -0
  73. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_entrypoint/__init__.py +0 -0
  74. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_entrypoint/test_entrypoint.py +0 -0
  75. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_input.py +0 -0
  76. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_inputs/test_data.csv +0 -0
  77. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_inputs/test_data.json +0 -0
  78. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_inputs/test_data.txt +0 -0
  79. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_logger.py +0 -0
  80. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_manifest.py +0 -0
  81. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_model.py +0 -0
  82. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_options.py +0 -0
  83. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_output.py +0 -0
  84. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_polling.py +0 -0
  85. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_run.py +0 -0
  86. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_safe.py +0 -0
  87. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_serialization.py +0 -0
  88. {nextmv-0.34.1.dev1 → nextmv-0.35.0.dev0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.34.1.dev1
3
+ Version: 0.35.0.dev0
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
@@ -0,0 +1 @@
1
+ __version__ = "v0.35.0.dev0"
@@ -103,6 +103,19 @@ def _run_build_command(
103
103
  log(result.stdout)
104
104
 
105
105
 
106
+ def _get_shell_command_elements(pre_push_command):
107
+ """Get the shell command elements based on the operating system."""
108
+ # Check if we're in a Unix-like shell (including MINGW on Windows)
109
+ if "SHELL" in os.environ and shutil.which("bash"):
110
+ return ["bash", "-c", pre_push_command]
111
+ # Default to cmd on Windows
112
+ elif platform.system() == "Windows":
113
+ return ["cmd", "/c", pre_push_command]
114
+ # Default to sh on Unix-like systems (Linux, macOS)
115
+ else:
116
+ return ["sh", "-c", pre_push_command]
117
+
118
+
106
119
  def _run_pre_push_command(
107
120
  app_dir: str,
108
121
  pre_push_command: Optional[str] = None,
@@ -113,9 +126,7 @@ def _run_pre_push_command(
113
126
  if pre_push_command is None or pre_push_command == "":
114
127
  return
115
128
 
116
- elements = ["bash", "-c", pre_push_command]
117
- if platform.system() == "Windows":
118
- elements = ["cmd", "/c", pre_push_command]
129
+ elements = _get_shell_command_elements(pre_push_command)
119
130
 
120
131
  command_str = " ".join(elements)
121
132
  log(f'🔨 Running pre-push command: "{command_str}"')
@@ -493,6 +493,52 @@ class Application:
493
493
  output_dir_path=output_dir_path,
494
494
  )
495
495
 
496
+ def run_logs(self, run_id: str) -> str:
497
+ """
498
+ Get the logs of a local run.
499
+
500
+ If the run does not have any logs, or they are empty, then this method
501
+ simply returns a blank string. This method is equivalent to fetching
502
+ the content of the `.nextmv/runs/{run_id}/logs/logs.log` file.
503
+
504
+ Parameters
505
+ ----------
506
+ run_id : str
507
+ ID of the run to retrieve logs for.
508
+
509
+ Returns
510
+ -------
511
+ str
512
+ The contents of the logs file for the run.
513
+
514
+ Raises
515
+ ------
516
+ ValueError
517
+ If the `.nextmv/runs` directory does not exist at the application
518
+ source, or if the specified run ID does not exist.
519
+ """
520
+
521
+ runs_dir = os.path.join(self.src, NEXTMV_DIR, RUNS_KEY)
522
+ if not os.path.exists(runs_dir):
523
+ raise ValueError(f"`.nextmv/runs` dir does not exist at app source: {self.src}")
524
+
525
+ run_dir = os.path.join(runs_dir, run_id)
526
+ if not os.path.exists(run_dir):
527
+ raise ValueError(f"`{run_id}` run dir does not exist at: {runs_dir}")
528
+
529
+ logs_dir = os.path.join(runs_dir, LOGS_KEY)
530
+ if not os.path.exists(logs_dir):
531
+ return ""
532
+
533
+ logs_file = os.path.join(logs_dir, LOGS_FILE)
534
+ if not os.path.exists(logs_file):
535
+ return ""
536
+
537
+ with open(logs_file) as f:
538
+ logs = f.read()
539
+
540
+ return logs
541
+
496
542
  def run_metadata(self, run_id: str) -> RunInformation:
497
543
  """
498
544
  Get the metadata of a local run.
@@ -32,13 +32,12 @@ process_run_visuals
32
32
  Function to process and save run visuals.
33
33
  resolve_stdout
34
34
  Function to parse subprocess stdout output.
35
- ignore_patterns
36
- Function to filter files and directories during source code copying.
37
35
  """
38
36
 
39
37
  import hashlib
40
38
  import json
41
39
  import os
40
+ import re
42
41
  import shutil
43
42
  import subprocess
44
43
  import sys
@@ -129,7 +128,7 @@ def execute_run(
129
128
  # place to work from, and be cleaned up afterwards.
130
129
  with tempfile.TemporaryDirectory() as temp_dir:
131
130
  temp_src = os.path.join(temp_dir, "src")
132
- shutil.copytree(src, temp_src, ignore=ignore_patterns)
131
+ shutil.copytree(src, temp_src, ignore=_ignore_patterns)
133
132
 
134
133
  manifest = Manifest.from_dict(manifest_dict)
135
134
 
@@ -353,7 +352,6 @@ def process_run_output(
353
352
  stdout_output=stdout_output,
354
353
  temp_src=temp_src,
355
354
  manifest=manifest,
356
- src=src,
357
355
  )
358
356
  process_run_assets(
359
357
  temp_run_outputs_dir=temp_run_outputs_dir,
@@ -361,7 +359,6 @@ def process_run_output(
361
359
  stdout_output=stdout_output,
362
360
  temp_src=temp_src,
363
361
  manifest=manifest,
364
- src=src,
365
362
  )
366
363
  process_run_solutions(
367
364
  run_id=run_id,
@@ -508,7 +505,6 @@ def process_run_statistics(
508
505
  stdout_output: Union[str, dict[str, Any]],
509
506
  temp_src: str,
510
507
  manifest: Manifest,
511
- src: str,
512
508
  ) -> None:
513
509
  """
514
510
  Processes the statistics of the run. Checks for an outputs/statistics folder
@@ -527,9 +523,6 @@ def process_run_statistics(
527
523
  The path to the temporary source directory.
528
524
  manifest : Manifest
529
525
  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.
533
526
  """
534
527
 
535
528
  stats_dst = os.path.join(outputs_dir, STATISTICS_KEY)
@@ -553,7 +546,7 @@ def process_run_statistics(
553
546
 
554
547
  stats_src = os.path.join(temp_run_outputs_dir, STATISTICS_KEY)
555
548
  if os.path.exists(stats_src) and os.path.isdir(stats_src):
556
- _copy_new_or_modified_files(stats_src, stats_dst, src)
549
+ shutil.copytree(stats_src, stats_dst, dirs_exist_ok=True)
557
550
  return
558
551
 
559
552
  if not isinstance(stdout_output, dict):
@@ -573,7 +566,6 @@ def process_run_assets(
573
566
  stdout_output: Union[str, dict[str, Any]],
574
567
  temp_src: str,
575
568
  manifest: Manifest,
576
- src: str,
577
569
  ) -> None:
578
570
  """
579
571
  Processes the assets of the run. Checks for an outputs/assets folder or
@@ -592,9 +584,6 @@ def process_run_assets(
592
584
  The path to the temporary source directory.
593
585
  manifest : Manifest
594
586
  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.
598
587
  """
599
588
 
600
589
  assets_dst = os.path.join(outputs_dir, ASSETS_KEY)
@@ -618,7 +607,7 @@ def process_run_assets(
618
607
 
619
608
  assets_src = os.path.join(temp_run_outputs_dir, ASSETS_KEY)
620
609
  if os.path.exists(assets_src) and os.path.isdir(assets_src):
621
- _copy_new_or_modified_files(assets_src, assets_dst, src)
610
+ shutil.copytree(assets_src, assets_dst, dirs_exist_ok=True)
622
611
  return
623
612
 
624
613
  if not isinstance(stdout_output, dict):
@@ -684,12 +673,9 @@ def process_run_solutions(
684
673
  solutions_dst = os.path.join(outputs_dir, SOLUTIONS_KEY)
685
674
  os.makedirs(solutions_dst, exist_ok=True)
686
675
 
687
- # Build list of directories to exclude from copying
688
- exclusion_dirs = _build_exclusion_directories(src, manifest, outputs_dir, run_dir)
689
-
690
676
  if output_format == OutputFormat.CSV_ARCHIVE:
691
677
  output_src = os.path.join(temp_src, OUTPUT_KEY)
692
- _copy_new_or_modified_files(output_src, solutions_dst, src, exclusion_dirs)
678
+ shutil.copytree(output_src, solutions_dst, dirs_exist_ok=True)
693
679
  elif output_format == OutputFormat.MULTI_FILE:
694
680
  solutions_src = os.path.join(temp_run_outputs_dir, SOLUTIONS_KEY)
695
681
  if (
@@ -700,7 +686,16 @@ def process_run_solutions(
700
686
  ):
701
687
  solutions_src = os.path.join(temp_src, manifest.configuration.content.multi_file.output.solutions)
702
688
 
703
- _copy_new_or_modified_files(solutions_src, solutions_dst, src, exclusion_dirs)
689
+ _copy_new_or_modified_files(
690
+ runtime_dir=solutions_src,
691
+ dst_dir=solutions_dst,
692
+ original_src_dir=src,
693
+ exclusion_dirs=[
694
+ os.path.join(outputs_dir, STATISTICS_KEY),
695
+ os.path.join(outputs_dir, ASSETS_KEY),
696
+ os.path.join(run_dir, INPUTS_KEY),
697
+ ],
698
+ )
704
699
  else:
705
700
  if bool(stdout_output):
706
701
  with open(os.path.join(solutions_dst, DEFAULT_OUTPUT_JSON_FILE), "w") as f:
@@ -788,7 +783,7 @@ def resolve_stdout(result: subprocess.CompletedProcess[str]) -> Union[str, dict[
788
783
  return raw_output
789
784
 
790
785
 
791
- def ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
786
+ def _ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
792
787
  """
793
788
  Custom ignore function for copytree that filters files and directories
794
789
  during source code copying. Excludes virtual environments, cache files,
@@ -817,7 +812,7 @@ def ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
817
812
  continue
818
813
 
819
814
  # Ignore virtual environment directories
820
- if name in ("venv", ".venv", "env", ".env", "virtualenv", ".virtualenv"):
815
+ if re.match(r"^\.?(venv|env|virtualenv).*$", name):
821
816
  ignored.append(name)
822
817
  continue
823
818
 
@@ -840,123 +835,127 @@ def ignore_patterns(dir_path: str, names: list[str]) -> list[str]:
840
835
  return ignored
841
836
 
842
837
 
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
838
+ def _copy_new_or_modified_files( # noqa: C901
839
+ runtime_dir: str,
840
+ dst_dir: str,
841
+ original_src_dir: Optional[str] = None,
842
+ exclusion_dirs: Optional[list[str]] = None,
900
843
  ) -> None:
901
844
  """
902
- Copy files from source to destination only if they meet specific criteria.
845
+ Copy only new or modified files from runtime directory to destination directory.
903
846
 
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
- Empty directories are not created or are removed after copying to avoid
911
- cluttering the output with empty folders.
847
+ This function identifies files that are either new (not present in the original
848
+ source) or have been modified (different content, checksum, or modification time)
849
+ compared to the original source. It excludes files that exist in specified
850
+ exclusion directories to avoid copying input data, statistics, or assets as
851
+ solution outputs.
912
852
 
913
853
  Parameters
914
854
  ----------
915
- src_dir : str
916
- The source directory path to copy from.
855
+ runtime_dir : str
856
+ The path to the runtime directory containing files to potentially copy.
917
857
  dst_dir : str
918
- The destination directory path to copy to.
858
+ The destination directory where new or modified files will be copied.
919
859
  original_src_dir : Optional[str], optional
920
- The original source directory to check against. Files present in this
921
- directory will NOT be copied, by default None.
860
+ The path to the original source directory for comparison, by default None.
861
+ If None, all files from runtime_dir are considered new.
922
862
  exclusion_dirs : Optional[list[str]], optional
923
- Additional directories to check against. Files present in any of these
924
- directories will NOT be copied, by default None.
925
- """
926
- # Build list of all exclusion directories
927
- exclusion_directories = []
863
+ List of directory paths containing files to exclude from copying,
864
+ by default None. Files matching those in exclusion directories will
865
+ not be copied even if they are new or modified.
866
+ """
867
+
868
+ # Gather a list of the files that are created/modified in the runtime dir,
869
+ # this is, the directory where the actual executable code is run from.
870
+ runtime_files_rel = []
871
+ runtime_files_abs = []
872
+ for root, _, files in os.walk(runtime_dir):
873
+ # Skip __pycache__ directories
874
+ if "__pycache__" in root:
875
+ continue
876
+
877
+ for rel_file in files:
878
+ # Skip .pyc files
879
+ if rel_file.endswith(".pyc"):
880
+ continue
881
+
882
+ file_path = os.path.join(root, rel_file)
883
+ runtime_files_rel.append(os.path.relpath(file_path, runtime_dir))
884
+ runtime_files_abs.append(file_path)
885
+
886
+ # Gather a list of the files that exist in the original source dir. Given
887
+ # that the source dir is copied to the runtime dir before execution, we can
888
+ # use this to determine which files are new or modified.
889
+ original_src_files_rel = set()
928
890
  if original_src_dir is not None:
929
- exclusion_directories.append(original_src_dir)
891
+ for root, _, files in os.walk(original_src_dir):
892
+ for rel_file in files:
893
+ file_path = os.path.join(root, rel_file)
894
+ original_src_files_rel.add(os.path.relpath(file_path, original_src_dir))
895
+
896
+ # Gather a list of the files that exist in the exclusion dirs. This is used
897
+ # to avoid copying files that are part of this special exclusion set.
898
+ exclusion_files_rel = set()
930
899
  if exclusion_dirs is not None:
931
- exclusion_directories.extend(exclusion_dirs)
900
+ for exclusion_dir in exclusion_dirs:
901
+ for root, _, files in os.walk(exclusion_dir):
902
+ for rel_file in files:
903
+ file_path = os.path.join(root, rel_file)
904
+ exclusion_files_rel.add(os.path.relpath(file_path, exclusion_dir))
905
+
906
+ # Now we filter the runtime files to only keep those that are new or
907
+ # modified compared to the original source files.
908
+ files_before_exclusion = []
909
+ for ix, rel_file in enumerate(runtime_files_rel):
910
+ abs_file = runtime_files_abs[ix]
911
+
912
+ # If the file is net new, we keep it.
913
+ if rel_file not in original_src_files_rel:
914
+ files_before_exclusion.append(abs_file)
915
+ continue
916
+
917
+ # If content of the file is different, we keep it.
918
+ runtime_checksum = _calculate_file_checksum(abs_file)
919
+ original_abs_file = os.path.join(original_src_dir, rel_file)
920
+ original_checksum = _calculate_file_checksum(original_abs_file)
921
+ if runtime_checksum != original_checksum:
922
+ files_before_exclusion.append(abs_file)
923
+ continue
932
924
 
933
- files_copied = False
934
- for root, _dirs, files in os.walk(src_dir):
935
- rel_root = os.path.relpath(root, src_dir)
936
- dst_root = dst_dir if rel_root == "." else os.path.join(dst_dir, rel_root)
925
+ # If content of the file is the same, but the date is newer, we keep it.
926
+ src_mtime = os.path.getmtime(abs_file)
927
+ original_mtime = os.path.getmtime(original_abs_file)
928
+ if src_mtime > original_mtime:
929
+ files_before_exclusion.append(abs_file)
930
+ continue
937
931
 
938
- files_to_copy = []
939
- for file in files:
940
- # Skip if file exists in any exclusion directory
941
- if exclusion_directories and _file_exists_in_exclusion_dirs(file, rel_root, exclusion_directories):
932
+ # Now we filter out any files that are part of the exclusion set.
933
+ final_files = []
934
+ if exclusion_dirs is not None:
935
+ for file in files_before_exclusion:
936
+ rel_file = os.path.relpath(file, runtime_dir)
937
+ if rel_file in exclusion_files_rel:
942
938
  continue
943
939
 
944
- src_file = os.path.join(root, file)
945
- dst_file = os.path.join(dst_root, file)
940
+ final_files.append(file)
941
+ else:
942
+ final_files = files_before_exclusion
943
+
944
+ # Now that we have a clean list of files that we are going to copy, we
945
+ # proceed to copy them over to the destination directory.
946
+ for file in final_files:
947
+ rel_file = os.path.relpath(file, runtime_dir)
948
+ dst_file = os.path.join(dst_dir, rel_file)
946
949
 
947
- if _should_copy_file(src_file, dst_file):
948
- files_to_copy.append((src_file, dst_file))
950
+ # Create the directory structure if it doesn't exist
951
+ dst_file_dir = os.path.dirname(dst_file)
952
+ os.makedirs(dst_file_dir, exist_ok=True)
949
953
 
950
- # Only create directory if there are files to copy
951
- if files_to_copy:
952
- os.makedirs(dst_root, exist_ok=True)
953
- for src_file, dst_file in files_to_copy:
954
- shutil.copy2(src_file, dst_file)
955
- files_copied = True
954
+ # Copy the file
955
+ shutil.copy2(file, dst_file)
956
956
 
957
- # Clean up empty directories after copying
958
- if files_copied:
959
- _remove_empty_directories(dst_dir)
957
+ # Finally, we remove any empty directories that might have been created.
958
+ _remove_empty_directories(dst_dir)
960
959
 
961
960
 
962
961
  def _remove_empty_directories(directory: str) -> None:
@@ -986,33 +985,6 @@ def _remove_empty_directories(directory: str) -> None:
986
985
  pass
987
986
 
988
987
 
989
- def _should_copy_file(src_file: str, dst_file: str) -> bool:
990
- """
991
- Determine if a file should be copied based on existence and content.
992
-
993
- Parameters
994
- ----------
995
- src_file : str
996
- Path to the source file.
997
- dst_file : str
998
- Path to the destination file.
999
-
1000
- Returns
1001
- -------
1002
- bool
1003
- True if the file should be copied, False otherwise.
1004
- """
1005
- if not os.path.exists(dst_file):
1006
- return True
1007
-
1008
- try:
1009
- src_checksum = _calculate_file_checksum(src_file)
1010
- dst_checksum = _calculate_file_checksum(dst_file)
1011
- return src_checksum != dst_checksum
1012
- except OSError:
1013
- return True
1014
-
1015
-
1016
988
  def _calculate_file_checksum(file_path: str) -> str:
1017
989
  """
1018
990
  Calculate MD5 checksum of a file.
@@ -1034,34 +1006,5 @@ def _calculate_file_checksum(file_path: str) -> str:
1034
1006
  return hash_md5.hexdigest()
1035
1007
 
1036
1008
 
1037
- def _file_exists_in_exclusion_dirs(file_name: str, rel_root: str, exclusion_dirs: list[str]) -> bool:
1038
- """
1039
- Check if a file exists in any of the exclusion directories.
1040
-
1041
- Parameters
1042
- ----------
1043
- file_name : str
1044
- The name of the file to check.
1045
- rel_root : str
1046
- The relative root path from the source directory.
1047
- exclusion_dirs : list[str]
1048
- List of directories to check against.
1049
-
1050
- Returns
1051
- -------
1052
- bool
1053
- True if the file exists in any exclusion directory, False otherwise.
1054
- """
1055
- for exclusion_dir in exclusion_dirs:
1056
- if rel_root != ".":
1057
- exclusion_file = os.path.join(exclusion_dir, rel_root, file_name)
1058
- else:
1059
- exclusion_file = os.path.join(exclusion_dir, file_name)
1060
-
1061
- if os.path.exists(exclusion_file):
1062
- return True
1063
- return False
1064
-
1065
-
1066
1009
  if __name__ == "__main__":
1067
1010
  main()
@@ -984,8 +984,10 @@ class ManifestConfiguration(BaseModel):
984
984
 
985
985
  Parameters
986
986
  ----------
987
- options : ManifestOptions
987
+ options : Optional[ManifestOptions], default=None
988
988
  Options for the decision model.
989
+ content : Optional[ManifestContent], default=None
990
+ Content configuration for specifying how the app input/output is handled.
989
991
 
990
992
  Examples
991
993
  --------
@@ -1254,13 +1256,25 @@ class Manifest(BaseModel):
1254
1256
  width=120,
1255
1257
  )
1256
1258
 
1257
- def extract_options(self) -> Optional[Options]:
1259
+ def extract_options(self, should_parse: bool = True) -> Optional[Options]:
1258
1260
  """
1259
1261
  Convert the manifest options to a `nextmv.Options` object.
1260
1262
 
1261
1263
  If the manifest does not have valid options defined in
1262
1264
  `.configuration.options.items`, this method returns `None`.
1263
1265
 
1266
+ Use the `should_parse` argument to decide if you want the options
1267
+ parsed, or not. For more information on option parsing, please read the
1268
+ docstrings on the `.parse()` method of the `nextmv.Options` object.
1269
+
1270
+ Parameters
1271
+ ----------
1272
+ should_parse : bool, default=True
1273
+ Whether to parse the options, or not. By default, options are
1274
+ parsed. When command-line arguments are parsed, the help menu is
1275
+ created, thus parsing Options more than once may result in
1276
+ unexpected behavior.
1277
+
1264
1278
  Returns
1265
1279
  -------
1266
1280
  Optional[nextmv.options.Options]
@@ -1293,7 +1307,11 @@ class Manifest(BaseModel):
1293
1307
 
1294
1308
  options = [option.to_option() for option in self.configuration.options.items]
1295
1309
 
1296
- return Options(*options)
1310
+ opt = Options(*options)
1311
+ if should_parse:
1312
+ opt.parse()
1313
+
1314
+ return opt
1297
1315
 
1298
1316
  @classmethod
1299
1317
  def from_model_configuration(
@@ -887,6 +887,27 @@ class Output:
887
887
  Configuration for writing JSON files. Default is None.
888
888
  assets : Optional[list[Union[Asset, dict[str, Any]]]], optional
889
889
  List of assets to be included in the output. Default is None.
890
+ solution_files: Optional[list[SolutionFile]], default = None
891
+ Optional list of solution files to be included in the output. These
892
+ files are of type `SolutionFile`, which allows for custom serialization
893
+ and writing of the solution data to files. When this field is
894
+ specified, then the `output_format` must be set to
895
+ `OutputFormat.MULTI_FILE`, otherwise an exception will be raised. The
896
+ `SolutionFile` class allows you to define the name of the file, the
897
+ data to be written, and the writer function that will handle the
898
+ serialization of the data. This is useful when you need to write the
899
+ solution to multiple files with different formats or configurations.
900
+
901
+ There are convenience functions to create `SolutionFile` objects for
902
+ common use cases, such as:
903
+
904
+ - `json_solution_file`: for writing JSON data to a file.
905
+ - `csv_solution_file`: for writing CSV data to a file.
906
+ - `text_solution_file`: for writing utf-8 encoded data to a file.
907
+
908
+ For other data types, such as Excel, you can create your own
909
+ `SolutionFile` objects by providing a `name`, `data`, and a `writer`
910
+ function that will handle the serialization of the data.
890
911
 
891
912
  Raises
892
913
  ------
@@ -1,9 +1,11 @@
1
1
  import os
2
+ import platform
2
3
  import shutil
4
+ import subprocess
3
5
  import tempfile
4
6
  import unittest
5
7
 
6
- from nextmv.cloud.package import _package
8
+ from nextmv.cloud.package import _get_shell_command_elements, _package
7
9
  from nextmv.manifest import Manifest, ManifestType
8
10
 
9
11
 
@@ -71,3 +73,11 @@ class TestPackageDir(unittest.TestCase):
71
73
  with self.assertRaises(Exception) as context:
72
74
  _package(self.app_dir, self.manifest, verbose=False)
73
75
  self.assertIn("missing mandatory files", str(context.exception))
76
+
77
+ def test_get_shell_command_elements(self):
78
+ if platform.system() == "Windows":
79
+ command = _get_shell_command_elements("echo Hello World")
80
+ subprocess.run(command, check=True)
81
+ else:
82
+ command = _get_shell_command_elements("echo 'Hello World'")
83
+ subprocess.run(command, check=True)