hpcflow-new2 0.2.0a190__py3-none-any.whl → 0.2.0a200__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.
Files changed (132) hide show
  1. hpcflow/__pyinstaller/hook-hpcflow.py +1 -0
  2. hpcflow/_version.py +1 -1
  3. hpcflow/data/scripts/bad_script.py +2 -0
  4. hpcflow/data/scripts/do_nothing.py +2 -0
  5. hpcflow/data/scripts/env_specifier_test/input_file_generator_pass_env_spec.py +4 -0
  6. hpcflow/data/scripts/env_specifier_test/main_script_test_pass_env_spec.py +8 -0
  7. hpcflow/data/scripts/env_specifier_test/output_file_parser_pass_env_spec.py +4 -0
  8. hpcflow/data/scripts/env_specifier_test/v1/input_file_generator_basic.py +4 -0
  9. hpcflow/data/scripts/env_specifier_test/v1/main_script_test_direct_in_direct_out.py +7 -0
  10. hpcflow/data/scripts/env_specifier_test/v1/output_file_parser_basic.py +4 -0
  11. hpcflow/data/scripts/env_specifier_test/v2/main_script_test_direct_in_direct_out.py +7 -0
  12. hpcflow/data/scripts/input_file_generator_basic.py +3 -0
  13. hpcflow/data/scripts/input_file_generator_basic_FAIL.py +3 -0
  14. hpcflow/data/scripts/input_file_generator_test_stdout_stderr.py +8 -0
  15. hpcflow/data/scripts/main_script_test_direct_in.py +3 -0
  16. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2.py +6 -0
  17. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed.py +6 -0
  18. hpcflow/data/scripts/main_script_test_direct_in_direct_out_2_fail_allowed_group.py +7 -0
  19. hpcflow/data/scripts/main_script_test_direct_in_direct_out_3.py +6 -0
  20. hpcflow/data/scripts/main_script_test_direct_in_group_direct_out_3.py +6 -0
  21. hpcflow/data/scripts/main_script_test_direct_in_group_one_fail_direct_out_3.py +6 -0
  22. hpcflow/data/scripts/main_script_test_hdf5_in_obj_2.py +12 -0
  23. hpcflow/data/scripts/main_script_test_json_out_FAIL.py +3 -0
  24. hpcflow/data/scripts/main_script_test_shell_env_vars.py +12 -0
  25. hpcflow/data/scripts/main_script_test_std_out_std_err.py +6 -0
  26. hpcflow/data/scripts/output_file_parser_basic.py +3 -0
  27. hpcflow/data/scripts/output_file_parser_basic_FAIL.py +7 -0
  28. hpcflow/data/scripts/output_file_parser_test_stdout_stderr.py +8 -0
  29. hpcflow/data/scripts/script_exit_test.py +5 -0
  30. hpcflow/data/template_components/environments.yaml +1 -1
  31. hpcflow/sdk/__init__.py +5 -0
  32. hpcflow/sdk/app.py +166 -92
  33. hpcflow/sdk/cli.py +263 -84
  34. hpcflow/sdk/cli_common.py +99 -5
  35. hpcflow/sdk/config/callbacks.py +38 -1
  36. hpcflow/sdk/config/config.py +102 -13
  37. hpcflow/sdk/config/errors.py +19 -5
  38. hpcflow/sdk/config/types.py +3 -0
  39. hpcflow/sdk/core/__init__.py +25 -1
  40. hpcflow/sdk/core/actions.py +914 -262
  41. hpcflow/sdk/core/cache.py +76 -34
  42. hpcflow/sdk/core/command_files.py +14 -128
  43. hpcflow/sdk/core/commands.py +35 -6
  44. hpcflow/sdk/core/element.py +122 -50
  45. hpcflow/sdk/core/errors.py +58 -2
  46. hpcflow/sdk/core/execute.py +207 -0
  47. hpcflow/sdk/core/loop.py +408 -50
  48. hpcflow/sdk/core/loop_cache.py +4 -4
  49. hpcflow/sdk/core/parameters.py +382 -37
  50. hpcflow/sdk/core/run_dir_files.py +13 -40
  51. hpcflow/sdk/core/skip_reason.py +7 -0
  52. hpcflow/sdk/core/task.py +119 -30
  53. hpcflow/sdk/core/task_schema.py +68 -0
  54. hpcflow/sdk/core/test_utils.py +66 -27
  55. hpcflow/sdk/core/types.py +54 -1
  56. hpcflow/sdk/core/utils.py +136 -19
  57. hpcflow/sdk/core/workflow.py +1587 -356
  58. hpcflow/sdk/data/workflow_spec_schema.yaml +2 -0
  59. hpcflow/sdk/demo/cli.py +7 -0
  60. hpcflow/sdk/helper/cli.py +1 -0
  61. hpcflow/sdk/log.py +42 -15
  62. hpcflow/sdk/persistence/base.py +405 -53
  63. hpcflow/sdk/persistence/json.py +177 -52
  64. hpcflow/sdk/persistence/pending.py +237 -69
  65. hpcflow/sdk/persistence/store_resource.py +3 -2
  66. hpcflow/sdk/persistence/types.py +15 -4
  67. hpcflow/sdk/persistence/zarr.py +928 -81
  68. hpcflow/sdk/submission/jobscript.py +1408 -489
  69. hpcflow/sdk/submission/schedulers/__init__.py +40 -5
  70. hpcflow/sdk/submission/schedulers/direct.py +33 -19
  71. hpcflow/sdk/submission/schedulers/sge.py +51 -16
  72. hpcflow/sdk/submission/schedulers/slurm.py +44 -16
  73. hpcflow/sdk/submission/schedulers/utils.py +7 -2
  74. hpcflow/sdk/submission/shells/base.py +68 -20
  75. hpcflow/sdk/submission/shells/bash.py +222 -129
  76. hpcflow/sdk/submission/shells/powershell.py +200 -150
  77. hpcflow/sdk/submission/submission.py +852 -119
  78. hpcflow/sdk/submission/types.py +18 -21
  79. hpcflow/sdk/typing.py +24 -5
  80. hpcflow/sdk/utils/arrays.py +71 -0
  81. hpcflow/sdk/utils/deferred_file.py +55 -0
  82. hpcflow/sdk/utils/hashing.py +16 -0
  83. hpcflow/sdk/utils/patches.py +12 -0
  84. hpcflow/sdk/utils/strings.py +33 -0
  85. hpcflow/tests/api/test_api.py +32 -0
  86. hpcflow/tests/conftest.py +19 -0
  87. hpcflow/tests/data/benchmark_script_runner.yaml +26 -0
  88. hpcflow/tests/data/multi_path_sequences.yaml +29 -0
  89. hpcflow/tests/data/workflow_test_run_abort.yaml +34 -35
  90. hpcflow/tests/schedulers/sge/test_sge_submission.py +36 -0
  91. hpcflow/tests/scripts/test_input_file_generators.py +282 -0
  92. hpcflow/tests/scripts/test_main_scripts.py +821 -70
  93. hpcflow/tests/scripts/test_non_snippet_script.py +46 -0
  94. hpcflow/tests/scripts/test_ouput_file_parsers.py +353 -0
  95. hpcflow/tests/shells/wsl/test_wsl_submission.py +6 -0
  96. hpcflow/tests/unit/test_action.py +176 -0
  97. hpcflow/tests/unit/test_app.py +20 -0
  98. hpcflow/tests/unit/test_cache.py +46 -0
  99. hpcflow/tests/unit/test_cli.py +133 -0
  100. hpcflow/tests/unit/test_config.py +122 -1
  101. hpcflow/tests/unit/test_element_iteration.py +47 -0
  102. hpcflow/tests/unit/test_jobscript_unit.py +757 -0
  103. hpcflow/tests/unit/test_loop.py +1332 -27
  104. hpcflow/tests/unit/test_meta_task.py +325 -0
  105. hpcflow/tests/unit/test_multi_path_sequences.py +229 -0
  106. hpcflow/tests/unit/test_parameter.py +13 -0
  107. hpcflow/tests/unit/test_persistence.py +190 -8
  108. hpcflow/tests/unit/test_run.py +109 -3
  109. hpcflow/tests/unit/test_run_directories.py +29 -0
  110. hpcflow/tests/unit/test_shell.py +20 -0
  111. hpcflow/tests/unit/test_submission.py +5 -76
  112. hpcflow/tests/unit/test_workflow_template.py +31 -0
  113. hpcflow/tests/unit/utils/test_arrays.py +40 -0
  114. hpcflow/tests/unit/utils/test_deferred_file_writer.py +34 -0
  115. hpcflow/tests/unit/utils/test_hashing.py +65 -0
  116. hpcflow/tests/unit/utils/test_patches.py +5 -0
  117. hpcflow/tests/unit/utils/test_redirect_std.py +50 -0
  118. hpcflow/tests/workflows/__init__.py +0 -0
  119. hpcflow/tests/workflows/test_directory_structure.py +31 -0
  120. hpcflow/tests/workflows/test_jobscript.py +332 -0
  121. hpcflow/tests/workflows/test_run_status.py +198 -0
  122. hpcflow/tests/workflows/test_skip_downstream.py +696 -0
  123. hpcflow/tests/workflows/test_submission.py +140 -0
  124. hpcflow/tests/workflows/test_workflows.py +142 -2
  125. hpcflow/tests/workflows/test_zip.py +18 -0
  126. hpcflow/viz_demo.ipynb +6587 -3
  127. {hpcflow_new2-0.2.0a190.dist-info → hpcflow_new2-0.2.0a200.dist-info}/METADATA +7 -4
  128. hpcflow_new2-0.2.0a200.dist-info/RECORD +222 -0
  129. hpcflow_new2-0.2.0a190.dist-info/RECORD +0 -165
  130. {hpcflow_new2-0.2.0a190.dist-info → hpcflow_new2-0.2.0a200.dist-info}/LICENSE +0 -0
  131. {hpcflow_new2-0.2.0a190.dist-info → hpcflow_new2-0.2.0a200.dist-info}/WHEEL +0 -0
  132. {hpcflow_new2-0.2.0a190.dist-info → hpcflow_new2-0.2.0a200.dist-info}/entry_points.txt +0 -0
