diff --git a/README.md b/README.md index 2bae0e2a6..397f337f5 100755 --- a/README.md +++ b/README.md @@ -296,6 +296,27 @@ In case the setup fails, the log file is saved under `$HOME/easy-install.log`. Y $ bench --help ``` +## Shell Completion + +Bench supports tab completion for bash and zsh. + +Run interactively to pick a shell and install location: + +```sh +bench completions +``` + +Or pass flags to skip the prompts: + +```sh +bench completions --zsh +bench completions --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 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 40ac8d5db..a74542168 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 completions + +bench_command.add_command(completions) diff --git a/bench/commands/completions.py b/bench/commands/completions.py new file mode 100644 index 000000000..037148dac --- /dev/null +++ b/bench/commands/completions.py @@ -0,0 +1,606 @@ +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"] + +# 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.command( + "completions", + help="Install shell completion for bench (bash or 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), + help="Where to write the completion script.", +) +@click.option( + "--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", + "-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, yes]) + + 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 interactive: + 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, 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 + + if rc_file is None: + click.echo(f"Source it manually with: source {shlex.quote(str(path))}") + return + + if not yes: + 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: + 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, verbose: bool = True +) -> str: + spec = build_completion_spec(root_command, verbose=verbose) + + if shell == "bash": + return render_bash_completion(spec) + + return render_zsh_completion(spec) + + +def build_completion_spec(root_command: click.Command, verbose: bool = True) -> 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, verbose=verbose + ) + + 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 _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") + + if verbose: + click.echo("Collecting frappe completion data...", err=True) + + 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 + + +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 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 + + # 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() + 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()) + + children = parsed["commands"] + if not path and fallback_commands: + children = _unique([*children, *fallback_commands]) + + options[key] = _unique(["--help", *parsed["options"]]) + value_options[key] = _unique(parsed["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: + 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/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 new file mode 100644 index 000000000..44cb89009 --- /dev/null +++ b/bench/tests/test_completions.py @@ -0,0 +1,104 @@ +import tempfile +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): + script = generate_completion("bash", bench_command) + + 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): + 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 ( + 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=bench_dir, + ), + 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_non_interactive_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, + [ + "completions", + "--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()) 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 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",