Skip to content

Commit ac751c7

Browse files
committed
feat: Make 'openjd run <template>' help output describe the job parameters
Because a job template is runnable code similar to a script, we can think of `openjd run <templatefile>` the same as `bash <scriptfile>`. Part of this is printing help output of the options available. This change makes it so `openjd run <templatefile> -h` prints help that is specific about the template file, displaying the job name, description, and all its parameters. Similarly, if there are missing required parameters, the error message says that and then displays this same help text. Signed-off-by: Mark <[email protected]>
1 parent 2d29e65 commit ac751c7

File tree

5 files changed

+2067
-132
lines changed

5 files changed

+2067
-132
lines changed

src/openjd/cli/_run/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22

33
from ._run_command import add_run_arguments, do_run
4+
from ._help_formatter import JobTemplateHelpAction
45
from .._common import add_common_arguments, CommonArgument, SubparserGroup
56

67

@@ -10,7 +11,17 @@ def populate_argparser(subcommands: SubparserGroup) -> None:
1011
"run",
1112
description="Takes a Job Template and runs the entire job or a selected Step from the job.",
1213
usage="openjd run JOB_TEMPLATE_PATH [arguments]",
14+
add_help=False, # Disable default help to use custom action
1315
)
1416
add_common_arguments(run_parser, {CommonArgument.PATH, CommonArgument.JOB_PARAMS})
1517
add_run_arguments(run_parser)
18+
19+
# Add custom help action that provides context-aware help based on job template
20+
run_parser.add_argument(
21+
"-h",
22+
"--help",
23+
action=JobTemplateHelpAction,
24+
help="Show help message. When a job template path is provided, displays job-specific help including parameter definitions.",
25+
)
26+
1627
run_parser.set_defaults(func=do_run)
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
"""
4+
Help formatter module for generating context-aware help text for job templates.
5+
"""
6+
7+
from argparse import Action, ArgumentParser, Namespace
8+
from pathlib import Path
9+
import sys
10+
import textwrap
11+
from typing import Any, Dict, Optional
12+
13+
from openjd.model import DecodeValidationError, JobTemplate
14+
from .._common import read_job_template, process_extensions_argument
15+
16+
17+
def format_parameter_info(param_def: Dict[str, Any]) -> str:
18+
"""
19+
Format a single parameter definition for help display.
20+
21+
This function converts a parameter definition from a job template into
22+
readable help text that includes the parameter's type, constraints, and
23+
default value (if any).
24+
25+
Args:
26+
param_def: Parameter definition dictionary from job template containing:
27+
- name (str): Parameter name
28+
- type (str): Parameter type (STRING, INT, FLOAT, PATH)
29+
- description (str, optional): Parameter description
30+
- default (Any, optional): Default value
31+
- minValue (int|float, optional): Minimum value for numeric types
32+
- maxValue (int|float, optional): Maximum value for numeric types
33+
- minLength (int, optional): Minimum length for string types
34+
- maxLength (int, optional): Maximum length for string types
35+
- allowedValues (list, optional): List of allowed values
36+
37+
Returns:
38+
Formatted string describing the parameter in argparse-style help format.
39+
40+
Example output formats:
41+
"ParamName (STRING) [required]"
42+
"ParamName (INT) [default: 42]"
43+
"ParamName (FLOAT) [default: 3.14] (range: 0.0 to 10.0)"
44+
"ParamName (STRING) [default: 'hello'] (allowed: 'hello', 'world')"
45+
"""
46+
param_name = param_def.get("name", "")
47+
param_type = param_def.get("type", "STRING")
48+
description = param_def.get("description", "")
49+
default_value = param_def.get("default")
50+
51+
# Start building the parameter info string
52+
parts = []
53+
54+
# Add parameter name and type
55+
type_info = f"{param_name} ({param_type})"
56+
parts.append(type_info)
57+
58+
# Add required/default status
59+
has_multiline_default = False
60+
if default_value is not None:
61+
# Check if default value contains newlines (multi-line string)
62+
if (
63+
param_type in ("STRING", "PATH")
64+
and isinstance(default_value, str)
65+
and "\n" in default_value
66+
):
67+
has_multiline_default = True
68+
parts.append("[default: see below]")
69+
else:
70+
# Format default value based on type
71+
if param_type in ("STRING", "PATH"):
72+
default_str = f"[default: '{default_value}']"
73+
else:
74+
default_str = f"[default: {default_value}]"
75+
parts.append(default_str)
76+
else:
77+
parts.append("[required]")
78+
79+
# Build the first line with name, type, and default/required
80+
first_line = " ".join(parts)
81+
82+
# Build constraint information
83+
constraints = []
84+
85+
# Handle numeric constraints (minValue, maxValue)
86+
min_val = param_def.get("minValue")
87+
max_val = param_def.get("maxValue")
88+
89+
if min_val is not None and max_val is not None:
90+
constraints.append(f"range: {min_val} to {max_val}")
91+
elif min_val is not None:
92+
constraints.append(f"minimum: {min_val}")
93+
elif max_val is not None:
94+
constraints.append(f"maximum: {max_val}")
95+
96+
# Handle string length constraints
97+
min_len = param_def.get("minLength")
98+
max_len = param_def.get("maxLength")
99+
100+
if min_len is not None and max_len is not None:
101+
constraints.append(f"length: {min_len} to {max_len} characters")
102+
elif min_len is not None:
103+
constraints.append(f"minimum length: {min_len} characters")
104+
elif max_len is not None:
105+
constraints.append(f"maximum length: {max_len} characters")
106+
107+
# Handle allowed values constraint
108+
allowed_values = param_def.get("allowedValues")
109+
if allowed_values:
110+
# Format allowed values based on type
111+
if param_type in ("STRING", "PATH"):
112+
formatted_values = ", ".join(f"'{v}'" for v in allowed_values)
113+
else:
114+
formatted_values = ", ".join(str(v) for v in allowed_values)
115+
constraints.append(f"allowed: {formatted_values}")
116+
117+
# Combine everything
118+
result_lines = [first_line]
119+
120+
# Add description if present
121+
if description:
122+
result_lines.append(f" {description}")
123+
124+
# Add constraints if present
125+
if constraints:
126+
constraint_str = " (" + ", ".join(constraints) + ")"
127+
result_lines[0] += constraint_str
128+
129+
# Add multi-line default value if present
130+
if has_multiline_default and isinstance(default_value, str):
131+
result_lines.append(" Default value:")
132+
# Indent each line of the default value
133+
result_lines.append(textwrap.indent(default_value, " "))
134+
135+
return "\n".join(result_lines)
136+
137+
138+
def generate_job_template_help(
139+
template: JobTemplate, parser: ArgumentParser, template_path: Path
140+
) -> str:
141+
"""
142+
Generate help text for a specific job template.
143+
144+
This function creates formatted help text that includes the job name,
145+
description (if present), parameter definitions with their types and
146+
constraints, and standard command options from the argument parser.
147+
148+
Args:
149+
template: The decoded job template object (JobTemplate from openjd.model)
150+
parser: The argument parser for the run command
151+
template_path: Path to the template file
152+
153+
Returns:
154+
Formatted help text string in argparse-style format
155+
156+
Example output:
157+
usage: openjd run my-template.yaml [arguments]
158+
159+
Job: my-job
160+
This is a sample job that demonstrates parameter usage.
161+
162+
Job Parameters (-p/--job-param PARAM_NAME=VALUE):
163+
Message (STRING) [default: 'Hello, world!']
164+
A message to display
165+
166+
Standard Options:
167+
--step STEP_NAME The name of the Step in the Job to run Tasks from.
168+
...
169+
"""
170+
lines = []
171+
172+
# Add usage line with actual template path instead of symbolic placeholder
173+
usage = parser.format_usage().strip()
174+
# Replace the symbolic JOB_TEMPLATE_PATH with the actual path provided
175+
usage = usage.replace("JOB_TEMPLATE_PATH", str(template_path))
176+
lines.append(usage)
177+
lines.append("")
178+
179+
# Print the job name and description (if present)
180+
lines.append(f"Job: {template.name}")
181+
if template.description:
182+
lines.append(template.description)
183+
lines.append("")
184+
185+
# Extract parameter definitions (optional field)
186+
param_definitions = template.parameterDefinitions
187+
188+
if param_definitions:
189+
lines.append("Job Parameters (-p/--job-param PARAM_NAME=VALUE):")
190+
191+
for param_def in param_definitions:
192+
param_dict: Dict[str, Any] = {
193+
"name": param_def.name,
194+
"type": param_def.type.value,
195+
"description": param_def.description,
196+
"default": param_def.default,
197+
}
198+
199+
# Add optional constraint fields if they exist
200+
for constraint in [
201+
"minValue",
202+
"maxValue",
203+
"minLength",
204+
"maxLength",
205+
"allowedValues",
206+
]:
207+
if hasattr(param_def, constraint):
208+
value = getattr(param_def, constraint)
209+
if value is not None:
210+
param_dict[constraint] = value
211+
212+
# Format the parameter info
213+
param_info = format_parameter_info(param_dict)
214+
215+
# Indent the parameter info with 2 spaces
216+
lines.append(textwrap.indent(param_info, " "))
217+
218+
lines.append("")
219+
220+
# Add standard options section
221+
lines.append("Standard Options:")
222+
223+
# Get the help text for all other arguments
224+
full_help = parser.format_help()
225+
226+
# Split into lines and find where the arguments section starts
227+
help_lines = full_help.split("\n")
228+
229+
# Skip usage and positional arguments, capture optional arguments
230+
in_options = False
231+
for line in help_lines:
232+
# Look for optional arguments section or specific argument patterns
233+
if "optional arguments:" in line.lower() or "options:" in line.lower():
234+
in_options = True
235+
continue
236+
elif "positional arguments:" in line.lower():
237+
continue
238+
elif in_options:
239+
# Add all option lines
240+
lines.append(line)
241+
242+
return "\n".join(lines)
243+
244+
245+
class JobTemplateHelpAction(Action):
246+
"""
247+
Custom argparse Action that intercepts help requests (-h/--help) and generates
248+
context-aware help text based on the job template file provided.
249+
250+
This action is triggered when the user invokes the run command with a job template
251+
path and the -h or --help flag. It loads and validates the template, then generates
252+
and displays help text that includes job-specific information (name, description,
253+
parameters) alongside standard command options.
254+
255+
The action handles errors gracefully, displaying user-friendly error messages for
256+
issues like missing files, invalid syntax, or schema validation failures.
257+
"""
258+
259+
def __init__(
260+
self,
261+
option_strings,
262+
dest,
263+
default=False,
264+
required=False,
265+
help=None,
266+
):
267+
"""
268+
Initialize the custom help action.
269+
270+
Args:
271+
option_strings: List of option strings (e.g., ['-h', '--help'])
272+
dest: Destination attribute name in the namespace
273+
default: Default value for the action
274+
required: Whether this argument is required
275+
help: Help text for this option
276+
"""
277+
super().__init__(
278+
option_strings=option_strings,
279+
dest=dest,
280+
default=default,
281+
nargs=0,
282+
required=required,
283+
help=help,
284+
)
285+
286+
def __call__(
287+
self,
288+
parser: ArgumentParser,
289+
namespace: Namespace,
290+
values: Any,
291+
option_string: Optional[str] = None,
292+
) -> None:
293+
"""
294+
Invoked when -h or --help is encountered.
295+
296+
This method intercepts the help request, checks if a job template path
297+
has been provided, loads and validates the template, generates custom
298+
help text, and displays it before exiting.
299+
300+
Args:
301+
parser: The argument parser instance
302+
namespace: The namespace containing parsed arguments so far
303+
values: The value associated with the action (unused for help)
304+
option_string: The option string that triggered this action ('-h' or '--help')
305+
306+
Exits:
307+
- Code 0: Help displayed successfully
308+
- Code 1: Error occurred (file not found, validation failed, etc.)
309+
"""
310+
# Check if a template path has been provided
311+
template_path = getattr(namespace, "path", None)
312+
313+
if template_path is None:
314+
# No template path provided, show standard help
315+
parser.print_help()
316+
sys.exit(0)
317+
318+
# Convert to Path object if it's a string
319+
if isinstance(template_path, str):
320+
template_path = Path(template_path)
321+
322+
try:
323+
# Process extensions argument (defaults to all supported extensions if not provided)
324+
extensions_arg = getattr(namespace, "extensions", None)
325+
supported_extensions = process_extensions_argument(extensions_arg)
326+
327+
# Load and validate the job template
328+
# This will raise RuntimeError for file issues or DecodeValidationError for validation issues
329+
template = read_job_template(template_path, supported_extensions=supported_extensions)
330+
331+
# Generate the custom help text
332+
help_text = generate_job_template_help(template, parser, template_path)
333+
334+
# Display the help text
335+
print(help_text)
336+
337+
# Exit successfully
338+
sys.exit(0)
339+
340+
except RuntimeError as e:
341+
# Handle file not found, parse errors, etc.
342+
print(f"Error: {str(e)}", file=sys.stderr)
343+
sys.exit(1)
344+
345+
except DecodeValidationError as e:
346+
# Handle template validation errors
347+
print(f"Error: Invalid job template: {str(e)}", file=sys.stderr)
348+
sys.exit(1)
349+
350+
except Exception as e:
351+
# Catch any unexpected errors
352+
print(f"Error: Failed to generate help: {str(e)}", file=sys.stderr)
353+
sys.exit(1)

0 commit comments

Comments
 (0)