hpcflow/sdk/cli.py CHANGED
@@ -3,8 +3,10 @@ Command line interface implementation.
3
3
  """
4
4
 
5
5
  from __future__ import annotations
6
+ import contextlib
6
7
  import json
7
8
  import os
9
+ import time
8
10
  import click
9
11
  from colorama import init as colorama_init
10
12
  from termcolor import colored # type: ignore
@@ -32,7 +34,9 @@ from hpcflow.sdk.cli_common import (
32
34
  tasks_opt,
33
35
  cancel_opt,
34
36
  submit_status_opt,
37
+ force_arr_opt,
35
38
  make_status_opt,
39
+ add_sub_opt,
36
40
  zip_path_opt,
37
41
  zip_overwrite_opt,
38
42
  zip_log_opt,
@@ -43,6 +47,13 @@ from hpcflow.sdk.cli_common import (
43
47
  rechunk_backup_opt,
44
48
  rechunk_chunk_size_opt,
45
49
  rechunk_status_opt,
50
+ cancel_status_opt,
51
+ list_js_max_js_opt,
52
+ list_js_jobscripts_opt,
53
+ list_task_js_max_js_opt,
54
+ list_task_js_task_names_opt,
55
+ list_js_width_opt,
56
+ jobscript_std_array_idx_opt,
46
57
  _add_doc_from_help,
47
58
  )
48
59
  from hpcflow.sdk.helper.cli import get_helper_CLI
@@ -84,6 +95,45 @@ _pass_js = click.make_pass_decorator(Jobscript)
84
95
  _add_doc_from_help(string_option, workflow_ref_type_opt)
85
96
 
86
97
 
98
+ class ErrorPropagatingClickContext(click.Context):
99
+ """A click Context class that passes on exception information.
100
+
101
+ Using the standard `click.Context` class, exceptions raised when using a resource specified
102
+ with `ctx.with_resource(my_ctx_manager())` are not passed on to the `__exit__` method of
103
+ `my_ctx_manager`. See: https://github.com/pallets/click/issues/2447.
104
+
105
+ Examples
106
+ --------
107
+ >>> @click.group()
108
+ ... @click.pass_context
109
+ ... def cli(ctx):
110
+ ... ctx.with_resource(my_context_manager())
111
+ ... cli.context_class = ErrorPropagatingClickContext
112
+
113
+ """
114
+
115
+ def __exit__(self, exc_type, exc_value, tb):
116
+ self._depth -= 1
117
+ if self._depth == 0:
118
+ self._exit_stack.__exit__(exc_type, exc_value, tb)
119
+ self._exit_stack = contextlib.ExitStack()
120
+ click.core.pop_context()
121
+
122
+
123
+ @contextlib.contextmanager
124
+ def redirect_std_to_file_click(file, mode: str = "a"):
125
+ def ignore(exc):
126
+ """Do not intercept Click's `Exit` exception when the exit code is zero."""
127
+ if type(exc) is click.exceptions.Exit:
128
+ if exc.exit_code == 0:
129
+ return True
130
+ return exc.exit_code
131
+ return 1 # default exit code
132
+
133
+ with utils.redirect_std_to_file(file=file, ignore=ignore):
134
+ yield
135
+
136
+
87
137
  def parse_jobscript_wait_spec(jobscripts: str) -> dict[int, list[int]]:
