diff --git a/README.md b/README.md index 8fe026e..dc70a20 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,8 @@ details on how Open Job Description's Jobs are run within Sessions. |`--maximum-tasks`|integer|no| A maximum number of Tasks to run from this Step. Unless present, the Session will run all Tasks defined in the Step's parameter space or the Task(s) selected by the `--task-params` or `--tasks` arguments. Mutually exclusive with `--task-param/-tp` and `--tasks`. |`--maximum-tasks 5`| |`--run-dependencies`|flag|no| If present, runs all of a Step's dependencies in the Session prior to the Step itself. |`--run-dependencies`| |`--path-mapping-rules`|string, path|no| The path mapping rules to apply to the template. Should be a JSON-formatted list of Open Job Description path mapping rules, provided as a string or a path to a JSON/YAML document prefixed with 'file://'. |`--path-mapping-rules [{"source_os": "Windows", "source_path": "C:\test", "destination_path": "/mnt/test"}]`, `--path-mapping-rules file://rules_file.json`| +|`--preserve`|flag|no| If present, the Session's working directory will not be deleted when the run is completed. |`--preserve`| +|`--verbose`|flag|no| If present, then verbose logging will be enabled in the Session's log. |`--verbose`| |`--output`|string|no| How to display the results of the command. Allowed values are `human-readable` (default), `json`, and `yaml`. |`--output json`, `--output yaml`| #### Example diff --git a/src/openjd/cli/_common/__init__.py b/src/openjd/cli/_common/__init__.py index 5ac95b6..caa6cb8 100644 --- a/src/openjd/cli/_common/__init__.py +++ b/src/openjd/cli/_common/__init__.py @@ -157,7 +157,7 @@ def print_cli_result(command: Callable[[Namespace], OpenJDCliResult]) -> Callabl Used to decorate the `do_` functions for each command. """ - def format_results(args: Namespace) -> None: + def format_results(args: Namespace) -> OpenJDCliResult: response = command(args) if args.output == "human-readable": @@ -175,4 +175,6 @@ def format_results(args: Namespace) -> None: if response.status == "error": raise SystemExit(1) + return response + return format_results diff --git a/src/openjd/cli/_run/_local_session/_session_manager.py b/src/openjd/cli/_run/_local_session/_session_manager.py index 2824e4a..fe3d966 100644 --- a/src/openjd/cli/_run/_local_session/_session_manager.py +++ b/src/openjd/cli/_run/_local_session/_session_manager.py @@ -62,6 +62,7 @@ def __init__( path_mapping_rules: Optional[list[PathMappingRule]] = None, environments: Optional[list[EnvironmentTemplate]] = None, should_print_logs: bool = True, + retain_working_dir: bool = False, ): self.session_id = session_id self.ended = Event() @@ -85,6 +86,7 @@ def __init__( job_parameter_values=job_parameters, path_mapping_rules=self._path_mapping_rules, callback=self._action_callback, + retain_working_dir=retain_working_dir, ) # Initialize the action queue diff --git a/src/openjd/cli/_run/_run_command.py b/src/openjd/cli/_run/_run_command.py index 2e739f9..02dccf1 100644 --- a/src/openjd/cli/_run/_run_command.py +++ b/src/openjd/cli/_run/_run_command.py @@ -6,6 +6,7 @@ import json from typing import Optional import re +import logging from ._local_session._session_manager import LocalSession, LogEntry from .._common import ( @@ -22,7 +23,7 @@ Step, StepParameterSpaceIterator, ) -from openjd.sessions import PathMappingRule +from openjd.sessions import PathMappingRule, LOG @dataclass @@ -119,6 +120,20 @@ def add_run_arguments(run_parser: ArgumentParser): metavar=" [] ...", help="Apply the given environments to the Session in the order given.", ) + run_parser.add_argument( + "--preserve", + action="store_const", + const=True, + default=False, + help="Do not automatically delete the Session's Working Directory when complete.", + ) + run_parser.add_argument( + "--verbose", + action="store_const", + const=True, + default=False, + help="Enable verbose logging while running the Session.", + ) def _collect_required_steps(step_map: dict[str, Step], step: Step) -> list[Step]: @@ -301,6 +316,7 @@ def _run_local_session( path_mapping_rules: Optional[list[PathMappingRule]], should_run_dependencies: bool = False, should_print_logs: bool = True, + retain_working_dir: bool = False, ) -> OpenJDCliResult: """ Creates a Session object and listens for log messages to synchronously end the session. @@ -320,6 +336,7 @@ def _run_local_session( path_mapping_rules=path_mapping_rules, environments=environments, should_print_logs=should_print_logs, + retain_working_dir=retain_working_dir, ) as session: session.initialize( dependencies=dependencies, @@ -332,10 +349,15 @@ def _run_local_session( # Monitor the local Session state session.ended.wait() + preserved_message: str = "" + if retain_working_dir: + preserved_message = ( + f"\nWorking directory preserved at: {str(session._inner_session.working_directory)}" + ) if session.failed: return OpenJDRunResult( status="error", - message="Session ended with errors; see Task logs for details", + message="Session ended with errors; see Task logs for details" + preserved_message, job_name=job.name, step_name=step.name, duration=session.get_duration(), @@ -345,7 +367,7 @@ def _run_local_session( return OpenJDRunResult( status="success", - message="Session ended successfully", + message="Session ended successfully" + preserved_message, job_name=job.name, step_name=step.name, duration=session.get_duration(), @@ -397,6 +419,9 @@ def do_run(args: Namespace) -> OpenJDCliResult: rules_list = parsed_rules.get("path_mapping_rules") path_mapping_rules = [PathMappingRule.from_dict(rule) for rule in rules_list] + if args.verbose: + LOG.setLevel(logging.DEBUG) + try: # Raises: RuntimeError the_job = generate_job(args) @@ -430,4 +455,5 @@ def do_run(args: Namespace) -> OpenJDCliResult: path_mapping_rules=path_mapping_rules, should_run_dependencies=(args.run_dependencies), should_print_logs=(args.output == "human-readable"), + retain_working_dir=args.preserve, ) diff --git a/test/openjd/cli/test_run_command.py b/test/openjd/cli/test_run_command.py index 04be9be..32d3ef6 100644 --- a/test/openjd/cli/test_run_command.py +++ b/test/openjd/cli/test_run_command.py @@ -7,6 +7,7 @@ import re import os from typing import Any, Optional +import logging import pytest from unittest.mock import Mock, patch @@ -21,7 +22,7 @@ _validate_task_params, ) from openjd.cli._run._local_session._session_manager import LocalSession -from openjd.sessions import PathMappingRule, PathFormat, Session +from openjd.sessions import LOG as SessionsLogger, PathMappingRule, PathFormat, Session from openjd.model import decode_job_template, create_job, ParameterValue, ParameterValueType @@ -283,6 +284,8 @@ def test_do_run_success( path_mapping_rules=None, environments=environments_files, output="human-readable", + verbose=False, + preserve=False, ) # WHEN @@ -300,6 +303,120 @@ def test_do_run_success( f.unlink() +def test_preserve_option( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the 'run' command preserves the session working directory when asked to.""" + + files_created: list[Path] = [] + try: + # GIVEN + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=".template.json", encoding="utf8", delete=False + ) as job_template_file: + json.dump( + { + "name": "TestJob", + "specificationVersion": "jobtemplate-2023-09", + "steps": [ + { + "name": "TestStep", + "script": { + "actions": {"onRun": {"command": "echo", "args": ["Hello World"]}} + }, + } + ], + }, + job_template_file.file, + ) + files_created.append(Path(job_template_file.name)) + + args = Namespace( + path=Path(job_template_file.name), + step="TestStep", + job_params=[], + task_params=None, + tasks=None, + maximum_tasks=-1, + run_dependencies=False, + path_mapping_rules=None, + environments=[], + output="human-readable", + verbose=False, + preserve=True, + ) + + # WHEN + result = do_run(args) + + # THEN + assert "Working directory preserved at" in result.message + # Extract the working directory from the output + match = re.search("Working directory preserved at: (.+)", result.message) + assert match is not None + dir = match[1] + assert Path(dir).exists() + finally: + for f in files_created: + f.unlink() + + +def test_verbose_option( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the verbose option has set the log level of the openjd-sessions library to DEBUG.""" + + files_created: list[Path] = [] + try: + # GIVEN + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=".template.json", encoding="utf8", delete=False + ) as job_template_file: + json.dump( + { + "name": "TestJob", + "specificationVersion": "jobtemplate-2023-09", + "steps": [ + { + "name": "TestStep", + "script": { + "actions": {"onRun": {"command": "echo", "args": ["Hello World"]}} + }, + } + ], + }, + job_template_file.file, + ) + files_created.append(Path(job_template_file.name)) + + args = Namespace( + path=Path(job_template_file.name), + step="TestStep", + job_params=[], + task_params=None, + tasks=None, + maximum_tasks=-1, + run_dependencies=False, + path_mapping_rules=None, + environments=[], + output="human-readable", + verbose=True, + preserve=False, + ) + + # WHEN + do_run(args) + + # THEN + assert SessionsLogger.isEnabledFor(logging.DEBUG) + + # Reset the state to not interfere with other tests. + SessionsLogger.setLevel(logging.INFO) + finally: + for f in files_created: + f.unlink() + + def test_do_run_error(): """ Test that the `run` command exits on any error (e.g., a non-existent template file). @@ -313,6 +430,8 @@ def test_do_run_error(): path_mapping_rules=None, environments=[], output="human-readable", + verbose=False, + preserve=False, ) with pytest.raises(SystemExit): do_run(mock_args) @@ -371,6 +490,8 @@ def test_do_run_path_mapping_rules(caplog: pytest.LogCaptureFixture): path_mapping_rules="file://" + temp_rules.name, environments=[], maximum_tasks=1, + verbose=False, + preserve=False, ) # WHEN @@ -415,6 +536,8 @@ def test_do_run_nonexistent_step(capsys: pytest.CaptureFixture): path_mapping_rules=None, environments=[], output="human-readable", + verbose=False, + preserve=False, ) with pytest.raises(SystemExit): do_run(mock_args)