From 9b11abbd535354e941c85dd49560bf82d5c78d3c Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Wed, 1 Apr 2026 23:47:49 +0530 Subject: [PATCH 1/7] feat(cli): add generated shell completions --- README.md | 29 ++ bench/commands/__init__.py | 4 + bench/commands/completions.py | 559 ++++++++++++++++++++++++++++++++ bench/tests/test_completions.py | 105 ++++++ 4 files changed, 697 insertions(+) create mode 100644 bench/commands/completions.py create mode 100644 bench/tests/test_completions.py diff --git a/README.md b/README.md index 2bae0e2a6..68f902271 100755 --- a/README.md +++ b/README.md @@ -296,6 +296,35 @@ In case the setup fails, the log file is saved under `$HOME/easy-install.log`. Y $ bench --help ``` +## Shell Completion + +Bench can generate fast shell-native completion scripts for bash and zsh. + +Use it in the current shell: + +```sh +source <(bench completion zsh) +``` + +```sh +source <(bench completion bash) +``` + +Or install it for your user: + +```sh +bench completion install +``` + +You can also pick a shell explicitly: + +```sh +bench completion install zsh +bench completion install bash +``` + +This writes a generated script to a user config path and can append a matching `source ...` line to your shell rc file. Regenerate it if installed apps or custom Frappe commands change. + For more in-depth information on commands and their usage, follow [Commands and Usage](https://github.com/frappe/bench/blob/develop/docs/commands_and_usage.md). As for a consolidated list of bench commands, check out [Bench Usage](https://github.com/frappe/bench/blob/develop/docs/bench_usage.md). diff --git a/bench/commands/__init__.py b/bench/commands/__init__.py index 40ac8d5db..fc0ff80ad 100755 --- a/bench/commands/__init__.py +++ b/bench/commands/__init__.py @@ -131,3 +131,7 @@ def bench_command(bench_path="."): from bench.commands.install import install bench_command.add_command(install) + +from bench.commands.completions import completion + +bench_command.add_command(completion) diff --git a/bench/commands/completions.py b/bench/commands/completions.py new file mode 100644 index 000000000..456250b43 --- /dev/null +++ b/bench/commands/completions.py @@ -0,0 +1,559 @@ +import os +import shlex +from pathlib import Path + +import click + +from bench.utils import find_parent_bench, get_cmd_output, get_env_frappe_commands +from bench.utils.bench import get_env_cmd + + +ROOT_KEY = "__root__" +FRAPPE_KEY = "__frappe__" +MAX_FRAPPE_DEPTH = 4 +FORWARDED_FLAGS = ["--verbose", "-v", "--profile", "--force"] +FORWARDED_VALUE_OPTIONS = ["--site", "-s"] + + +@click.group( + "completion", + help="Generate a shell completion script without runtime Python completion calls.", +) +def completion(): + """Shell completion command group.""" + pass + + +@completion.command("bash", help="Generate a bash completion script.") +def completion_bash(): + from bench.commands import bench_command + + click.echo(generate_completion("bash", bench_command), nl=False) + + +@completion.command("zsh", help="Generate a zsh completion script.") +def completion_zsh(): + from bench.commands import bench_command + + click.echo(generate_completion("zsh", bench_command), nl=False) + + +@completion.command( + "install", help="Interactively install shell completion for the current user." +) +@click.argument("shell", required=False, type=click.Choice(["bash", "zsh"])) +@click.option( + "--path", type=click.Path(path_type=Path, dir_okay=False, resolve_path=True) +) +@click.option( + "--rc-file", type=click.Path(path_type=Path, dir_okay=False, resolve_path=True) +) +@click.option( + "--skip-rc", + is_flag=True, + help="Write the completion file but do not modify shell rc files.", +) +@click.option("--yes", is_flag=True, help="Accept detected defaults without prompting.") +def install_completion(shell, path, rc_file, skip_rc, yes): + from bench.commands import bench_command + + shell = shell or _detect_shell() + if shell not in {"bash", "zsh"}: + raise click.UsageError("Could not detect shell. Pass 'bash' or 'zsh'.") + + path = path or _default_completion_path(shell) + rc_file = rc_file or _default_rc_file(shell) + + if not yes: + path = Path( + click.prompt("Completion file", default=str(path), type=str) + ).expanduser() + if not skip_rc: + rc_default = str(rc_file) if rc_file else "" + rc_response = click.prompt("Shell rc file", default=rc_default, type=str) + rc_file = Path(rc_response).expanduser() if rc_response else None + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(generate_completion(shell, bench_command), encoding="utf-8") + click.echo(f"Wrote completion script to {path}") + + if skip_rc: + click.echo(f"Source it manually with: source {shlex.quote(str(path))}") + return + + should_update_rc = yes or click.confirm( + f"Append a source line to {rc_file}?", default=True + ) + if not should_update_rc or rc_file is None: + click.echo(f"Source it manually with: source {shlex.quote(str(path))}") + return + + loader_line = _loader_line(path) + created = _ensure_line(rc_file, loader_line) + if created: + click.echo(f"Updated {rc_file}") + else: + click.echo(f"Loader already present in {rc_file}") + + click.echo("Open a new shell or source your rc file to activate completions.") + + +def generate_completion(shell: str, root_command: click.Command) -> str: + spec = build_completion_spec(root_command) + + if shell == "bash": + return render_bash_completion(spec) + + return render_zsh_completion(spec) + + +def build_completion_spec(root_command: click.Command) -> dict: + subcommands = {} + options = {} + value_options = {} + + _collect_command_tree(root_command, (), subcommands, options, value_options) + + bench_path = _find_current_bench_path() + frappe_commands = [] + if bench_path: + frappe_commands = _unique(get_env_frappe_commands(bench_path)) + _collect_frappe_tree( + bench_path, subcommands, options, value_options, frappe_commands + ) + + return { + "subcommands": subcommands, + "options": options, + "value_options": value_options, + "frappe_commands": frappe_commands, + } + + +def _find_current_bench_path() -> str | None: + current_dir = os.path.abspath(".") + return find_parent_bench(current_dir) + + +def _collect_frappe_tree( + bench_path, subcommands, options, value_options, fallback_commands +): + seen = set() + _walk_frappe_help( + bench_path, (), subcommands, options, value_options, seen, fallback_commands + ) + + +def _walk_frappe_help( + bench_path, path, subcommands, options, value_options, seen, fallback_commands +): + key = _path_key((FRAPPE_KEY, *path)) + if key in seen: + return + seen.add(key) + + help_text = _get_frappe_help_text(bench_path, path) + parsed = _parse_click_help(help_text) + + command_options = ["--help", *parsed["options"]] + command_value_options = parsed["value_options"] + children = parsed["commands"] + + if not path and fallback_commands: + children = _unique([*children, *fallback_commands]) + + options[key] = _unique(command_options) + value_options[key] = _unique(command_value_options) + subcommands[key] = _unique(children) + + if len(path) >= MAX_FRAPPE_DEPTH: + return + + for child in children: + _walk_frappe_help( + bench_path, + (*path, child), + subcommands, + options, + value_options, + seen, + [], + ) + + +def _get_frappe_help_text(bench_path, path) -> str: + python = get_env_cmd("python", bench_path=bench_path) + sites_path = os.path.join(bench_path, "sites") + args = " ".join(shlex.quote(part) for part in path) + cmd = f"{python} -m frappe.utils.bench_helper frappe" + if args: + cmd = f"{cmd} {args}" + cmd = f"{cmd} --help" + return get_cmd_output(cmd, cwd=sites_path, _raise=False) + + +def _parse_click_help(help_text: str) -> dict: + commands = [] + options = [] + value_options = [] + section = None + + for raw_line in help_text.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + + if stripped == "Options:": + section = "options" + continue + if stripped == "Commands:": + section = "commands" + continue + if not stripped: + continue + if not line.startswith(" "): + section = None + continue + + if section == "commands": + commands.append(stripped.split()[0]) + continue + + if section == "options" and stripped.startswith("-"): + for option_text in stripped.split(" ", 1)[0].split(","): + option_text = option_text.strip() + if not option_text.startswith("-"): + continue + parts = option_text.split() + options.append(parts[0]) + if len(parts) > 1: + value_options.append(parts[0]) + + return { + "commands": _unique(commands), + "options": _unique(options), + "value_options": _unique(value_options), + } + + +def _detect_shell() -> str | None: + shell = os.environ.get("SHELL", "").rsplit("/", 1)[-1] + return shell if shell in {"bash", "zsh"} else None + + +def _default_completion_path(shell: str) -> Path: + return Path.home() / ".config" / "bench" / f"completion.{shell}" + + +def _default_rc_file(shell: str) -> Path: + return Path.home() / f".{shell}rc" + + +def _loader_line(path: Path) -> str: + return f"source {shlex.quote(str(path))}" + + +def _ensure_line(path: Path, line: str) -> bool: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + content = path.read_text(encoding="utf-8") + if line in content: + return False + else: + content = "" + + with path.open("a", encoding="utf-8") as handle: + if content and not content.endswith("\n"): + handle.write("\n") + handle.write(line) + handle.write("\n") + + return True + + +def _collect_command_tree( + command: click.Command, path, subcommands, options, value_options +): + key = _path_key(path) + command_options = ["--help"] + command_value_options = [] + + for param in command.params: + if not isinstance(param, click.Option): + continue + + flags = _unique([*param.opts, *param.secondary_opts]) + command_options.extend(flags) + + if _option_takes_value(param): + command_value_options.extend(flags) + + options[key] = _unique(command_options) + value_options[key] = _unique(command_value_options) + + command_map = getattr(command, "commands", None) + if command_map is not None: + children = _unique(list(command_map.keys())) + subcommands[key] = children + + for name, child in command_map.items(): + _collect_command_tree( + child, (*path, name), subcommands, options, value_options + ) + else: + subcommands[key] = [] + + +def _option_takes_value(option: click.Option) -> bool: + return not option.is_flag and option.nargs != 0 + + +def _path_key(path) -> str: + return " ".join(path) if path else ROOT_KEY + + +def _unique(values): + return list(dict.fromkeys(value for value in values if value)) + + +def render_bash_completion(spec: dict) -> str: + return _render_completion_script(spec, shell="bash") + + +def render_zsh_completion(spec: dict) -> str: + return _render_completion_script(spec, shell="zsh") + + +def _render_completion_script(spec: dict, shell: str) -> str: + parts = [] + + if shell == "zsh": + parts.extend( + [ + "#compdef bench", + "autoload -U bashcompinit", + "bashcompinit", + "", + ] + ) + + parts.extend( + [ + "# shellcheck shell=bash", + f"_BENCH_ROOT_KEY={shlex.quote(ROOT_KEY)}", + f"_BENCH_FRAPPE_KEY={shlex.quote(FRAPPE_KEY)}", + f"_BENCH_FRAPPE_COMMANDS={shlex.quote(' '.join(spec['frappe_commands']))}", + f"_BENCH_FORWARDED_FLAGS={shlex.quote(' '.join(FORWARDED_FLAGS))}", + f"_BENCH_FORWARDED_VALUE_OPTIONS={shlex.quote(' '.join(FORWARDED_VALUE_OPTIONS))}", + "", + _render_case_function("_bench_subcommands_for", spec["subcommands"]), + "", + _render_case_function("_bench_options_for", spec["options"]), + "", + _render_case_function("_bench_value_options_for", spec["value_options"]), + "", + _BASH_RUNTIME, + "", + "complete -o nosort -F _bench_completion bench", + ] + ) + + return "\n".join(parts) + "\n" + + +def _render_case_function(name: str, mapping: dict) -> str: + lines = [f"{name}() {{", '\tcase "$1" in'] + + for key, values in mapping.items(): + joined = " ".join(values) + lines.append(f"\t\t{shlex.quote(key)}) printf '%s' {shlex.quote(joined)} ;;") + + lines.extend(["\t\t*) printf '%s' '' ;;", "\tesac", "}"]) + return "\n".join(lines) + + +_BASH_RUNTIME = r"""_bench_find_root() { + local dir="$PWD" + + while [[ -n "$dir" && "$dir" != "/" ]]; do + if [[ -d "$dir/apps" && -d "$dir/sites" && -d "$dir/config" && -d "$dir/logs" ]]; then + printf '%s\n' "$dir" + return 0 + fi + dir="${dir%/*}" + if [[ -z "$dir" ]]; then + break + fi + done + + return 1 +} + +_bench_list_sites() { + local root + local path + local site + + root="$(_bench_find_root)" || return 0 + + for path in "$root"/sites/*/site_config.json; do + [[ -f "$path" ]] || continue + site="${path%/site_config.json}" + site="${site##*/}" + printf '%s\n' "$site" + done +} + +_bench_list_apps() { + local root + local path + local app + + root="$(_bench_find_root)" || return 0 + + if [[ -f "$root/sites/apps.txt" ]]; then + while IFS= read -r path; do + [[ -n "$path" ]] || continue + printf '%s\n' "$path" + done < "$root/sites/apps.txt" + return 0 + fi + + for path in "$root"/apps/*; do + [[ -d "$path" ]] || continue + app="${path##*/}" + printf '%s\n' "$app" + done +} + +_bench_has_word() { + local needle="$1" + local haystack="$2" + local word + + for word in $haystack; do + [[ "$word" == "$needle" ]] && return 0 + done + + return 1 +} + +_bench_join_path() { + if [[ "$1" == "$_BENCH_ROOT_KEY" ]]; then + printf '%s' "$2" + return 0 + fi + + printf '%s %s' "$1" "$2" +} + +_bench_collect_context() { + local path="$_BENCH_ROOT_KEY" + local skip_next=0 + local index + local token + local value_opts + local subcommands + + for ((index = 1; index < COMP_CWORD; index++)); do + token="${COMP_WORDS[index]}" + + if (( skip_next )); then + skip_next=0 + continue + fi + + if [[ "$token" == "--" ]]; then + break + fi + + value_opts="$(_bench_value_options_for "$path")" + if [[ "$path" == "$_BENCH_ROOT_KEY" ]]; then + value_opts="$value_opts $_BENCH_FORWARDED_VALUE_OPTIONS" + fi + + if _bench_has_word "$token" "$value_opts"; then + skip_next=1 + continue + fi + + if [[ "$token" == -* ]]; then + continue + fi + + subcommands="$(_bench_subcommands_for "$path")" + if _bench_has_word "$token" "$subcommands"; then + path="$(_bench_join_path "$path" "$token")" + continue + fi + + if [[ "$path" == "$_BENCH_ROOT_KEY" ]] && _bench_has_word "$token" "$_BENCH_FRAPPE_COMMANDS"; then + path="$_BENCH_FRAPPE_KEY" + fi + done + + printf '%s' "$path" +} + +_bench_complete_words() { + local cur="$1" + local words="$2" + + COMPREPLY=( $(compgen -W "$words" -- "$cur") ) +} + +_bench_lines_to_words() { + local lines="$1" + + printf '%s' "${lines//$'\n'/ }" +} + +_bench_completion() { + local cur="${COMP_WORDS[COMP_CWORD]}" + local prev="" + local path + local words + local options + local subcommands + local dynamic_words + + COMPREPLY=() + + if (( COMP_CWORD > 0 )); then + prev="${COMP_WORDS[COMP_CWORD-1]}" + fi + + case "$prev" in + --site|-s) + dynamic_words="$(_bench_list_sites)" + dynamic_words="$(_bench_lines_to_words "$dynamic_words")" + _bench_complete_words "$cur" "$dynamic_words" + return 0 + ;; + --app) + dynamic_words="$(_bench_list_apps)" + dynamic_words="$(_bench_lines_to_words "$dynamic_words")" + _bench_complete_words "$cur" "$dynamic_words" + return 0 + ;; + esac + + path="$(_bench_collect_context)" + options="$(_bench_options_for "$path")" + subcommands="$(_bench_subcommands_for "$path")" + + if [[ "$path" == "$_BENCH_ROOT_KEY" ]]; then + subcommands="$subcommands $_BENCH_FRAPPE_COMMANDS" + options="$options $_BENCH_FORWARDED_FLAGS $_BENCH_FORWARDED_VALUE_OPTIONS" + elif [[ "$path" == "$_BENCH_FRAPPE_KEY" ]]; then + options="$_BENCH_FORWARDED_FLAGS $_BENCH_FORWARDED_VALUE_OPTIONS" + fi + + if [[ "$cur" == -* ]]; then + words="$options" + else + words="$subcommands $options" + fi + + _bench_complete_words "$cur" "$words" + return 0 +}""" diff --git a/bench/tests/test_completions.py b/bench/tests/test_completions.py new file mode 100644 index 000000000..160507f54 --- /dev/null +++ b/bench/tests/test_completions.py @@ -0,0 +1,105 @@ +import unittest +from pathlib import Path +from unittest.mock import patch + +from click.testing import CliRunner + +from bench.commands import bench_command +from bench.commands.completions import _loader_line, generate_completion + + +class TestBenchCompletionGeneration(unittest.TestCase): + def test_bash_completion_is_shell_only(self): + runner = CliRunner() + result = runner.invoke(bench_command, ["completion", "bash"]) + + self.assertEqual(result.exit_code, 0) + self.assertIn("_bench_subcommands_for()", result.output) + self.assertIn("complete -o nosort -F _bench_completion bench", result.output) + self.assertNotIn("_BENCH_COMPLETE", result.output) + + def test_generation_embeds_current_frappe_commands(self): + def fake_help(cmd, cwd=".", _raise=True): + if "frappe migrate --help" in cmd: + return ( + "Usage: frappe migrate [OPTIONS]\n\n" + "Options:\n" + " --skip-failing TEXT\n" + " --help Show this message and exit.\n" + ) + if "frappe list-apps --help" in cmd: + return ( + "Usage: frappe list-apps [OPTIONS]\n\n" + "Options:\n" + " --format TEXT\n" + " --help Show this message and exit.\n" + ) + return ( + "Usage: frappe [OPTIONS] COMMAND [ARGS]...\n\n" + "Options:\n" + " --site TEXT\n" + " --help Show this message and exit.\n\n" + "Commands:\n" + " migrate\n" + " list-apps\n" + ) + + with ( + patch( + "bench.commands.completions.get_env_frappe_commands", + return_value=["migrate", "list-apps", "migrate"], + ), + patch( + "bench.commands.completions.find_parent_bench", + return_value="/tmp/bench", + ), + patch("bench.commands.completions.get_env_cmd", return_value="python"), + patch("bench.commands.completions.get_cmd_output", side_effect=fake_help), + ): + script = generate_completion("bash", bench_command) + + self.assertIn("_BENCH_FRAPPE_COMMANDS='migrate list-apps'", script) + self.assertIn("__frappe__) printf '%s' 'migrate list-apps'", script) + self.assertIn("__frappe__) printf '%s' '--help --site'", script) + self.assertIn( + "'__frappe__ migrate') printf '%s' '--help --skip-failing'", script + ) + + def test_zsh_completion_bootstraps_bash_compat(self): + script = generate_completion("zsh", bench_command) + + self.assertIn("#compdef bench", script) + self.assertIn("autoload -U bashcompinit", script) + self.assertIn("complete -o nosort -F _bench_completion bench", script) + + def test_runtime_avoids_external_coreutils(self): + script = generate_completion("bash", bench_command) + + self.assertNotIn("tr '\\n' ' '", script) + self.assertNotIn("dirname", script) + self.assertNotIn("basename", script) + + def test_install_command_writes_script_and_rc_loader(self): + runner = CliRunner() + with runner.isolated_filesystem(): + completion_path = Path("completion.zsh").resolve() + rc_path = Path(".zshrc").resolve() + + result = runner.invoke( + bench_command, + [ + "completion", + "install", + "zsh", + "--path", + str(completion_path), + "--rc-file", + str(rc_path), + "--yes", + ], + ) + + self.assertEqual(result.exit_code, 0) + self.assertTrue(completion_path.exists()) + self.assertTrue(rc_path.exists()) + self.assertIn(_loader_line(completion_path), rc_path.read_text()) From 68eefadd2b54ed34a16ecba144f2a855048ec83d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 16:59:45 +0530 Subject: [PATCH 2/7] refactor(completions): single command, batch frappe spec collection - Collapse `bench completion bash/zsh/install` into one `bench completions` command; interactive by default, non-interactive when any flag is passed - Replace per-command --help subprocess calls with a single frappe Python process that introspects the click group via get_app_groups(), reducing generation time from ~40s (sequential) to ~1-2s - Extract the frappe introspection script to frappe_spec_collector.py so it can be linted, syntax-highlighted, and run directly for debugging - Fall back to parallel BFS of --help subprocesses for older frappe versions - Suppress progress output in non-interactive mode Co-Authored-By: Claude Sonnet 4.6 --- README.md | 24 +-- bench/commands/__init__.py | 4 +- bench/commands/completions.py | 192 ++++++++++++++---------- bench/commands/frappe_spec_collector.py | 60 ++++++++ bench/tests/test_completions.py | 18 +-- 5 files changed, 193 insertions(+), 105 deletions(-) create mode 100644 bench/commands/frappe_spec_collector.py diff --git a/README.md b/README.md index 68f902271..397f337f5 100755 --- a/README.md +++ b/README.md @@ -298,32 +298,24 @@ In case the setup fails, the log file is saved under `$HOME/easy-install.log`. Y ## Shell Completion -Bench can generate fast shell-native completion scripts for bash and zsh. +Bench supports tab completion for bash and zsh. -Use it in the current shell: +Run interactively to pick a shell and install location: ```sh -source <(bench completion zsh) +bench completions ``` -```sh -source <(bench completion bash) -``` - -Or install it for your user: +Or pass flags to skip the prompts: ```sh -bench completion install +bench completions --zsh +bench completions --bash ``` -You can also pick a shell explicitly: - -```sh -bench completion install zsh -bench completion install bash -``` +> **Note:** Run this from your frappe-bench directory. The command detects the bench root from the current working directory to include your installed apps' commands in the completion script. -This writes a generated script to a user config path and can append a matching `source ...` line to your shell rc file. Regenerate it if installed apps or custom Frappe commands change. +This writes a completion script to `~/.config/bench/` and appends a `source` line to your shell rc file. Re-run it after installing new apps or upgrading bench, since the script is generated from the current command tree. For more in-depth information on commands and their usage, follow [Commands and Usage](https://github.com/frappe/bench/blob/develop/docs/commands_and_usage.md). As for a consolidated list of bench commands, check out [Bench Usage](https://github.com/frappe/bench/blob/develop/docs/bench_usage.md). diff --git a/bench/commands/__init__.py b/bench/commands/__init__.py index fc0ff80ad..a74542168 100755 --- a/bench/commands/__init__.py +++ b/bench/commands/__init__.py @@ -132,6 +132,6 @@ def bench_command(bench_path="."): bench_command.add_command(install) -from bench.commands.completions import completion +from bench.commands.completions import completions -bench_command.add_command(completion) +bench_command.add_command(completions) diff --git a/bench/commands/completions.py b/bench/commands/completions.py index 456250b43..c5f95000b 100644 --- a/bench/commands/completions.py +++ b/bench/commands/completions.py @@ -14,57 +14,46 @@ FORWARDED_FLAGS = ["--verbose", "-v", "--profile", "--force"] FORWARDED_VALUE_OPTIONS = ["--site", "-s"] +# Path to the collector script that runs inside the frappe virtualenv. +# Kept as a separate file so it gets syntax highlighting, linting, and can be +# run or inspected directly without extracting it from a string constant. +_FRAPPE_SPEC_COLLECTOR = Path(__file__).parent / "frappe_spec_collector.py" -@click.group( - "completion", - help="Generate a shell completion script without runtime Python completion calls.", -) -def completion(): - """Shell completion command group.""" - pass - - -@completion.command("bash", help="Generate a bash completion script.") -def completion_bash(): - from bench.commands import bench_command - - click.echo(generate_completion("bash", bench_command), nl=False) - - -@completion.command("zsh", help="Generate a zsh completion script.") -def completion_zsh(): - from bench.commands import bench_command - - click.echo(generate_completion("zsh", bench_command), nl=False) - -@completion.command( - "install", help="Interactively install shell completion for the current user." +@click.command( + "completions", + help="Install shell completion for bench (bash or zsh).", ) -@click.argument("shell", required=False, type=click.Choice(["bash", "zsh"])) +@click.option("--bash", "shell", flag_value="bash", help="Generate bash completion.") +@click.option("--zsh", "shell", flag_value="zsh", help="Generate zsh completion.") @click.option( - "--path", type=click.Path(path_type=Path, dir_okay=False, resolve_path=True) + "--path", + type=click.Path(path_type=Path, dir_okay=False, resolve_path=True), + help="Where to write the completion script.", ) @click.option( - "--rc-file", type=click.Path(path_type=Path, dir_okay=False, resolve_path=True) + "--rc-file", + type=click.Path(path_type=Path, dir_okay=False, resolve_path=True), + help="Shell rc file to append the source line to.", ) @click.option( "--skip-rc", is_flag=True, help="Write the completion file but do not modify shell rc files.", ) -@click.option("--yes", is_flag=True, help="Accept detected defaults without prompting.") -def install_completion(shell, path, rc_file, skip_rc, yes): +def completions(shell, path, rc_file, skip_rc): from bench.commands import bench_command + interactive = not any([shell, path, rc_file, skip_rc]) + shell = shell or _detect_shell() if shell not in {"bash", "zsh"}: - raise click.UsageError("Could not detect shell. Pass 'bash' or 'zsh'.") + raise click.UsageError("Could not detect shell. Pass --bash or --zsh.") path = path or _default_completion_path(shell) rc_file = rc_file or _default_rc_file(shell) - if not yes: + if interactive: path = Path( click.prompt("Completion file", default=str(path), type=str) ).expanduser() @@ -74,20 +63,27 @@ def install_completion(shell, path, rc_file, skip_rc, yes): rc_file = Path(rc_response).expanduser() if rc_response else None path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(generate_completion(shell, bench_command), encoding="utf-8") + path.write_text( + generate_completion(shell, bench_command, verbose=interactive), encoding="utf-8" + ) click.echo(f"Wrote completion script to {path}") if skip_rc: click.echo(f"Source it manually with: source {shlex.quote(str(path))}") return - should_update_rc = yes or click.confirm( - f"Append a source line to {rc_file}?", default=True - ) - if not should_update_rc or rc_file is None: + if rc_file is None: click.echo(f"Source it manually with: source {shlex.quote(str(path))}") return + if interactive: + should_update_rc = click.confirm( + f"Append a source line to {rc_file}?", default=True + ) + if not should_update_rc: + click.echo(f"Source it manually with: source {shlex.quote(str(path))}") + return + loader_line = _loader_line(path) created = _ensure_line(rc_file, loader_line) if created: @@ -98,8 +94,10 @@ def install_completion(shell, path, rc_file, skip_rc, yes): click.echo("Open a new shell or source your rc file to activate completions.") -def generate_completion(shell: str, root_command: click.Command) -> str: - spec = build_completion_spec(root_command) +def generate_completion( + shell: str, root_command: click.Command, verbose: bool = True +) -> str: + spec = build_completion_spec(root_command, verbose=verbose) if shell == "bash": return render_bash_completion(spec) @@ -107,7 +105,7 @@ def generate_completion(shell: str, root_command: click.Command) -> str: return render_zsh_completion(spec) -def build_completion_spec(root_command: click.Command) -> dict: +def build_completion_spec(root_command: click.Command, verbose: bool = True) -> dict: subcommands = {} options = {} value_options = {} @@ -119,7 +117,7 @@ def build_completion_spec(root_command: click.Command) -> dict: if bench_path: frappe_commands = _unique(get_env_frappe_commands(bench_path)) _collect_frappe_tree( - bench_path, subcommands, options, value_options, frappe_commands + bench_path, subcommands, options, value_options, frappe_commands, verbose=verbose ) return { @@ -135,50 +133,92 @@ def _find_current_bench_path() -> str | None: return find_parent_bench(current_dir) -def _collect_frappe_tree( - bench_path, subcommands, options, value_options, fallback_commands -): - seen = set() - _walk_frappe_help( - bench_path, (), subcommands, options, value_options, seen, fallback_commands - ) +def _get_frappe_spec_batch(bench_path, verbose: bool = True) -> dict | None: + import json + import subprocess + python = get_env_cmd("python", bench_path=bench_path) + sites_path = os.path.join(bench_path, "sites") -def _walk_frappe_help( - bench_path, path, subcommands, options, value_options, seen, fallback_commands -): - key = _path_key((FRAPPE_KEY, *path)) - if key in seen: - return - seen.add(key) - - help_text = _get_frappe_help_text(bench_path, path) - parsed = _parse_click_help(help_text) + if verbose: + click.echo("Collecting frappe completion data...", err=True) - command_options = ["--help", *parsed["options"]] - command_value_options = parsed["value_options"] - children = parsed["commands"] + try: + proc = subprocess.run( + [python, str(_FRAPPE_SPEC_COLLECTOR)], + cwd=sites_path, + stdout=subprocess.PIPE, + stderr=None if verbose else subprocess.DEVNULL, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return None + return json.loads(proc.stdout) + except Exception: + return None - if not path and fallback_commands: - children = _unique([*children, *fallback_commands]) - options[key] = _unique(command_options) - value_options[key] = _unique(command_value_options) - subcommands[key] = _unique(children) +def _collect_frappe_tree( + bench_path, subcommands, options, value_options, fallback_commands, verbose: bool = True +): + spec = _get_frappe_spec_batch(bench_path, verbose=verbose) - if len(path) >= MAX_FRAPPE_DEPTH: + if spec is not None: + if FRAPPE_KEY in spec and fallback_commands: + spec[FRAPPE_KEY]["commands"] = _unique( + [*spec[FRAPPE_KEY]["commands"], *fallback_commands] + ) + for key, entry in spec.items(): + subcommands[key] = entry["commands"] + options[key] = entry["options"] + value_options[key] = entry["value_options"] return - for child in children: - _walk_frappe_help( - bench_path, - (*path, child), - subcommands, - options, - value_options, - seen, - [], - ) + # get_app_groups() isn't available on older frappe versions, so fall back to + # spawning one --help subprocess per command, parallelised across each BFS level. + from concurrent.futures import ThreadPoolExecutor, as_completed + + seen = set() + pending = [()] + + with ThreadPoolExecutor() as executor: + while pending: + to_fetch = [] + for path in pending: + key = _path_key((FRAPPE_KEY, *path)) + if key not in seen: + seen.add(key) + to_fetch.append(path) + + if not to_fetch: + break + + futures = { + executor.submit(_get_frappe_help_text, bench_path, path): path + for path in to_fetch + } + + next_pending = [] + for future in as_completed(futures): + path = futures[future] + key = _path_key((FRAPPE_KEY, *path)) + parsed = _parse_click_help(future.result()) + + command_options = ["--help", *parsed["options"]] + command_value_options = parsed["value_options"] + children = parsed["commands"] + + if not path and fallback_commands: + children = _unique([*children, *fallback_commands]) + + options[key] = _unique(command_options) + value_options[key] = _unique(command_value_options) + subcommands[key] = _unique(children) + + if len(path) < MAX_FRAPPE_DEPTH: + next_pending.extend((*path, child) for child in children) + + pending = next_pending def _get_frappe_help_text(bench_path, path) -> str: diff --git a/bench/commands/frappe_spec_collector.py b/bench/commands/frappe_spec_collector.py new file mode 100644 index 000000000..2e34517e8 --- /dev/null +++ b/bench/commands/frappe_spec_collector.py @@ -0,0 +1,60 @@ +""" +Collect the frappe click command tree and emit it as JSON to stdout. + +Run inside the frappe virtualenv (bench's Python) so that frappe is importable. +Progress lines are written to stderr so they can be shown or suppressed +independently of the JSON output. + +stdout: JSON object mapping completion-key strings to + {"options": [...], "value_options": [...], "commands": [...]} +stderr: one line per command as it is scanned +exit 1: if the frappe click group cannot be located +""" + +import json +import sys + +import click +import frappe.utils.bench_helper as _bh + +FRAPPE_KEY = "__frappe__" +MAX_DEPTH = 4 + + +def _walk(cmd, path, depth, result): + key = (FRAPPE_KEY + " " + " ".join(path)) if path else FRAPPE_KEY + opts, vopts, kids = [], [], [] + + for param in cmd.params: + if not isinstance(param, click.Option): + continue + flags = list(dict.fromkeys([*param.opts, *(param.secondary_opts or [])])) + opts.extend(flags) + if not param.is_flag and param.nargs != 0: + vopts.extend(flags) + + if hasattr(cmd, "commands") and depth < MAX_DEPTH: + for name, child in cmd.commands.items(): + kids.append(name) + _walk(child, path + [name], depth + 1, result) + + result[key] = { + "options": list(dict.fromkeys(["--help", *opts])), + "value_options": list(dict.fromkeys(vopts)), + "commands": kids, + } + + label = " ".join(["frappe", *path]) if path else "frappe" + print(f" {label}", file=sys.stderr, flush=True) + + +app_groups = _bh.get_app_groups() +frappe_group = app_groups.get("frappe") + +if frappe_group is None or not hasattr(frappe_group, "commands"): + print("error: frappe group not found in bench_helper", file=sys.stderr) + sys.exit(1) + +result = {} +_walk(frappe_group, [], 0, result) +print(json.dumps(result)) diff --git a/bench/tests/test_completions.py b/bench/tests/test_completions.py index 160507f54..7cd97bb7a 100644 --- a/bench/tests/test_completions.py +++ b/bench/tests/test_completions.py @@ -10,13 +10,11 @@ class TestBenchCompletionGeneration(unittest.TestCase): def test_bash_completion_is_shell_only(self): - runner = CliRunner() - result = runner.invoke(bench_command, ["completion", "bash"]) + script = generate_completion("bash", bench_command) - self.assertEqual(result.exit_code, 0) - self.assertIn("_bench_subcommands_for()", result.output) - self.assertIn("complete -o nosort -F _bench_completion bench", result.output) - self.assertNotIn("_BENCH_COMPLETE", result.output) + self.assertIn("_bench_subcommands_for()", script) + self.assertIn("complete -o nosort -F _bench_completion bench", script) + self.assertNotIn("_BENCH_COMPLETE", script) def test_generation_embeds_current_frappe_commands(self): def fake_help(cmd, cwd=".", _raise=True): @@ -79,7 +77,7 @@ def test_runtime_avoids_external_coreutils(self): self.assertNotIn("dirname", script) self.assertNotIn("basename", script) - def test_install_command_writes_script_and_rc_loader(self): + def test_non_interactive_writes_script_and_rc_loader(self): runner = CliRunner() with runner.isolated_filesystem(): completion_path = Path("completion.zsh").resolve() @@ -88,14 +86,12 @@ def test_install_command_writes_script_and_rc_loader(self): result = runner.invoke( bench_command, [ - "completion", - "install", - "zsh", + "completions", + "--zsh", "--path", str(completion_path), "--rc-file", str(rc_path), - "--yes", ], ) From 05c404442de28cfc6a687dc2cc1d8fa39451bf0d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 17:06:03 +0530 Subject: [PATCH 3/7] remove: delete legacy completion.sh completion.sh used Click's _BENCH_COMPLETE=complete mechanism (removed in Click 8), spawned Python on every keypress, used `cd` as a side effect during completion, and required hardcoded paths that only worked from the bench root. bench completions supersedes it entirely. Co-Authored-By: Claude Sonnet 4.6 --- completion.sh | 37 ------------------------------------- 1 file changed, 37 deletions(-) delete mode 100644 completion.sh diff --git a/completion.sh b/completion.sh deleted file mode 100644 index 264cafcee..000000000 --- a/completion.sh +++ /dev/null @@ -1,37 +0,0 @@ -_bench_completion() { - # Complete commands using click bashcomplete - COMPREPLY=( $( COMP_WORDS="${COMP_WORDS[*]}" \ - COMP_CWORD=$COMP_CWORD \ - _BENCH_COMPLETE=complete $1 ) ) - if [ -d "sites" ]; then - # Also add frappe commands if present - - # bench_helper.py expects to be executed from "sites" directory - cd sites - - # All frappe commands are subcommands under "bench frappe" - # Frappe is only installed in virtualenv "env" so use appropriate python executable - COMPREPLY+=( $( COMP_WORDS="bench frappe "${COMP_WORDS[@]:1} \ - COMP_CWORD=$(($COMP_CWORD+1)) \ - _BENCH_COMPLETE=complete ../env/bin/python ../apps/frappe/frappe/utils/bench_helper.py ) ) - - # If the word before the current cursor position in command typed so far is "--site" then only list sites - if [ ${COMP_WORDS[COMP_CWORD-1]} == "--site" ]; then - COMPREPLY=( $( ls -d ./*/site_config.json | cut -f 2 -d "/" | xargs echo ) ) - fi - - # Get out of sites directory now - cd .. - fi - return 0 -} - -# Only support bash and zsh -if [ -n "$BASH" ] ; then - complete -F _bench_completion -o default bench; -elif [ -n "$ZSH_VERSION" ]; then - # Use zsh in bash compatibility mode - autoload bashcompinit - bashcompinit - complete -F _bench_completion -o default bench; -fi From 9247e4dc9af24a00449bcfdc39b73c6bcf40d45f Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 17:12:03 +0530 Subject: [PATCH 4/7] fix(completions): always confirm rc-file modification unless --yes is passed Writing to ~/.zshrc without asking is too aggressive. The completion script file itself is safe to write silently, but dotfile modification should always prompt so users know what changed. --yes/-y opts out for scripts and dotfile managers. Co-Authored-By: Claude Sonnet 4.6 --- bench/commands/completions.py | 12 +++++++++--- bench/tests/test_completions.py | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/bench/commands/completions.py b/bench/commands/completions.py index c5f95000b..50d0e7110 100644 --- a/bench/commands/completions.py +++ b/bench/commands/completions.py @@ -41,10 +41,16 @@ is_flag=True, help="Write the completion file but do not modify shell rc files.", ) -def completions(shell, path, rc_file, skip_rc): +@click.option( + "--yes", + "-y", + is_flag=True, + help="Skip the rc-file confirmation (useful in scripts or dotfile setups).", +) +def completions(shell, path, rc_file, skip_rc, yes): from bench.commands import bench_command - interactive = not any([shell, path, rc_file, skip_rc]) + interactive = not any([shell, path, rc_file, skip_rc, yes]) shell = shell or _detect_shell() if shell not in {"bash", "zsh"}: @@ -76,7 +82,7 @@ def completions(shell, path, rc_file, skip_rc): click.echo(f"Source it manually with: source {shlex.quote(str(path))}") return - if interactive: + if not yes: should_update_rc = click.confirm( f"Append a source line to {rc_file}?", default=True ) diff --git a/bench/tests/test_completions.py b/bench/tests/test_completions.py index 7cd97bb7a..980ffbda0 100644 --- a/bench/tests/test_completions.py +++ b/bench/tests/test_completions.py @@ -92,6 +92,7 @@ def test_non_interactive_writes_script_and_rc_loader(self): str(completion_path), "--rc-file", str(rc_path), + "--yes", ], ) From a7b15f47c4c4411e7a1e7da8c1818152a456e386 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 17:17:48 +0530 Subject: [PATCH 5/7] refactor(completions): extract BFS fallback to reduce cognitive complexity Sonar flagged _collect_frappe_tree at complexity 20 (limit 15). Extracting the BFS fallback into _collect_frappe_tree_bfs brings both functions well under the threshold with no logic changes. Co-Authored-By: Claude Sonnet 4.6 --- bench/commands/completions.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bench/commands/completions.py b/bench/commands/completions.py index 50d0e7110..037148dac 100644 --- a/bench/commands/completions.py +++ b/bench/commands/completions.py @@ -182,6 +182,10 @@ def _collect_frappe_tree( # get_app_groups() isn't available on older frappe versions, so fall back to # spawning one --help subprocess per command, parallelised across each BFS level. + _collect_frappe_tree_bfs(bench_path, subcommands, options, value_options, fallback_commands) + + +def _collect_frappe_tree_bfs(bench_path, subcommands, options, value_options, fallback_commands): from concurrent.futures import ThreadPoolExecutor, as_completed seen = set() @@ -210,15 +214,12 @@ def _collect_frappe_tree( key = _path_key((FRAPPE_KEY, *path)) parsed = _parse_click_help(future.result()) - command_options = ["--help", *parsed["options"]] - command_value_options = parsed["value_options"] children = parsed["commands"] - if not path and fallback_commands: children = _unique([*children, *fallback_commands]) - options[key] = _unique(command_options) - value_options[key] = _unique(command_value_options) + options[key] = _unique(["--help", *parsed["options"]]) + value_options[key] = _unique(parsed["value_options"]) subcommands[key] = _unique(children) if len(path) < MAX_FRAPPE_DEPTH: From 5ddeaf47e7884e013c7fc2c725885c5da7f0e4a7 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 17:21:09 +0530 Subject: [PATCH 6/7] fix(tests): replace hardcoded /tmp/bench with TemporaryDirectory Hardcoded paths under /tmp are publicly writable and can be exploited via symlink attacks. TemporaryDirectory creates a private directory with restricted permissions and cleans it up automatically after the test. Co-Authored-By: Claude Sonnet 4.6 --- bench/tests/test_completions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bench/tests/test_completions.py b/bench/tests/test_completions.py index 980ffbda0..44cb89009 100644 --- a/bench/tests/test_completions.py +++ b/bench/tests/test_completions.py @@ -1,3 +1,4 @@ +import tempfile import unittest from pathlib import Path from unittest.mock import patch @@ -43,13 +44,14 @@ def fake_help(cmd, cwd=".", _raise=True): ) with ( + tempfile.TemporaryDirectory() as bench_dir, patch( "bench.commands.completions.get_env_frappe_commands", return_value=["migrate", "list-apps", "migrate"], ), patch( "bench.commands.completions.find_parent_bench", - return_value="/tmp/bench", + return_value=bench_dir, ), patch("bench.commands.completions.get_env_cmd", return_value="python"), patch("bench.commands.completions.get_cmd_output", side_effect=fake_help), From 96c094d94141c49619e212ac9a10cbcea391afcf Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 11 Apr 2026 17:23:11 +0530 Subject: [PATCH 7/7] fix(deps): bump requests and uv to fix known vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requests 2.32.5 → 2.33.0 (CVE-2026-25645) uv 0.9.30 → 0.11.6 (GHSA-pjjw-68hj-v9mw) Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4a9ee21c..8abe74ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,11 +24,11 @@ dependencies = [ "honcho", "Jinja2~=3.1.3", "python-crontab~=2.6.0", - "requests~=2.32.5", + "requests~=2.33.0", "semantic-version~=2.10.0", "setuptools>=71.0.0,<82.0.0", "tomli;python_version<'3.11'", - "uv~=0.9.0 " + "uv~=0.11.6" ] dynamic = [ "version",