88
138
  """
89
139
  Parse a jobscript wait specification.
@@ -118,6 +168,7 @@ def _make_API_CLI(app: BaseApp):
118
168
  @ts_name_fmt_option
119
169
  @variables_option
120
170
  @make_status_opt
171
+ @add_sub_opt
121
172
  def make_workflow(
122
173
  template_file_or_str: str,
123
174
  string: bool,
@@ -130,6 +181,7 @@ def _make_API_CLI(app: BaseApp):
130
181
  ts_name_fmt: str | None = None,
131
182
  variables: list[tuple[str, str]] | None = None,
132
183
  status: bool = True,
184
+ add_submission: bool = False,
133
185
  ):
134
186
  """Generate a new {app_name} workflow.
135
187
 
@@ -149,7 +201,9 @@ def _make_API_CLI(app: BaseApp):
149
201
  ts_name_fmt=ts_name_fmt,
150
202
  variables=dict(variables) if variables is not None else None,
151
203
  status=status,
204
+ add_submission=add_submission,
152
205
  )
206
+ assert isinstance(wk, Workflow)
153
207
  click.echo(wk.path)
154
208
 
155
209
  @click.command(name="go")
@@ -297,6 +351,20 @@ def _make_workflow_submission_jobscript_CLI(app: BaseApp):
297
351
  with job.jobscript_path.open("rt") as fp:
