diff --git a/smart_tests/args4p/command.py b/smart_tests/args4p/command.py index 22e61adfb..f1523b1ab 100644 --- a/smart_tests/args4p/command.py +++ b/smart_tests/args4p/command.py @@ -264,6 +264,45 @@ def error(msg: str) -> BadConfigException: for c in self.commands: c.check_consistency() + def _usage_line(self, program_name) -> str: + parts = ["Usage:"] + + # Program name + parts.append(program_name) + + # Build command path (for subcommands) + command_path: List[str] = [] + current = self + while current.parent is not None: + command_path.insert(0, current.name) + current = current.parent + if len(command_path) > 0: + parts.append(" ".join(command_path)) + + # Add options placeholder if we have options + if self.options: + parts.append("[OPTIONS]") + + # Add arguments + for a in self.arguments: + if a.required: + if a.multiple: + parts.append(f"<{a.metavar}>...") + else: + parts.append(f"<{a.metavar}>") + else: + if a.multiple: + parts.append(f"[{a.metavar}...]") + else: + parts.append(f"[{a.metavar}]") + if isinstance(self, Group): + if len(self.arguments) == 0: + # Add subcommand placeholder for groups + parts.append("COMMAND") + parts.append("...") + + return " ".join(parts) + def format_help(self, program_name: str = os.path.basename(sys.argv[0])) -> str: """ Generate and return a formatted help message for this command. @@ -271,46 +310,8 @@ def format_help(self, program_name: str = os.path.basename(sys.argv[0])) -> str: :param program_name Name of the program to display in the usage line. Defaults to the name of the running script. """ - def usage_line() -> str: - parts = ["Usage:"] - - # Program name - parts.append(program_name) - - # Build command path (for subcommands) - command_path: List[str] = [] - current = self - while current.parent is not None: - command_path.insert(0, current.name) - current = current.parent - if len(command_path) > 0: - parts.append(" ".join(command_path)) - - # Add options placeholder if we have options - if self.options: - parts.append("[OPTIONS]") - - # Add arguments - for a in self.arguments: - if a.required: - if a.multiple: - parts.append(f"<{a.metavar}>...") - else: - parts.append(f"<{a.metavar}>") - else: - if a.multiple: - parts.append(f"[{a.metavar}...]") - else: - parts.append(f"[{a.metavar}]") - if isinstance(self, Group): - if len(self.arguments) == 0: - # Add subcommand placeholder for groups - parts.append("COMMAND") - parts.append("...") - return " ".join(parts) - - lines = [usage_line()] + lines = [self._usage_line(program_name)] # Description from docstring if self.callback.__doc__: @@ -416,6 +417,94 @@ def _format_options(self, caption: str, lines: list[str]): if self.parent: self.parent._format_options(f"Options (common to {self.parent.name})", lines) + def format_asciidoc_table(self, program_name: str) -> str: + """ + Generate an AsciiDoc table for the command's options and arguments. + Returns the table as a string. + """ + lines = [f"`{self._usage_line(program_name)}`", ""] + + def _print_required(p: Parameter) -> str: + return "Yes" if p.required else "No" + + if self.arguments: + lines.append("[cols=\"2,4,1\"]") + lines.append("|===") + lines.append("|Argument |Description |Required") + lines.append("") + + for arg in self.arguments: + def _print_name() -> str: + if arg.multiple: + return f"`<{arg.metavar}>...`" + else: + return f"`<{arg.metavar}>`" + + def _print_description() -> str: + desc_parts = [] + if arg.help: + desc_parts.append(arg.help) + + if arg.type != str: + type_name = getattr(arg.type, '__name__', str(arg.type)) + desc_parts.append(f"(type: {type_name})") + + if arg.default != NO_DEFAULT: + desc_parts.append(f"Default: `{arg.default}`") + + return " ".join(desc_parts) + + lines.append("// GENERATED. MODIFY IN CLI SOURCE CODE") + lines.append(f"|{_print_name()}") + lines.append(f"|{_print_description()}") + lines.append(f"|{_print_required(arg)}") + lines.append("") + + lines.append("|===") + + if self.options: + lines.append("[cols=\"2,4,1\"]") + lines.append("|===") + lines.append("|Option |Description |Required") + lines.append("") + + for opt in sorted([opt for opt in self.options if not opt.hidden], key=lambda o: o.name): + def _print_name() -> str: + names = ", ".join([f"`{name}`" for name in opt.option_names]) + + # Add metavar for non-boolean options + if opt.type != bool: + if opt.metavar: + names += f" {opt.metavar}" + else: + type_name = getattr(opt.type, '__name__', str(opt.type)) + names += f" {type_name.upper()}" + + return names + + def _print_description() -> str: + desc_parts = [] + if opt.help: + desc_parts.append(opt.help) + + if opt.default != NO_DEFAULT and opt.type != bool: + desc_parts.append(f"Default: `{opt.default}`") + + if opt.multiple: + desc_parts.append("(can be specified multiple times)") + + return " ".join(desc_parts) + + lines.append("// GENERATED. MODIFY IN CLI SOURCE CODE") + lines.append(f"|{_print_name()}") + lines.append(f"|{_print_description()}") + lines.append(f"|{_print_required(opt)}") + lines.append("") + + lines.append("|===") + + return "\n".join(lines) + def __repr__(self): return f"" diff --git a/smart_tests/commands/inspect/subset.py b/smart_tests/commands/inspect/subset.py index 88691839d..fe180c75c 100644 --- a/smart_tests/commands/inspect/subset.py +++ b/smart_tests/commands/inspect/subset.py @@ -99,11 +99,11 @@ def display(self): def subset( app: Application, subset_id: Annotated[int, typer.Option( - help="subset id", + help="Subset id", required=True )], json: Annotated[bool, typer.Option( - help="display JSON format" + help="Display JSON format" )] = False, ): is_json_format = json # Map parameter name diff --git a/smart_tests/commands/record/build.py b/smart_tests/commands/record/build.py index 3d8243d85..57bbfce17 100644 --- a/smart_tests/commands/record/build.py +++ b/smart_tests/commands/record/build.py @@ -35,34 +35,37 @@ def build( app: Application, build_name: Annotated[str, typer.Option( "--build", - help="build name", - metavar="BUILD_NAME", + help="Build name", + metavar="NAME", required=True )], branch: Annotated[str | None, typer.Option( "--branch", - help="Branch name. A branch is a set of test sessions grouped and this option value will be used for a lineage name." + help="Set branch name. A branch is a set of test sessions grouped and this option value will be used for a branch name.", + metavar="NAME" )] = None, repositories: Annotated[List[str], typer.Option( "--repo-branch-map", multiple=True, help="Set repository name and branch name when you use --no-commit-collection option. " - "Please use the same repository name with a commit option" + "Please use the same repository name with a commit option", + metavar="REPO_NAME=BRANCH_NAME" )] = [], source: Annotated[List[str], typer.Option( multiple=True, - help="path to local Git workspace, optionally prefixed by a label. " + help="Path to local Git workspace, optionally prefixed by a label. " "like --source path/to/ws or --source main=path/to/ws", - metavar="REPO_NAME" + metavar="DIR" )] = ["."], max_days: Annotated[int, typer.Option( - help="the maximum number of days to collect commits retroactively" + help="The maximum number of days to collect commits retroactively", + metavar="DAYS" )] = 30, no_submodules: Annotated[bool, typer.Option( - help="stop collecting information from Git Submodules" + help="Stop collecting information from Git Submodules" )] = False, no_commit_collection: Annotated[bool, typer.Option( - help="do not collect commit data. " + help="Do not collect commit data. " "This is useful if the repository is a shallow clone and the RevWalk is not " "possible. The commit data must be collected with a separate fully-cloned " "repository." @@ -70,11 +73,13 @@ def build( commits: Annotated[List[str], typer.Option( "--commit", multiple=True, - help="set repository name and commit hash when you use --no-commit-collection option" + help="Set repository name and commit hash when you use --no-commit-collection option", + metavar="REPO_NAME=COMMIT_HASH" )] = [], timestamp: Annotated[str | None, typer.Option( help="Used to overwrite the build time when importing historical data. " - "Note: Format must be `YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)" + "Note: Format must be `YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)", + metavar="TIMESTAMP" )] = None, links: Annotated[List[KeyValue], typer.Option( "--link", diff --git a/smart_tests/commands/record/commit.py b/smart_tests/commands/record/commit.py index 94c529300..dffe3be4d 100644 --- a/smart_tests/commands/record/commit.py +++ b/smart_tests/commands/record/commit.py @@ -28,20 +28,24 @@ def commit( app: Application, name: Annotated[str | None, typer.Option( - help="repository name" + help="Repository name", + metavar="NAME", )] = None, source: Annotated[str, typer.Option( - help="repository path" + help="Repository path", + metavar="DIR", )] = os.getcwd(), executable: Annotated[str, typer.Option( help="[Obsolete] it was to specify how to perform commit collection but has been removed", hidden=True )] = "jar", max_days: Annotated[int, typer.Option( - help="the maximum number of days to collect commits retroactively" + help="The maximum number of days to collect commits retroactively", + metavar="DAYS", )] = 30, import_git_log_output: Annotated[str | None, typer.Option( - help="import from the git-log output" + help="Import from the git-log output", + metavar="FILE", )] = None, ): if executable == 'docker': diff --git a/smart_tests/commands/record/session.py b/smart_tests/commands/record/session.py index c734d98ea..c9f315a8f 100644 --- a/smart_tests/commands/record/session.py +++ b/smart_tests/commands/record/session.py @@ -26,29 +26,32 @@ def session( app: Application, build_name: Annotated[str, typer.Option( "--build", - help="build name", + help="Build name", + metavar="NAME", required=True )], test_suite: Annotated[str, typer.Option( "--test-suite", help="Set test suite name. A test suite is a collection of test sessions. Setting a test suite allows you to " "manage data over test sessions and lineages.", + metavar="NAME", required=True )], flavors: Annotated[List[KeyValue], typer.Option( "--flavor", - help="flavors", + help="Flavors", multiple=True, metavar="KEY=VALUE", type=parse_key_value )] = [], is_observation: Annotated[bool, typer.Option( "--observation", - help="enable observation mode" + help="Enable observation mode" )] = False, links: Annotated[List[KeyValue], typer.Option( "--link", - help="Set external link of a title and url", + help="Set external link of title and url", + metavar="TITLE=URL", multiple=True, type=parse_key_value, )] = [], @@ -58,7 +61,8 @@ def session( )] = False, timestamp: Annotated[str | None, typer.Option( help="Used to overwrite the session time when importing historical data. Note: Format must be " - "`YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)" + "`YYYY-MM-DDThh:mm:ssTZD` or `YYYY-MM-DDThh:mm:ss` (local timezone applied)", + metavar="TIMESTAMP" )] = None, ): diff --git a/smart_tests/commands/record/tests.py b/smart_tests/commands/record/tests.py index c1776a61f..e8fc0ff71 100644 --- a/smart_tests/commands/record/tests.py +++ b/smart_tests/commands/record/tests.py @@ -149,7 +149,8 @@ def __init__( hidden=True )] = False, group: Annotated[str | None, typer.Option( - help="Grouping name for test results" + help="Grouping name for test results", + metavar="NAME" )] = "", is_allow_test_before_build: Annotated[bool, typer.Option( "--allow-test-before-build", diff --git a/smart_tests/commands/subset.py b/smart_tests/commands/subset.py index 83f57457c..0a67ea386 100644 --- a/smart_tests/commands/subset.py +++ b/smart_tests/commands/subset.py @@ -101,18 +101,22 @@ def __init__( session: Annotated[SessionId, SessionId.as_option()], target: Annotated[Percentage | None, typer.Option( type=parse_percentage, - help="subsetting target from 0% to 100%" + help="Subsetting target from 0% to 100%", + metavar="PERCENTAGE" )] = None, time: Annotated[Duration | None, typer.Option( type=parse_duration, - help="subsetting by absolute time, in seconds e.g) 300, 5m" + help="Subsetting by absolute time, in seconds e.g) 300, 5m", + metavar="TIME" )] = None, confidence: Annotated[Percentage | None, typer.Option( type=parse_percentage, - help="subsetting by confidence from 0% to 100%" + help="Subsetting by confidence from 0% to 100%", + metavar="PERCENTAGE" )] = None, goal_spec: Annotated[str | None, typer.Option( - help="subsetting by programmatic goal definition" + help="Subsetting by programmatic goal definition", + metavar="GOAL_SPEC" )] = None, base_path: Annotated[str | None, typer.Option( '--base', @@ -120,7 +124,8 @@ def __init__( metavar="DIR" )] = None, rest: Annotated[str | None, typer.Option( - help="Output the subset remainder to a file, e.g. `--rest=remainder.txt`" + help="Output the subset remainder to a file, e.g. --rest=remainder.txt", + metavar="FILE" )] = None, # TODO(Konboi): omit from the smart-tests command initial release # split: Annotated[bool, typer.Option( @@ -142,11 +147,11 @@ def __init__( )] = False, is_get_tests_from_previous_sessions: Annotated[bool, typer.Option( "--get-tests-from-previous-sessions", - help="get subset list from previous full tests" + help="Get subset list from previous full tests" )] = False, is_output_exclusion_rules: Annotated[bool, typer.Option( "--output-exclusion-rules", - help="outputs the exclude test list. Switch the subset and rest." + help="Outputs the exclude test list. Switch the subset and rest." )] = False, is_non_blocking: Annotated[bool, typer.Option( "--non-blocking", @@ -155,16 +160,19 @@ def __init__( )] = False, ignore_flaky_tests_above: Annotated[float | None, typer.Option( help="Ignore flaky tests above the value set by this option. You can confirm flaky scores in WebApp", - type=floatType(min=0.0, max=1.0) + type=floatType(min=0.0, max=1.0), + metavar="N" )] = None, prioritize_tests_failed_within_hours: Annotated[int | None, typer.Option( help="Prioritize tests that failed within the specified hours; maximum 720 hours (= 24 hours * 30 days)", - type=intType(min=0, max=24 * 30) + type=intType(min=0, max=24 * 30), + metavar="N" )] = None, prioritized_tests_mapping_file: Annotated[TextIOWrapper | None, typer.Option( "--prioritized-tests-mapping", help="Prioritize tests based on test mapping file", - type=fileText(mode="r") + type=fileText(mode="r"), + metavar="FILE" )] = None, is_get_tests_from_guess: Annotated[bool, typer.Option( "--get-tests-from-guess", diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 000000000..29992e74e --- /dev/null +++ b/tools/README.md @@ -0,0 +1,70 @@ +# Documentation Generation Tools + +## generate_reference.py + +This script automatically generates AsciiDoc tables for CLI command options and arguments from the args4p command definitions. +This is meant to be used with https://github.com/cloudbees/docsite-cloudbees-smart-tests/blob/main/docs/modules/resources/pages/cli-reference.adoc + +### Usage + +```bash +uv run python tools/generate_reference.py path/to/reference.adoc +``` + +### How It Works + +The script looks for special markers in the AsciiDoc file: + +```asciidoc +// [generate:COMMAND_PATH] +... content to be replaced ... +// [/generate] +``` + +Where `COMMAND_PATH` is a space-separated command path like: +- `inspect subset` +- `record build` +- `subset` + +The script will: +1. Parse the AsciiDoc file to find all `[generate:...]` markers +2. Resolve each command path to the actual Command object +3. Generate an AsciiDoc table from the command's options and arguments +4. Replace the content between the markers with the generated table + +### Marking Sections for Generation + +To mark a section for automatic generation, wrap it with markers: + +```asciidoc +=== inspect subset + +Display details of a subset request. + +`smart-tests inspect subset --subset-id 26876` + +// [generate:inspect subset] +[cols="1,2,1"] +|=== +|Option |Description |Required + +|`--subset-id` INT +|subset id +|Yes + +|`--json` +|display JSON format +|No + +|=== +// [/generate] + +Additional documentation... +``` + +### Benefits + +- **Single Source of Truth**: Option/argument definitions live in code +- **Always Up-to-Date**: Regenerate docs whenever code changes +- **Human Control**: Manually written documentation is preserved +- **Easy Maintenance**: Just run the script to update all marked sections diff --git a/tools/generate_reference.py b/tools/generate_reference.py new file mode 100755 index 000000000..8fe5f036e --- /dev/null +++ b/tools/generate_reference.py @@ -0,0 +1,80 @@ +#!/usr/bin/env -S uv run --script +""" +Generate AsciiDoc documentation tables from args4p command definitions. + +This script parses reference.adoc and replaces sections marked with: + // [generate:COMMAND_PATH] + ... + // [/generate] + +Where COMMAND_PATH is a space-separated command path like "inspect subset" or "record build". +""" + +import re +import sys +from pathlib import Path +from typing import Annotated + +import smart_tests.args4p.typer as typer +from smart_tests import args4p +from smart_tests.args4p.command import Command, Group +from smart_tests.args4p.converters import path +from smart_tests.args4p.exceptions import BadCmdLineException + +# Add parent directory to path to import smart_tests modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + + +def resolve_command(root: Group, command_path: str) -> Command: + current = root + + for part in command_path.strip().split(): + if not isinstance(current, Group): + raise BadCmdLineException(f"Command '{command_path}' is invalid: '{part}' is not a group command") + + try: + current = current.find_subcommand(part) + except BadCmdLineException: + raise BadCmdLineException(f"Command '{command_path}' is invalid: no such subcommand '{part}'") + + return current + + +def process_reference_file(reference_path: Path, cli_root: Group): + """ + Process the reference.adoc file and replace marked sections with generated tables. + + Returns: + The new content of the file + """ + content = reference_path.read_text() + + # Pattern to match: // [generate:COMMAND_PATH] + # Captures everything until // [/generate] + pattern = r'// \[generate:([^\]]+)\]\n(.*?)// \[/generate\]' + + def replace_section(match): + command_path = match.group(1).strip() + + table = resolve_command(cli_root, command_path).format_asciidoc_table("smart-tests") + + return f"// [generate:{command_path}]\n{table}\n// [/generate]" + + # Replace all marked sections + new_content = re.sub(pattern, replace_section, content, flags=re.DOTALL) + + reference_path.write_text(new_content) + print(f"Updated {reference_path}") + + +@args4p.command(help="Generate AsciiDoc documentation tables from args4p commands") +def main(reference_file: Annotated[Path, typer.Argument(type=path(exists=True, file_okay=True, dir_okay=False), required=True)]): + """Main entry point for the script.""" + + from smart_tests.__main__ import cli + + process_reference_file(reference_file, cli) + + +if __name__ == "__main__": + main.main()