Spaces:
Sleeping
Sleeping
| """Bash completion script generator. | |
| Generates static bash completion scripts using COMPREPLY and compgen. | |
| Targets bash 3.2+ with no external dependencies. | |
| """ | |
| import re | |
| from typing import TYPE_CHECKING | |
| from cyclopts.annotations import is_iterable_type | |
| from cyclopts.completion._base import ( | |
| CompletionAction, | |
| CompletionData, | |
| clean_choice_text, | |
| escape_for_shell_pattern, | |
| extract_completion_data, | |
| get_completion_action, | |
| ) | |
| if TYPE_CHECKING: | |
| from cyclopts import App | |
| def generate_completion_script(app: "App", prog_name: str) -> str: | |
| """Generate bash completion script. | |
| Parameters | |
| ---------- | |
| app : App | |
| The Cyclopts application to generate completion for. | |
| prog_name : str | |
| Program name (alphanumeric with hyphens/underscores). | |
| Returns | |
| ------- | |
| str | |
| Complete bash 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.") | |
| func_name = prog_name.replace("-", "_") | |
| completion_data = extract_completion_data(app) | |
| lines = [ | |
| f"# Bash completion for {prog_name}", | |
| "# Generated by Cyclopts", | |
| "", | |
| f"_{func_name}() {{", | |
| " local cur prev", | |
| "", | |
| ] | |
| lines.extend(_generate_completion_function_body(completion_data, prog_name, app)) | |
| lines.extend(["}"]) | |
| lines.append("") | |
| lines.append(f"complete -F _{func_name} {prog_name}") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def _escape_bash_choice(choice: str) -> str: | |
| r"""Escape single quotes for bash strings.""" | |
| return choice.replace("'", "'\\''") | |
| def _escape_bash_description(text: str) -> str: | |
| r"""Escape description text for bash comments.""" | |
| text = text.replace("\n", " ") | |
| text = text.replace("\r", " ") | |
| return text | |
| def _map_completion_action_to_bash(action: CompletionAction) -> str: | |
| """Map completion action to bash compgen flags. | |
| Parameters | |
| ---------- | |
| action : CompletionAction | |
| Completion action type. | |
| Returns | |
| ------- | |
| str | |
| Compgen flags ("-f", "-d", or ""). | |
| """ | |
| if action == CompletionAction.FILES: | |
| return "-f" | |
| elif action == CompletionAction.DIRECTORIES: | |
| return "-d" | |
| return "" | |
| def _generate_completion_function_body( | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| prog_name: str, | |
| app: "App", | |
| ) -> list[str]: | |
| """Generate the body of the bash completion function. | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| All extracted completion data. | |
| prog_name : str | |
| Program name. | |
| app : App | |
| Application instance. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for the completion function body. | |
| """ | |
| lines = [] | |
| lines.append(' cur="${COMP_WORDS[COMP_CWORD]}"') | |
| lines.append(' prev="${COMP_WORDS[COMP_CWORD-1]}"') | |
| lines.append("") | |
| lines.extend(_generate_command_path_detection(completion_data)) | |
| lines.append("") | |
| lines.extend(_generate_completion_logic(completion_data, prog_name, app)) | |
| return lines | |
| def _generate_command_path_detection(completion_data: dict[tuple[str, ...], CompletionData]) -> list[str]: | |
| """Generate bash code to detect the current command path. | |
| This function generates two passes through COMP_WORDS: | |
| 1. First pass builds cmd_path by identifying valid command names | |
| 2. Second pass counts positionals (non-option words after the command path) | |
| The two-pass approach is necessary because we need to know the full command | |
| path length before we can correctly identify which words are positionals. | |
| Note: all_commands is built globally across all command levels. If a positional | |
| argument value happens to match a command name from a different level, it could | |
| be incorrectly classified (though this represents poor CLI design). | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| All extracted completion data. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for command path detection. | |
| """ | |
| options_with_values = set() | |
| all_commands = 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) | |
| for registered_command in data.commands: | |
| for cmd_name in registered_command.names: | |
| if not cmd_name.startswith("-"): | |
| all_commands.add(cmd_name) | |
| lines = [] | |
| lines.append(" # Build list of options that take values (to skip their arguments)") | |
| if options_with_values: | |
| escaped_opts = [_escape_bash_choice(opt) for opt in sorted(options_with_values)] | |
| opts_str = " ".join(escaped_opts) | |
| lines.append(f" local options_with_values='{opts_str}'") | |
| else: | |
| lines.append(" local options_with_values=''") | |
| lines.append("") | |
| lines.append(" # Build list of all valid command names (to distinguish from positionals)") | |
| if all_commands: | |
| escaped_cmds = [_escape_bash_choice(cmd) for cmd in sorted(all_commands)] | |
| cmds_str = " ".join(escaped_cmds) | |
| lines.append(f" local all_commands='{cmds_str}'") | |
| else: | |
| lines.append(" local all_commands=''") | |
| lines.append("") | |
| lines.append(" # Detect command path by collecting valid command words only") | |
| lines.append(" local -a cmd_path=()") | |
| lines.append(" local i skip_next=0") | |
| lines.append(" for ((i=1; i<COMP_CWORD; i++)); do") | |
| lines.append(' local word="${COMP_WORDS[i]}"') | |
| lines.append(" if [[ $skip_next -eq 1 ]]; then") | |
| lines.append(" skip_next=0") | |
| lines.append(" continue") | |
| lines.append(" fi") | |
| lines.append(" if [[ $word =~ ^- ]]; then") | |
| lines.append(" # Check if this option takes a value") | |
| lines.append(' if [[ " $options_with_values " =~ " $word " ]]; then') | |
| lines.append(" skip_next=1") | |
| lines.append(" fi") | |
| lines.append(" else") | |
| lines.append(" # Non-option word - only add to cmd_path if it's a valid command") | |
| lines.append(' if [[ " $all_commands " =~ " $word " ]]; then') | |
| lines.append(' cmd_path+=("$word")') | |
| lines.append(" fi") | |
| lines.append(" fi") | |
| lines.append(" done") | |
| lines.append("") | |
| lines.append(" # Count positionals (non-option words after command path)") | |
| lines.append(" local positional_count=0") | |
| lines.append(" local cmd_path_len=${#cmd_path[@]}") | |
| lines.append(" skip_next=0") | |
| lines.append(" local cmd_depth=0") | |
| lines.append(" for ((i=1; i<COMP_CWORD; i++)); do") | |
| lines.append(' local word="${COMP_WORDS[i]}"') | |
| lines.append(" if [[ $skip_next -eq 1 ]]; then") | |
| lines.append(" skip_next=0") | |
| lines.append(" continue") | |
| lines.append(" fi") | |
| lines.append(" if [[ $word =~ ^- ]]; then") | |
| lines.append(' if [[ " $options_with_values " =~ " $word " ]]; then') | |
| lines.append(" skip_next=1") | |
| lines.append(" fi") | |
| lines.append(" else") | |
| lines.append(" # Non-option word") | |
| lines.append(" if [[ $cmd_depth -lt $cmd_path_len ]]; then") | |
| lines.append(" # Still in command path") | |
| lines.append(" ((cmd_depth++))") | |
| lines.append(" else") | |
| lines.append(" # Past command path, this is a positional") | |
| lines.append(" ((positional_count++))") | |
| lines.append(" fi") | |
| lines.append(" fi") | |
| lines.append(" done") | |
| return lines | |
| def _generate_completion_logic( | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| prog_name: str, | |
| app: "App", | |
| ) -> list[str]: | |
| """Generate the main completion logic using case statements. | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| All extracted completion data. | |
| prog_name : str | |
| Program name. | |
| app : App | |
| Application instance. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for completion logic. | |
| """ | |
| lines = [] | |
| help_flags = tuple(app.help_flags) if app.help_flags else () | |
| version_flags = tuple(app.version_flags) if app.version_flags else () | |
| lines.append(" # Determine command level and generate completions") | |
| lines.append(' case "${#cmd_path[@]}" in') | |
| max_depth = max(len(path) for path in completion_data.keys()) | |
| for depth in range(max_depth + 1): | |
| relevant_paths = [path for path in completion_data.keys() if len(path) == depth] | |
| if not relevant_paths: | |
| continue | |
| lines.append(f" {depth})") | |
| if depth == 0: | |
| lines.extend(_generate_completions_for_path(completion_data, (), " ", help_flags, version_flags)) | |
| else: | |
| lines.append(' case "${cmd_path[@]}" in') | |
| for path in sorted(relevant_paths): | |
| # Escape glob characters in command names for case pattern matching | |
| escaped_path = [escape_for_shell_pattern(cmd) for cmd in path] | |
| path_str = " ".join(escaped_path) | |
| lines.append(f' "{path_str}")') | |
| lines.extend( | |
| _generate_completions_for_path(completion_data, path, " ", help_flags, version_flags) | |
| ) | |
| lines.append(" ;;") | |
| lines.append(" *)") | |
| lines.append(" ;;") | |
| lines.append(" esac") | |
| lines.append(" ;;") | |
| lines.append(" *)") | |
| lines.append(" ;;") | |
| lines.append(" esac") | |
| return lines | |
| def _generate_completions_for_path( | |
| completion_data: dict[tuple[str, ...], CompletionData], | |
| command_path: tuple[str, ...], | |
| indent: str, | |
| help_flags: tuple[str, ...], | |
| version_flags: tuple[str, ...], | |
| ) -> list[str]: | |
| """Generate completions for a specific command path. | |
| Parameters | |
| ---------- | |
| completion_data : dict | |
| All extracted completion data. | |
| command_path : tuple[str, ...] | |
| Current command path. | |
| indent : str | |
| Indentation string. | |
| help_flags : tuple[str, ...] | |
| Help flag names. | |
| version_flags : tuple[str, ...] | |
| Version flag names. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for completions at this command path. | |
| """ | |
| if command_path not in completion_data: | |
| return [f"{indent}COMPREPLY=()"] | |
| data = completion_data[command_path] | |
| lines = [] | |
| options = [] | |
| keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] | |
| for argument in keyword_args: | |
| for name in argument.parameter.name or []: | |
| if name.startswith("-"): | |
| options.append(name) | |
| for name in argument.negatives: | |
| if name.startswith("-"): | |
| options.append(name) | |
| flag_commands = [] | |
| for registered_command in data.commands: | |
| for name in registered_command.names: | |
| if name.startswith("-"): | |
| flag_commands.append(name) | |
| for flag in help_flags: | |
| if flag.startswith("-") and flag not in options and flag not in flag_commands: | |
| options.append(flag) | |
| for flag in version_flags: | |
| if flag.startswith("-") and flag not in options and flag not in flag_commands: | |
| options.append(flag) | |
| options.extend(flag_commands) | |
| commands = [] | |
| for registered_command in data.commands: | |
| for cmd_name in registered_command.names: | |
| if not cmd_name.startswith("-"): | |
| commands.append(cmd_name) | |
| positional_args = [arg for arg in data.arguments if arg.index is not None and arg.show] | |
| positional_args.sort(key=lambda a: a.index if a.index is not None else 0) | |
| lines.append(f"{indent}if [[ ${{cur}} == -* ]]; then") | |
| if options: | |
| escaped_options = [_escape_bash_choice(opt) for opt in options] | |
| options_str = " ".join(escaped_options) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{options_str}' -- \"${{cur}}\") )") | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent}else") | |
| needs_value_completion = _check_if_prev_needs_value(data.arguments) | |
| if needs_value_completion: | |
| value_completion_lines = _generate_value_completion_for_prev( | |
| data.arguments, commands, positional_args, f"{indent} " | |
| ) | |
| lines.extend(value_completion_lines) | |
| elif commands: | |
| escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] | |
| commands_str = " ".join(escaped_commands) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") | |
| elif positional_args: | |
| lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent}fi") | |
| return lines | |
| def _generate_positional_completion(positional_args, indent: str) -> list[str]: | |
| """Generate position-aware positional argument completion. | |
| Parameters | |
| ---------- | |
| positional_args : list | |
| List of positional arguments sorted by index. | |
| indent : str | |
| Indentation string. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for position-aware positional completion. | |
| """ | |
| lines = [] | |
| if len(positional_args) == 1: | |
| # Single positional - simple case | |
| choices = positional_args[0].get_choices(force=True) | |
| action = get_completion_action(positional_args[0].hint) | |
| if choices: | |
| escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] | |
| choices_str = " ".join(escaped_choices) | |
| lines.append(f"{indent}COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") | |
| else: | |
| compgen_flag = _map_completion_action_to_bash(action) | |
| if compgen_flag: | |
| lines.append(f'{indent}COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') | |
| else: | |
| lines.append(f"{indent}COMPREPLY=()") | |
| else: | |
| # Multiple positionals - use case statement for position-aware completion | |
| lines.append(f"{indent}case ${{positional_count}} in") | |
| for idx, argument in enumerate(positional_args): | |
| choices = argument.get_choices(force=True) | |
| action = get_completion_action(argument.hint) | |
| lines.append(f"{indent} {idx})") | |
| if choices: | |
| escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] | |
| choices_str = " ".join(escaped_choices) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") | |
| else: | |
| compgen_flag = _map_completion_action_to_bash(action) | |
| if compgen_flag: | |
| lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent} ;;") | |
| # Default case for positions beyond defined positionals | |
| # If any positional is a collection type, use it as the default | |
| iterable_arg = next((arg for arg in positional_args if is_iterable_type(arg.hint)), None) | |
| lines.append(f"{indent} *)") | |
| if iterable_arg: | |
| choices = iterable_arg.get_choices(force=True) | |
| action = get_completion_action(iterable_arg.hint) | |
| if choices: | |
| escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] | |
| choices_str = " ".join(escaped_choices) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") | |
| else: | |
| compgen_flag = _map_completion_action_to_bash(action) | |
| if compgen_flag: | |
| lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent} ;;") | |
| lines.append(f"{indent}esac") | |
| return lines | |
| def _check_if_prev_needs_value(arguments) -> bool: | |
| """Check if any options take values, requiring prev-word completion logic. | |
| Parameters | |
| ---------- | |
| arguments : ArgumentCollection | |
| Arguments to check. | |
| Returns | |
| ------- | |
| bool | |
| True if any option (starts with -) takes a value (is not a flag). | |
| """ | |
| for argument in arguments: | |
| if not argument.is_flag(): | |
| for name in argument.parameter.name or []: | |
| if name.startswith("-"): | |
| return True | |
| return False | |
| def _generate_value_completion_for_prev(arguments, commands: list[str], positional_args, indent: str) -> list[str]: | |
| """Generate value completion based on previous word. | |
| Parameters | |
| ---------- | |
| arguments : ArgumentCollection | |
| Arguments with potential values. | |
| commands : list[str] | |
| Available commands at this level. | |
| positional_args : list | |
| List of positional arguments sorted by index. | |
| indent : str | |
| Indentation string. | |
| Returns | |
| ------- | |
| list[str] | |
| Lines of bash code for value completion. | |
| """ | |
| lines = [] | |
| lines.append(f'{indent}case "${{prev}}" in') | |
| has_cases = False | |
| for argument in arguments: | |
| if argument.is_flag(): | |
| continue | |
| names = [name for name in (argument.parameter.name or []) if name.startswith("-")] | |
| if not names: | |
| continue | |
| has_cases = True | |
| choices = argument.get_choices(force=True) | |
| action = get_completion_action(argument.hint) | |
| for name in names: | |
| lines.append(f"{indent} {name})") | |
| if choices: | |
| escaped_choices = [_escape_bash_choice(clean_choice_text(c)) for c in choices] | |
| choices_str = " ".join(escaped_choices) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{choices_str}' -- \"${{cur}}\") )") | |
| else: | |
| compgen_flag = _map_completion_action_to_bash(action) | |
| if compgen_flag: | |
| lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent} ;;") | |
| if has_cases: | |
| lines.append(f"{indent} *)") | |
| if commands: | |
| escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] | |
| commands_str = " ".join(escaped_commands) | |
| lines.append(f"{indent} COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") | |
| elif positional_args: | |
| lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) | |
| else: | |
| lines.append(f"{indent} COMPREPLY=()") | |
| lines.append(f"{indent} ;;") | |
| lines.append(f"{indent}esac") | |
| else: | |
| lines = [] | |
| if commands: | |
| escaped_commands = [_escape_bash_choice(cmd) for cmd in commands] | |
| commands_str = " ".join(escaped_commands) | |
| lines.append(f"{indent}COMPREPLY=( $(compgen -W '{commands_str}' -- \"${{cur}}\") )") | |
| elif positional_args: | |
| lines.extend(_generate_positional_completion(positional_args, indent)) | |
| else: | |
| lines.append(f"{indent}COMPREPLY=()") | |
| return lines | |