298
352
  click.echo(fp.read())
299
353
 
354
+ @jobscript.command()
355
+ @jobscript_std_array_idx_opt
356
+ @_pass_js
357
+ def stdout(job: Jobscript, array_idx: int):
358
+ """Print the contents of the standard output stream file."""
359
+ job.print_stdout(array_idx=array_idx)
360
+
361
+ @jobscript.command()
362
+ @jobscript_std_array_idx_opt
363
+ @_pass_js
364
+ def stderr(job: Jobscript, array_idx: int):
365
+ """Print the contents of the standard error stream file."""
366
+ job.print_stderr(array_idx=array_idx)
367
+
300
368
  _set_help_name(jobscript, app)
301
369
  return jobscript
302
370
 
@@ -346,6 +414,49 @@ def _make_workflow_submission_CLI(app: BaseApp):
346
414
  """Show active jobscripts and their jobscript-element states."""
347
415
  pprint(sb.get_active_jobscripts(as_json=True))
348
416
 
417
+ @submission.command()
418
+ @_pass_submission
419
+ def get_scheduler_job_IDs(sb: Submission):
420
+ """Print jobscript scheduler job IDs."""
421
+ job_IDs = sb.get_scheduler_job_IDs()
422
+ if job_IDs:
423
+ print("\n".join(job_IDs))
424
+
425
+ @submission.command()
426
+ @_pass_submission
427
+ def get_process_IDs(sb: Submission):
428
+ """Print jobscript process IDs."""
429
+ proc_IDs = sb.get_process_IDs()
430
+ if proc_IDs:
431
+ print("\n".join(str(i) for i in proc_IDs))
432
+
433
+ @submission.command()
434
+ @list_js_max_js_opt
435
+ @list_js_jobscripts_opt
436
+ @list_js_width_opt
437
+ @_pass_submission
438
+ def list_jobscripts(
439
+ sb: Submission, max_js: int | None, jobscripts: str | None, width: int | None
440
+ ):
441
+ """Print a table listing jobscripts and associated information."""
442
+ jobscripts_ = [int(i) for i in jobscripts.split(",")] if jobscripts else None
443
+ sb.list_jobscripts(max_js=max_js, jobscripts=jobscripts_, width=width)
444
+
445
+ @submission.command()
446
+ @list_task_js_max_js_opt
447
+ @list_task_js_task_names_opt
448
+ @list_js_width_opt
449
+ @_pass_submission
450
+ def list_task_jobscripts(
451
+ sb: Submission,
452
+ max_js: int | None,
453
+ task_names: str | None,
454
+ width: int | None,
455
+ ):
456
+ """Print a table listing tasks and their associated jobscripts."""
457
+ task_names_ = list(task_names.split(",")) if task_names else None
458
+ sb.list_task_jobscripts(task_names=task_names_, max_js=max_js, width=width)
459
+
349
460
  _set_help_name(submission, app)
350
461
  submission.add_command(_make_workflow_submission_jobscript_CLI(app))
351
462
  return submission
@@ -399,6 +510,27 @@ def _make_workflow_CLI(app: BaseApp):
399
510
  if print_idx:
400
511
  click.echo(out)
401
512
 
513
+ @workflow.command(name="add-submission")
514
+ @js_parallelism_option
515
+ @tasks_opt
516
+ @force_arr_opt
517
+ @submit_status_opt
518
+ @click.pass_context
519
+ def add_submission(
520
+ ctx,
521
+ js_parallelism=None,
522
+ tasks=None,
523
+ force_array=False,
524
+ status=True,
525
+ ):
526
+ """Add a new submission to the workflow, but do not submit."""
527
+ ctx.obj["workflow"].add_submission(
528
+ JS_parallelism=js_parallelism,
529
+ tasks=tasks,
530
+ force_array=force_array,
531
+ status=status,
532
+ )
533
+
402
534
  @workflow.command(name="wait")
