Spaces:
Sleeping
Sleeping
| """Fish completion script generator. | |
| Generates static fish completion scripts using `complete -c COMMAND` statements. | |
| Completions auto-load from ~/.config/fish/completions/PROGNAME.fish. | |
| """ | |
| import re | |
| from typing import TYPE_CHECKING | |
| from cyclopts.completion._base import ( | |
| CompletionAction, | |
| CompletionData, | |
| clean_choice_text, | |
| extract_completion_data, | |
| get_completion_action, | |
| strip_markup, | |
| ) | |
| if TYPE_CHECKING: | |
| from cyclopts import App | |
| from cyclopts.command_spec import CommandSpec | |
| def generate_completion_script(app: "App", prog_name: str) -> str: | |
| """Generate fish completion script. | |
| Parameters | |
| ---------- | |
| app : App | |
| The Cyclopts application to generate completion for. | |
| prog_name : str | |
| Program name for completion (alphanumeric with hyphens/underscores). | |
| Returns | |
| ------- | |
| str | |
| Complete fish completion script. | |
| Raises | |
| ------ | |
| ValueError | |
| If prog_name contains invalid characters. | |
| """ | |
| if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name): | |
| raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.") | |
| completion_data = extract_completion_data(app) | |
| lines = [ | |
| f"# Fish completion for {prog_name}", | |
| "# Generated by Cyclopts", | |
| "", | |
| ] | |
| has_nested_commands = any(len(path) > 0 for path in completion_data.keys()) | |
| if has_nested_commands: | |
| lines.extend(_generate_helper_functions(prog_name, completion_data)) | |
| lines.append("") | |
| help_flags = tuple(app.help_flags) if app.help_flags else () | |
| version_flags = tuple(app.version_flags) if app.version_flags else () | |
| lines.extend(_generate_completions(completion_data, prog_name, help_flags, version_flags)) | |
| return "\n".join(lines) + "\n" | |
| def _escape_fish_string(text: str) -> str: | |
| r"""Escape single quotes for fish strings.""" | |
| return text.replace("'", r"'\''") | |
| def _escape_fish_description(text: str) -> str: | |
| """Escape description text for fish.""" | |
| text = text.replace("\n", " ") | |
| text = text.replace("\r", " ") | |
| return _escape_fish_string(text) | |
| def _generate_helper_functions( | |
| prog_name: str, | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| ) -> list[str]: | |
| """Generate helper function for command path detection. | |
| Parameters | |
| ---------- | |
| prog_name : str | |
| Program name. | |
| completion_data : dict | |
| Completion data used to identify options that take values. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines defining the helper function. | |
| """ | |
| options_with_values = set() | |
| for data in completion_data.values(): | |
| for argument in data.arguments: | |
| if not argument.is_flag() and argument.parameter.name: | |
| for name in argument.parameter.name: | |
| if name.startswith("-"): | |
| options_with_values.add(name) | |
| func_name = f"__fish_{prog_name}_using_command" | |
| lines = [ | |
| "# Helper function to check exact command path sequence", | |
| f"function {func_name}", | |
| " set -l cmd (commandline -opc)", | |
| " set -l subcommands", | |
| ] | |
| if options_with_values: | |
| escaped_opts = " ".join(_escape_fish_string(opt) for opt in sorted(options_with_values)) | |
| lines.append(f" set -l options_with_values '{escaped_opts}'") | |
| else: | |
| lines.append(" set -l options_with_values ''") | |
| lines.extend( | |
| [ | |
| " set -l skip_next 0", | |
| " # Extract non-option words (commands) from command line", | |
| " for i in (seq 2 (count $cmd))", | |
| " set -l word $cmd[$i]", | |
| " if test $skip_next -eq 1", | |
| " set skip_next 0", | |
| " continue", | |
| " end", | |
| " if string match -qr -- '^-' $word", | |
| " # Check if this option takes a value (exact match)", | |
| ' if string match -q -- "* $word *" " $options_with_values "', | |
| " set skip_next 1", | |
| " end", | |
| " else", | |
| " # Non-option word is a command", | |
| " set -a subcommands $word", | |
| " end", | |
| " end", | |
| " # Check if subcommand sequence matches expected path", | |
| " if test (count $subcommands) -ne (count $argv)", | |
| " return 1", | |
| " end", | |
| " for i in (seq 1 (count $argv))", | |
| " if test $subcommands[$i] != $argv[$i]", | |
| " return 1", | |
| " end", | |
| " end", | |
| " return 0", | |
| "end", | |
| ] | |
| ) | |
| return lines | |
| def _map_completion_action_to_fish(action: CompletionAction) -> str: | |
| """Map completion action to fish flags. | |
| Parameters | |
| ---------- | |
| action : CompletionAction | |
| Completion action type. | |
| Returns | |
| ------- | |
| str | |
| Fish completion flags ("-r -F" for files, "-r -a '(...)'" for directories, "" otherwise). | |
| """ | |
| if action == CompletionAction.FILES: | |
| return "-r -F" | |
| if action == CompletionAction.DIRECTORIES: | |
| return "-r -a '(__fish_complete_directories)'" | |
| return "" | |
| def _generate_completions( | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| prog_name: str, | |
| help_flags: tuple[str, ...], | |
| version_flags: tuple[str, ...], | |
| ) -> list[str]: | |
| """Generate all fish completion commands. | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| Extracted completion data. | |
| prog_name : str | |
| Program name. | |
| help_flags : tuple[str, ...] | |
| Help flags. | |
| version_flags : tuple[str, ...] | |
| Version flags. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| lines = [] | |
| for command_path, _data in sorted(completion_data.items()): | |
| lines.extend( | |
| _generate_completions_for_path( | |
| completion_data, | |
| command_path, | |
| prog_name, | |
| help_flags, | |
| version_flags, | |
| ) | |
| ) | |
| if command_path != max(completion_data.keys(), key=len): | |
| lines.append("") | |
| return lines | |
| def _generate_completions_for_path( | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| command_path: tuple[str, ...], | |
| prog_name: str, | |
| help_flags: tuple[str, ...], | |
| version_flags: tuple[str, ...], | |
| ) -> list[str]: | |
| """Generate completions for a specific command path. | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| Extracted completion data. | |
| command_path : tuple[str, ...] | |
| Command path. | |
| prog_name : str | |
| Program name. | |
| help_flags : tuple[str, ...] | |
| Help flags. | |
| version_flags : tuple[str, ...] | |
| Version flags. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| if command_path not in completion_data: | |
| return [] | |
| data = completion_data[command_path] | |
| lines = [] | |
| condition = _get_condition_for_path(command_path, prog_name) | |
| lines.extend(_generate_subcommand_completions(data, command_path, prog_name, condition)) | |
| keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] | |
| if keyword_args or help_flags or version_flags: | |
| lines.extend(_generate_option_section_header(command_path)) | |
| lines.extend(_generate_help_version_completions(prog_name, condition, help_flags, version_flags)) | |
| lines.extend(_generate_keyword_arg_completions(keyword_args, prog_name, condition, data.help_format)) | |
| lines.extend(_generate_command_option_completions(data.commands, prog_name, condition, data.help_format)) | |
| return lines | |
| def _generate_subcommand_completions( | |
| data: CompletionData, | |
| command_path: tuple[str, ...], | |
| prog_name: str, | |
| condition: str, | |
| ) -> list[str]: | |
| """Generate completions for subcommands. | |
| Parameters | |
| ---------- | |
| data : CompletionData | |
| Completion data. | |
| command_path : tuple[str, ...] | |
| Command path. | |
| prog_name : str | |
| Program name. | |
| condition : str | |
| Fish condition. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| commands = [ | |
| name for registered_command in data.commands for name in registered_command.names if not name.startswith("-") | |
| ] | |
| if not commands: | |
| return [] | |
| lines = [] | |
| if command_path: | |
| lines.append(f"# Subcommands for: {' '.join(command_path)}") | |
| else: | |
| lines.append("# Root-level commands") | |
| for registered_command in data.commands: | |
| for cmd_name in registered_command.names: | |
| if cmd_name.startswith("-"): | |
| continue | |
| desc = _get_description_from_app(registered_command.app, data.help_format) | |
| escaped_desc = _escape_fish_description(desc) | |
| escaped_cmd = _escape_fish_string(cmd_name) | |
| lines.append(f"complete -c {prog_name} {condition} -a '{escaped_cmd}' -d '{escaped_desc}'") | |
| return lines | |
| def _generate_option_section_header(command_path: tuple[str, ...]) -> list[str]: | |
| """Generate section header comment for options. | |
| Parameters | |
| ---------- | |
| command_path : tuple[str, ...] | |
| Command path. | |
| Returns | |
| ------- | |
| list[str] | |
| Comment line. | |
| """ | |
| if command_path: | |
| return [f"# Options for: {' '.join(command_path)}"] | |
| return ["# Root-level options"] | |
| def _generate_help_version_completions( | |
| prog_name: str, | |
| condition: str, | |
| help_flags: tuple[str, ...], | |
| version_flags: tuple[str, ...], | |
| ) -> list[str]: | |
| """Generate completions for help and version flags. | |
| Parameters | |
| ---------- | |
| prog_name : str | |
| Program name. | |
| condition : str | |
| Fish condition. | |
| help_flags : tuple[str, ...] | |
| Help flags. | |
| version_flags : tuple[str, ...] | |
| Version flags. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| lines = [] | |
| for flag in help_flags: | |
| if flag.startswith("--"): | |
| long_name = flag[2:] | |
| lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display this message and exit.'") | |
| elif flag.startswith("-") and len(flag) == 2: | |
| short_name = flag[1] | |
| lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display this message and exit.'") | |
| for flag in version_flags: | |
| if flag.startswith("--"): | |
| long_name = flag[2:] | |
| lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display application version.'") | |
| elif flag.startswith("-") and len(flag) == 2: | |
| short_name = flag[1] | |
| lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display application version.'") | |
| return lines | |
| def _generate_keyword_arg_completions( | |
| keyword_args: list, | |
| prog_name: str, | |
| condition: str, | |
| help_format: str, | |
| ) -> list[str]: | |
| """Generate completions for keyword arguments. | |
| Parameters | |
| ---------- | |
| keyword_args : list | |
| Keyword arguments. | |
| prog_name : str | |
| Program name. | |
| condition : str | |
| Fish condition. | |
| help_format : str | |
| Help text format. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| lines = [] | |
| for argument in keyword_args: | |
| desc = strip_markup(argument.parameter.help or "", format=help_format) | |
| escaped_desc = _escape_fish_description(desc) | |
| is_flag = argument.is_flag() | |
| choices = argument.get_choices(force=True) | |
| action = get_completion_action(argument.hint) | |
| for name in argument.parameter.name or []: | |
| if not name.startswith("-"): | |
| continue | |
| if name.startswith("--"): | |
| long_name = name[2:] | |
| line_parts = [f"complete -c {prog_name} {condition} -l {long_name}"] | |
| elif len(name) == 2: | |
| short_name = name[1] | |
| line_parts = [f"complete -c {prog_name} {condition} -s {short_name}"] | |
| else: | |
| continue | |
| if is_flag: | |
| line_parts.append(f"-d '{escaped_desc}'") | |
| elif choices: | |
| escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices] | |
| choices_str = " ".join(escaped_choices) | |
| line_parts.append(f"-x -a '{choices_str}' -d '{escaped_desc}'") | |
| else: | |
| action_flags = _map_completion_action_to_fish(action) | |
| if action_flags: | |
| line_parts.append(f"{action_flags} -d '{escaped_desc}'") | |
| else: | |
| line_parts.append(f"-r -d '{escaped_desc}'") | |
| lines.append(" ".join(line_parts)) | |
| for name in argument.negatives: | |
| if not name.startswith("-"): | |
| continue | |
| if name.startswith("--"): | |
| long_name = name[2:] | |
| lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") | |
| elif len(name) == 2: | |
| short_name = name[1] | |
| lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") | |
| return lines | |
| def _generate_command_option_completions( | |
| commands: list, | |
| prog_name: str, | |
| condition: str, | |
| help_format: str, | |
| ) -> list[str]: | |
| """Generate completions for commands that look like options. | |
| Parameters | |
| ---------- | |
| commands : list | |
| List of RegisteredCommand tuples. | |
| prog_name : str | |
| Program name. | |
| condition : str | |
| Fish condition. | |
| help_format : str | |
| Help text format. | |
| Returns | |
| ------- | |
| list[str] | |
| Completion command lines. | |
| """ | |
| lines = [] | |
| for registered_command in commands: | |
| for cmd_name in registered_command.names: | |
| if not cmd_name.startswith("-"): | |
| continue | |
| desc = _get_description_from_app(registered_command.app, help_format) | |
| escaped_desc = _escape_fish_description(desc) | |
| if cmd_name.startswith("--"): | |
| long_name = cmd_name[2:] | |
| lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") | |
| elif len(cmd_name) == 2: | |
| short_name = cmd_name[1] | |
| lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") | |
| return lines | |
| def _get_condition_for_path(command_path: tuple[str, ...], prog_name: str) -> str: | |
| """Generate fish condition string for a command path. | |
| Parameters | |
| ---------- | |
| command_path : tuple[str, ...] | |
| Command path (empty for root). | |
| prog_name : str | |
| Program name. | |
| Returns | |
| ------- | |
| str | |
| Fish condition flag. | |
| """ | |
| if not command_path: | |
| return "-n __fish_use_subcommand" | |
| func_name = f"__fish_{prog_name}_using_command" | |
| escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in command_path) | |
| return f"-n '{func_name} {escaped_commands}'" | |
| def _get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) -> str: | |
| """Extract description from App. | |
| Parameters | |
| ---------- | |
| cmd_app : App | CommandSpec | |
| Command app or spec. | |
| help_format : str | |
| Help text format. | |
| Returns | |
| ------- | |
| str | |
| Description text. | |
| """ | |
| from cyclopts.help.help import docstring_parse | |
| try: | |
| parsed = docstring_parse(cmd_app.help, "plaintext") | |
| text = parsed.short_description or "" | |
| except Exception: | |
| text = str(cmd_app.help or "") | |
| return strip_markup(text, format=help_format) | |