diff --git a/.changes/unreleased/added-20261026-103451.yaml b/.changes/unreleased/added-20261026-103451.yaml new file mode 100644 index 000000000..73de8f04b --- /dev/null +++ b/.changes/unreleased/added-20261026-103451.yaml @@ -0,0 +1,6 @@ +kind: added +body: Add machine-readable JSON help for root, group, and leaf commands with offline help discovery and a versioned schema +time: 2026-10-26T10:34:51.302706323Z +custom: + Author: aviatco + AuthorLink: https://github.com/aviatco diff --git a/docs/essentials/output_format.md b/docs/essentials/output_format.md index 39c9d118c..8ef302f4e 100644 --- a/docs/essentials/output_format.md +++ b/docs/essentials/output_format.md @@ -72,6 +72,62 @@ The configuration is stored in your Fabric CLI configuration file and applies to ## JSON Output Schema +### help in JSON format + +Use JSON help to discover commands, arguments, and flags for scripts and automation. + +```bash +# Root help +fab --help --output_format json + +# Command help +fab mkdir -h --output_format json + +# Subcommand help +fab job run --help --output_format json + +# Equivalent shorthand +fab job run --help --json + +# Help accepts combined short flags +fab ls -lh --output_format json +``` + +In interactive mode, omit the `fab` prefix: + +```text +help --output_format json +help --output_format text +``` + +JSON help follows this response structure. Array contents depend on the selected +command; root help lists available commands and global flags. + +```json +{ + "timestamp": "", + "status": "Success", + "command": "", + "result": { + "data": [ + { + "schema_version": "1.0", + "cli_version": "", + "description": "", + "usage": "fab [flags]", + "args": [], + "flags": [], + "aliases": [], + "subcommands": [], + "examples": [], + "notes": [], + "learn_more": [] + } + ] + } +} +``` + ### Success Response Structure ```json { diff --git a/docs/essentials/parameters.md b/docs/essentials/parameters.md index c253d7468..1a26dd7ea 100644 --- a/docs/essentials/parameters.md +++ b/docs/essentials/parameters.md @@ -14,6 +14,16 @@ For command-specific parameters: fab --help ``` +For machine-readable help, add `--output_format json`: + +```bash +fab job run --help --output_format json +``` + +`--help`, `-h`, and `-help` support JSON and text. See +[Json format help](output_format.md#help-in-json-format) for the response +contract, format precedence, and offline behavior. + For a complete list of commands, see the [Commands page](../commands/index.md). @@ -61,6 +71,7 @@ For a complete list of commands, see the [Commands page](../commands/index.md). | `--interval` | Specify the interval for `job` schedule | | `--json` | Use JSON output (same as `--output_format json`) | | `--mode` | Specify the operational mode for `table load` | +| `--output_format` | Select `json` or `text` for results and help | | `--retain_n_hours` | Retain specified hours for `table vacuum` | | `--schedule` | Set the job schedule for `job` | | `--show_headers` | Turn headers on for `api` | diff --git a/src/fabric_cli/core/fab_help.py b/src/fabric_cli/core/fab_help.py new file mode 100644 index 000000000..a2cd0fec3 --- /dev/null +++ b/src/fabric_cli/core/fab_help.py @@ -0,0 +1,313 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Resolve help controls and describe registered parsers without executing commands.""" + +import argparse +from copy import copy +from dataclasses import dataclass, field +from typing import Any, Iterator, Optional + +from fabric_cli import __version__ + + +@dataclass +class HelpRequest: + """A help target and its explicit selectors, before output-format resolution.""" + + parser: argparse.ArgumentParser + selectors: list[str] = field(default_factory=list) + error: Optional[str] = None + + +def child_parsers( + parser: argparse.ArgumentParser, +) -> Iterator[tuple[str, argparse.ArgumentParser, list[str], str]]: + """Yield immediate canonical children once, with aliases and descriptions.""" + for action in parser._actions: + if not isinstance(action, argparse._SubParsersAction): + continue + seen: set[int] = set() + descriptions = {entry.dest: entry.help for entry in action._get_subactions()} + for name, child in action.choices.items(): + if id(child) in seen: + continue + seen.add(id(child)) + aliases = [ + alias + for alias, candidate in action.choices.items() + if candidate is child and alias != name + ] + yield name, child, aliases, descriptions.get( + name + ) or child.description or name + + +def canonical_command(parser: argparse.ArgumentParser) -> str: + """Return the canonical command name used in help response envelopes.""" + parts = parser.prog.split() + if parts and parts[0] == "fab": + parts = parts[1:] + return " ".join(parts) or "fab" + + +def _is_option(token: str) -> bool: + if not token.startswith("-") or token == "-": + return False + try: + float(token) + except ValueError: + return True + return False + + +def resolve_help( + parser: argparse.ArgumentParser, tokens: list[str] +) -> Optional[HelpRequest]: + """Find help while respecting registered option arity and literal value boundaries. + + Operation requiredness, choices and value validators deliberately do not run here. + Structural errors are retained only if the request actually asks for help. + """ + request = HelpRequest(parser) + current = parser + found = False + index = 0 + positional_index = 0 + while index < len(tokens): + token = tokens[index] + if token == "--": + break + options = current._option_string_actions + spelling, separator, value = token.partition("=") + attached: Optional[str] = value + action = options.get(spelling) + if action is None and _is_option(token): + matches = current._get_option_tuples(token) + if len(matches) > 1: + request.error = "An ambiguous option was supplied." + index += 1 + continue + if matches: + action, spelling = matches[0][:2] + # argparse versions differ in tuple size; the explicit value is last. + attached = matches[0][-1] + separator = "=" if attached is not None else "" + if ( + len(spelling) == 2 + and token.startswith(spelling) + and not token.startswith(f"{spelling}=") + ): + while action is not None and action.nargs == 0 and attached: + if isinstance(action, argparse._HelpAction): + found = True + spelling = f"-{attached[0]}" + attached = attached[1:] or None + separator = "=" if attached is not None else "" + action = options.get(spelling) + if action is not None: + if isinstance(action, argparse._HelpAction): + found = True + if separator: + request.error = "A help flag does not accept a value." + index += 1 + continue + if action.nargs == 0: + if action.dest == "output_format": + request.selectors.append(action.const) + if separator: + request.error = "A boolean flag does not accept a value." + index += 1 + continue + values = [attached] if separator and attached is not None else [] + index += 1 + nargs = action.nargs + limit = ( + 1 if nargs in (None, "?") else nargs if isinstance(nargs, int) else None + ) + while not separator and index < len(tokens): + value = tokens[index] + if limit is not None and len(values) >= limit: + break + if value == "--" or ( + _is_option(value) and spelling not in ("-c", "--command") + ): + break + values.append(value) + index += 1 + minimum = ( + 0 if nargs in ("?", "*") else nargs if isinstance(nargs, int) else 1 + ) + if len(values) < minimum: + request.error = "A supplied option is missing its value." + if action.dest == "output_format": + request.selectors.extend(values) + continue + if _is_option(token): + request.error = "An unknown option was supplied." + index += 1 + continue + children = [ + (name, child, aliases) for name, child, aliases, _ in child_parsers(current) + ] + child = next( + (child for name, child, aliases in children if token in [name, *aliases]), + None, + ) + if children: + if child is None or found: + request.error = ( + "An unknown help target or unexpected argument was supplied." + ) + else: + current = child + request.parser = child + positional_index = 0 + else: + positionals = [ + action for action in current._actions if not action.option_strings + ] + if positional_index >= len(positionals): + request.error = "An unexpected argument was supplied." + elif positionals[positional_index].nargs not in ("+", "*"): + positional_index += 1 + index += 1 + return request if found else None + + +def _describe_action(action: argparse.Action) -> dict[str, Any]: + boolean = isinstance( + action, + (argparse._HelpAction, argparse._StoreConstAction), + ) + value_type = ( + "boolean" + if boolean + else ( + "integer" + if action.type is int + else "number" if action.type is float else "string" + ) + ) + metadata = getattr(action, "fab_help", {}) + entry: dict[str, Any] = { + "name": action.dest, + "type": value_type, + "required": bool(action.required), + "description": action.help or f"Value for {action.dest.replace('_', ' ')}.", + } + if action.option_strings: + name = next( + (option for option in action.option_strings if option.startswith("--")), + action.option_strings[0], + ) + entry["name"] = name + entry["aliases"] = [ + option for option in action.option_strings if option != name + ] + if not boolean: + entry["repeat_behavior"] = ( + "append" if isinstance(action, argparse._AppendAction) else "replace" + ) + if action.nargs in ("?", "*"): + entry["value_optional"] = True + if action.choices is not None: + entry["choices"] = list(action.choices) + if ( + isinstance(action.default, (str, int, float, bool)) + and action.default != argparse.SUPPRESS + ): + entry["default"] = action.default + entry.update(metadata) + return entry + + +def build_help_data(parser: argparse.ArgumentParser) -> dict[str, Any]: + """Generate the versioned public help payload from a registered parser.""" + command = canonical_command(parser) + args: list[dict[str, Any]] = [] + flags: list[dict[str, Any]] = [] + required_flags: list[str] = [] + formatter = argparse.HelpFormatter(parser.prog) + help_spellings: list[str] = [] + for action in parser._actions: + if ( + isinstance(action, argparse._SubParsersAction) + or action.help == argparse.SUPPRESS + ): + continue + if isinstance(action, argparse._HelpAction): + help_spellings.extend(action.option_strings) + continue + entry = _describe_action(action) + (flags if action.option_strings else args).append(entry) + if action.option_strings and action.required: + required_flag = entry["name"] + if action.nargs != 0: + usage_action = copy(action) + usage_action.metavar = action.metavar or f"<{action.dest}>" + required_flag += f" {formatter._format_args(usage_action, action.dest)}" + required_flags.append(required_flag) + if help_spellings: + flags.insert( + 0, + { + "name": "--help", + "aliases": [ + spelling for spelling in help_spellings if spelling != "--help" + ], + "type": "boolean", + "required": False, + "description": "Show command help.", + }, + ) + examples: list[dict[str, str]] = [] + description = "Example invocation." + for raw in getattr(parser, "fab_examples", []): + example = raw.strip() + if example.startswith("#"): + description = example.lstrip("#").strip() + elif example: + invocation = example.removeprefix("$").strip() + if "interactive mode" in description.lower(): + invocation = invocation.removeprefix("fab ") + elif not invocation.startswith("fab "): + invocation = f"fab {invocation}" + examples.append({"description": description, "command": invocation}) + notes = list(getattr(parser, "fab_notes", [])) + links = ["https://aka.ms/fabric-cli"] + for item in getattr(parser, "fab_learnmore", []): + if item.startswith(("https://", "http://")): + if item not in links: + links.append(item) + elif item != "_": + notes.append(item) + path = "" if command == "fab" else f" {command}" + usage_args = " ".join( + f"<{entry['name']}>" if entry["required"] else f"[<{entry['name']}>]" + for entry in args + ) + children = list(child_parsers(parser)) + suffix = " " if children else "" + if usage_args: + suffix += f" {usage_args}" + if required_flags: + suffix += f" {' '.join(required_flags)}" + return { + "schema_version": "1.0", + "cli_version": __version__, + "description": parser.description + or getattr(parser, "fab_description", None) + or command, + "usage": f"fab{path}{suffix} [flags]", + "args": args, + "flags": flags, + "aliases": list(getattr(parser, "fab_aliases", [])), + "subcommands": [ + {"name": name, "aliases": aliases, "description": description} + for name, _, aliases, description in children + ], + "examples": examples, + "notes": notes, + "learn_more": links, + } diff --git a/src/fabric_cli/core/fab_interactive.py b/src/fabric_cli/core/fab_interactive.py index fc2cc6b4a..5c5bdb580 100644 --- a/src/fabric_cli/core/fab_interactive.py +++ b/src/fabric_cli/core/fab_interactive.py @@ -13,7 +13,7 @@ from fabric_cli.core.fab_commands import Command from fabric_cli.core.fab_context import Context from fabric_cli.core.fab_decorators import singleton -from fabric_cli.utils import fab_commands +from fabric_cli.core.fab_help import resolve_help from fabric_cli.utils import fab_ui as utils_ui from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers @@ -45,18 +45,19 @@ def init_session(self, session_history: InMemoryHistory) -> PromptSession: def handle_command(self, command): """Process the user command.""" - fab_logger.print_log_file_path() - command_parts = shlex.split(command.strip()) + if command_parts and command_parts[0] in fab_constant.INTERACTIVE_HELP_COMMANDS: + command_parts = ["--help", *command_parts[1:]] + help_request = resolve_help(self.parser, command_parts) + if help_request is not None: + utils_ui.print_help_request(help_request) + return False + + fab_logger.print_log_file_path() if command in fab_constant.INTERACTIVE_QUIT_COMMANDS: utils_ui.print(fab_constant.INTERACTIVE_EXIT_MESSAGE) return True - elif command in fab_constant.INTERACTIVE_HELP_COMMANDS: - utils_ui.display_help( - fab_commands.COMMANDS, "Usage: [flags]" - ) - return False elif command in fab_constant.INTERACTIVE_VERSION_COMMANDS: utils_ui.print_version() return False diff --git a/src/fabric_cli/core/fab_parser_setup.py b/src/fabric_cli/core/fab_parser_setup.py index 513b0924e..156fad40f 100644 --- a/src/fabric_cli/core/fab_parser_setup.py +++ b/src/fabric_cli/core/fab_parser_setup.py @@ -4,13 +4,13 @@ import argparse import re import sys -from collections.abc import Iterable from functools import partial from typing import Any import argcomplete from fabric_cli.core import fab_constant, fab_logger +from fabric_cli.core.fab_help import resolve_help from fabric_cli.parsers import fab_acls_parser as acls_parser from fabric_cli.parsers import fab_api_parser as api_parser from fabric_cli.parsers import fab_auth_parser as auth_parser @@ -120,6 +120,7 @@ def __init__( fab_examples=None, fab_aliases=None, fab_learnmore=None, + fab_notes=None, output_format_default: str | None = None, **kwargs, ): @@ -137,6 +138,8 @@ def __init__( self.fab_mode = fab_constant.FAB_MODE_COMMANDLINE self.fab_examples = fab_examples or [] self.fab_aliases = fab_aliases or [] + self.fab_learnmore = fab_learnmore or [] + self.fab_notes = fab_notes or [] def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: """Keep child defaults from overwriting an explicit output format.""" @@ -146,34 +149,45 @@ def add_subparsers(self, **kwargs: Any) -> argparse._SubParsersAction: ) return super().add_subparsers(**kwargs) - def parse_args( - self, - args: Iterable[str] | None = None, - namespace: Any = None, - ) -> Any: - """Supply the output format fallback when parsing any parser directly.""" - parsed_args = super().parse_args(args, namespace) - if not hasattr(parsed_args, "output_format"): - setattr(parsed_args, "output_format", None) - return parsed_args + def parse_args(self, args=None, namespace=None): + tokens = list(sys.argv[1:] if args is None else args) + request = resolve_help(self, tokens) + if request is not None: + self.exit(fab_ui.print_help_request(request)) + parsed = super().parse_args(tokens, namespace) + if not hasattr(parsed, "output_format"): + setattr(parsed, "output_format", None) + return parsed def print_help(self, file=None): + from fabric_cli.core.fab_help import HelpRequest + + fab_ui.print_help_request(HelpRequest(self)) + + def print_text_help(self): command_name = self.prog.split()[-1] help_functions = { - "acl": lambda: acls_parser.show_help(None), - "job": lambda: jobs_parser.show_help(None), - "label": lambda: labels_parser.show_help(None), - "table": lambda: tables_parser.show_help(None), - "auth": lambda: auth_parser.show_help(None), - "config": lambda: config_parser.show_help(None), - "fab": lambda: fab_ui.display_help(COMMANDS), + "acl": lambda: acls_parser.show_help(argparse.Namespace()), + "job": lambda: jobs_parser.show_help(argparse.Namespace()), + "label": lambda: labels_parser.show_help(argparse.Namespace()), + "table": lambda: tables_parser.show_help(argparse.Namespace()), + "auth": lambda: auth_parser.show_help(argparse.Namespace()), + "config": lambda: config_parser.show_help(argparse.Namespace()), + "fab": lambda: fab_ui.display_help( + COMMANDS, + custom_header=( + "Usage: [flags]" + if self.fab_mode == fab_constant.FAB_MODE_INTERACTIVE + else None + ), + ), } if command_name in help_functions: help_functions[command_name]() else: - super().print_help(file) + super().print_help() def set_mode(self, mode): self.fab_mode = mode @@ -200,9 +214,12 @@ def error(self, message): _global_parser = None _global_subparsers = None + def create_parser_and_subparsers(): """Create parser and subparsers for reuse across CLI modes""" - parser = CustomArgumentParser(description="Fabric CLI") + parser = CustomArgumentParser( + prog="fab", description="Work seamlessly with Fabric from the command line." + ) # -c option for command line execution parser.add_argument( @@ -213,7 +230,9 @@ def create_parser_and_subparsers(): ) # -version and --version - parser.add_argument("-v", "--version", action="store_true") + parser.add_argument( + "-v", "--version", action="store_true", help="Show installed CLI version." + ) subparsers = parser.add_subparsers(dest="command", required=False) @@ -260,14 +279,23 @@ def create_parser_and_subparsers(): ) version_parser.set_defaults(func=fab_ui.print_version) + def retain_help_metadata(parent: argparse.ArgumentParser) -> None: + from fabric_cli.core.fab_help import child_parsers + + for _, child, aliases, description in child_parsers(parent): + setattr(child, "fab_description", description) + setattr(child, "fab_aliases", aliases) + retain_help_metadata(child) + + retain_help_metadata(parser) return parser, subparsers + def get_global_parser_and_subparsers(): """Get singleton parser and subparsers instances""" global _global_parser, _global_subparsers - + if _global_parser is None: _global_parser, _global_subparsers = create_parser_and_subparsers() - - return _global_parser, _global_subparsers + return _global_parser, _global_subparsers diff --git a/src/fabric_cli/core/fab_state_config.py b/src/fabric_cli/core/fab_state_config.py index a7f946a24..7500d188f 100644 --- a/src/fabric_cli/core/fab_state_config.py +++ b/src/fabric_cli/core/fab_state_config.py @@ -18,7 +18,7 @@ def config_location(): return _location -config_file = os.path.join(config_location(), "config.json") +config_file = os.path.join(expanduser("~/.config/fab/"), "config.json") def read_config(file_path) -> dict: @@ -32,6 +32,7 @@ def read_config(file_path) -> dict: def write_config(data): + create_restricted_dir(os.path.dirname(config_file)) write_restricted_file(config_file, json.dumps(data, indent=4)) diff --git a/src/fabric_cli/errors/common.py b/src/fabric_cli/errors/common.py index 3cfcf5976..140cafc9f 100644 --- a/src/fabric_cli/errors/common.py +++ b/src/fabric_cli/errors/common.py @@ -9,6 +9,21 @@ class CommonErrors: + @staticmethod + def invalid_help_request(reason: str) -> str: + """Describe an invalid help request without echoing argument values.""" + return f"Invalid help request: {reason}" + + @staticmethod + def invalid_help_format() -> str: + """Describe an unsupported help output format.""" + return "Help output format must be json or text" + + @staticmethod + def conflicting_help_formats() -> str: + """Describe conflicting explicit output selectors.""" + return "Conflicting help output formats were supplied" + @staticmethod def invalid_entries_format() -> str: return "Invalid entries format" @@ -260,7 +275,9 @@ def query_not_supported_for_set(query: str) -> str: @staticmethod def invalid_definition_format(valid_formats: list[str]) -> str: if valid_formats: - message = f"Only the following formats are supported: {', '.join(valid_formats)}" + message = ( + f"Only the following formats are supported: {', '.join(valid_formats)}" + ) else: message = "No formats are supported" return f"Invalid format. {message}" diff --git a/src/fabric_cli/main.py b/src/fabric_cli/main.py index 9aa5d6b3e..bc809f43c 100644 --- a/src/fabric_cli/main.py +++ b/src/fabric_cli/main.py @@ -3,19 +3,27 @@ import sys -import argcomplete -from fabric_cli.core import fab_constant, fab_logger, fab_state_config -from fabric_cli.core.fab_commands import Command -from fabric_cli.core.fab_exceptions import FabricCLIError -from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers -from fabric_cli.parsers import fab_auth_parser as auth_parser -from fabric_cli.utils import fab_ui +def main(): + from fabric_cli.utils import fab_ui + if sys.argv[1:] in (["--version"], ["-v"], ["version"]): + fab_ui.print_version() + return + + from fabric_cli.core.fab_help import resolve_help + from fabric_cli.core.fab_parser_setup import get_global_parser_and_subparsers -def main(): parser, subparsers = get_global_parser_and_subparsers() + help_request = resolve_help(parser, sys.argv[1:]) + if help_request is not None: + sys.exit(fab_ui.print_help_request(help_request)) + + import argcomplete + + from fabric_cli.core import fab_constant, fab_logger, fab_state_config + argcomplete.autocomplete(parser, default_completer=None) args = parser.parse_args() @@ -24,6 +32,8 @@ def main(): fab_state_config.init_defaults() if args.command == "auth" and args.auth_command == None: + from fabric_cli.parsers import fab_auth_parser as auth_parser + auth_parser.show_help(args) return @@ -95,6 +105,10 @@ def main(): def _handle_keyboard_interrupt(args): """Handle KeyboardInterrupt with proper error formatting.""" + from fabric_cli.core import fab_constant + from fabric_cli.core.fab_exceptions import FabricCLIError + from fabric_cli.utils import fab_ui + fab_ui.print_output_error( FabricCLIError( "Operation cancelled", @@ -107,6 +121,10 @@ def _handle_keyboard_interrupt(args): def _handle_unexpected_error(err, args): """Handle unexpected errors with proper error formatting.""" + from fabric_cli.core import fab_constant + from fabric_cli.core.fab_exceptions import FabricCLIError + from fabric_cli.utils import fab_ui + try: error_message = str(err.args[0]) if err.args else str(err) except: @@ -120,6 +138,8 @@ def _handle_unexpected_error(err, args): def _execute_command(args, subparsers, parser): + from fabric_cli.core.fab_commands import Command + if args.command in subparsers.choices: subparser_args = args subparser_args.command = args.command diff --git a/src/fabric_cli/parsers/fab_extension_parser.py b/src/fabric_cli/parsers/fab_extension_parser.py index 35876f2ee..882fdd242 100644 --- a/src/fabric_cli/parsers/fab_extension_parser.py +++ b/src/fabric_cli/parsers/fab_extension_parser.py @@ -8,7 +8,11 @@ def register_parser(subparsers: _SubParsersAction) -> None: parser = subparsers.add_parser( - "extension", help=fab_constant.COMMAND_EXTENSIONS_DESCRIPTION + "extension", + help=fab_constant.COMMAND_EXTENSIONS_DESCRIPTION, + fab_notes=[ + "This command is a registered placeholder and is not yet supported." + ], ) parser.set_defaults(func=show_help) diff --git a/src/fabric_cli/parsers/fab_find_parser.py b/src/fabric_cli/parsers/fab_find_parser.py index dc27cc2e5..adb687c14 100644 --- a/src/fabric_cli/parsers/fab_find_parser.py +++ b/src/fabric_cli/parsers/fab_find_parser.py @@ -5,10 +5,9 @@ from argparse import Namespace, _SubParsersAction -from fabric_cli.commands.find import fab_find as find from fabric_cli.utils import fab_error_parser as utils_error_parser from fabric_cli.utils import fab_ui as utils_ui - +from fabric_cli.utils.fab_lazy_load import lazy_command COMMAND_FIND_DESCRIPTION = "Search the OneLake catalog for items." @@ -75,7 +74,9 @@ def register_parser(subparsers: _SubParsersAction) -> None: ) parser.usage = f"{utils_error_parser.get_usage_prog(parser)}" - parser.set_defaults(func=find.find_command) + parser.set_defaults( + func=lazy_command("fabric_cli.commands.find.fab_find", "find_command") + ) def show_help(args: Namespace) -> None: diff --git a/src/fabric_cli/parsers/fab_fs_parser.py b/src/fabric_cli/parsers/fab_fs_parser.py index 4747e2127..b12dec58f 100644 --- a/src/fabric_cli/parsers/fab_fs_parser.py +++ b/src/fabric_cli/parsers/fab_fs_parser.py @@ -80,6 +80,10 @@ def register_mkdir_parser(subparsers: _SubParsersAction) -> None: fab_examples=mkdir_examples, fab_aliases=mkdir_aliases, fab_learnmore=mkdir_learnmore, + fab_notes=[ + "Bare -P shows target-specific parameter help instead of creating the resource.", + "Target-specific parameter names and allowed values are not included in static help.", + ], ) mkdir_parser.add_argument("path", nargs="+", type=str, help="Directory path") mkdir_parser.add_argument( @@ -169,6 +173,7 @@ def register_mv_parser(subparsers: _SubParsersAction) -> None: fab_examples=mv_examples, fab_aliases=mv_aliases, fab_learnmore=["_"], + fab_notes=["Quote complete source and destination paths containing spaces."], ) mv_parser.add_argument("from_path", nargs="+", type=str, help="Source path") mv_parser.add_argument("to_path", nargs="+", type=str, help="Target path") @@ -209,6 +214,7 @@ def register_cp_parser(subparsers: _SubParsersAction) -> None: fab_examples=cp_examples, fab_aliases=cp_aliases, fab_learnmore=["_"], + fab_notes=["Quote complete source and destination paths containing spaces."], ) cp_parser.add_argument("from_path", nargs="+", type=str, help="Source path") diff --git a/src/fabric_cli/parsers/fab_jobs_parser.py b/src/fabric_cli/parsers/fab_jobs_parser.py index 150f54079..37405c07b 100644 --- a/src/fabric_cli/parsers/fab_jobs_parser.py +++ b/src/fabric_cli/parsers/fab_jobs_parser.py @@ -47,6 +47,10 @@ def register_parser(subparsers: _SubParsersAction) -> None: help="Start an item", fab_examples=start_examples, fab_learnmore=["_"], + fab_notes=[ + "--input cannot be combined with --params or --config.", + "Target-specific parameter names and allowed values are not included in static help.", + ], ) start_parser.add_argument("path", nargs="+", help="Path to the item") start_parser.add_argument( @@ -75,12 +79,13 @@ def register_parser(subparsers: _SubParsersAction) -> None: help="Timeout in seconds (0 or greater). Requires --wait. Optional", type=int, ) - start_parser.add_argument( + polling_interval = start_parser.add_argument( "--polling_interval", metavar="", help="Custom job status polling interval in seconds. Requires --wait. Optional", type=validate_positive_int, ) + setattr(polling_interval, "fab_help", {"type": "integer", "minimum": 1}) start_parser.usage = f"{utils_error_parser.get_usage_prog(start_parser)}" start_parser.set_defaults(func=lazy_command(_jobs_module_path, 'run_command')) @@ -102,6 +107,11 @@ def register_parser(subparsers: _SubParsersAction) -> None: help="Run an item", fab_examples=run_examples, fab_learnmore=["_"], + fab_notes=[ + "--input cannot be combined with --params or --config.", + "When both are supplied, --polling_interval must be below --timeout.", + "Target-specific parameter names and allowed values are not included in static help.", + ], ) run_parser.add_argument("path", nargs="+", help="Path to the item") run_parser.add_argument( @@ -124,13 +134,14 @@ def register_parser(subparsers: _SubParsersAction) -> None: run_parser.add_argument( "--timeout", metavar="", help="Timeout in seconds. Optional", type=int ) - run_parser.add_argument( + polling_interval = run_parser.add_argument( "--polling_interval", metavar="", dest="polling_interval", help="Custom job status polling interval in seconds. Optional", type=validate_positive_int, ) + setattr(polling_interval, "fab_help", {"type": "integer", "minimum": 1}) run_parser.usage = f"{utils_error_parser.get_usage_prog(run_parser)}" run_parser.set_defaults(func=lazy_command(_jobs_module_path, 'run_command')) diff --git a/src/fabric_cli/utils/fab_ui.py b/src/fabric_cli/utils/fab_ui.py index 1ff741b58..992a5a0d0 100644 --- a/src/fabric_cli/utils/fab_ui.py +++ b/src/fabric_cli/utils/fab_ui.py @@ -3,11 +3,12 @@ import builtins import html +import json import shutil import sys import unicodedata from argparse import Namespace -from typing import Any, Optional, Sequence +from typing import TYPE_CHECKING, Any, Optional, Sequence from fabric_cli import __version__ from fabric_cli.core import fab_constant, fab_state_config @@ -17,6 +18,9 @@ from fabric_cli.utils import fab_lazy_load from fabric_cli.utils import fab_util +if TYPE_CHECKING: + from fabric_cli.core.fab_help import HelpRequest + def get_common_style(): return fab_lazy_load.questionary().Style( @@ -102,7 +106,8 @@ def print_output_format( """Create a FabricCLIOutput instance and print it depends on the format. Args: - args: The command arguments namespace containing command and output_format + args: The command arguments namespace containing command and output_format. + For help, help contains the HelpRequest. message: Success message to display data: Optional data to include in output hidden_data: Optional hidden data to include in output @@ -138,6 +143,11 @@ def print_output_format( case "json": _print_output_format_json(output.to_json()) case "text": + help_request: Optional["HelpRequest"] = getattr(args, "help", None) + if help_request: + parser = help_request.parser + getattr(parser, "print_text_help", parser.print_help)() + return if ( truncate_columns and isinstance(output.result.data, list) @@ -148,9 +158,7 @@ def print_output_format( _print_output_format_result_text(output) case _: raise FabricCLIError( - ErrorMessages.Common.output_format_not_supported( - str(format_type) - ), + ErrorMessages.Common.output_format_not_supported(str(format_type)), fab_constant.ERROR_NOT_SUPPORTED, ) @@ -211,9 +219,7 @@ def print_output_error( return case _: raise FabricCLIError( - ErrorMessages.Common.output_format_not_supported( - str(format_type) - ), + ErrorMessages.Common.output_format_not_supported(str(format_type)), fab_constant.ERROR_NOT_SUPPORTED, ) @@ -232,6 +238,79 @@ def print_info(text, command: Optional[str] = None) -> None: # Display +def _help_output_format(selectors: list[str]) -> str: + if selectors: + if any(value not in ("json", "text") for value in selectors): + raise FabricCLIError( + ErrorMessages.Common.invalid_help_format(), + fab_constant.ERROR_INVALID_INPUT, + ) + if len(set(selectors)) > 1: + raise FabricCLIError( + ErrorMessages.Common.conflicting_help_formats(), + fab_constant.ERROR_INVALID_INPUT, + ) + return selectors[0] + try: + with open(fab_state_config.config_file, encoding="utf-8") as config: + saved = json.load(config) + if not isinstance(saved, dict): + raise ValueError("Config must be an object") + format_type = saved.get(fab_constant.FAB_OUTPUT_FORMAT, "text") + if format_type not in ("json", "text"): + raise ValueError("Invalid saved output format") + return format_type + except FileNotFoundError: + return "text" + except (OSError, ValueError): + print_warning(ErrorMessages.Common.invalid_help_format()) + return "text" + + +def print_help_request(request: "HelpRequest") -> int: + """Print help through the common output pipeline and return its exit code.""" + from fabric_cli.core.fab_help import build_help_data, canonical_command + + command = canonical_command(request.parser) + # Explicit JSON also governs invalid requests, including conflicting selectors. + format_type = "json" if "json" in request.selectors else "text" + try: + format_type = _help_output_format(request.selectors) + if request.error: + raise FabricCLIError( + ErrorMessages.Common.invalid_help_request(request.error), + fab_constant.ERROR_INVALID_INPUT, + ) + except FabricCLIError as error: + print_output_error(error, command=command, output_format_type=format_type) + return 2 + + try: + data = build_help_data(request.parser) + print_output_format( + Namespace( + command=command, + output_format=format_type, + help=request, + ), + data=data, + ) + except Exception: + # This entrypoint is an error boundary: never expose payloads or partial help. + print_output_error( + FabricCLIError( + ErrorMessages.Common.invalid_help_request( + "Unable to generate command help" + ), + fab_constant.ERROR_UNEXPECTED_ERROR, + ), + command=command, + output_format_type=format_type, + ) + return 1 + return 0 + + # Display all available commands organized by category with descriptions def display_help( commands: dict[str, dict[str, str]], custom_header: Optional[str] = None @@ -295,10 +374,7 @@ def print_entries_unix_style( widths = [ max( _get_visual_length(field), - max( - get_visual_length(entry, field) - for entry in _entries - ), + max(get_visual_length(entry, field) for entry in _entries), ) for field in fields ] @@ -327,8 +403,7 @@ def print_entries_unix_style( new_widths = [max(min_col_width, int(w * scale)) for w in widths] # Fine-tune: trim the largest column until we fit while sum(new_widths) + separator_space > terminal_width: - max_idx = max(range(len(new_widths)), - key=lambda k: new_widths[k]) + max_idx = max(range(len(new_widths)), key=lambda k: new_widths[k]) if new_widths[max_idx] <= min_col_width: break new_widths[max_idx] -= 1 @@ -337,8 +412,7 @@ def print_entries_unix_style( base = available // n remainder = available % n new_widths = [ - max(1, base + (1 if i < remainder else 0)) - for i in range(n) + max(1, base + (1 if i < remainder else 0)) for i in range(n) ] widths = new_widths @@ -347,8 +421,11 @@ def print_entries_unix_style( for line in header_lines: print_grey(line, to_stderr=False) # Print a separator line matching the width of the first header line - separator_width = _get_visual_length( - header_lines[0]) if header_lines else (sum(widths) + len(widths) - 1) + separator_width = ( + _get_visual_length(header_lines[0]) + if header_lines + else (sum(widths) + len(widths) - 1) + ) print_grey("-" * separator_width, to_stderr=False) for entry in _entries: @@ -416,9 +493,9 @@ def _print_output_format_result_text(output: FabricCLIOutput) -> None: data_keys = output.result.get_data_keys() if output_result.data else [] if len(data_keys) > 0: print_entries_unix_style( - output_result.data, data_keys, header=( - len(data_keys) > 1 or show_headers - ) + output_result.data, + data_keys, + header=(len(data_keys) > 1 or show_headers), ) else: _print_raw_data(output_result.data) @@ -503,9 +580,7 @@ def _print_error_format_json(output: str) -> None: def _print_error_format_text(message: str, command: Optional[str] = None) -> None: command_text = f"{command}: " if command else "" - _safe_print_formatted_text( - f"x {command_text}{message}", message - ) + _safe_print_formatted_text(f"x {command_text}{message}", message) def _print_fallback(text: str, e: Exception, to_stderr: bool = False) -> None: @@ -629,21 +704,24 @@ def _format_key_to_convert_to_title_case(key: str) -> str: ValueError: If the key is not in the expected underscore-separated format """ # Allow letters, numbers, and underscores only - if not key.replace('_', '').replace(' ', '').isalnum(): + if not key.replace("_", "").replace(" ", "").isalnum(): raise ValueError( - f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + f"Invalid key format: '{key}'. Only underscore-separated words are allowed." + ) # Check for invalid patterns (camelCase, spaces mixed with underscores, etc.) - if ' ' in key and '_' in key: + if " " in key and "_" in key: raise ValueError( - f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + f"Invalid key format: '{key}'. Only underscore-separated words are allowed." + ) # Check for camelCase pattern (uppercase letters not at the start) - if any(char.isupper() for char in key[1:]) and '_' not in key: + if any(char.isupper() for char in key[1:]) and "_" not in key: raise ValueError( - f"Invalid key format: '{key}'. Only underscore-separated words are allowed.") + f"Invalid key format: '{key}'. Only underscore-separated words are allowed." + ) - pretty = key.replace('_', ' ').title().strip() + pretty = key.replace("_", " ").title().strip() return _check_special_cases(pretty) diff --git a/tests/test_core/test_fab_interactive.py b/tests/test_core/test_fab_interactive.py index 660a2a871..a38f34fbf 100644 --- a/tests/test_core/test_fab_interactive.py +++ b/tests/test_core/test_fab_interactive.py @@ -2,16 +2,21 @@ # Licensed under the MIT License. import argparse -from unittest.mock import Mock, patch +import json +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch import pytest -from fabric_cli.core import fab_constant +from fabric_cli.core import fab_constant, fab_state_config from fabric_cli.core.fab_commands import Command from fabric_cli.core.fab_decorators import handle_exceptions from fabric_cli.core.fab_exceptions import FabricCLIError from fabric_cli.core.fab_interactive import InteractiveCLI from fabric_cli.core.fab_parser_setup import create_parser_and_subparsers +from fabric_cli.errors import ErrorMessages +from fabric_cli.utils import fab_commands, fab_ui + class TestInteractiveCLI: """Test suite for InteractiveCLI class functionality.""" @@ -50,23 +55,242 @@ def test_handle_command_quit_variations_success( def test_handle_command_help_variations_success( self, interactive_cli, - mock_display_help, mock_print_log_file_path, - mock_commands, + mock_parser, ): """Test help command and its variations.""" - for cmd in fab_constant.INTERACTIVE_HELP_COMMANDS: - result = interactive_cli.handle_command(cmd) - assert result is False - - # Verify help display was called - assert mock_display_help.call_count == len( - fab_constant.INTERACTIVE_HELP_COMMANDS + from fabric_cli.core.fab_help import HelpRequest + + with ( + patch( + "fabric_cli.core.fab_interactive.resolve_help", + return_value=HelpRequest(mock_parser), + ) as resolve, + patch( + "fabric_cli.core.fab_interactive.utils_ui.print_help_request" + ) as render, + ): + for cmd in fab_constant.INTERACTIVE_HELP_COMMANDS: + assert interactive_cli.handle_command(cmd) is False + resolve.assert_called_with(mock_parser, ["--help"]) + assert render.call_count == len(fab_constant.INTERACTIVE_HELP_COMMANDS) + mock_print_log_file_path.assert_not_called() + + @pytest.mark.parametrize("config_kind", ["invalid_object", "unreadable"]) + @pytest.mark.parametrize( + ("command", "target"), + [ + ("help", "fab"), + ("job --help", "job"), + ("job run -h", "job run"), + ("ls -lh", "ls"), + ], + ) + @pytest.mark.parametrize("selector", ["", "--output_format=json"]) + def test_help_before_config_logging_success( + self, + interactive_cli: InteractiveCLI, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + config_kind: str, + command: str, + target: str, + selector: str, + ) -> None: + parser, subparsers = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + monkeypatch.setattr(interactive_cli, "subparsers", subparsers) + config = tmp_path / "config.json" + config.write_text("[]", encoding="utf-8") + monkeypatch.setattr(fab_state_config, "config_file", str(config)) + + with ( + patch( + "fabric_cli.core.fab_interactive.fab_logger.get_log_file_path" + ) as log, + patch( + "fabric_cli.core.fab_state_config.get_config", + wraps=fab_state_config.get_config, + ) as get_config, + ( + patch("builtins.open", side_effect=PermissionError) + if config_kind == "unreadable" + else patch("builtins.open", wraps=open) + ), + ): + assert interactive_cli.handle_command(f"{command} {selector}") is False + captured = capsys.readouterr() + log.assert_not_called() + get_config.assert_not_called() + if selector: + output = json.loads(captured.out) + assert output["status"] == "Success" + assert output["command"] == target + assert captured.err == "" + else: + assert ( + "Manage and schedule jobs." if target == "job" else "Usage:" + ) in captured.out + assert ErrorMessages.Common.invalid_help_format() in captured.err + + assert interactive_cli.handle_command("help --output_format=json") is False + assert json.loads(capsys.readouterr().out)["status"] == "Success" + + def test_interactive_session_continues_after_help_with_invalid_config_success( + self, + interactive_cli: InteractiveCLI, + mock_context: MagicMock, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + parser, subparsers = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + monkeypatch.setattr(interactive_cli, "subparsers", subparsers) + config = tmp_path / "config.json" + config.write_text("[]", encoding="utf-8") + monkeypatch.setattr(fab_state_config, "config_file", str(config)) + commands = iter( + ["help --output_format=json", "job --help --output_format=json", "quit"] ) - # Verify log file path is printed for each command - assert mock_print_log_file_path.call_count == len( - fab_constant.INTERACTIVE_HELP_COMMANDS + + def prompt(*args: object, **kwargs: object) -> str: + command = next(commands) + if command == "quit": + config.write_text('{"debug_enabled": "false"}', encoding="utf-8") + return command + + with patch.object(interactive_cli.session, "prompt", side_effect=prompt) as ask: + interactive_cli.start_interactive() + + captured = capsys.readouterr() + assert ask.call_count == 3 + assert captured.out.count('"status": "Success"') == 2 + assert "Error in interactive session" not in captured.out + assert captured.err == "" + assert interactive_cli._is_running is False + + @pytest.mark.parametrize("alias", fab_constant.INTERACTIVE_HELP_COMMANDS) + @pytest.mark.parametrize( + ("selector", "saved"), + [ + ("--output_format json", "text"), + ("--output_format=json", "text"), + ("--json", "text"), + ("", "json"), + ], + ) + def test_handle_command_root_json_help_success( + self, + interactive_cli: InteractiveCLI, + mock_print_log_file_path: MagicMock, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + alias: str, + selector: str, + saved: str, + ) -> None: + parser, _ = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + config = tmp_path / "config.json" + config.write_text(json.dumps({"output_format": saved}), encoding="utf-8") + monkeypatch.setattr(fab_state_config, "config_file", str(config)) + + assert interactive_cli.handle_command(f"{alias} {selector}") is False + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["command"] == "fab" + assert output["status"] == "Success" + data = output["result"]["data"][0] + assert {"mkdir", "job", "deploy"} <= { + child["name"] for child in data["subcommands"] + } + assert {"--help", "--output_format", "--version", "--command"} <= { + flag["name"] for flag in data["flags"] + } + assert captured.err == "" + assert parser.get_mode() == fab_constant.FAB_MODE_INTERACTIVE + + def test_handle_command_group_help_after_invalid_command_success( + self, + interactive_cli: InteractiveCLI, + mock_print_log_file_path: MagicMock, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + parser, subparsers = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + monkeypatch.setattr(interactive_cli, "subparsers", subparsers) + + assert not interactive_cli.handle_command("job unknown") + capsys.readouterr() + + assert not interactive_cli.handle_command("job --help --output_format=json") + output = json.loads(capsys.readouterr().out) + assert output["command"] == "job" + assert output["status"] == "Success" + assert output["result"]["data"][0]["usage"] == "fab job [flags]" + + @pytest.mark.parametrize("alias", fab_constant.INTERACTIVE_HELP_COMMANDS) + def test_handle_command_root_text_help_success( + self, + interactive_cli: InteractiveCLI, + mock_print_log_file_path: MagicMock, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + alias: str, + ) -> None: + parser, _ = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + config = tmp_path / "config.json" + config.write_text('{"output_format": "json"}', encoding="utf-8") + monkeypatch.setattr(fab_state_config, "config_file", str(config)) + fab_ui.display_help( + fab_commands.COMMANDS, "Usage: [flags]" ) + expected = capsys.readouterr().out + + assert interactive_cli.handle_command(f"{alias} --output_format text") is False + captured = capsys.readouterr() + assert captured.out == expected + assert captured.err == "" + + @pytest.mark.parametrize( + "command", + [ + "help --unknown --output_format json", + "help --output_format=json --output_format=text", + "help --output_format=json --output_format", + ], + ) + def test_handle_command_invalid_help_failure( + self, + interactive_cli: InteractiveCLI, + mock_print_log_file_path: MagicMock, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + command: str, + ) -> None: + parser, _ = create_parser_and_subparsers() + parser.set_mode(fab_constant.FAB_MODE_INTERACTIVE) + monkeypatch.setattr(interactive_cli, "parser", parser) + + assert interactive_cli.handle_command(command) is False + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["status"] == "Failure" + assert "data" not in output["result"] + assert captured.err == "" + assert interactive_cli.handle_command("ls") is False + interactive_cli.subparsers.choices["ls"].parse_args.assert_called_once_with([]) def test_handle_command_version_variations_success( self, interactive_cli, mock_print_version, mock_print_log_file_path @@ -107,10 +331,11 @@ def test_handle_command_fab_in_interactive_mode_success( # Verify it stays in interactive mode and shows message assert result is False - mock_print_ui.assert_called_with("In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands.") + mock_print_ui.assert_called_with( + "In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands." + ) mock_print_log_file_path.assert_called_once() - def test_handle_command_with_arguments_success( self, interactive_cli, @@ -277,7 +502,8 @@ def mock_handle_command(cmd): # Verify that EOF error message is shown calls = mock_print_ui.call_args_list error_message_found = any( - "Error in interactive session:" in str(call) or "Session will continue" in str(call) + "Error in interactive session:" in str(call) + or "Session will continue" in str(call) for call in calls ) assert error_message_found, "Should show EOF error handling message" @@ -364,15 +590,21 @@ def test_fab_command_in_interactive_mode_shows_message_success( # Verify it stays in interactive mode and shows message assert result is False - mock_print_ui.assert_called_with("In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands.") + mock_print_ui.assert_called_with( + "In interactive mode, commands don't require the fab prefix. Use --help to view the list of supported commands." + ) mock_print_log_file_path.assert_called_once() def test_interactive_cli_singleton_pattern_success(self): """Test that InteractiveCLI follows singleton pattern""" from fabric_cli.core.fab_interactive import InteractiveCLI - with patch("fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers") as mock_get_parsers: - mock_parser = type('MockParser', (), {'set_mode': lambda self, mode: None})() + with patch( + "fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers" + ) as mock_get_parsers: + mock_parser = type( + "MockParser", (), {"set_mode": lambda self, mode: None} + )() mock_subparsers = object() mock_get_parsers.return_value = (mock_parser, mock_subparsers) @@ -385,7 +617,9 @@ def test_interactive_cli_singleton_pattern_success(self): def test_start_interactive_mode_success(self): """Test mode switching creates singleton and launches interactive CLI""" - with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli: + with patch( + "fabric_cli.core.fab_interactive.InteractiveCLI" + ) as mock_interactive_cli: from unittest.mock import Mock mock_cli_instance = Mock() @@ -393,6 +627,7 @@ def test_start_interactive_mode_success(self): mock_interactive_cli.return_value = mock_cli_instance from fabric_cli.core.fab_interactive import start_interactive_mode + start_interactive_mode() mock_interactive_cli.assert_called_once() @@ -400,8 +635,12 @@ def test_start_interactive_mode_success(self): def test_start_interactive_mode_already_running(self): """Test that calling start_interactive_mode when already running prints message""" - with patch("fabric_cli.core.fab_interactive.InteractiveCLI") as mock_interactive_cli, \ - patch("fabric_cli.core.fab_interactive.utils_ui.print") as mock_print: + with ( + patch( + "fabric_cli.core.fab_interactive.InteractiveCLI" + ) as mock_interactive_cli, + patch("fabric_cli.core.fab_interactive.utils_ui.print") as mock_print, + ): from unittest.mock import Mock from fabric_cli.core import fab_interactive @@ -425,6 +664,7 @@ def mock_start_interactive(): # endregion + @pytest.fixture def mock_parser(): """Mock parser with interactive mode setting capability.""" @@ -433,6 +673,9 @@ def mock_parser(): parser = Mock() parser.set_mode = Mock() parser.error = Mock() + parser._actions = [] + parser._option_string_actions = {} + parser._get_option_tuples.return_value = [] yield parser @@ -500,13 +743,6 @@ def mock_print_log_file_path(): yield mock -@pytest.fixture -def mock_display_help(): - """Mock utils_ui.display_help function.""" - with patch("fabric_cli.core.fab_interactive.utils_ui.display_help") as mock: - yield mock - - @pytest.fixture def mock_print_version(): """Mock utils_ui.print_version function.""" @@ -514,16 +750,6 @@ def mock_print_version(): yield mock -@pytest.fixture -def mock_commands(): - """Mock fab_commands.COMMANDS.""" - with patch( - "fabric_cli.core.fab_interactive.fab_commands.COMMANDS", - ["ls", "help", "config"], - ) as mock: - yield mock - - @pytest.fixture def mock_html_escape(): """Mock html.escape function.""" @@ -555,17 +781,19 @@ def interactive_cli( ): """Create InteractiveCLI instance with mocked dependencies.""" from fabric_cli.core.fab_interactive import InteractiveCLI - + # Mock the get_global_parser_and_subparsers function to return our mocks - with patch("fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers") as mock_get_parsers: + with patch( + "fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers" + ) as mock_get_parsers: mock_get_parsers.return_value = (mock_parser, mock_subparsers) - + # Create a fresh InteractiveCLI instance for each test by directly creating an instance # and assigning the mock objects to ensure tests use the same mocks cli = InteractiveCLI(mock_parser, mock_subparsers) - + # Ensure the instance uses our mock objects cli.parser = mock_parser cli.subparsers = mock_subparsers - + yield cli diff --git a/tests/test_main.py b/tests/test_main.py index b50466411..eada56f60 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,8 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json +import os +import site +import socket +import subprocess import sys from argparse import Namespace +from pathlib import Path +from typing import Any from unittest.mock import patch import pytest @@ -31,12 +38,12 @@ def fail(args: Namespace) -> None: subparsers.choices[command].set_defaults(func=fail) with ( patch( - "fabric_cli.main.get_global_parser_and_subparsers", + "fabric_cli.core.fab_parser_setup.get_global_parser_and_subparsers", return_value=(parser, subparsers), ), - patch("fabric_cli.main.argcomplete.autocomplete"), - patch("fabric_cli.main.fab_state_config.init_defaults"), - patch("fabric_cli.main.fab_logger.print_log_file_path"), + patch("argcomplete.autocomplete"), + patch("fabric_cli.core.fab_state_config.init_defaults"), + patch("fabric_cli.core.fab_logger.print_log_file_path"), patch("fabric_cli.utils.fab_ui.print_output_error") as print_error, patch.object(sys, "argv", ["fab", "-c", command]), ): @@ -61,10 +68,10 @@ def test_main_no_args_starts_interactive_mode_success(self): "MockParser", (), { - "parse_args": lambda: type( + "parse_args": lambda self: type( "Args", (), {"command": None, "version": False} )(), - "set_mode": lambda mode: None, + "set_mode": lambda self, mode: None, }, )() mock_subparsers = object() @@ -96,3 +103,188 @@ def test_start_interactive_mode_directly_success(self): mock_interactive_cli.assert_called_once() mock_cli_instance.start_interactive.assert_called_once() + + @pytest.mark.parametrize( + "tokens", + [ + ["job", "run", "--help", "--output_format=json"], + ["--version"], + ["version"], + ], + ) + def test_help_version_no_execution_dependencies_success( + self, + tokens: list[str], + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + ) -> None: + from fabric_cli import main as main_module + + monkeypatch.setattr(sys, "argv", ["fab", *tokens]) + with ( + patch("argcomplete.autocomplete") as completion, + patch("fabric_cli.core.fab_state_config.init_defaults") as init_config, + patch("fabric_cli.core.fab_logger.print_log_file_path") as log, + patch.object(main_module, "_execute_command") as execute, + patch.object( + socket.socket, "connect", side_effect=AssertionError("network") + ), + ): + if "--help" in tokens: + with pytest.raises(SystemExit) as exit_info: + main_module.main() + assert exit_info.value.code == 0 + else: + main_module.main() + completion.assert_not_called() + init_config.assert_not_called() + log.assert_not_called() + execute.assert_not_called() + assert capsys.readouterr().err == "" + + +def _run_help(home: Path, tokens: list[str]) -> subprocess.CompletedProcess[str]: + executable = os.environ.get("FAB_HELP_EXECUTABLE") + command = ( + [executable] + if executable + else [sys.executable, "-B", "-c", "from fabric_cli.main import main; main()"] + ) + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("FAB_", "AZURE_", "_ARGCOMPLETE")) + } + env.update( + { + "HOME": str(home), + "USERPROFILE": str(home), + "APPDATA": str(home), + "LOCALAPPDATA": str(home), + "XDG_CONFIG_HOME": str(home), + "XDG_STATE_HOME": str(home), + "PYTHONIOENCODING": "utf-8", + "PYTHONUSERBASE": site.getuserbase(), + "_ARGCOMPLETE": "1", + "COMP_LINE": "fab --help", + "COMP_POINT": "10", + } + ) + if executable: + env.pop("PYTHONPATH", None) + else: + env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src") + return subprocess.run( + [*command, *tokens], + cwd=home, + env=env, + capture_output=True, + encoding="utf-8", + timeout=30, + ) + + +@pytest.mark.parametrize( + ("tokens", "command"), + [ + (["--help", "--output_format", "json"], "fab"), + (["job", "-h", "--output_format=json"], "job"), + (["job", "run", "--help", "--output_format", "json"], "job run"), + (["--output_format", "json", "create", "-help"], "mkdir"), + (["--output_format=json", "job", "run", "-h"], "job run"), + (["--help", "--json"], "fab"), + (["--json", "job", "run", "-h"], "job run"), + (["create", "--help", "--json"], "mkdir"), + (["import", "--help", "--output_format=json"], "import"), + (["job", "run", "--timeout", "bad", "-h", "--output_format=json"], "job run"), + ], +) +def test_offline_json_help_success( + tmp_path: Path, + tokens: list[str], + command: str, +) -> None: + result = _run_help(tmp_path, tokens) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert set(payload) == {"timestamp", "status", "command", "result"} + assert payload["status"] == "Success" + assert payload["command"] == command + assert set(payload["result"]) == {"data"} + assert len(payload["result"]["data"]) == 1 + assert result.stdout.endswith("\n") and not result.stdout.endswith("\n\n") + assert "\x1b" not in result.stdout + assert result.stderr == "" + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("config_kind", ["corrupt", "blocked", "missing", "readonly"]) +def test_help_with_unusable_config_success( + tmp_path: Path, + config_kind: str, +) -> None: + config = tmp_path / ".config" + if config_kind == "blocked": + config.write_text("not a directory", encoding="utf-8") + elif config_kind in ("corrupt", "readonly"): + directory = config / "fab" + directory.mkdir(parents=True) + (directory / "config.json").write_text( + "{" if config_kind == "corrupt" else '{"output_format": "json"}', + encoding="utf-8", + ) + if config_kind == "readonly": + directory.chmod(0o500) + try: + before = sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")) + result = _run_help(tmp_path, ["mkdir", "-h", "--output_format=json"]) + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["status"] == "Success" + assert payload["command"] == "mkdir" + assert len(payload["result"]["data"]) == 1 + assert result.stderr == "" + assert ( + sorted(str(path.relative_to(tmp_path)) for path in tmp_path.rglob("*")) + == before + ) + result = _run_help(tmp_path, ["--version"]) + assert result.returncode == 0, result.stderr + assert result.stdout.startswith("fab version ") + finally: + if config_kind == "readonly": + (config / "fab").chmod(0o700) + + +@pytest.mark.parametrize( + ("tokens", "command"), + [ + (["unknown", "--help", "--output_format=json"], "fab"), + (["job", "unknown", "-h", "--output_format=json"], "job"), + (["mkdir", "--unknown=secret-value", "-h", "--output_format=json"], "mkdir"), + (["mkdir", "--unknown=secret-value", "-h", "--json"], "mkdir"), + (["mkdir", "-h", "--output_format=json", "--output_format=text"], "mkdir"), + (["mkdir", "--output_format=json", "-h", "--output_format"], "mkdir"), + ( + ["mkdir", "--output_format=json", "-h", "--output_format=invalid-secret"], + "mkdir", + ), + ], +) +def test_invalid_json_help_failure( + tmp_path: Path, + tokens: list[str], + command: str, +) -> None: + result = _run_help(tmp_path, tokens) + assert result.returncode == 2, result.stderr + payload: dict[str, Any] = json.loads(result.stdout) + assert set(payload) == {"timestamp", "status", "command", "result"} + assert payload["status"] == "Failure" + assert payload["command"] == command + assert set(payload["result"]) == {"error_code", "message"} + assert payload["result"]["error_code"] == fab_constant.ERROR_INVALID_INPUT + assert payload["result"]["message"] + assert "secret" not in result.stdout + result.stderr + assert result.stderr == "" + assert list(tmp_path.iterdir()) == [] diff --git a/tests/test_parsers/test_fab_help.py b/tests/test_parsers/test_fab_help.py new file mode 100644 index 000000000..15d82177b --- /dev/null +++ b/tests/test_parsers/test_fab_help.py @@ -0,0 +1,574 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Parser-derived help metadata and help-control boundary tests.""" + +import argparse +import json +from typing import Any, Iterator + +import pytest + +from fabric_cli import __version__ +from fabric_cli.core.fab_help import ( + build_help_data, + canonical_command, + child_parsers, + resolve_help, +) +from fabric_cli.core.fab_parser_setup import ( + CustomArgumentParser, + create_parser_and_subparsers, +) + + +def _nodes( + parser: argparse.ArgumentParser, +) -> Iterator[argparse.ArgumentParser]: + yield parser + for _, child, _, _ in child_parsers(parser): + yield from _nodes(child) + + +@pytest.fixture +def parser() -> CustomArgumentParser: + return create_parser_and_subparsers()[0] + + +@pytest.mark.parametrize( + ("prog", "expected"), + [ + ("fab", "fab"), + ("fab job", "job"), + ("job", "job"), + ("fab job run", "job run"), + ("job run", "job run"), + ], +) +def test_canonical_command_prefix_success(prog: str, expected: str) -> None: + assert canonical_command(argparse.ArgumentParser(prog=prog)) == expected + + +def test_all_public_nodes_match_contract_success(parser: CustomArgumentParser) -> None: + for node in _nodes(parser): + data = build_help_data(node) + assert set(data) == { + "schema_version", + "cli_version", + "description", + "usage", + "args", + "flags", + "aliases", + "subcommands", + "examples", + "notes", + "learn_more", + } + assert data["cli_version"] == __version__ + assert data["schema_version"] == "1.0" + assert len(data["examples"]) == sum( + 1 + for example in getattr(node, "fab_examples", []) + if example.strip() and not example.strip().startswith("#") + ) + assert "--skill" not in [flag["name"] for flag in data["flags"]] + assert len(data["flags"]) == len({flag["name"] for flag in data["flags"]}) + for flag in data["flags"]: + if flag["required"]: + assert flag["name"] in data["usage"].removesuffix(" [flags]").split() + expected = [ + action.dest + for action in node._actions + if not action.option_strings + and not isinstance(action, argparse._SubParsersAction) + and action.help != argparse.SUPPRESS + ] + assert [entry["name"] for entry in data["args"]] == expected + spellings = { + spelling + for flag in data["flags"] + for spelling in [flag["name"], *flag["aliases"]] + } + assert spellings == { + spelling + for action in node._actions + if action.help != argparse.SUPPRESS + for spelling in action.option_strings + } + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + ( + "fab", + { + "description": "Work seamlessly with Fabric from the command line.", + "usage": "fab [flags]", + "args": [], + "aliases": [], + }, + ), + ( + "job", + { + "description": "Manage and schedule jobs.", + "usage": "fab job [flags]", + "args": [], + "aliases": [], + }, + ), + ( + "mkdir", + { + "description": "Create a workspace, item, or directory.", + "usage": "fab mkdir [flags]", + "args": [ + { + "name": "path", + "type": "string", + "required": True, + "description": "Directory path", + }, + ], + "aliases": ["create"], + "examples": [ + { + "description": "create an empty notebook", + "command": "fab mkdir ws1.Workspace/nb1.Notebook", + }, + { + "description": "create a directory (Lakehouse only)", + "command": "fab mkdir ws1.Workspace/lh1.Lakehouse/Files/fabdir", + }, + ], + }, + ), + ( + "job run", + { + "description": "Run an item", + "usage": "fab job run [flags]", + "args": [ + { + "name": "path", + "type": "string", + "required": True, + "description": "Path to the item", + }, + ], + "aliases": [], + }, + ), + ], +) +def test_representative_help_values_success( + parser: CustomArgumentParser, command: str, expected: dict[str, Any] +) -> None: + node = next(node for node in _nodes(parser) if canonical_command(node) == command) + data = build_help_data(node) + assert {field: data[field] for field in expected} == expected + + +@pytest.mark.parametrize( + ("tokens", "expected"), + [ + (["deploy"], "fab deploy --config [flags]"), + ( + ["export"], + "fab export --output [ ...] [flags]", + ), + ( + ["import"], + "fab import --input [ ...] [flags]", + ), + ( + ["table", "load"], + "fab table load --file [ ...] [flags]", + ), + ( + ["set"], + "fab set --query --input [ ...] [flags]", + ), + ], +) +def test_required_flags_json_usage_success( + parser: CustomArgumentParser, + capsys: pytest.CaptureFixture[str], + tokens: list[str], + expected: str, +) -> None: + node = next( + node for node in _nodes(parser) if canonical_command(node) == " ".join(tokens) + ) + text_help = node.format_help() + with pytest.raises(SystemExit) as exit_info: + parser.parse_args([*tokens, "--help", "--output_format=json"]) + assert exit_info.value.code == 0 + output = json.loads(capsys.readouterr().out) + assert output["result"]["data"][0]["usage"] == expected + assert node.format_help() == text_help + + +@pytest.mark.parametrize( + ("nargs", "expected"), + [ + (None, "--value "), + ("?", "--value []"), + ("*", "--value [ ...]"), + ("+", "--value [ ...]"), + (2, "--value "), + ], +) +def test_required_flag_value_arity_usage_success( + nargs: str | int | None, expected: str +) -> None: + parser = CustomArgumentParser(prog="fab example") + parser.add_argument("--value", required=True, nargs=nargs) + parser.add_argument("--optional") + assert build_help_data(parser)["usage"] == f"fab example {expected} [flags]" + + +def test_required_boolean_flag_usage_success() -> None: + parser = CustomArgumentParser(prog="fab example") + parser.add_argument("-f", "--force", required=True, action="store_true") + assert build_help_data(parser)["usage"] == "fab example --force [flags]" + + +def test_metadata_semantics_success(parser: CustomArgumentParser) -> None: + nodes = {canonical_command(node): build_help_data(node) for node in _nodes(parser)} + root = nodes["fab"] + children = {entry["name"]: entry for entry in root["subcommands"]} + assert "deploy" in children + assert children["mkdir"]["aliases"] == ["create"] + assert "create" not in children + flags = {flag["name"]: flag for flag in root["flags"]} + assert flags["--command"]["repeat_behavior"] == "append" + assert flags["--json"]["type"] == "boolean" + assert "repeat_behavior" not in flags["--json"] + assert "default" not in flags["--json"] + for name, data in nodes.items(): + if name != "fab": + assert "--command" not in {flag["name"] for flag in data["flags"]} + mkdir = nodes["mkdir"] + assert mkdir["usage"] == "fab mkdir [flags]" + assert mkdir["args"][0]["required"] is True + flags = {flag["name"]: flag for flag in mkdir["flags"]} + assert flags["--help"]["aliases"] == ["-h", "-help"] + assert flags["--params"]["value_optional"] is True + assert "default" not in flags["--params"] + assert "default" not in flags["--output_format"] + assert all(link.startswith("https://") for link in mkdir["learn_more"]) + assert "target-specific" in " ".join(mkdir["notes"]) + flags = {flag["name"]: flag for flag in nodes["job run"]["flags"]} + assert flags["--timeout"]["type"] == "integer" + assert flags["--params"]["repeat_behavior"] == "replace" + assert flags["--input"]["repeat_behavior"] == "replace" + assert "--input cannot be combined" in " ".join(nodes["job run"]["notes"]) + flags = {flag["name"]: flag for flag in nodes["import"]["flags"]} + assert flags["--input"]["required"] is True + assert "placeholder" in " ".join(nodes["extension"]["notes"]) + + +@pytest.mark.parametrize("command", ["start", "run"]) +def test_job_polling_interval_json_help_success( + parser: CustomArgumentParser, + capsys: pytest.CaptureFixture[str], + command: str, +) -> None: + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(["job", command, "--help", "--json"]) + assert exit_info.value.code == 0 + output = json.loads(capsys.readouterr().out) + flags = {flag["name"]: flag for flag in output["result"]["data"][0]["flags"]} + assert flags["--polling_interval"]["type"] == "integer" + assert flags["--polling_interval"]["minimum"] == 1 + + +def test_every_command_alias_resolves_success(parser: CustomArgumentParser) -> None: + def check(parent: argparse.ArgumentParser, prefix: list[str]) -> None: + for name, child, aliases, _ in child_parsers(parent): + assert build_help_data(child)["aliases"] == aliases + for spelling in [name, *aliases]: + request = resolve_help( + parser, [*prefix, spelling, "--help", "--output_format=json"] + ) + assert request is not None + assert request.parser is child + assert request.error is None + check(child, [*prefix, name]) + + check(parser, []) + + +@pytest.mark.parametrize( + ("tokens", "command", "aliases"), + [ + (["clear"], "clear", ["cls"]), + (["cls"], "clear", ["cls"]), + (["config", "ls"], "config ls", ["dir"]), + (["config", "dir"], "config ls", ["dir"]), + ], +) +def test_registered_leaf_aliases_json_help_success( + parser: CustomArgumentParser, + capsys: pytest.CaptureFixture[str], + tokens: list[str], + command: str, + aliases: list[str], +) -> None: + with pytest.raises(SystemExit) as exit_info: + parser.parse_args([*tokens, "--help", "--output_format=json"]) + assert exit_info.value.code == 0 + output = json.loads(capsys.readouterr().out) + assert output["command"] == command + assert output["result"]["data"][0]["aliases"] == aliases + + +@pytest.mark.parametrize( + ("tokens", "command"), + [ + (["--help", "--output_format", "json"], "fab"), + (["job", "--output_format=json", "-h"], "job"), + (["--output_format", "json", "job", "run", "-help"], "job run"), + (["create", "--help", "--output_format=json"], "mkdir"), + (["import", "--help"], "import"), + (["job", "run", "--timeout", "not-an-int", "--help"], "job run"), + (["mkdir", "-P", "--help"], "mkdir"), + ], +) +def test_resolve_help_success( + parser: CustomArgumentParser, tokens: list[str], command: str +) -> None: + request = resolve_help(parser, tokens) + assert request is not None + assert canonical_command(request.parser) == command + assert request.error is None + + +@pytest.mark.parametrize( + ("tokens", "command"), + [ + (["ls", "-la", "--help", "--output_format=json"], "ls"), + (["ls", "--out=json", "--help"], "ls"), + (["ls", "-lh", "--output_format=json"], "ls"), + (["ls", "-alh", "--out", "json"], "ls"), + (["ls", "-lqname", "--help", "--out=json"], "ls"), + (["ls", "-lq--help", "--he", "--out=json"], "ls"), + (["ls", "-la", "--out", "json", "--he"], "ls"), + (["--json", "ls", "--help"], "ls"), + (["job", "--json", "run", "--help"], "job run"), + (["job", "run", "--help", "--json"], "job run"), + (["ls", "--json", "--help", "--output_format=json"], "ls"), + ], +) +def test_argparse_option_spellings_json_help_success( + parser: CustomArgumentParser, + capsys: pytest.CaptureFixture[str], + tokens: list[str], + command: str, +) -> None: + request = resolve_help(parser, tokens) + assert request is not None + assert request.error is None + assert set(request.selectors) == {"json"} + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(tokens) + assert exit_info.value.code == 0 + output = json.loads(capsys.readouterr().out) + assert output["command"] == command + assert output["status"] == "Success" + + +@pytest.mark.parametrize( + "tokens", + [ + ["ls", "-q--help"], + ["ls", "-lq--help"], + ["ls", "--que=--help"], + ["--comm=job --help"], + ["ls", "--", "-lh"], + ], +) +def test_argparse_option_value_boundaries_success( + parser: CustomArgumentParser, tokens: list[str] +) -> None: + assert resolve_help(parser, tokens) is None + + +def test_ambiguous_option_abbreviation_failure( + capsys: pytest.CaptureFixture[str], +) -> None: + parser = CustomArgumentParser(prog="fab example") + parser.add_argument("--other") + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(["--o=json", "--help", "--output_format=json"]) + assert exit_info.value.code == 2 + assert json.loads(capsys.readouterr().out)["status"] == "Failure" + + +def test_disabled_option_abbreviation_failure() -> None: + parser = CustomArgumentParser(prog="fab example", allow_abbrev=False) + request = resolve_help(parser, ["--out=json", "--help"]) + assert request is not None + assert request.error is not None + + +@pytest.mark.parametrize("token", ["-lx", "-la=yes", "-lhx"]) +def test_invalid_combined_options_failure( + parser: CustomArgumentParser, token: str +) -> None: + request = resolve_help(parser, ["ls", token, "--help", "--output_format=json"]) + assert request is not None + assert request.error is not None + + +@pytest.mark.parametrize( + "tokens", + [ + ["api", "--input", '{"value": "--help"}'], + ["api", "--input=--help"], + ["-c", "mkdir --help"], + ["-c", "--help"], + ["-cmkdir --help"], + ["mkdir", "--", "--help"], + ["find", "text containing --help"], + ], +) +def test_literal_help_tokens_success( + parser: CustomArgumentParser, tokens: list[str] +) -> None: + assert resolve_help(parser, tokens) is None + + +def test_literal_values_use_normal_parser_success( + parser: CustomArgumentParser, +) -> None: + args = parser.parse_args(["api", "v1/workspaces", "--input=--help"]) + assert args.input == ["--help"] + args = parser.parse_args(["mkdir", "--", "--help"]) + assert args.path == ["--help"] + + +@pytest.mark.parametrize( + "tokens", + [ + ["unknown", "--help"], + ["job", "unknown", "--help"], + ["mkdir", "--unknown", "--help"], + ["mkdir", "--help", "--output_format"], + ["job", "run", "--timeout", "--help"], + ["--help", "mkdir"], + ["mkdir", "--help=yes"], + ["ls", "--long=yes", "--help"], + ["ls", "--json=yes", "--help"], + ], +) +def test_invalid_help_structure_failure( + parser: CustomArgumentParser, tokens: list[str] +) -> None: + request = resolve_help(parser, tokens) + assert request is not None + assert request.error is not None + + +def test_parser_selector_and_singleton_reuse_success( + parser: CustomArgumentParser, capsys: pytest.CaptureFixture[str] +) -> None: + args = parser.parse_args(["--output_format", "json", "mkdir", "x.Notebook"]) + assert args.output_format == "json" + with pytest.raises(SystemExit) as exit_info: + parser.parse_args(["mkdir", "--help", "--output_format=json"]) + assert exit_info.value.code == 0 + assert json.loads(capsys.readouterr().out)["command"] == "mkdir" + args = parser.parse_args(["mkdir", "x.Notebook"]) + assert args.output_format is None + args = parser.parse_args(["job", "--output_format=json", "run", "x.Notebook"]) + assert args.output_format == "json" + args = parser.parse_args(["-c", "pwd", "-c", "ls"]) + assert args.command == ["pwd", "ls"] + + +def test_examples_preserve_payload_quoting_success() -> None: + parser = CustomArgumentParser( + prog="fab example", + fab_examples=["# Pass JSON", """$ example --input '{"key": "--help"}'"""], + ) + examples: list[dict[str, Any]] = build_help_data(parser)["examples"] + assert examples == [ + { + "description": "Pass JSON", + "command": """fab example --input '{"key": "--help"}'""", + } + ] + + +@pytest.mark.parametrize("operation", ["login", "logout", "status"]) +def test_auth_examples_preserve_modes_success( + parser: CustomArgumentParser, operation: str +) -> None: + node = next( + node + for node in _nodes(parser) + if canonical_command(node) == f"auth {operation}" + ) + expected = [ + { + "description": "interactive mode", + "command": f"auth {operation}", + }, + { + "description": "command_line mode", + "command": f"fab auth {operation}", + }, + ] + if operation == "login": + expected.extend( + [ + { + "description": "command_line mode using Azure CLI auth", + "command": "fab auth login --azure-cli", + }, + { + "description": "command_line mode using service principal auth", + "command": "fab auth login -u -p --tenant ", + }, + { + "description": "command_line mode using system assigned managed identity auth", + "command": "fab auth login --identity", + }, + { + "description": "command_line mode using user assigned managed identity auth", + "command": "fab auth login -i -u ", + }, + ] + ) + assert build_help_data(node)["examples"] == expected + assert getattr(node, "fab_examples")[:2] == [ + "# interactive mode", + f"$ auth {operation}\n", + ] + + +@pytest.mark.parametrize( + ("description", "invocation", "expected"), + [ + ("interactive mode", "example", "example"), + ("interactive mode", "fab example", "example"), + ("command_line mode", "example", "fab example"), + ("command_line mode", "fab example", "fab example"), + ("List items", "ls -l", "fab ls -l"), + ("List items", "fab ls -l", "fab ls -l"), + ], +) +def test_example_prefix_matches_mode_success( + description: str, invocation: str, expected: str +) -> None: + parser = CustomArgumentParser( + prog="fab example", + fab_examples=[f"# {description}", f"$ {invocation}"], + ) + assert build_help_data(parser)["examples"] == [ + {"description": description, "command": expected} + ] diff --git a/tests/test_utils/test_fab_ui.py b/tests/test_utils/test_fab_ui.py index c98074871..ebe43878a 100644 --- a/tests/test_utils/test_fab_ui.py +++ b/tests/test_utils/test_fab_ui.py @@ -5,6 +5,8 @@ import platform from argparse import Namespace from enum import Enum +from pathlib import Path +from unittest.mock import patch import pytest @@ -14,6 +16,161 @@ from fabric_cli.core.fab_output import FabricCLIOutput, OutputStatus from fabric_cli.errors import ErrorMessages from fabric_cli.utils import fab_ui as ui +from fabric_cli.core.fab_help import HelpRequest +from fabric_cli.core.fab_parser_setup import create_parser_and_subparsers + + +@pytest.mark.parametrize( + ("selectors", "saved", "expected"), + [ + (["json"], "text", "json"), + (["text"], "json", "text"), + (["json", "json"], "text", "json"), + ([], "json", "json"), + ([], "text", "text"), + ], +) +def test_help_format_precedence_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + selectors: list[str], + saved: str, + expected: str, + capsys: pytest.CaptureFixture[str], +) -> None: + config = tmp_path / "config.json" + config.write_text(json.dumps({"output_format": saved}), encoding="utf-8") + monkeypatch.setattr(ui.fab_state_config, "config_file", str(config)) + parser, subparsers = create_parser_and_subparsers() + target = subparsers.choices["mkdir"] + with patch.object( + ui, "print_output_format", wraps=ui.print_output_format + ) as output: + assert ui.print_help_request(HelpRequest(target, selectors)) == 0 + assert output.call_args.args[0].output_format == expected + assert output.call_args.args[0].help.parser is target + captured = capsys.readouterr() + assert captured.err == "" + if expected == "json": + assert json.loads(captured.out)["command"] == "mkdir" + else: + assert captured.out == target.format_help() + + +@pytest.mark.parametrize("content", ["{", "[]", '{"output_format": "invalid"}']) +def test_help_corrupt_config_warning_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + content: str, + capsys: pytest.CaptureFixture[str], +) -> None: + config = tmp_path / "config.json" + config.write_text(content, encoding="utf-8") + monkeypatch.setattr(ui.fab_state_config, "config_file", str(config)) + parser, _ = create_parser_and_subparsers() + assert ui.print_help_request(HelpRequest(parser)) == 0 + captured = capsys.readouterr() + assert ErrorMessages.Common.invalid_help_format() in captured.err + assert "Usage: fab" in captured.out + assert config.read_text(encoding="utf-8") == content + + +def test_help_unreadable_config_warning_success( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + parser, _ = create_parser_and_subparsers() + with patch("builtins.open", side_effect=PermissionError): + assert ui.print_help_request(HelpRequest(parser)) == 0 + captured = capsys.readouterr() + assert ErrorMessages.Common.invalid_help_format() in captured.err + assert "Usage: fab" in captured.out + + +def test_help_missing_config_default_text_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config = tmp_path / "missing" / "config.json" + monkeypatch.setattr(ui.fab_state_config, "config_file", str(config)) + parser, _ = create_parser_and_subparsers() + assert ui.print_help_request(HelpRequest(parser)) == 0 + captured = capsys.readouterr() + assert "Usage: fab" in captured.out + assert captured.err == "" + assert not config.parent.exists() + + +@pytest.mark.parametrize("target", ["fab", "job", "mkdir"]) +def test_help_legacy_text_renderer_success( + target: str, capsys: pytest.CaptureFixture[str] +) -> None: + parser, subparsers = create_parser_and_subparsers() + node = parser if target == "fab" else subparsers.choices[target] + node.print_text_help() + expected = capsys.readouterr().out + assert ui.print_help_request(HelpRequest(node, ["text"])) == 0 + assert capsys.readouterr().out == expected + + +@pytest.mark.parametrize("output_format", ["text", "json"]) +def test_print_output_format_help_in_args_success( + output_format: str, capsys: pytest.CaptureFixture[str] +) -> None: + from fabric_cli.core.fab_help import build_help_data + + _, subparsers = create_parser_and_subparsers() + parser = subparsers.choices["mkdir"] + data = build_help_data(parser) + args = Namespace( + command="mkdir", output_format=output_format, help=HelpRequest(parser) + ) + with patch.object( + parser, "print_text_help", wraps=parser.print_text_help + ) as help_text: + ui.print_output_format(args, data=data) + captured = capsys.readouterr() + assert captured.err == "" + if output_format == "text": + help_text.assert_called_once_with() + assert captured.out == parser.format_help() + else: + help_text.assert_not_called() + assert json.loads(captured.out)["result"]["data"] == [data] + + +def test_print_output_format_without_help_success( + capsys: pytest.CaptureFixture[str], +) -> None: + ui.print_output_format( + Namespace(command="pwd", output_format="text", help=False), data="workspace" + ) + captured = capsys.readouterr() + assert captured.out.strip() == "workspace" + assert captured.err == "" + + +@pytest.mark.parametrize("stage", ["metadata", "serialization"]) +def test_help_generation_failure( + stage: str, capsys: pytest.CaptureFixture[str] +) -> None: + parser, _ = create_parser_and_subparsers() + options = ( + {"side_effect": ValueError("secret-value")} + if stage == "metadata" + else {"return_value": {"not_serializable": object()}} + ) + with patch("fabric_cli.core.fab_help.build_help_data", **options): + assert ui.print_help_request(HelpRequest(parser, ["json"])) == 1 + captured = capsys.readouterr() + assert "secret-value" not in captured.out + captured.err + output = json.loads(captured.out) + assert output["status"] == "Failure" + assert output["result"]["message"] == ErrorMessages.Common.invalid_help_request( + "Unable to generate command help" + ) + assert output["result"]["error_code"] == fab_constant.ERROR_UNEXPECTED_ERROR + assert "data" not in output["result"] class OutputType(Enum):