403
535
  @click.option(
404
536
  "-j",
@@ -525,6 +657,72 @@ def _make_workflow_CLI(app: BaseApp):
525
657
  """Rechunk the parameters/base array."""
526
658
  wf.rechunk_parameter_base(backup=backup, chunk_size=chunk_size, status=status)
527
659
 
660
+ @workflow.command()
661
+ @_pass_workflow
662
+ def get_scheduler_job_IDs(wf: Workflow):
663
+ """Print jobscript scheduler job IDs from all submissions of this workflow."""
664
+ job_IDs = wf.get_scheduler_job_IDs()
665
+ if job_IDs:
666
+ print("\n".join(job_IDs))
667
+
668
+ @workflow.command()
669
+ @_pass_workflow
670
+ def get_process_IDs(wf: Workflow):
671
+ """Print jobscript process IDs from all submissions of this workflow."""
672
+ proc_IDs = wf.get_process_IDs()
673
+ if proc_IDs:
674
+ print("\n".join(str(i) for i in proc_IDs))
675
+
676
+ @workflow.command()
677
+ @click.option(
678
+ "--sub-idx",
679
+ type=click.INT,
680
+ default=0,
681
+ help="Submission index whose jobscripts are to be shown.",
682
+ )
683
+ @list_js_max_js_opt
684
+ @list_js_jobscripts_opt
685
+ @list_js_width_opt
686
+ @_pass_workflow
687
+ def list_jobscripts(
688
+ wf: Workflow,
689
+ sub_idx: int,
690
+ max_js: int | None,
691
+ jobscripts: str | None,
692
+ width: int | None,
693
+ ):
694
+ """Print a table listing jobscripts and associated information from the specified
695
+ submission."""
696
+ jobscripts_ = [int(i) for i in jobscripts.split(",")] if jobscripts else None
697
+ wf.list_jobscripts(
698
+ sub_idx=sub_idx, max_js=max_js, jobscripts=jobscripts_, width=width
699
+ )
700
+
701
+ @workflow.command()
702
+ @click.option(
703
+ "--sub-idx",
704
+ type=click.INT,
705
+ default=0,
706
+ help="Submission index whose tasks are to be shown.",
707
+ )
708
+ @list_task_js_max_js_opt
709
+ @list_task_js_task_names_opt
710
+ @list_js_width_opt
711
+ @_pass_workflow
712
+ def list_task_jobscripts(
713
+ wf: Workflow,
714
+ sub_idx: int,
715
+ max_js: int | None,
716
+ task_names: str | None,
717
+ width: int | None,
718
+ ):
719
+ """Print a table listing tasks and their associated jobscripts from the specified
720
+ submission."""
721
+ task_names_ = list(task_names.split(",")) if task_names else None
722
+ wf.list_task_jobscripts(
723
+ sub_idx=sub_idx, task_names=task_names_, max_js=max_js, width=width
724
+ )
725
+
528
726
  _set_help_name(workflow, app)
529
727
  workflow.add_command(_make_workflow_submission_CLI(app))
530
728
  return workflow
@@ -606,6 +804,20 @@ def _make_internal_CLI(app: BaseApp):
606
804
  """Get the invocation command for this app instance."""
607
805
  click.echo(app.run_time_info.invocation_command)
608
806
 
807
+ @internal.command()
808
+ @click.pass_context
809
+ @click.option("--raise", "raise_opt", is_flag=True)
810
+ @click.option("--click-exit-code", type=click.INT)
811
+ @click.option("--sleep", type=click.INT)
812
+ def noop(ctx, raise_opt, click_exit_code, sleep):
813
+ """Used only in CLI tests."""
814
+ if raise_opt:
815
+ raise ValueError("internal noop raised!")
816
+ elif click_exit_code is not None:
817
+ ctx.exit(click_exit_code)
818
+ elif sleep:
819
+ time.sleep(sleep)
820
+
609
821
  @internal.group()
610
822
  @click.argument("path", type=click.Path(exists=True))
611
823
  @click.pass_context
@@ -615,38 +827,51 @@ def _make_internal_CLI(app: BaseApp):
615
827
 
616
828
  @workflow.command()
617
829
  @_pass_workflow
618
- @click.pass_context
619
830
  @click.argument("submission_idx", type=click.INT)
620
831
  @click.argument("jobscript_idx", type=click.INT)
621
- @click.argument("js_action_idx", type=click.INT)
622
- @click.argument("ear_id", type=click.INT)
623
- def write_commands(
624
- ctx: click.Context,
832
+ @click.argument("block_idx", type=click.INT)
833
+ @click.argument("block_action_idx", type=click.INT)
834
+ @click.argument("run_id", type=click.INT)
835
+ def execute_run(
625
836
  wf: Workflow,
626
837
  submission_idx: int,
627
838
  jobscript_idx: int,
628
- js_action_idx: int,
629
- ear_id: int,
839
+ block_idx: int,
840
+ block_action_idx: int,
841
+ run_id: int,
630
842
  ):
631
- app.CLI_logger.info(f"write commands for EAR ID {ear_id!r}.")
632
- wf.write_commands(
633
- submission_idx,
634
- jobscript_idx,
635
- js_action_idx,
636
- ear_id,
843
+ app.CLI_logger.info(f"execute commands for EAR ID {run_id!r}.")
844
+ wf.execute_run(
845
+ submission_idx=submission_idx,
846
+ block_act_key=(jobscript_idx, block_idx, block_action_idx),
847
+ run_ID=run_id,
848
+ )
849
+
850
+ @workflow.command()
851
+ @_pass_workflow
852
+ @click.argument("submission_idx", type=click.INT)
853
+ @click.argument("jobscript_idx", type=click.INT)
854
+ def execute_combined_runs(
855
+ wf: Workflow,
856
+ submission_idx: int,
857
+ jobscript_idx: int,
858
+ ):
859
+ app.CLI_logger.info(
860
+ f"execute command for combined scripts of jobscript {jobscript_idx}."
861
+ )
862
+ wf.execute_combined_runs(
863
+ submission_idx=submission_idx,
864
+ jobscript_idx=jobscript_idx,
637
865
  )
638
- ctx.exit()
639
866
 
640
867
  @workflow.command()
641
868
  @_pass_workflow
642
- @click.pass_context
643
869
  @click.argument("name")
644
870
  @click.argument("value")
645
871
  @click.argument("ear_id", type=click.INT)
646
872
  @click.argument("cmd_idx", type=click.INT)
647
873
  @click.option("--stderr", is_flag=True, default=False)
648
874
  def save_parameter(
649
- ctx: click.Context,
650
875
  wf: Workflow,
651
876
  name: str,
652
877
  value: str,
@@ -668,71 +893,7 @@ def _make_internal_CLI(app: BaseApp):
668
893
  stderr=stderr,
669
894
  )
670
895
  app.CLI_logger.debug(f"save parameter processed value is: {value!r}")
671
- ctx.exit(wf.save_parameter(name=name, value=value, EAR_ID=ear_id))
672
-
673
- @workflow.command()
674
- @_pass_workflow
675
- @click.pass_context
676
- @click.argument("ear_id", type=click.INT)
677
- def set_EAR_start(ctx: click.Context, wf: Workflow, ear_id: int):
678
- app.CLI_logger.info(f"set EAR start for EAR ID {ear_id!r}.")
679
- wf.set_EAR_start(ear_id)
680
- ctx.exit()
681
-
682
- @workflow.command()
683
- @_pass_workflow
684
- @click.pass_context
685
- @click.argument("js_idx", type=click.INT)
686
- @click.argument("js_act_idx", type=click.INT)
687
- @click.argument("ear_id", type=click.INT)
688
- @click.argument("exit_code", type=click.INT)
689
- def set_EAR_end(
690
- ctx: click.Context,
691
- wf: Workflow,
692
- js_idx: int,
693
- js_act_idx: int,
694
- ear_id: int,
695
- exit_code: int,
696
- ):
697
- app.CLI_logger.info(
698
- f"set EAR end for EAR ID {ear_id!r} with exit code {exit_code!r}."
699
- )
700
- wf.set_EAR_end(
701
- js_idx=js_idx,
702
- js_act_idx=js_act_idx,
703
- EAR_ID=ear_id,
704
- exit_code=exit_code,
705
- )
706
- ctx.exit()
707
-
708
- @workflow.command()
709
- @_pass_workflow
710
- @click.pass_context
711
- @click.argument("ear_id", type=click.INT)
712
- def set_EAR_skip(ctx: click.Context, wf: Workflow, ear_id: int):
713
- app.CLI_logger.info(f"set EAR skip for EAR ID {ear_id!r}.")
714
- wf.set_EAR_skip(ear_id)
715
- ctx.exit()
716
-
717
- @workflow.command()
718
- @_pass_workflow
719
- @click.pass_context
720
- @click.argument("ear_id", type=click.INT)
721
- def get_EAR_skipped(ctx: click.Context, wf: Workflow, ear_id: int):
722
- """Return 1 if the given EAR is to be skipped, else return 0."""
723
- app.CLI_logger.info(f"get EAR skip for EAR ID {ear_id!r}.")
724
- click.echo(int(wf.get_EAR_skipped(ear_id)))
725
-
726
- @workflow.command()
727
- @_pass_workflow
728
- @click.pass_context
729
- @click.argument("loop_name", type=click.STRING)
730
- @click.argument("ear_id", type=click.INT)
731
- def check_loop(ctx: click.Context, wf: Workflow, loop_name: str, ear_id: int):
732
- """Check if an iteration has met its loop's termination condition."""
733
- app.CLI_logger.info(f"check_loop for loop {loop_name!r} and EAR ID {ear_id!r}.")
734
- wf.check_loop_termination(loop_name, ear_id)
735
- ctx.exit()
896
+ wf.save_parameter(name=name, value=value, EAR_ID=ear_id)
736
897
 
737
898
  # TODO: in general, maybe the workflow command group can expose the simple Workflow
738
899
  # properties; maybe use a decorator on the Workflow property object to signify
@@ -856,14 +1017,15 @@ def _make_cancel_CLI(app: BaseApp):
856
1017
  @click.command()
857
1018
  @click.argument("workflow_ref")
858
1019
  @workflow_ref_type_opt
859
- def cancel(workflow_ref: str, ref_type: str | None):
1020
+ @cancel_status_opt
1021
+ def cancel(workflow_ref: str, ref_type: str | None, status: bool):
860
1022
  """Stop all running jobscripts of the specified workflow.
861
1023
 
862
1024
  WORKFLOW_REF is the local ID (that provided by the `show` command}) or the
863
1025
  workflow path.
864
1026
 
865
1027
  """
866
- app.cancel(workflow_ref, ref_type)
1028
+ app.cancel(workflow_ref=workflow_ref, ref_is_path=ref_type, status=status)
867
1029
 
868
1030
  return cancel
869
1031
 
@@ -1204,10 +1366,25 @@ def make_cli(app: BaseApp):
1204
1366
  "`TimeIt.decorator` are included."
1205
1367
  ),
1206
1368
  )
1369
+ @click.option(
1370
+ "--std-stream",
1371
+ help="File to redirect standard output and error to, and to print exceptions to.",
1372
+ )
1207
1373
  @click.pass_context
1208
1374
  def new_CLI(
1209
- ctx: click.Context, config_dir, config_key, with_config, timeit, timeit_file
1375
+ ctx: click.Context,
1376
+ config_dir,
1377
+ config_key,
1378
+ with_config,
1379
+ timeit,
1380
+ timeit_file,
1381
+ std_stream: str,
1210
1382
  ):
1383
+ ctx.ensure_object(dict)
1384
+
1385
+ if std_stream:
1386
+ ctx.with_resource(redirect_std_to_file_click(std_stream))
1387
+
1211
1388
  app.run_time_info.from_CLI = True
1212
1389
  TimeIt.active = timeit or timeit_file
1213
1390
  TimeIt.file_path = timeit_file
@@ -1250,6 +1427,8 @@ def make_cli(app: BaseApp):
1250
1427
  env_source_file=None if env_source_file is None else Path(env_source_file),
1251
1428
  )
