From 2729ce16966fa0d35ae6bedb05e0baba7e3606ac Mon Sep 17 00:00:00 2001 From: Prajwalprakash3722 Date: Sun, 12 Apr 2026 13:11:17 +0530 Subject: [PATCH 1/2] add compact full-help and compact output mode --- drove-cli/SKILL.md | 43 +++++- drove.py | 36 +---- drovecli.py | 104 +++++++++++++- droveutils.py | 65 ++++++++- tests/test_offline_full_help.py | 236 +++++++++++++++++++++++--------- 5 files changed, 373 insertions(+), 111 deletions(-) diff --git a/drove-cli/SKILL.md b/drove-cli/SKILL.md index b2d16b0..15a6699 100644 --- a/drove-cli/SKILL.md +++ b/drove-cli/SKILL.md @@ -44,20 +44,51 @@ fallback invocation prefix. --- -## Step 1 — Load the Full Command Reference +## Step 1 — Bootstrap Session for Token-Efficient Usage -Run this once to bring every command and sub-command into context: +Do these two things at the start of every session: + +### 1a. Enable compact data output + +```bash +export DROVE_COMPACT=1 +``` + +This makes **all data-producing commands** return token-optimised output +automatically — TSV tables (tab-separated, no padding), flat `key=value` +dicts, and single-line JSON. + +Affected commands: `apps list`, `apps summary`, `cluster summary`, +`executor list`, `localservices list/summary`, `lsinstances list/info`, +`tasks list/show`, `describe *`, `appinstances list/info`. + +You can also pass `--compact` per-command instead of the env var. +`--compact` is a **global flag** — it goes before the plugin name: + +```bash +drove --compact apps list # ✅ correct +drove apps list --compact # ❌ won't work +``` + +> `DROVE_COMPACT` / `--compact` does **not** affect `--full-help` output +> (that has its own compact format by default). + +### 1b. Load the command reference ```bash drove --full-help ``` -This prints the complete, always-up-to-date help for every command and -sub-command in one shot. Use it to look up flags, argument names, and -available options — no need to memorise them. +Prints a **compact one-line-per-command** reference (~3 KB). +Use it to look up flags, argument names, and available options. + +For the full verbose argparse output (human-readable), add `--verbose`: + +```bash +drove --full-help --verbose # ~35 KB, human-readable +``` > **Full documentation:** https://phonepe.github.io/drove-orchestrator/cli/ -> Refer to this whenever the built-in help isn't enough or something is unclear. --- diff --git a/drove.py b/drove.py index d3d27ba..153c197 100755 --- a/drove.py +++ b/drove.py @@ -19,45 +19,13 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--password", "-p", help="Drove cluster password") parser.add_argument("--debug", "-d", help="Print details of errors", default=False, action="store_true") parser.add_argument("--full-help", help="Show help for every command and sub-command", default=False, action="store_true") + parser.add_argument("--compact", help="Token-optimised compact output", default=False, action="store_true") + parser.add_argument("--verbose", help="Verbose output (use with --full-help for detailed help)", default=False, action="store_true") parser.add_argument("--print-completion", choices=["bash", "zsh", "tcsh"], help="Print shell completion script for the given shell") return parser def get_parser(): parser = build_parser() - client = None - client = drovecli.DroveCli(parser) - return client.parser - - -def run(): - parser = build_parser() - client = None - try: - client = drovecli.DroveCli(parser) - client.run() - except (BrokenPipeError, IOError, KeyboardInterrupt): - pass - except droveclient.DroveException as e: - debug = True if client is not None and client.debug else False - droveutils.print_drove_error(e, debug) - if debug: - traceback.print_exc() - - except Exception as e: - print("Drove CLI error: " + str(e)) - debug = True if client is not None and client.debug else False - if debug: - traceback.print_exc() - else: - parser.print_help() - -if __name__ == '__main__': - run() - - -def get_parser(): - parser = build_parser() - client = None client = drovecli.DroveCli(parser) return client.parser diff --git a/drovecli.py b/drovecli.py index 344fd19..48b7810 100644 --- a/drovecli.py +++ b/drovecli.py @@ -1,5 +1,7 @@ import argparse +import os import droveclient +import droveutils from plugins import DrovePlugin from types import SimpleNamespace import shtab @@ -32,6 +34,99 @@ def _print_full_help(parser: argparse.ArgumentParser) -> None: DroveCli._print_full_help(subparser) break + @staticmethod + def _print_compact_help(parser: argparse.ArgumentParser) -> None: + """Print a compact one-line-per-command reference for LLM consumption.""" + # Print global options from the root parser + global_opts = DroveCli._format_opts(parser, skip={"full_help", "compact", "verbose", "print_completion"}) + print(f"drove {global_opts}") + + # Walk the parser tree and collect leaf commands grouped by top-level plugin + lines_by_group: dict[str, list[str]] = {} + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + for group_name, group_parser in sorted(action.choices.items()): + lines_by_group[group_name] = [] + DroveCli._collect_compact_lines(group_name, group_parser, lines_by_group[group_name]) + break + + for group_name, lines in lines_by_group.items(): + print() + for line in lines: + print(line) + + @staticmethod + def _collect_compact_lines(path: str, parser: argparse.ArgumentParser, lines: list[str]) -> None: + """Recursively collect compact lines for a parser into the lines list.""" + # Check if this parser has sub-parsers (i.e., it's a group, not a leaf) + sub_action = None + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + sub_action = action + break + + if sub_action is not None: + # This is a group — recurse into its sub-commands + for name, subparser in sorted(sub_action.choices.items()): + DroveCli._collect_compact_lines(f"{path} {name}", subparser, lines) + else: + # This is a leaf command — build one compact line + positionals = DroveCli._format_positionals(parser) + opts = DroveCli._format_opts(parser) + parts = [path] + if positionals: + parts.append(positionals) + if opts: + parts.append(opts) + lines.append(" ".join(parts)) + + @staticmethod + def _format_positionals(parser: argparse.ArgumentParser) -> str: + """Format positional arguments as or .""" + parts = [] + for action in parser._actions: + if isinstance(action, argparse._SubParsersAction): + continue + if action.option_strings: + continue # skip optionals + if isinstance(action, argparse._HelpAction): + continue + metavar = action.metavar or action.dest + if action.nargs in ("+", "*"): + parts.append(f"<{metavar}...>") + else: + parts.append(f"<{metavar}>") + return " ".join(parts) + + @staticmethod + def _format_opts(parser: argparse.ArgumentParser, skip: set = None) -> str: + """Format optional arguments as [-short VAL] compact notation.""" + skip = skip or set() + parts = [] + for action in parser._actions: + if isinstance(action, argparse._HelpAction): + continue + if not action.option_strings: + continue # skip positionals + if action.dest in skip: + continue + + # Pick the shortest flag (prefer -f over --file) + flags = sorted(action.option_strings, key=len) + flag = flags[0] + + if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction, argparse._CountAction)): + parts.append(f"[{flag}]") + else: + metavar = action.metavar or action.dest.upper() + if isinstance(metavar, tuple): + metavar = " ".join(metavar) + if action.required: + parts.append(f"{flag} {metavar}") + else: + parts.append(f"[{flag} {metavar}]") + return " ".join(parts) + def run(self): args = self.parser.parse_args() self.debug = args.debug @@ -41,9 +136,16 @@ def run(self): exit(0) if args.full_help: - self._print_full_help(self.parser) + if args.verbose: + self._print_full_help(self.parser) + else: + self._print_compact_help(self.parser) exit(0) + # Set compact mode for data output + compact = args.compact or os.environ.get("DROVE_COMPACT", "") == "1" + droveutils.set_compact(compact) + # Load plugins if args.debug: print("Selected plugin: " + args.plugin) diff --git a/droveutils.py b/droveutils.py index 9cc48df..979b97f 100644 --- a/droveutils.py +++ b/droveutils.py @@ -4,7 +4,24 @@ import tabulate import time +# --------------------------------------------------------------------------- +# Compact mode state +# --------------------------------------------------------------------------- +_compact_mode = False + +def is_compact() -> bool: + """Check if compact output mode is active.""" + return _compact_mode + +def set_compact(val: bool) -> None: + """Set compact output mode (called by DroveCli at dispatch time).""" + global _compact_mode + _compact_mode = val + def print_dict(data: dict, level: int = 0): + if is_compact() and level == 0: + _print_dict_compact(data, "") + return for key, value in data.items(): print(level * 4 * " ", end='') if type(value) is dict and not len(dict(value)) == 0: @@ -18,17 +35,55 @@ def print_dict(data: dict, level: int = 0): else: print(f"{key: <30}{value}") +def _print_dict_compact(data: dict, prefix: str): + """Print dict as key=value lines, flattening nested dicts with dot notation.""" + for key, value in data.items(): + full_key = f"{prefix}.{key}" if prefix else str(key) + if isinstance(value, dict) and len(value) > 0: + _print_dict_compact(value, full_key) + elif isinstance(value, list) and all(isinstance(n, dict) for n in value): + for i, item in enumerate(value): + _print_dict_compact(item, f"{full_key}[{i}]") + else: + print(f"{full_key}={value}") + def print_json(data: dict): - print(json.dumps(data, indent = 4)) + if is_compact(): + print(json.dumps(data, separators=(',', ':'))) + else: + print(json.dumps(data, indent = 4)) def print_table(headers: list, data: list): - print(tabulate.tabulate(data, headers=headers)) + if is_compact(): + print("\t".join(str(h) for h in headers)) + for row in data: + print("\t".join(str(c) for c in row)) + else: + print(tabulate.tabulate(data, headers=headers)) def print_dict_table(data: dict, headers: list = None): - if headers: - print(tabulate.tabulate(data, headers=headers)) + if is_compact(): + if headers: + header_keys = list(headers.keys()) if isinstance(headers, dict) else headers + header_names = list(headers.values()) if isinstance(headers, dict) else headers + print("\t".join(str(h) for h in header_names)) + if isinstance(data, list): + for row in data: + print("\t".join(str(row.get(k, "")) for k in header_keys)) + elif isinstance(data, dict): + for row in data.values() if isinstance(data, dict) else data: + print("\t".join(str(row.get(k, "") if isinstance(row, dict) else row) for k in header_keys)) + else: + if isinstance(data, list) and len(data) > 0: + keys = list(data[0].keys()) if isinstance(data[0], dict) else [] + print("\t".join(keys)) + for row in data: + print("\t".join(str(row.get(k, "")) for k in keys)) else: - print(tabulate.tabulate(data, headers="keys")) + if headers: + print(tabulate.tabulate(data, headers=headers)) + else: + print(tabulate.tabulate(data, headers="keys")) def to_date(epoch: int) -> str: date = datetime.datetime.fromtimestamp(epoch/1000) diff --git a/tests/test_offline_full_help.py b/tests/test_offline_full_help.py index 7d46422..3e54a5a 100644 --- a/tests/test_offline_full_help.py +++ b/tests/test_offline_full_help.py @@ -1,17 +1,14 @@ """ tests/test_offline_full_help.py — offline tests for the ``--full-help`` flag. -``drove --full-help`` recursively prints the argparse help for the root parser -and every sub-command parser in a single pass. It must: +``drove --full-help`` now prints **compact** output by default (one line per +command) for LLM/token-optimised consumption. The verbose argparse output is +available via ``drove --full-help --verbose``. -* Exit with code 0. -* Require no live cluster (works without DROVE_ENDPOINT / ~/.drove). -* Print exactly 80 ``=``-separator sections (1 root + 79 command/sub-command - parsers). -* Cover every top-level plugin group and representative sub-commands. +Tests are split into two groups: -All tests use the mock server fixture only to inherit the offline_env -environment; ``--full-help`` itself never issues any HTTP request. +1. **TestCompactHelp** — validates the new compact default. +2. **TestVerboseHelp*** — validates the ``--verbose`` mode (original behavior). Run with: pytest -m offline tests/test_offline_full_help.py """ @@ -26,7 +23,7 @@ SEPARATOR = "=" * 72 EXPECTED_SECTIONS = 80 # 1 root + 9 plugin groups + ~70 sub-commands -EXPECTED_LINES = 987 # total line count of full-help output +EXPECTED_LINES = 981 # total line count of verbose full-help output # All top-level plugin groups that must appear in the output TOP_LEVEL_GROUPS = [ @@ -83,13 +80,16 @@ # ── helpers ────────────────────────────────────────────────────────────────── -def _run_full_help(extra_env: dict | None = None) -> subprocess.CompletedProcess: +def _run_full_help(extra_args: list | None = None, extra_env: dict | None = None) -> subprocess.CompletedProcess: """Invoke ``drove --full-help`` as a subprocess and return the result.""" + cmd = [sys.executable, "drove.py", "--full-help"] + if extra_args: + cmd.extend(extra_args) env = os.environ.copy() if extra_env: env.update(extra_env) return subprocess.run( - [sys.executable, "drove.py", "--full-help"], + cmd, capture_output=True, text=True, env=env, @@ -97,63 +97,174 @@ def _run_full_help(extra_env: dict | None = None) -> subprocess.CompletedProcess ) -# ── test classes ───────────────────────────────────────────────────────────── +# ── compact help tests (new default) ──────────────────────────────────────── -class TestFullHelpExitAndBasics: - """Basic sanity checks: exit code, non-empty output, line count.""" +class TestCompactHelp: + """Validate the compact one-line-per-command output (default mode).""" - def test_exit_code_zero(self, offline_env): + def test_compact_exit_code_zero(self, offline_env): result = _run_full_help() assert result.returncode == 0, ( f"--full-help exited with non-zero code {result.returncode}.\n" f"stderr: {result.stderr}" ) - def test_no_stderr_output(self, offline_env): + def test_compact_no_stderr_output(self, offline_env): result = _run_full_help() assert result.stderr.strip() == "", ( f"Unexpected stderr from --full-help:\n{result.stderr}" ) - def test_output_is_not_empty(self, offline_env): + def test_compact_output_is_not_empty(self, offline_env): result = _run_full_help() assert len(result.stdout.strip()) > 0, "--full-help produced no output" - def test_output_line_count(self, offline_env): + def test_compact_output_much_smaller_than_verbose(self, offline_env): + compact = _run_full_help() + verbose = _run_full_help(extra_args=["--verbose"]) + compact_size = len(compact.stdout) + verbose_size = len(verbose.stdout) + reduction = 1.0 - (compact_size / verbose_size) + assert reduction >= 0.80, ( + f"Compact output ({compact_size} chars) is not >=80% smaller than " + f"verbose ({verbose_size} chars). Reduction: {reduction:.1%}" + ) + + def test_compact_no_separator_bars(self, offline_env): + result = _run_full_help() + sep_lines = [l for l in result.stdout.splitlines() if l == SEPARATOR] + assert len(sep_lines) == 0, ( + f"Compact output should have no separator bars, found {len(sep_lines)}" + ) + + def test_compact_no_help_flag_noise(self, offline_env): + result = _run_full_help() + assert "--help" not in result.stdout, ( + "Compact output should not contain --help flag text" + ) + assert "show this help" not in result.stdout.lower(), ( + "Compact output should not contain help descriptions" + ) + + @pytest.mark.parametrize("group", TOP_LEVEL_GROUPS) + def test_compact_all_groups_present(self, offline_env, group): + result = _run_full_help() + assert group in result.stdout, ( + f"Top-level group '{group}' missing from compact --full-help output" + ) + + @pytest.mark.parametrize("group,subcommand", SUBCOMMAND_SAMPLES) + def test_compact_all_subcommands_present(self, offline_env, group, subcommand): result = _run_full_help() + expected = f"{group} {subcommand}" + assert expected in result.stdout, ( + f"Sub-command '{group} {subcommand}' missing from compact --full-help" + ) + + def test_compact_required_flags_not_bracketed(self, offline_env): + """Required flags must render without square brackets (e.g. -e ENDPOINT, not [-e ENDPOINT]).""" + result = _run_full_help() + lines = result.stdout.splitlines() + # config init has required --endpoint/-e + init_lines = [l for l in lines if l.startswith("config init")] + assert len(init_lines) == 1, f"Expected 1 'config init' line, found {len(init_lines)}" + line = init_lines[0] + assert "-e ENDPOINT" in line, f"Required -e ENDPOINT missing from: {line!r}" + assert "[-e ENDPOINT]" not in line, ( + f"Required flag -e ENDPOINT should NOT be in brackets: {line!r}" + ) + + def test_compact_optional_flags_are_bracketed(self, offline_env): + """Optional flags must render with square brackets.""" + result = _run_full_help() + lines = result.stdout.splitlines() + init_lines = [l for l in lines if l.startswith("config init")] + assert len(init_lines) == 1 + line = init_lines[0] + # -u USERNAME is optional in config init + assert "[-u USERNAME]" in line, ( + f"Optional flag -u should be bracketed in: {line!r}" + ) + + def test_compact_starts_with_global_opts(self, offline_env): + result = _run_full_help() + first_line = result.stdout.splitlines()[0] + assert first_line.startswith("drove "), ( + f"First line should start with 'drove ', got: {first_line!r}" + ) + assert "[-f" in first_line, "Global opts should include [-f ...]" + + def test_compact_works_without_drove_endpoint(self, offline_env): + clean_env = os.environ.copy() + clean_env.pop("DROVE_ENDPOINT", None) + clean_env.pop("DROVE_CLUSTER", None) + result = subprocess.run( + [sys.executable, "drove.py", "--full-help"], + capture_output=True, + text=True, + env=clean_env, + timeout=30, + ) + assert result.returncode == 0, ( + "--full-help should succeed even without DROVE_ENDPOINT.\n" + f"stderr: {result.stderr}" + ) + assert len(result.stdout.strip()) > 0 + + +# ── verbose help tests (--verbose flag, original behavior) ─────────────────── + +class TestVerboseHelpExitAndBasics: + """Basic sanity checks for --full-help --verbose: exit code, output, line count.""" + + def test_exit_code_zero(self, offline_env): + result = _run_full_help(extra_args=["--verbose"]) + assert result.returncode == 0, ( + f"--full-help --verbose exited with non-zero code {result.returncode}.\n" + f"stderr: {result.stderr}" + ) + + def test_no_stderr_output(self, offline_env): + result = _run_full_help(extra_args=["--verbose"]) + assert result.stderr.strip() == "", ( + f"Unexpected stderr from --full-help --verbose:\n{result.stderr}" + ) + + def test_output_is_not_empty(self, offline_env): + result = _run_full_help(extra_args=["--verbose"]) + assert len(result.stdout.strip()) > 0, "--full-help --verbose produced no output" + + def test_output_line_count(self, offline_env): + result = _run_full_help(extra_args=["--verbose"]) lines = result.stdout.splitlines() assert len(lines) == EXPECTED_LINES, ( f"Expected {EXPECTED_LINES} lines, got {len(lines)}" ) def test_works_without_drove_endpoint(self, offline_env): - """--full-help must not require a live cluster endpoint.""" - env_override = { - "DROVE_ENDPOINT": "", # explicitly blank - } - # Also strip DROVE_CLUSTER so ~/.drove is not consulted + """--full-help --verbose must not require a live cluster endpoint.""" clean_env = os.environ.copy() clean_env.pop("DROVE_ENDPOINT", None) - clean_env.pop("DROVE_CLUSTER", None) + clean_env.pop("DROVE_CLUSTER", None) result = subprocess.run( - [sys.executable, "drove.py", "--full-help"], + [sys.executable, "drove.py", "--full-help", "--verbose"], capture_output=True, text=True, env=clean_env, timeout=30, ) assert result.returncode == 0, ( - "--full-help should succeed even without DROVE_ENDPOINT.\n" + "--full-help --verbose should succeed even without DROVE_ENDPOINT.\n" f"stderr: {result.stderr}" ) assert len(result.stdout.strip()) > 0 -class TestFullHelpSeparators: - """Validate the ``=``×72 section separators.""" +class TestVerboseHelpSeparators: + """Validate the ``=``×72 section separators in verbose mode.""" def test_separator_count(self, offline_env): - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) sep_lines = [l for l in result.stdout.splitlines() if l == SEPARATOR] assert len(sep_lines) == EXPECTED_SECTIONS, ( f"Expected {EXPECTED_SECTIONS} separator lines, found {len(sep_lines)}" @@ -161,7 +272,7 @@ def test_separator_count(self, offline_env): def test_separator_is_72_equals(self, offline_env): """Each separator must be exactly 72 '=' characters.""" - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) sep_lines = [l for l in result.stdout.splitlines() if l.startswith("=")] for line in sep_lines: assert line == SEPARATOR, ( @@ -171,7 +282,7 @@ def test_separator_is_72_equals(self, offline_env): def test_separator_precedes_usage(self, offline_env): """Every ``usage:`` line must be immediately preceded by a separator.""" - lines = _run_full_help().stdout.splitlines() + lines = _run_full_help(extra_args=["--verbose"]).stdout.splitlines() for i, line in enumerate(lines): if line.startswith("usage: drove"): assert i > 0 and lines[i - 1] == SEPARATOR, ( @@ -181,63 +292,62 @@ def test_separator_precedes_usage(self, offline_env): ) -class TestFullHelpRootParser: - """Verify the root ``drove`` parser section is present and correct.""" +class TestVerboseHelpRootParser: + """Verify the root ``drove`` parser section is present and correct in verbose mode.""" def test_root_usage_present(self, offline_env): - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) assert "usage: drove " in result.stdout, ( - "Root 'usage: drove ...' line not found in --full-help output" + "Root 'usage: drove ...' line not found in --full-help --verbose output" ) def test_full_help_flag_self_documented(self, offline_env): """The --full-help option must document itself in the root section.""" - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) assert "--full-help" in result.stdout, ( "--full-help flag not found in its own help output" ) def test_full_help_description_present(self, offline_env): - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) assert "Show help for every command and sub-command" in result.stdout def test_root_positional_subcommands_listed(self, offline_env): """Root section must list available plugin groups.""" - result = _run_full_help() - # argparse lists subcommand choices in the root help + result = _run_full_help(extra_args=["--verbose"]) for group in ("apps", "tasks", "cluster"): assert group in result.stdout, ( f"Plugin group '{group}' not mentioned in root help section" ) -class TestFullHelpTopLevelGroups: - """Every top-level plugin group must have its own usage section.""" +class TestVerboseHelpTopLevelGroups: + """Every top-level plugin group must have its own usage section in verbose mode.""" @pytest.mark.parametrize("group", TOP_LEVEL_GROUPS) def test_group_usage_present(self, offline_env, group): - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) expected = f"usage: drove {group} " assert expected in result.stdout, ( - f"Top-level group '{group}' usage line missing from --full-help output" + f"Top-level group '{group}' usage line missing from --full-help --verbose" ) -class TestFullHelpSubCommands: - """Representative sub-commands must each have their own usage section.""" +class TestVerboseHelpSubCommands: + """Representative sub-commands must each have their own usage section in verbose mode.""" @pytest.mark.parametrize("group,subcommand", SUBCOMMAND_SAMPLES) def test_subcommand_usage_present(self, offline_env, group, subcommand): - result = _run_full_help() + result = _run_full_help(extra_args=["--verbose"]) expected = f"usage: drove {group} {subcommand} " assert expected in result.stdout, ( f"Sub-command 'drove {group} {subcommand}' usage line missing " - f"from --full-help output" + f"from --full-help --verbose output" ) -class TestFullHelpOrdering: - """Sub-commands within a group must appear in alphabetical order.""" +class TestVerboseHelpOrdering: + """Sub-commands within a group must appear in alphabetical order in verbose mode.""" def _usage_lines_for_group(self, output: str, group: str) -> list[str]: """Return usage lines whose second token is `group`.""" @@ -247,16 +357,15 @@ def _usage_lines_for_group(self, output: str, group: str) -> list[str]: ] def test_apps_subcommands_alphabetical(self, offline_env): - output = _run_full_help().stdout + output = _run_full_help(extra_args=["--verbose"]).stdout usage_lines = self._usage_lines_for_group(output, "apps") - # Extract sub-command names (4th token: "usage: drove apps ") subcmds = [l.split()[3] for l in usage_lines if len(l.split()) >= 4] assert subcmds == sorted(subcmds), ( f"apps sub-commands not alphabetically ordered: {subcmds}" ) def test_appinstances_subcommands_alphabetical(self, offline_env): - output = _run_full_help().stdout + output = _run_full_help(extra_args=["--verbose"]).stdout usage_lines = self._usage_lines_for_group(output, "appinstances") subcmds = [l.split()[3] for l in usage_lines if len(l.split()) >= 4] assert subcmds == sorted(subcmds), ( @@ -264,7 +373,7 @@ def test_appinstances_subcommands_alphabetical(self, offline_env): ) def test_tasks_subcommands_alphabetical(self, offline_env): - output = _run_full_help().stdout + output = _run_full_help(extra_args=["--verbose"]).stdout usage_lines = self._usage_lines_for_group(output, "tasks") subcmds = [l.split()[3] for l in usage_lines if len(l.split()) >= 4] assert subcmds == sorted(subcmds), ( @@ -272,7 +381,7 @@ def test_tasks_subcommands_alphabetical(self, offline_env): ) def test_describe_subcommands_alphabetical(self, offline_env): - output = _run_full_help().stdout + output = _run_full_help(extra_args=["--verbose"]).stdout usage_lines = self._usage_lines_for_group(output, "describe") subcmds = [l.split()[3] for l in usage_lines if len(l.split()) >= 4] assert subcmds == sorted(subcmds), ( @@ -280,32 +389,29 @@ def test_describe_subcommands_alphabetical(self, offline_env): ) -class TestFullHelpMutualExclusion: - """--full-help and normal commands must be mutually exclusive.""" +class TestVerboseHelpMutualExclusion: + """--full-help --verbose and normal commands must be mutually exclusive.""" def test_full_help_with_subcommand_still_prints_help(self, offline_env): - """Passing --full-help alongside a sub-command should still print help - (argparse evaluates --full-help before dispatching to sub-parsers).""" + """Passing --full-help --verbose alongside a sub-command should still print help.""" result = subprocess.run( - [sys.executable, "drove.py", "--full-help", "apps", "list"], + [sys.executable, "drove.py", "--full-help", "--verbose", "apps", "list"], capture_output=True, text=True, timeout=30, ) - # Should still succeed and output help, not execute the sub-command assert result.returncode == 0 assert "usage: drove " in result.stdout def test_full_help_does_not_output_tabular_app_list(self, offline_env): - """Full-help output must not contain an actual apps listing table.""" + """Full-help --verbose output must not contain an actual apps listing table.""" result = subprocess.run( - [sys.executable, "drove.py", "--full-help"], + [sys.executable, "drove.py", "--full-help", "--verbose"], capture_output=True, text=True, timeout=30, env={**os.environ, "DROVE_ENDPOINT": offline_env.endpoint}, ) - # The real apps list contains "TEST_APP"; help output should not assert "TEST_APP" not in result.stdout, ( - "full-help output appears to contain live API data" + "full-help --verbose output appears to contain live API data" ) From 58ec79dec9509641d91ed02dfde89c012d2c8b9a Mon Sep 17 00:00:00 2001 From: Prajwalprakash3722 Date: Sun, 12 Apr 2026 13:19:10 +0530 Subject: [PATCH 2/2] relax line count assert for CI tests --- tests/test_offline_full_help.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_offline_full_help.py b/tests/test_offline_full_help.py index 3e54a5a..fbce8a8 100644 --- a/tests/test_offline_full_help.py +++ b/tests/test_offline_full_help.py @@ -23,7 +23,8 @@ SEPARATOR = "=" * 72 EXPECTED_SECTIONS = 80 # 1 root + 9 plugin groups + ~70 sub-commands -EXPECTED_LINES = 981 # total line count of verbose full-help output +MIN_EXPECTED_LINES = 975 # lower bound for verbose full-help line count +MAX_EXPECTED_LINES = 1000 # upper bound (argparse wrapping varies by Python version) # All top-level plugin groups that must appear in the output TOP_LEVEL_GROUPS = [ @@ -237,8 +238,8 @@ def test_output_is_not_empty(self, offline_env): def test_output_line_count(self, offline_env): result = _run_full_help(extra_args=["--verbose"]) lines = result.stdout.splitlines() - assert len(lines) == EXPECTED_LINES, ( - f"Expected {EXPECTED_LINES} lines, got {len(lines)}" + assert MIN_EXPECTED_LINES <= len(lines) <= MAX_EXPECTED_LINES, ( + f"Expected {MIN_EXPECTED_LINES}–{MAX_EXPECTED_LINES} lines, got {len(lines)}" ) def test_works_without_drove_endpoint(self, offline_env):