diff --git a/src/openjd/cli/_run/__init__.py b/src/openjd/cli/_run/__init__.py index 6d1af40..eaf1f76 100644 --- a/src/openjd/cli/_run/__init__.py +++ b/src/openjd/cli/_run/__init__.py @@ -1,6 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. from ._run_command import add_run_arguments, do_run +from ._help_formatter import JobTemplateHelpAction from .._common import add_common_arguments, CommonArgument, SubparserGroup @@ -10,7 +11,17 @@ def populate_argparser(subcommands: SubparserGroup) -> None: "run", description="Takes a Job Template and runs the entire job or a selected Step from the job.", usage="openjd run JOB_TEMPLATE_PATH [arguments]", + add_help=False, # Disable default help to use custom action ) add_common_arguments(run_parser, {CommonArgument.PATH, CommonArgument.JOB_PARAMS}) add_run_arguments(run_parser) + + # Add custom help action that provides context-aware help based on job template + run_parser.add_argument( + "-h", + "--help", + action=JobTemplateHelpAction, + help="Show help message. When a job template path is provided, displays job-specific help including parameter definitions.", + ) + run_parser.set_defaults(func=do_run) diff --git a/src/openjd/cli/_run/_help_formatter.py b/src/openjd/cli/_run/_help_formatter.py new file mode 100644 index 0000000..713f69a --- /dev/null +++ b/src/openjd/cli/_run/_help_formatter.py @@ -0,0 +1,353 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Help formatter module for generating context-aware help text for job templates. +""" + +from argparse import Action, ArgumentParser, Namespace +from pathlib import Path +import sys +import textwrap +from typing import Any, Dict, Optional + +from openjd.model import DecodeValidationError, JobTemplate +from .._common import read_job_template, process_extensions_argument + + +def format_parameter_info(param_def: Dict[str, Any]) -> str: + """ + Format a single parameter definition for help display. + + This function converts a parameter definition from a job template into + readable help text that includes the parameter's type, constraints, and + default value (if any). + + Args: + param_def: Parameter definition dictionary from job template containing: + - name (str): Parameter name + - type (str): Parameter type (STRING, INT, FLOAT, PATH) + - description (str, optional): Parameter description + - default (Any, optional): Default value + - minValue (int|float, optional): Minimum value for numeric types + - maxValue (int|float, optional): Maximum value for numeric types + - minLength (int, optional): Minimum length for string types + - maxLength (int, optional): Maximum length for string types + - allowedValues (list, optional): List of allowed values + + Returns: + Formatted string describing the parameter in argparse-style help format. + + Example output formats: + "ParamName (STRING) [required]" + "ParamName (INT) [default: 42]" + "ParamName (FLOAT) [default: 3.14] (range: 0.0 to 10.0)" + "ParamName (STRING) [default: 'hello'] (allowed: 'hello', 'world')" + """ + param_name = param_def.get("name", "") + param_type = param_def.get("type", "STRING") + description = param_def.get("description", "") + default_value = param_def.get("default") + + # Start building the parameter info string + parts = [] + + # Add parameter name and type + type_info = f"{param_name} ({param_type})" + parts.append(type_info) + + # Add required/default status + has_multiline_default = False + if default_value is not None: + # Check if default value contains newlines (multi-line string) + if ( + param_type in ("STRING", "PATH") + and isinstance(default_value, str) + and "\n" in default_value + ): + has_multiline_default = True + parts.append("[default: see below]") + else: + # Format default value based on type + if param_type in ("STRING", "PATH"): + default_str = f"[default: '{default_value}']" + else: + default_str = f"[default: {default_value}]" + parts.append(default_str) + else: + parts.append("[required]") + + # Build the first line with name, type, and default/required + first_line = " ".join(parts) + + # Build constraint information + constraints = [] + + # Handle numeric constraints (minValue, maxValue) + min_val = param_def.get("minValue") + max_val = param_def.get("maxValue") + + if min_val is not None and max_val is not None: + constraints.append(f"range: {min_val} to {max_val}") + elif min_val is not None: + constraints.append(f"minimum: {min_val}") + elif max_val is not None: + constraints.append(f"maximum: {max_val}") + + # Handle string length constraints + min_len = param_def.get("minLength") + max_len = param_def.get("maxLength") + + if min_len is not None and max_len is not None: + constraints.append(f"length: {min_len} to {max_len} characters") + elif min_len is not None: + constraints.append(f"minimum length: {min_len} characters") + elif max_len is not None: + constraints.append(f"maximum length: {max_len} characters") + + # Handle allowed values constraint + allowed_values = param_def.get("allowedValues") + if allowed_values: + # Format allowed values based on type + if param_type in ("STRING", "PATH"): + formatted_values = ", ".join(f"'{v}'" for v in allowed_values) + else: + formatted_values = ", ".join(str(v) for v in allowed_values) + constraints.append(f"allowed: {formatted_values}") + + # Combine everything + result_lines = [first_line] + + # Add description if present + if description: + result_lines.append(f" {description}") + + # Add constraints if present + if constraints: + constraint_str = " (" + ", ".join(constraints) + ")" + result_lines[0] += constraint_str + + # Add multi-line default value if present + if has_multiline_default and isinstance(default_value, str): + result_lines.append(" Default value:") + # Indent each line of the default value + result_lines.append(textwrap.indent(default_value, " ")) + + return "\n".join(result_lines) + + +def generate_job_template_help( + template: JobTemplate, parser: ArgumentParser, template_path: Path +) -> str: + """ + Generate help text for a specific job template. + + This function creates formatted help text that includes the job name, + description (if present), parameter definitions with their types and + constraints, and standard command options from the argument parser. + + Args: + template: The decoded job template object (JobTemplate from openjd.model) + parser: The argument parser for the run command + template_path: Path to the template file + + Returns: + Formatted help text string in argparse-style format + + Example output: + usage: openjd run my-template.yaml [arguments] + + Job: my-job + This is a sample job that demonstrates parameter usage. + + Job Parameters (-p/--job-param PARAM_NAME=VALUE): + Message (STRING) [default: 'Hello, world!'] + A message to display + + Standard Options: + --step STEP_NAME The name of the Step in the Job to run Tasks from. + ... + """ + lines = [] + + # Add usage line with actual template path instead of symbolic placeholder + usage = parser.format_usage().strip() + # Replace the symbolic JOB_TEMPLATE_PATH with the actual path provided + usage = usage.replace("JOB_TEMPLATE_PATH", str(template_path)) + lines.append(usage) + lines.append("") + + # Print the job name and description (if present) + lines.append(f"Job: {template.name}") + if template.description: + lines.append(template.description) + lines.append("") + + # Extract parameter definitions (optional field) + param_definitions = template.parameterDefinitions + + if param_definitions: + lines.append("Job Parameters (-p/--job-param PARAM_NAME=VALUE):") + + for param_def in param_definitions: + param_dict: Dict[str, Any] = { + "name": param_def.name, + "type": param_def.type.value, + "description": param_def.description, + "default": param_def.default, + } + + # Add optional constraint fields if they exist + for constraint in [ + "minValue", + "maxValue", + "minLength", + "maxLength", + "allowedValues", + ]: + if hasattr(param_def, constraint): + value = getattr(param_def, constraint) + if value is not None: + param_dict[constraint] = value + + # Format the parameter info + param_info = format_parameter_info(param_dict) + + # Indent the parameter info with 2 spaces + lines.append(textwrap.indent(param_info, " ")) + + lines.append("") + + # Add standard options section + lines.append("Standard Options:") + + # Get the help text for all other arguments + full_help = parser.format_help() + + # Split into lines and find where the arguments section starts + help_lines = full_help.split("\n") + + # Skip usage and positional arguments, capture optional arguments + in_options = False + for line in help_lines: + # Look for optional arguments section or specific argument patterns + if "optional arguments:" in line.lower() or "options:" in line.lower(): + in_options = True + continue + elif "positional arguments:" in line.lower(): + continue + elif in_options: + # Add all option lines + lines.append(line) + + return "\n".join(lines) + + +class JobTemplateHelpAction(Action): + """ + Custom argparse Action that intercepts help requests (-h/--help) and generates + context-aware help text based on the job template file provided. + + This action is triggered when the user invokes the run command with a job template + path and the -h or --help flag. It loads and validates the template, then generates + and displays help text that includes job-specific information (name, description, + parameters) alongside standard command options. + + The action handles errors gracefully, displaying user-friendly error messages for + issues like missing files, invalid syntax, or schema validation failures. + """ + + def __init__( + self, + option_strings, + dest, + default=False, + required=False, + help=None, + ): + """ + Initialize the custom help action. + + Args: + option_strings: List of option strings (e.g., ['-h', '--help']) + dest: Destination attribute name in the namespace + default: Default value for the action + required: Whether this argument is required + help: Help text for this option + """ + super().__init__( + option_strings=option_strings, + dest=dest, + default=default, + nargs=0, + required=required, + help=help, + ) + + def __call__( + self, + parser: ArgumentParser, + namespace: Namespace, + values: Any, + option_string: Optional[str] = None, + ) -> None: + """ + Invoked when -h or --help is encountered. + + This method intercepts the help request, checks if a job template path + has been provided, loads and validates the template, generates custom + help text, and displays it before exiting. + + Args: + parser: The argument parser instance + namespace: The namespace containing parsed arguments so far + values: The value associated with the action (unused for help) + option_string: The option string that triggered this action ('-h' or '--help') + + Exits: + - Code 0: Help displayed successfully + - Code 1: Error occurred (file not found, validation failed, etc.) + """ + # Check if a template path has been provided + template_path = getattr(namespace, "path", None) + + if template_path is None: + # No template path provided, show standard help + parser.print_help() + sys.exit(0) + + # Convert to Path object if it's a string + if isinstance(template_path, str): + template_path = Path(template_path) + + try: + # Process extensions argument (defaults to all supported extensions if not provided) + extensions_arg = getattr(namespace, "extensions", None) + supported_extensions = process_extensions_argument(extensions_arg) + + # Load and validate the job template + # This will raise RuntimeError for file issues or DecodeValidationError for validation issues + template = read_job_template(template_path, supported_extensions=supported_extensions) + + # Generate the custom help text + help_text = generate_job_template_help(template, parser, template_path) + + # Display the help text + print(help_text) + + # Exit successfully + sys.exit(0) + + except RuntimeError as e: + # Handle file not found, parse errors, etc. + print(f"Error: {str(e)}", file=sys.stderr) + sys.exit(1) + + except DecodeValidationError as e: + # Handle template validation errors + print(f"Error: Invalid job template: {str(e)}", file=sys.stderr) + sys.exit(1) + + except Exception as e: + # Catch any unexpected errors + print(f"Error: Failed to generate help: {str(e)}", file=sys.stderr) + sys.exit(1) diff --git a/src/openjd/cli/_run/_run_command.py b/src/openjd/cli/_run/_run_command.py index a2d09f1..7b59079 100644 --- a/src/openjd/cli/_run/_run_command.py +++ b/src/openjd/cli/_run/_run_command.py @@ -415,8 +415,8 @@ def do_run(args: Namespace) -> OpenJDCliResult: filename = Path(env).expanduser() try: # Raises: RuntimeError, DecodeValidationError - template = read_environment_template(filename) - environments.append(template) + env_template = read_environment_template(filename) + environments.append(env_template) except (RuntimeError, DecodeValidationError) as e: return OpenJDCliResult(status="error", message=str(e)) @@ -504,7 +504,31 @@ def do_run(args: Namespace) -> OpenJDCliResult: task_parameter_values = [] except RuntimeError as rte: - return OpenJDCliResult(status="error", message=str(rte)) + error_message = str(rte) + # Print the help information along with the error + from ._help_formatter import generate_job_template_help + from .._common import read_job_template, add_common_arguments, CommonArgument + + try: + # Load the template to generate help + job_template = read_job_template(args.path, supported_extensions=extensions) + # Create a minimal parser for help generation with the same usage format + temp_parser = ArgumentParser( + prog="openjd run", + usage="openjd run JOB_TEMPLATE_PATH [arguments]", + add_help=False, + ) + temp_parser.add_argument("path") + add_common_arguments(temp_parser, {CommonArgument.PATH, CommonArgument.JOB_PARAMS}) + add_run_arguments(temp_parser) + + help_text = generate_job_template_help(job_template, temp_parser, args.path) + error_message = f"{error_message}\n\n{help_text}" + except Exception: + # If we can't generate help, just show the original error + pass + + return OpenJDCliResult(status="error", message=error_message) step_list: list[Step] = [] try: diff --git a/test/openjd/cli/test_help_formatter.py b/test/openjd/cli/test_help_formatter.py new file mode 100644 index 0000000..bf8fed1 --- /dev/null +++ b/test/openjd/cli/test_help_formatter.py @@ -0,0 +1,1155 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +""" +Unit tests for the help formatter module that generates context-aware help text. +""" + +import pytest +from openjd.cli._run._help_formatter import format_parameter_info + + +class TestFormatParameterInfo: + """Tests for the format_parameter_info function.""" + + @pytest.mark.parametrize( + "param_name,param_type,expected", + [ + ("Message", "STRING", "Message (STRING) [required]"), + ("Count", "INT", "Count (INT) [required]"), + ("Ratio", "FLOAT", "Ratio (FLOAT) [required]"), + ("OutputDir", "PATH", "OutputDir (PATH) [required]"), + ], + ) + def test_required_parameters(self, param_name, param_type, expected): + """Test formatting required parameters of different types.""" + param_def = {"name": param_name, "type": param_type} + result = format_parameter_info(param_def) + assert expected in result + + @pytest.mark.parametrize( + "param_name,param_type,default_value,expected", + [ + ("Message", "STRING", "Hello", "Message (STRING) [default: 'Hello']"), + ("Count", "INT", 42, "Count (INT) [default: 42]"), + ("Ratio", "FLOAT", 3.14, "Ratio (FLOAT) [default: 3.14]"), + ("OutputDir", "PATH", "/tmp/output", "OutputDir (PATH) [default: '/tmp/output']"), + ], + ) + def test_parameters_with_defaults(self, param_name, param_type, default_value, expected): + """Test formatting parameters with default values.""" + param_def = {"name": param_name, "type": param_type, "default": default_value} + result = format_parameter_info(param_def) + assert expected in result + + @pytest.mark.parametrize( + "param_type,default,min_val,max_val,expected", + [ + ("INT", 10, 1, None, "Count (INT) [default: 10] (minimum: 1)"), + ("INT", 10, None, 100, "Count (INT) [default: 10] (maximum: 100)"), + ("INT", 10, 1, 100, "Count (INT) [default: 10] (range: 1 to 100)"), + ("FLOAT", 0.5, 0.0, 1.0, "Ratio (FLOAT) [default: 0.5] (range: 0.0 to 1.0)"), + ], + ) + def test_numeric_parameters_with_constraints( + self, param_type, default, min_val, max_val, expected + ): + """Test formatting numeric parameters with min/max constraints.""" + param_name = "Count" if param_type == "INT" else "Ratio" + param_def = {"name": param_name, "type": param_type, "default": default} + if min_val is not None: + param_def["minValue"] = min_val + if max_val is not None: + param_def["maxValue"] = max_val + result = format_parameter_info(param_def) + assert expected in result + + @pytest.mark.parametrize( + "min_len,max_len,expected", + [ + (3, None, "Name (STRING) [default: 'test'] (minimum length: 3 characters)"), + (None, 50, "Name (STRING) [default: 'test'] (maximum length: 50 characters)"), + (3, 50, "Name (STRING) [default: 'test'] (length: 3 to 50 characters)"), + ], + ) + def test_string_parameters_with_length_constraints(self, min_len, max_len, expected): + """Test formatting STRING parameters with length constraints.""" + param_def = {"name": "Name", "type": "STRING", "default": "test"} + if min_len is not None: + param_def["minLength"] = min_len + if max_len is not None: + param_def["maxLength"] = max_len + result = format_parameter_info(param_def) + assert expected in result + + @pytest.mark.parametrize( + "param_name,param_type,default,allowed_values,expected", + [ + ( + "Environment", + "STRING", + "dev", + ["dev", "staging", "prod"], + "Environment (STRING) [default: 'dev'] (allowed: 'dev', 'staging', 'prod')", + ), + ("Priority", "INT", 1, [1, 2, 3], "Priority (INT) [default: 1] (allowed: 1, 2, 3)"), + ], + ) + def test_parameters_with_allowed_values( + self, param_name, param_type, default, allowed_values, expected + ): + """Test formatting parameters with allowedValues constraint.""" + param_def = { + "name": param_name, + "type": param_type, + "default": default, + "allowedValues": allowed_values, + } + result = format_parameter_info(param_def) + assert expected in result + + @pytest.mark.parametrize( + "default,expected_status", + [ + ("Hello, world!", "[default: 'Hello, world!']"), + (None, "[required]"), + ], + ) + def test_parameters_with_description(self, default, expected_status): + """Test formatting parameters with descriptions.""" + param_def = {"name": "Message", "type": "STRING", "description": "A message to display"} + if default is not None: + param_def["default"] = default + result = format_parameter_info(param_def) + assert expected_status in result + assert "A message to display" in result + + @pytest.mark.parametrize( + "param_name,param_type,default,expected_in,expected_not_in", + [ + ("RequiredParam", "STRING", None, "[required]", "[default:"), + ( + "OptionalParam", + "STRING", + "default_value", + "[default: 'default_value']", + "[required]", + ), + ("Count", "INT", 0, "Count (INT) [default: 0]", "[required]"), + ("Message", "STRING", "", "Message (STRING) [default: '']", "[required]"), + ], + ) + def test_required_vs_optional_parameters( + self, param_name, param_type, default, expected_in, expected_not_in + ): + """Test that parameters are correctly marked as required or optional.""" + param_def = {"name": param_name, "type": param_type} + if default is not None or default == "" or default == 0: + param_def["default"] = default + result = format_parameter_info(param_def) + assert expected_in in result + assert expected_not_in not in result + + def test_string_parameter_with_multiline_default(self): + """Test formatting a STRING parameter with multi-line default value.""" + param_def = { + "name": "Script", + "type": "STRING", + "default": "echo 'Hello'\necho 'World'\nls -la", + "description": "A bash script to run", + } + result = format_parameter_info(param_def) + # Should indicate default is shown below + assert "Script (STRING) [default: see below]" in result + # Should include description + assert "A bash script to run" in result + # Should include "Default value:" header + assert "Default value:" in result + # Should include each line of the default value, indented + assert "echo 'Hello'" in result + assert "echo 'World'" in result + assert "ls -la" in result + + def test_path_parameter_with_multiline_default(self): + """Test formatting a PATH parameter with multi-line default value.""" + param_def = { + "name": "ConfigFile", + "type": "PATH", + "default": "/path/to/file1\n/path/to/file2", + } + result = format_parameter_info(param_def) + # Should indicate default is shown below + assert "ConfigFile (PATH) [default: see below]" in result + # Should include "Default value:" header + assert "Default value:" in result + # Should include each line + assert "/path/to/file1" in result + assert "/path/to/file2" in result + + +class TestGenerateJobTemplateHelp: + """Tests for the generate_job_template_help function.""" + + def test_minimal_template_name_only(self): + """Test help generation for a template with only a name field.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a minimal mock template with only name + template = Mock() + template.name = "minimal-job" + template.description = None + template.parameterDefinitions = None + + # Create a basic parser + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + + template_path = Path("minimal.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name appears in output + assert "Job: minimal-job" in result + # Verify usage line is present + assert "usage:" in result.lower() + # Verify no parameter section since there are no parameters + assert "Job Parameters" not in result + + def test_template_with_name_and_description(self): + """Test help generation for a template with name and description.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a mock template with name and description + template = Mock() + template.name = "described-job" + template.description = "This is a sample job that demonstrates basic functionality." + template.parameterDefinitions = None + + # Create a basic parser + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + + template_path = Path("described.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name appears in output + assert "Job: described-job" in result + # Verify description appears in output + assert "This is a sample job that demonstrates basic functionality." in result + # Verify no parameter section since there are no parameters + assert "Job Parameters" not in result + + def test_template_with_no_parameters(self): + """Test help generation for a template with no parameters.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a mock template with name, description, but no parameters + template = Mock() + template.name = "no-params-job" + template.description = "A job without parameters" + template.parameterDefinitions = [] # Empty list + + # Create a basic parser + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + + template_path = Path("no-params.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name and description appear + assert "Job: no-params-job" in result + assert "A job without parameters" in result + # Verify no parameter section since the list is empty + assert "Job Parameters" not in result + + def test_template_with_multiple_parameters(self): + """Test help generation for a template with multiple parameters.""" + from argparse import ArgumentParser + from pathlib import Path + from openjd.model import decode_job_template + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a real template with multiple parameters + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "name": "multi-param-job", + "description": "A job with multiple parameters", + "parameterDefinitions": [ + { + "name": "Message", + "type": "STRING", + "description": "A message to display", + "default": "Hello", + }, + { + "name": "Count", + "type": "INT", + "description": "Number of iterations", + "default": 10, + "minValue": 1, + "maxValue": 100, + }, + {"name": "OutputPath", "type": "PATH"}, + ], + "steps": [{"name": "Step1", "script": {"actions": {"onRun": {"command": "echo"}}}}], + } + ) + + # Create a basic parser + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + + template_path = Path("multi-param.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name and description appear + assert "Job: multi-param-job" in result + assert "A job with multiple parameters" in result + + # Verify parameter section exists + assert "Job Parameters (-p/--job-param PARAM_NAME=VALUE):" in result + + # Verify all parameters are formatted correctly + assert "Message (STRING) [default: 'Hello']" in result + assert "A message to display" in result + + assert "Count (INT) [default: 10]" in result + assert "range: 1 to 100" in result + assert "Number of iterations" in result + + assert "OutputPath (PATH) [required]" in result + + def test_template_with_various_parameter_types_and_constraints(self): + """Test help generation with various parameter types and constraints.""" + from argparse import ArgumentParser + from pathlib import Path + from openjd.model import decode_job_template + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a real template with various constraints + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "name": "constraint-job", + "description": "Job with various constraints", + "parameterDefinitions": [ + { + "name": "Environment", + "type": "STRING", + "description": "Target environment", + "default": "dev", + "allowedValues": ["dev", "staging", "prod"], + }, + { + "name": "Ratio", + "type": "FLOAT", + "description": "Processing ratio", + "default": 0.5, + "minValue": 0.0, + "maxValue": 1.0, + }, + { + "name": "Username", + "type": "STRING", + "default": "user", + "minLength": 3, + "maxLength": 20, + }, + ], + "steps": [{"name": "Step1", "script": {"actions": {"onRun": {"command": "echo"}}}}], + } + ) + + # Create a basic parser + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + + template_path = Path("constraint.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name and description + assert "Job: constraint-job" in result + assert "Job with various constraints" in result + + # Verify parameter with allowedValues + assert "Environment (STRING) [default: 'dev']" in result + assert "allowed: 'dev', 'staging', 'prod'" in result + assert "Target environment" in result + + # Verify parameter with numeric range + assert "Ratio (FLOAT) [default: 0.5]" in result + assert "range: 0.0 to 1.0" in result + assert "Processing ratio" in result + + # Verify parameter with string length constraints + assert "Username (STRING) [default: 'user']" in result + assert "length: 3 to 20 characters" in result + + def test_job_name_and_description_appear_in_output(self): + """Test that job name and description are prominently displayed.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + template = Mock() + template.name = "test-job-name" + template.description = "This is the test job description." + template.parameterDefinitions = None + + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + + template_path = Path("test.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job name appears with "Job:" prefix + assert "Job: test-job-name" in result + + # Verify description appears + assert "This is the test job description." in result + + # Verify they appear near the beginning (before any parameters or options) + job_index = result.index("Job: test-job-name") + desc_index = result.index("This is the test job description.") + + # Description should come after job name + assert desc_index > job_index + + def test_all_parameters_formatted_correctly(self): + """Test that all parameters in a template are formatted correctly.""" + from argparse import ArgumentParser + from pathlib import Path + from openjd.model import decode_job_template + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a real template with all parameter types + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "name": "all-params-job", + "description": "Job with all parameter types", + "parameterDefinitions": [ + { + "name": "RequiredString", + "type": "STRING", + "description": "A required string", + }, + { + "name": "OptionalInt", + "type": "INT", + "description": "An optional integer", + "default": 42, + }, + { + "name": "ConstrainedFloat", + "type": "FLOAT", + "description": "A constrained float", + "default": 5.0, + "minValue": 0.0, + "maxValue": 10.0, + }, + { + "name": "FilePath", + "type": "PATH", + "description": "A file path", + "default": "/tmp/output", + }, + ], + "steps": [{"name": "Step1", "script": {"actions": {"onRun": {"command": "echo"}}}}], + } + ) + + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + + template_path = Path("all-params.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify each parameter is formatted correctly + assert "RequiredString (STRING) [required]" in result + assert "A required string" in result + + assert "OptionalInt (INT) [default: 42]" in result + assert "An optional integer" in result + + assert "ConstrainedFloat (FLOAT) [default: 5.0]" in result + assert "range: 0.0 to 10.0" in result + assert "A constrained float" in result + + assert "FilePath (PATH) [default: '/tmp/output']" in result + assert "A file path" in result + + def test_standard_options_appear_in_help_text(self): + """Test that standard run command options appear in the help text.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a simple template + template = Mock() + template.name = "simple-job" + template.description = "A simple job" + template.parameterDefinitions = None + + # Create a parser with standard options + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="The name of the Step in the Job to run Tasks from") + parser.add_argument("--task-param", "-tp", help="Task parameter") + parser.add_argument("--environment", help="Environment to use") + parser.add_argument("--preserve", action="store_true", help="Preserve session") + + template_path = Path("simple.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify standard options section exists + assert "Standard Options:" in result + + # Verify standard options appear in the output + assert "--step" in result + assert "--task-param" in result or "-tp" in result + assert "--environment" in result + assert "--preserve" in result + + def test_job_specific_info_appears_before_standard_options(self): + """Test that job-specific information appears before standard options.""" + from argparse import ArgumentParser + from pathlib import Path + from openjd.model import decode_job_template + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a real template + template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "name": "ordered-job", + "description": "Job to test ordering", + "parameterDefinitions": [ + { + "name": "Message", + "type": "STRING", + "description": "A message", + "default": "Hello", + } + ], + "steps": [{"name": "Step1", "script": {"actions": {"onRun": {"command": "echo"}}}}], + } + ) + + # Create a parser with standard options + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + parser.add_argument("--task-param", help="Task parameter") + + template_path = Path("ordered.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Find the positions of key sections + job_name_index = result.index("Job: ordered-job") + job_params_index = result.index("Job Parameters (-p/--job-param PARAM_NAME=VALUE):") + standard_options_index = result.index("Standard Options:") + + # Verify ordering: job name < job parameters < standard options + assert job_name_index < job_params_index + assert job_params_index < standard_options_index + + # Verify job description appears before standard options + desc_index = result.index("Job to test ordering") + assert desc_index < standard_options_index + + def test_standard_options_with_no_job_parameters(self): + """Test that standard options appear even when there are no job parameters.""" + from argparse import ArgumentParser + from pathlib import Path + from unittest.mock import Mock + from openjd.cli._run._help_formatter import generate_job_template_help + + # Create a template without parameters + template = Mock() + template.name = "no-params-job" + template.description = "Job without parameters" + template.parameterDefinitions = None + + # Create a parser with standard options + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + parser.add_argument("--task-param", help="Task parameter") + parser.add_argument("--environment", help="Environment") + + template_path = Path("no-params.yaml") + + result = generate_job_template_help(template, parser, template_path) + + # Verify job info appears + assert "Job: no-params-job" in result + assert "Job without parameters" in result + + # Verify no job parameters section + assert "Job Parameters" not in result + + # Verify standard options still appear + assert "Standard Options:" in result + assert "--step" in result + assert "--task-param" in result + assert "--environment" in result + + # Verify job name appears before standard options + job_index = result.index("Job: no-params-job") + options_index = result.index("Standard Options:") + assert job_index < options_index + + +class TestJobTemplateHelpAction: + """Tests for the JobTemplateHelpAction class.""" + + def test_action_triggered_with_h_flag(self): + """Test that the action is triggered with -h flag.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Create a temporary template file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "test-job", + "steps": [ + { + "name": "Step1", + "script": {"actions": {"onRun": {"command": "echo", "args": ["test"]}}}, + } + ], + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock sys.exit to prevent actual exit + with patch("sys.exit") as mock_exit: + with patch("builtins.print") as mock_print: + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 0 + mock_exit.assert_called_once_with(0) + + # Verify print was called (help was displayed) + assert mock_print.called + finally: + # Clean up + Path(template_path).unlink() + + def test_action_triggered_with_help_flag(self): + """Test that the action is triggered with --help flag.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Create a temporary template file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "test-job", + "steps": [ + { + "name": "Step1", + "script": {"actions": {"onRun": {"command": "echo", "args": ["test"]}}}, + } + ], + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock sys.exit to prevent actual exit + with patch("sys.exit") as mock_exit: + with patch("builtins.print") as mock_print: + # Call the action with --help + action(parser, namespace, None, "--help") + + # Verify sys.exit was called with 0 + mock_exit.assert_called_once_with(0) + + # Verify print was called (help was displayed) + assert mock_print.called + finally: + # Clean up + Path(template_path).unlink() + + def test_action_receives_correct_parser_and_namespace(self): + """Test that the action receives correct parser and namespace.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Create a temporary template file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "test-job", + "steps": [ + { + "name": "Step1", + "script": {"actions": {"onRun": {"command": "echo", "args": ["test"]}}}, + } + ], + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser with specific attributes + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + parser.add_argument("--step", help="Step name") + + # Create action + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with specific attributes + namespace = Namespace(path=template_path, extensions=None, step=None) + + # Mock sys.exit and generate_job_template_help to verify they receive correct arguments + with patch("sys.exit") as mock_exit: + with patch( + "openjd.cli._run._help_formatter.generate_job_template_help" + ) as mock_generate: + mock_generate.return_value = "Test help text" + + # Call the action + action(parser, namespace, None, "-h") + + # Verify generate_job_template_help was called with correct parser + assert mock_generate.called + call_args = mock_generate.call_args + assert call_args[0][1] == parser # Second argument should be the parser + assert isinstance(call_args[0][2], Path) # Third argument should be Path + + # Verify sys.exit was called + mock_exit.assert_called_once_with(0) + finally: + # Clean up + Path(template_path).unlink() + + def test_error_with_non_existent_template_file(self): + """Test error handling when template file does not exist.""" + from argparse import ArgumentParser, Namespace + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with non-existent template path + namespace = Namespace(path="/non/existent/template.json", extensions=None) + + # Mock sys.exit and sys.stderr + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write") as mock_stderr: + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 1 (error) + mock_exit.assert_called_once_with(1) + + # Verify error message was written to stderr + assert mock_stderr.called + + def test_error_with_invalid_json_syntax(self): + """Test error handling when template has invalid JSON syntax.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + + # Create a temporary file with invalid JSON + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write('{"invalid": json syntax}') # Missing quotes around 'json' + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with invalid template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock sys.exit + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write") as mock_stderr: + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 1 (error) + mock_exit.assert_called_once_with(1) + + # Verify error message was written to stderr + assert mock_stderr.called + finally: + # Clean up + Path(template_path).unlink() + + def test_error_with_invalid_yaml_syntax(self): + """Test error handling when template has invalid YAML syntax.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + + # Create a temporary file with invalid YAML + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write("invalid:\n - yaml\n syntax:\nbroken") # Invalid YAML structure + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with invalid template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock sys.exit + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write") as mock_stderr: + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 1 (error) + mock_exit.assert_called_once_with(1) + + # Verify error message was written to stderr + assert mock_stderr.called + finally: + # Clean up + Path(template_path).unlink() + + def test_error_with_schema_validation_failure(self): + """Test error handling when template fails schema validation.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Create a temporary file with invalid schema (missing required fields) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + # Missing required "name" field + # Missing required "steps" field + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with invalid template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock sys.exit + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write") as mock_stderr: + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 1 (error) + mock_exit.assert_called_once_with(1) + + # Verify error message was written to stderr + assert mock_stderr.called + finally: + # Clean up + Path(template_path).unlink() + + def test_error_messages_are_user_friendly(self): + """Test that error messages are user-friendly and don't expose stack traces.""" + from argparse import ArgumentParser, Namespace + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import io + + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with non-existent template path + namespace = Namespace(path="/non/existent/template.json", extensions=None) + + # Capture stderr output + stderr_capture = io.StringIO() + + # Mock sys.exit and redirect stderr + with patch("sys.exit") as mock_exit: + with patch("sys.stderr", stderr_capture): + # Call the action + action(parser, namespace, None, "-h") + + # Get the error message + error_output = stderr_capture.getvalue() + + # Verify error message starts with "Error:" + assert error_output.startswith("Error:") + + # Verify no stack trace is present (no "Traceback" keyword) + assert "Traceback" not in error_output + + # Verify sys.exit was called with 1 + mock_exit.assert_called_once_with(1) + + def test_exit_code_is_1_for_errors(self): + """Test that exit code is 1 for all error scenarios.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Test with non-existent file + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + namespace = Namespace(path="/non/existent/file.json", extensions=None) + + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write"): + action(parser, namespace, None, "-h") + mock_exit.assert_called_once_with(1) + + # Test with invalid JSON + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("{invalid json}") + invalid_json_path = f.name + + try: + namespace = Namespace(path=invalid_json_path, extensions=None) + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write"): + action(parser, namespace, None, "-h") + mock_exit.assert_called_once_with(1) + finally: + Path(invalid_json_path).unlink() + + # Test with schema validation failure + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"specificationVersion": "jobtemplate-2023-09"}, f) + invalid_schema_path = f.name + + try: + namespace = Namespace(path=invalid_schema_path, extensions=None) + with patch("sys.exit") as mock_exit: + with patch("sys.stderr.write"): + action(parser, namespace, None, "-h") + mock_exit.assert_called_once_with(1) + finally: + Path(invalid_schema_path).unlink() + + def test_help_displays_and_exits_with_code_0(self): + """Test that help displays successfully and exits with code 0.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + import io + + # Create a valid temporary template file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "success-test-job", + "description": "A job for testing successful help display", + "parameterDefinitions": [ + {"name": "TestParam", "type": "STRING", "default": "test_value"} + ], + "steps": [ + { + "name": "TestStep", + "script": {"actions": {"onRun": {"command": "echo", "args": ["test"]}}}, + } + ], + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", help="Path to job template") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with valid template path + namespace = Namespace(path=template_path, extensions=None) + + # Capture stdout + stdout_capture = io.StringIO() + + # Mock sys.exit and redirect stdout + with patch("sys.exit") as mock_exit: + with patch("sys.stdout", stdout_capture): + # Call the action + action(parser, namespace, None, "-h") + + # Verify sys.exit was called with 0 (success) + mock_exit.assert_called_once_with(0) + + # Verify help was displayed + help_output = stdout_capture.getvalue() + assert len(help_output) > 0 + assert "success-test-job" in help_output + assert "A job for testing successful help display" in help_output + assert "TestParam" in help_output + finally: + # Clean up + Path(template_path).unlink() + + def test_template_is_validated_before_help_generation(self): + """Test that template is validated before help generation.""" + from argparse import ArgumentParser, Namespace + from pathlib import Path + from unittest.mock import patch, MagicMock + from openjd.cli._run._help_formatter import JobTemplateHelpAction + import tempfile + import json + + # Create a valid temporary template file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + template_data = { + "specificationVersion": "jobtemplate-2023-09", + "name": "validation-test-job", + "steps": [ + { + "name": "TestStep", + "script": {"actions": {"onRun": {"command": "echo", "args": ["test"]}}}, + } + ], + } + json.dump(template_data, f) + template_path = f.name + + try: + # Create parser and action + parser = ArgumentParser(prog="openjd run") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace with valid template path + namespace = Namespace(path=template_path, extensions=None) + + # Mock read_job_template to verify it's called (this performs validation) + with patch("openjd.cli._run._help_formatter.read_job_template") as mock_read: + # Create a mock template object + mock_template = MagicMock() + mock_template.name = "validation-test-job" + mock_template.description = None + mock_template.parameterDefinitions = None + mock_read.return_value = mock_template + + with patch("sys.exit"): + with patch("builtins.print"): + # Call the action + action(parser, namespace, None, "-h") + + # Verify read_job_template was called (which validates the template) + mock_read.assert_called_once() + call_args = mock_read.call_args + assert str(call_args[0][0]) == template_path + finally: + # Clean up + Path(template_path).unlink() + + def test_help_without_template_shows_standard_help(self): + """Test that help without template path shows standard help.""" + from argparse import ArgumentParser, Namespace + from unittest.mock import patch + from openjd.cli._run._help_formatter import JobTemplateHelpAction + + # Create parser and action + parser = ArgumentParser(prog="openjd run") + parser.add_argument("path", nargs="?", help="Path to job template") + parser.add_argument("--step", help="Step name") + action = JobTemplateHelpAction(["-h", "--help"], "help") + + # Create namespace without template path + namespace = Namespace(path=None, extensions=None) + + # Create a custom exception to stop execution + class SystemExitMock(Exception): + pass + + # Mock sys.exit to raise exception and stop execution + def mock_exit_func(code): + raise SystemExitMock(code) + + with patch( + "openjd.cli._run._help_formatter.sys.exit", side_effect=mock_exit_func + ) as mock_exit: + with patch.object(parser, "print_help") as mock_print_help: + try: + # Call the action + action(parser, namespace, None, "-h") + except SystemExitMock as e: + # Expected - sys.exit was called + assert e.args[0] == 0 + + # Verify parser.print_help was called (standard help) + mock_print_help.assert_called_once() + + # Verify sys.exit was called with 0 + mock_exit.assert_called_once_with(0) diff --git a/test/openjd/cli/test_run_command.py b/test/openjd/cli/test_run_command.py index 368ad21..7285328 100644 --- a/test/openjd/cli/test_run_command.py +++ b/test/openjd/cli/test_run_command.py @@ -243,130 +243,118 @@ def test_do_run_success( def test_preserve_option( caplog: pytest.LogCaptureFixture, + tmp_path: Path, ) -> 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": "python", - "args": ["-c", "print('Hello World')"], - } + # GIVEN + template_file = tmp_path / "template.json" + template_file.write_text( + json.dumps( + { + "name": "TestJob", + "specificationVersion": "jobtemplate-2023-09", + "steps": [ + { + "name": "TestStep", + "script": { + "actions": { + "onRun": { + "command": "python", + "args": ["-c", "print('Hello World')"], } - }, - } - ], - }, - job_template_file.file, - ) - files_created.append(Path(job_template_file.name)) - - args = Namespace( - path=Path(job_template_file.name), - step="TestStep", - timestamp_format=LoggingTimestampFormat.RELATIVE, - 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, - extensions="", + } + }, + } + ], + } ) + ) - # WHEN - result = do_run(args) + args = Namespace( + path=template_file, + step="TestStep", + timestamp_format=LoggingTimestampFormat.RELATIVE, + 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, + extensions="", + ) - # 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() + # 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() def test_verbose_option( caplog: pytest.LogCaptureFixture, + tmp_path: Path, ) -> 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": "python", - "args": ["-c", "print('Hello World')"], - } + # GIVEN + template_file = tmp_path / "template.json" + template_file.write_text( + json.dumps( + { + "name": "TestJob", + "specificationVersion": "jobtemplate-2023-09", + "steps": [ + { + "name": "TestStep", + "script": { + "actions": { + "onRun": { + "command": "python", + "args": ["-c", "print('Hello World')"], } - }, - } - ], - }, - job_template_file.file, - ) - files_created.append(Path(job_template_file.name)) - - args = Namespace( - path=Path(job_template_file.name), - step="TestStep", - timestamp_format=LoggingTimestampFormat.RELATIVE, - 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, - extensions="", + } + }, + } + ], + } ) + ) - # WHEN - do_run(args) + args = Namespace( + path=template_file, + step="TestStep", + timestamp_format=LoggingTimestampFormat.RELATIVE, + 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, + extensions="", + ) - # THEN - assert SessionsLogger.isEnabledFor(logging.DEBUG) + # WHEN + do_run(args) - # Reset the state to not interfere with other tests. - SessionsLogger.setLevel(logging.INFO) - finally: - for f in files_created: - f.unlink() + # THEN + assert SessionsLogger.isEnabledFor(logging.DEBUG) + + # Reset the state to not interfere with other tests. + SessionsLogger.setLevel(logging.INFO) def test_do_run_error(): @@ -473,32 +461,30 @@ def test_do_run_path_mapping_rules(caplog: pytest.LogCaptureFixture): @pytest.mark.usefixtures("capsys") -def test_do_run_nonexistent_step(capsys: pytest.CaptureFixture): +def test_do_run_nonexistent_step(capsys: pytest.CaptureFixture, tmp_path: Path): """ Test that invoking the `run` command with an incorrect Step name produces the right output. (This doesn't actually raise an error, so we have to test the output by capturing `stdout`.) """ - with tempfile.NamedTemporaryFile( - mode="w+t", suffix=".template.json", encoding="utf8", delete=False - ) as temp_template: - json.dump(MOCK_TEMPLATE, temp_template.file) - - mock_args = Namespace( - path=Path(temp_template.name), - step="FakeStep", - timestamp_format=LoggingTimestampFormat.RELATIVE, - job_params=None, - task_params=None, - tasks=None, - maximum_tasks=-1, - run_dependencies=False, - path_mapping_rules=None, - environments=[], - output="human-readable", - verbose=False, - preserve=False, - extensions="", - ) + template_file = tmp_path / "template.json" + template_file.write_text(json.dumps(MOCK_TEMPLATE)) + + mock_args = Namespace( + path=template_file, + step="FakeStep", + timestamp_format=LoggingTimestampFormat.RELATIVE, + job_params=None, + task_params=None, + tasks=None, + maximum_tasks=-1, + run_dependencies=False, + path_mapping_rules=None, + environments=[], + output="human-readable", + verbose=False, + preserve=False, + extensions="", + ) with pytest.raises(SystemExit): do_run(mock_args) assert ( @@ -506,8 +492,6 @@ def test_do_run_nonexistent_step(capsys: pytest.CaptureFixture): in capsys.readouterr().out ) - Path(temp_template.name).unlink() - PARAMETRIZE_CASES = ( pytest.param(SampleSteps.BareStep, [], [], id="Bare Step without --run-dependencies"), @@ -894,3 +878,411 @@ def test_task_param_validation_errors( assert ( expected_error in outerr.out ), f"Message r'{expected_error}' was not found in the output:\n{format_capsys_outerr(outerr)}" + + +# Integration tests for context-aware help functionality + + +class TestContextAwareHelp: + """Integration tests for the context-aware help feature of the run command.""" + + def test_help_with_json_template(self, capsys: pytest.CaptureFixture) -> None: + """Test that help displays job-specific information for JSON templates.""" + # GIVEN + template_dir = Path(__file__).parent / "templates" + template_path = template_dir / "job_with_test_steps.yaml" + + # WHEN + args = ["run", str(template_path), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Verify job name appears in output + assert "Job: my-job" in outerr.out, "Job name should appear in help output" + + # Verify parameter information appears + assert ( + "Job Parameters (-p/--job-param PARAM_NAME=VALUE):" in outerr.out + ), "Job parameters section should appear" + assert "Message (STRING)" in outerr.out, "Parameter name and type should appear" + assert "[default: 'Hello, world!']" in outerr.out, "Parameter default should appear" + + def test_help_with_yaml_template_long_flag(self, capsys: pytest.CaptureFixture) -> None: + """Test that help displays job-specific information with --help flag.""" + # GIVEN + template_dir = Path(__file__).parent / "templates" + template_path = template_dir / "basic.yaml" + + # WHEN + args = ["run", str(template_path), "--help"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Verify job name appears + assert "Job: Job" in outerr.out, "Job name should appear in help output" + + # Verify parameter information appears + assert ( + "Job Parameters (-p/--job-param PARAM_NAME=VALUE):" in outerr.out + ), "Job parameters section should appear" + assert "J (STRING)" in outerr.out, "Parameter J should appear with type" + assert "[required]" in outerr.out, "Required parameter should be marked as required" + + def test_help_with_template_with_description( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that help displays job description when present in template.""" + # GIVEN - Create a temporary template with description + template_file = tmp_path / "test_template.json" + template_file.write_text( + json.dumps( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "TestJob", + "description": "This is a test job with a description", + "steps": [ + { + "name": "TestStep", + "script": { + "actions": { + "onRun": { + "command": "python", + "args": ["-c", "print('test')"], + } + } + }, + } + ], + } + ) + ) + + # WHEN + args = ["run", str(template_file), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + assert "Job: TestJob" in outerr.out, "Job name should appear" + assert ( + "This is a test job with a description" in outerr.out + ), "Job description should appear" + + def test_help_with_multiple_parameters( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that help displays all parameters with various types and constraints.""" + # GIVEN - Create a template with multiple parameters + template_file = tmp_path / "multi_param.json" + template_file.write_text( + json.dumps( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "MultiParamJob", + "parameterDefinitions": [ + { + "name": "StringParam", + "type": "STRING", + "default": "hello", + "description": "A string parameter", + }, + { + "name": "IntParam", + "type": "INT", + "minValue": 1, + "maxValue": 10, + }, + { + "name": "FloatParam", + "type": "FLOAT", + "default": 3.14, + }, + { + "name": "PathParam", + "type": "PATH", + }, + ], + "steps": [ + { + "name": "TestStep", + "script": { + "actions": { + "onRun": { + "command": "python", + "args": ["-c", "print('test')"], + } + } + }, + } + ], + } + ) + ) + + # WHEN + args = ["run", str(template_file), "--help"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Verify all parameters appear with correct types + assert "StringParam (STRING)" in outerr.out, "String parameter should appear" + assert "[default: 'hello']" in outerr.out, "String default should appear" + assert "A string parameter" in outerr.out, "Parameter description should appear" + + assert "IntParam (INT)" in outerr.out, "Int parameter should appear" + assert "[required]" in outerr.out, "Required parameter should be marked" + assert "range: 1 to 10" in outerr.out, "Int constraints should appear" + + assert "FloatParam (FLOAT)" in outerr.out, "Float parameter should appear" + assert "[default: 3.14]" in outerr.out, "Float default should appear" + + assert "PathParam (PATH)" in outerr.out, "Path parameter should appear" + + def test_help_includes_standard_options(self, capsys: pytest.CaptureFixture) -> None: + """Test that help includes standard run command options.""" + # GIVEN + template_dir = Path(__file__).parent / "templates" + template_path = template_dir / "basic.yaml" + + # WHEN + args = ["run", str(template_path), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Verify standard options section appears + assert "Standard Options:" in outerr.out, "Standard options section should appear" + + # Verify some key standard options are present + assert "--step" in outerr.out, "--step option should appear" + assert "--run-dependencies" in outerr.out, "Run dependencies option should appear" + assert ( + "--environment" in outerr.out or "--env" in outerr.out + ), "Environment option should appear" + + +class TestBackwardCompatibility: + """Integration tests for backward compatibility of the run command.""" + + def test_help_without_template_shows_standard_help(self, capsys: pytest.CaptureFixture) -> None: + """Test that 'openjd run --help' without template shows standard help.""" + # WHEN + args = ["run", "--help"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Should show standard help, not job-specific help + assert "usage:" in outerr.out.lower(), "Usage line should appear" + # Should NOT show job-specific sections + assert "Job:" not in outerr.out, "Should not show job-specific information" + assert "Job Parameters" not in outerr.out, "Should not show job parameters section" + + def test_normal_job_execution_unaffected(self, capsys: pytest.CaptureFixture) -> None: + """Test that normal job execution works without help flag.""" + # GIVEN + template_dir = Path(__file__).parent / "templates" + template_path = template_dir / "simple_with_j_param.yaml" + + # WHEN - Run a job normally without help flag + args = [ + "run", + str(template_path), + "--step", + "SimpleStep", + "-p", + "J=TestValue", + "--extensions", + "", + ] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Job should execute normally + assert "DoTask" in outerr.out, "Job should execute normally" + # Should NOT show help text + assert "Job Parameters" not in outerr.out, "Should not show help during execution" + + def test_existing_run_command_functionality_unchanged( + self, capsys: pytest.CaptureFixture + ) -> None: + """Test that existing run command options still work correctly.""" + # GIVEN + template_dir = Path(__file__).parent / "templates" + template_path = template_dir / "basic.yaml" + + # WHEN - Use existing options like --step, -p, --run-dependencies + args = [ + "run", + str(template_path), + "--step", + "First", + "-p", + "J=TestValue", + "-tp", + "Foo=1", + "-tp", + "Bar=Bar1", + "--extensions", + "", + ] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=0) + + # THEN + # Job should execute with specified parameters + assert "J=TestValue" in outerr.out, "Job parameter should be used" + assert "Foo=1" in outerr.out, "Task parameter should be used" + assert "Bar=Bar1" in outerr.out, "Task parameter should be used" + + +class TestHelpErrorScenarios: + """Integration tests for error handling in context-aware help.""" + + def test_help_with_nonexistent_template(self, capsys: pytest.CaptureFixture) -> None: + """Test that help with non-existent template shows error message.""" + # GIVEN + nonexistent_path = "nonexistent_template.json" + + # WHEN + args = ["run", nonexistent_path, "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN + # Should show error message + assert "Error:" in outerr.err, "Error message should appear in stderr" + # Error should mention the file issue + assert ( + "not found" in outerr.err.lower() + or "no such file" in outerr.err.lower() + or "does not exist" in outerr.err.lower() + ), "Error should indicate file not found" + + def test_help_with_invalid_json_template( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that help with invalid JSON shows error message.""" + # GIVEN - Create a file with invalid JSON + template_file = tmp_path / "invalid.json" + template_file.write_text("{invalid json content") + + # WHEN + args = ["run", str(template_file), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN + assert "Error:" in outerr.err, "Error message should appear in stderr" + + def test_help_with_invalid_yaml_template( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that help with invalid YAML shows error message.""" + # GIVEN - Create a file with invalid YAML + template_file = tmp_path / "invalid.yaml" + template_file.write_text("invalid: yaml: content: [unclosed") + + # WHEN + args = ["run", str(template_file), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN + assert "Error:" in outerr.err, "Error message should appear in stderr" + + def test_help_with_schema_validation_failure( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that help with template that fails schema validation shows error.""" + # GIVEN - Create a template missing required fields + template_file = tmp_path / "invalid_schema.json" + template_file.write_text( + json.dumps( + { + "specificationVersion": "jobtemplate-2023-09", + # Missing required 'name' field + "steps": [], + } + ) + ) + + # WHEN + args = ["run", str(template_file), "-h"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN + assert "Error:" in outerr.err, "Error message should appear in stderr" + assert ( + "Invalid job template" in outerr.err or "validation" in outerr.err.lower() + ), "Error should indicate validation failure" + + def test_help_error_messages_are_user_friendly(self, capsys: pytest.CaptureFixture) -> None: + """Test that error messages don't expose internal stack traces.""" + # GIVEN + nonexistent_path = "does_not_exist.json" + + # WHEN + args = ["run", nonexistent_path, "--help"] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN + # Error message should be present but not contain stack trace indicators + assert "Error:" in outerr.err, "Error message should appear" + # Should not contain Python stack trace elements + assert "Traceback" not in outerr.err, "Should not show Python traceback" + assert 'File "' not in outerr.err, "Should not show file paths from stack trace" + + def test_missing_parameters_shows_help( + self, capsys: pytest.CaptureFixture, tmp_path: Path + ) -> None: + """Test that when required parameters are missing, help information is displayed.""" + # GIVEN - Create a template with required parameters + template_file = tmp_path / "test_template.json" + template_file.write_text( + json.dumps( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "TestJob", + "description": "A test job with required parameters", + "parameterDefinitions": [ + { + "name": "RequiredParam1", + "type": "STRING", + "description": "First required parameter", + }, + { + "name": "RequiredParam2", + "type": "INT", + "description": "Second required parameter", + }, + ], + "steps": [ + { + "name": "TestStep", + "script": {"actions": {"onRun": {"command": "echo"}}}, + } + ], + } + ) + ) + + # WHEN - Run without providing required parameters + args = ["run", str(template_file)] + outerr = run_openjd_cli_main(capsys, args=args, expected_exit_code=1) + + # THEN - Should show error about missing parameters + assert ( + "Values missing for required job parameters" in outerr.out + ), "Should show missing parameters error" + + # AND - Should also show help information + assert "Job: TestJob" in outerr.out, "Should show job name in help" + assert ( + "A test job with required parameters" in outerr.out + ), "Should show job description in help" + assert ( + "Job Parameters (-p/--job-param PARAM_NAME=VALUE):" in outerr.out + ), "Should show parameters section header" + assert ( + "RequiredParam1 (STRING) [required]" in outerr.out + ), "Should show first required parameter" + assert "First required parameter" in outerr.out, "Should show first parameter description" + assert ( + "RequiredParam2 (INT) [required]" in outerr.out + ), "Should show second required parameter" + assert "Second required parameter" in outerr.out, "Should show second parameter description" + assert "Standard Options:" in outerr.out, "Should show standard options section"