1252
1429
 
1430
+ new_CLI.context_class = ErrorPropagatingClickContext
1431
+
1253
1432
  new_CLI.__doc__ = app.description
1254
1433
  new_CLI.add_command(get_config_CLI(app))
1255
1434
  new_CLI.add_command(get_demo_software_CLI(app))
hpcflow/sdk/cli_common.py CHANGED
@@ -8,6 +8,44 @@ from hpcflow.sdk.persistence.defaults import DEFAULT_STORE_FORMAT
8
8
  from hpcflow.sdk.persistence.discovery import ALL_STORE_FORMATS
9
9
 
10
10
 
11
+ class BoolOrString(click.ParamType):
12
+ """Custom Click parameter type to accepts a bool or a choice of strings."""
13
+
14
+ name = "bool-or-string"
15
+
16
+ def __init__(self, allowed_strings, true_strings=None, false_strings=None):
17
+ self.allowed_strings = allowed_strings
18
+ self.true_strings = true_strings if true_strings else ["true", "yes", "on"]
19
+ self.false_strings = false_strings if false_strings else ["false", "no", "off"]
20
+
21
+ def convert(self, value, param, ctx):
22
+ # Check if the value is a boolean
23
+ if isinstance(value, bool):
24
+ return value
25
+
26
+ # Normalize value to string
27
+ value = str(value).lower()
28
+
29
+ # Check if the value is one of the true strings
30
+ if value in self.true_strings:
31
+ return True
32
+
33
+ # Check if the value is one of the false strings
34
+ if value in self.false_strings:
35
+ return False
36
+
37
+ # If the value matches neither, it must be one of the expected strings
38
+ if value not in self.allowed_strings:
39
+ allowed_fmt = ", ".join(f"{i!r}" for i in self.allowed_strings)
40
+ self.fail(
41
+ message=f"{value} is not a valid boolean or one of {allowed_fmt}.",
42
+ param=param,
43
+ ctx=ctx,
44
+ )
45
+
46
+ return value
47
+
48
+
11
49
  def sub_tasks_callback(ctx, param, value: str | None) -> list[int] | None:
12
50
  """
13
51
  Parse subtasks.
@@ -95,12 +133,14 @@ variables_option = click.option(
95
133
  js_parallelism_option = click.option(
96
134
  "--js-parallelism",
97
135
  help=(
98
- "If True, allow multiple jobscripts to execute simultaneously. Raises if "
99
- "set to True but the store type does not support the "
100
- "`jobscript_parallelism` feature. If not set, jobscript parallelism will "
101
- "be used if the store type supports it."
136
+ "If True, allow multiple jobscripts to execute simultaneously. If "
137
+ "'scheduled'/'direct', only allow simultaneous execution of scheduled/direct "
138
+ "jobscripts. Raises if set to True, 'scheduled', or 'direct', but the store type "
139
+ "does not support the `jobscript_parallelism` feature. If not set, jobscript "
140
+ "parallelism will be used if the store type supports it, for scheduled "
141
+ "jobscripts only."
102
142
  ),
103
- type=click.BOOL,
143
+ type=BoolOrString(["direct", "scheduled"]),
104
144
  )
105
145
  #: Standard option
106
146
  wait_option = click.option(
@@ -144,6 +184,17 @@ submit_status_opt = click.option(
144
184
  help="If True, display a live status to track submission progress.",
145
185
  default=True,
146
186
  )
187
+ #: Standard option
188
+ force_arr_opt = click.option(
189
+ "--force-array",
190
+ help=(
191
+ "Used to force the use of job arrays, even if the scheduler does not support it. "
192
+ "This is provided for testing purposes only."
193
+ ),
194
+ is_flag=True,
195
+ default=False,
196
+ )
197
+
147
198
  #: Standard option
148
199
  make_status_opt = click.option(
149
200
  "--status/--no-status",
@@ -151,6 +202,14 @@ make_status_opt = click.option(
151
202
  default=True,
152
203
  )
153
204
 
205
+ #: Standard option
206
+ add_sub_opt = click.option(
207
+ "--add-submission",
208
+ help=("If True, add a submission to the workflow (but do not submit)."),
209
+ is_flag=True,
210
+ default=False,
211
+ )
212
+
154
213
  #: Standard option
155
214
  zip_path_opt = click.option(
156
215
  "--path",
@@ -211,6 +270,34 @@ rechunk_status_opt = click.option(
211
270
  default=True,
212
271
  help="If True, display a live status to track rechunking progress.",
213
272
  )
273
+ cancel_status_opt = click.option(
274
+ "--status/--no-status",
275
+ default=True,
276
+ help="If True, display a live status to track cancel progress.",
277
+ )
278
+
279
+ list_js_max_js_opt = click.option(
280
+ "--max-js", type=click.INT, help="Display up to this jobscript only."
281
+ )
282
+ list_js_jobscripts_opt = click.option(
283
+ "--jobscripts", help="Comma-separated list of jobscript indices to show."
284
+ )
285
+ list_task_js_max_js_opt = click.option(
286
+ "--max-js", type=click.INT, help="Include jobscripts up to this jobscript only."
287
+ )
288
+ list_task_js_task_names_opt = click.option(
289
+ "--task-names", help="Comma-separated list of task name sub-strings to show."
290
+ )
291
+ list_js_width_opt = click.option(
292
+ "--width", type=click.INT, help="Width in characters of the table to print."
293
+ )
294
+ jobscript_std_array_idx_opt = click.option(
295
+ "--array-idx",
296
+ type=click.INT,
297
+ help=(
298
+ "For array jobs only, the job array index whose standard stream is to be printed."
299
+ ),
300
+ )
214
301
 
215
302
 
216
303
  def _add_doc_from_help(*args):
@@ -259,4 +346,11 @@ _add_doc_from_help(
259
346
  rechunk_backup_opt,
260
347
  rechunk_chunk_size_opt,
261
348
  rechunk_status_opt,
349
+ cancel_status_opt,
350
+ list_js_max_js_opt,
351
+ list_js_jobscripts_opt,
352
+ list_task_js_max_js_opt,
353
+ list_task_js_task_names_opt,
354
+ list_js_width_opt,
355
+ jobscript_std_array_idx_opt,
262
356
  )
@@ -207,8 +207,45 @@ def check_load_data_files(config: Config, value: Any) -> None:
207
207
  config._app.reload_template_components(warn=False)
208
208
 
209
209
 
210
+ def callback_log_file_path(config, value):
211
+ value = value.strip()
212
+ if value:
213
+ return config._resolve_path(value)
214
+ else:
215
+ return value
216
+
217
+
210
218
  def callback_update_log_console_level(config: Config, value: str) -> None:
211
219
  """
212
220
  Callback to set the logging level.
213
221
  """
214
- config._app.log.update_console_level(value)
222
+ config._app.log.update_console_level(new_level=value)
223
+
224
+
225
+ def callback_unset_log_console_level(config: Config) -> None:
226
+ """Reset the console handler to the default level."""
227
+ config._app.log.update_console_level()
228
+
229
+
230
+ def callback_update_log_file_level(config: Config, value: str) -> None:
231
+ """Callback to set the level of the log file handler."""
232
+ config._app.log.update_file_level(new_level=value)
233
+
234
+
235
+ def callback_update_log_file_path(config: Config, value: str) -> None:
236
+ """
237
+ Callback to update the log file path, or remove the file handler if no path specifed.
238
+ """
239
+ config._app.log.remove_file_handler()
240
+ if value:
241
+ config._app.log.add_file_logger(path=value, level=config.get("log_file_level"))
242
+
243
+
244
+ def callback_unset_log_file_level(config: Config) -> None:
245
+ """Callback to reset the file handler to the default level."""
246
+ config._app.log.update_file_level()
247
+
248
+
249
+ def callback_unset_log_file_path(config: Config) -> None:
250
+ """Callback to remove the log file handler."""
251
+ config._app.log.remove_file_handler()