From ffd32dfe8a5d9ca89554552d11cb9fa0f3904bff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Mon, 6 Oct 2025 20:38:53 +0545 Subject: [PATCH 1/3] completion: support fish completion Fixes #10879. Upstream PR: https://github.com/iterative/shtab/pull/195. --- dvc/cli/completion.py | 81 +++++++++++++++++++++++++++++++++++--- dvc/commands/completion.py | 2 +- pyproject.toml | 3 +- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/dvc/cli/completion.py b/dvc/cli/completion.py index b257bc20b3..36568f13b7 100644 --- a/dvc/cli/completion.py +++ b/dvc/cli/completion.py @@ -74,22 +74,72 @@ } """ +FISH_PREAMBLE = """ +function __fish_complete_dvc_files + __fish_complete_path | string match -re '\\*?.dvc|Dvcfile|dvc\\.yaml' +end + +function __fish_complete_dvc_stages + for line in (dvc stage list -q) + set -l parts (string split -m1 ' ' -- $line) + set -l name $parts[1] + set -l desc (string trim $parts[2]) + echo -e "$name $desc" + end +end + +function __fish_complete_dvc_stages_and_files + __fish_complete_dvc_stages + __fish_complete_dvc_files +end + +function __fish_complete_dvc_experiments + dvc exp list -q --all-commits --names-only +end + +function __fish_complete_dvc_remotes + dvc remote list | cut -d' ' -f1 +end +""" + PREAMBLE = { "bash": BASH_PREAMBLE, "zsh": ZSH_PREAMBLE, + "fish": FISH_PREAMBLE, } FILE = shtab.FILE DIR = shtab.DIRECTORY -DVC_FILE = {"bash": "_dvc_compgen_DVCFiles", "zsh": "_dvc_compadd_DVCFiles"} -STAGE = {"bash": "_dvc_compgen_stages", "zsh": "_dvc_compadd_stages"} +DVC_FILE = { + "bash": "_dvc_compgen_DVCFiles", + "zsh": "_dvc_compadd_DVCFiles", + "fish": "__fish_complete_dvc_files", +} +STAGE = { + "bash": "_dvc_compgen_stages", + "zsh": "_dvc_compadd_stages", + "fish": "__fish_complete_dvc_stages", +} DVCFILES_AND_STAGE = { "bash": "_dvc_compgen_stages_and_files", "zsh": "_dvc_compadd_stages_and_files", + "fish": "__fish_complete_dvc_stages_and_files", +} +EXPERIMENT = { + "bash": "_dvc_compgen_exps", + "zsh": "_dvc_compadd_exps", + "fish": "__fish_complete_dvc_experiments", +} +REMOTE = { + "bash": "_dvc_compgen_remotes", + "zsh": "_dvc_compadd_remotes", + "fish": "__fish_complete_dvc_remotes", +} +CONFIG_VARS = { + "bash": "_dvc_compgen_config_vars", + "zsh": "_dvc_compadd_config_vars", + "fish": "__fish_complete_dvc_config_vars", } -EXPERIMENT = {"bash": "_dvc_compgen_exps", "zsh": "_dvc_compadd_exps"} -REMOTE = {"bash": "_dvc_compgen_remotes", "zsh": "_dvc_compadd_remotes"} -CONFIG_VARS = {"bash": "_dvc_compgen_config_vars", "zsh": "_dvc_compadd_config_vars"} def get_preamble() -> dict[str, str]: @@ -103,7 +153,26 @@ def get_preamble() -> dict[str, str]: _dvc_config_vars=( {nl.join(config_vars)} ) +""" + indent = "\t\t".expandtabs(4) # 8 spaces + lines = ( + "\n".join( + f"{indent}{c} \\" + for c in config_vars[:-1] # all but last + ) + + "\n" + + f"{indent}{config_vars[-1]}" + ) # last line without backslash + config_vars_arr_fish = f""" +function __fish_complete_dvc_config_vars + set -l _dvc_config_vars \\ +{lines} + printf %s\\n $_dvc_config_vars +end """ for shell, preamble in PREAMBLE.items(): - ret[shell] = config_vars_arr + preamble + if shell != "fish": + ret[shell] = config_vars_arr + preamble + else: + ret[shell] = config_vars_arr_fish + preamble return ret diff --git a/dvc/commands/completion.py b/dvc/commands/completion.py index ca492df39d..0efec61378 100644 --- a/dvc/commands/completion.py +++ b/dvc/commands/completion.py @@ -8,7 +8,7 @@ logger = logger.getChild(__name__) -SUPPORTED_SHELLS = ["bash", "zsh"] +SUPPORTED_SHELLS = ["bash", "zsh", "fish"] class CmdCompletion(CmdBaseNoRepo): diff --git a/pyproject.toml b/pyproject.toml index 48454abd87..03a017ebf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,8 @@ dependencies = [ "ruamel.yaml>=0.17.11", "scmrepo>=3.5.2,<4", "shortuuid>=0.5", - "shtab<2,>=1.3.4", + # "shtab<2,>=1.3.4", + "shtab @ git+https://github.com/skshetry/shtab.git@fish-shell-v2", "tabulate>=0.8.7", "tomlkit>=0.11.1", "tqdm<5,>=4.63.1", From f2b1c41cdf83a9d8aec031523c4408634c6d2ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Tue, 7 Oct 2025 14:34:39 +0545 Subject: [PATCH 2/3] fix remote config completion --- dvc/cli/completion.py | 67 ++++++++++++++++++++++++++++++++++++++---- dvc/commands/config.py | 12 ++++++++ dvc/commands/remote.py | 4 ++- dvc/config_schema.py | 47 ++++++++++++++++++++--------- 4 files changed, 109 insertions(+), 21 deletions(-) diff --git a/dvc/cli/completion.py b/dvc/cli/completion.py index 36568f13b7..7106aacd00 100644 --- a/dvc/cli/completion.py +++ b/dvc/cli/completion.py @@ -31,6 +31,27 @@ _dvc_compgen_config_vars() { compgen -W "${_dvc_config_vars[*]}" -- $1 } + +_dvc_compgen_remote_config_vars() { + local cur prev words cword remote_name filtered + _init_completion || return + + # last non-option word before cursor + local nonopts=() + for w in "${words[@]:1:$((cword-1))}"; do + [[ $w == -* ]] || nonopts+=("$w") + done + + remote_name="${nonopts[-1]}" + if [[ -n $remote_name ]]; then + local _dvc_remote_config_vars=($( + dvc config --available-options -q 2> /dev/null \ + | grep "^remote\\.${remote_name}\\." \ + | sed "s/^remote\\.${remote_name}\\.//" + )) + compgen -W "${_dvc_remote_config_vars[*]}" -- $1 + fi +} """ ZSH_PREAMBLE = """ @@ -69,9 +90,27 @@ _describe 'remotes' "($(dvc remote list | cut -d' ' -f1))" } +_dvc_config_config_vars_for_completion() { + dvc config --available-options -q 2> /dev/null +} + _dvc_compadd_config_vars() { _describe 'config_vars' _dvc_config_vars } + +_dvc_compadd_remote_config_vars() { + local remote_name filtered + + # last non-option word before cursor + remote_name=${${words[1,CURRENT-1]##-*}[-1]} + if [[ -n $remote_name ]]; then + filtered=$(_dvc_config_config_vars_for_completion \\ + | grep "^remote.${remote_name}\\\\." \\ + | sed "s/^remote\\\\.${remote_name}\\\\.//" + ) + compadd -- $=filtered + fi +} """ FISH_PREAMBLE = """ @@ -100,6 +139,17 @@ function __fish_complete_dvc_remotes dvc remote list | cut -d' ' -f1 end + +function __fish_complete_dvc_remote_config_vars + # last non-option word before cursor + set -l remote_name (commandline -opc | string match -rv '^-' | tail -n 1) + + if test -n "$remote_name" + dvc config --available-options -q 2>/dev/null | + string match -re "^remote\\.$remote_name\\." | + string replace -r "^remote\\.$remote_name\\." '' + end +end """ PREAMBLE = { @@ -113,32 +163,37 @@ DVC_FILE = { "bash": "_dvc_compgen_DVCFiles", "zsh": "_dvc_compadd_DVCFiles", - "fish": "__fish_complete_dvc_files", + "fish": '-f -a "(__fish_complete_dvc_files)"', } STAGE = { "bash": "_dvc_compgen_stages", "zsh": "_dvc_compadd_stages", - "fish": "__fish_complete_dvc_stages", + "fish": '-f -a "(__fish_complete_dvc_stages)"', } DVCFILES_AND_STAGE = { "bash": "_dvc_compgen_stages_and_files", "zsh": "_dvc_compadd_stages_and_files", - "fish": "__fish_complete_dvc_stages_and_files", + "fish": '-f -a "(__fish_complete_dvc_stages_and_files)"', } EXPERIMENT = { "bash": "_dvc_compgen_exps", "zsh": "_dvc_compadd_exps", - "fish": "__fish_complete_dvc_experiments", + "fish": '-f -a "(__fish_complete_dvc_experiments)"', } REMOTE = { "bash": "_dvc_compgen_remotes", "zsh": "_dvc_compadd_remotes", - "fish": "__fish_complete_dvc_remotes", + "fish": '-f -a "(__fish_complete_dvc_remotes)"', } CONFIG_VARS = { "bash": "_dvc_compgen_config_vars", "zsh": "_dvc_compadd_config_vars", - "fish": "__fish_complete_dvc_config_vars", + "fish": '-f -a "(__fish_complete_dvc_config_vars)"', +} +REMOTE_CONFIG_VARS = { + "bash": "_dvc_compgen_remote_config_vars", + "zsh": "_dvc_compadd_remote_config_vars", + "fish": '-f -a "(__fish_complete_dvc_remote_config_vars)"', } diff --git a/dvc/commands/config.py b/dvc/commands/config.py index 70fdd4207c..acddb305ff 100644 --- a/dvc/commands/config.py +++ b/dvc/commands/config.py @@ -40,6 +40,15 @@ def __init__(self, args): self.config = Config.from_cwd(validate=False) def run(self): + if self.args.available_options: + from dvc.config_schema import contextual_config_vars_for_completion + + ui.write( + "\n".join(list(contextual_config_vars_for_completion(self.config))), + force=True, + ) + return 0 + if self.args.show_origin and (self.args.value or self.args.unset): logger.error( "--show-origin can't be used together with any of these " @@ -212,6 +221,9 @@ def add_parser(subparsers, parent_parser): help=CONFIG_HELP, formatter_class=formatter.RawDescriptionHelpFormatter, ) + config_parser.add_argument( + "--available-options", default=False, action="store_true" + ) config_parser.add_argument( "-u", "--unset", diff --git a/dvc/commands/remote.py b/dvc/commands/remote.py index bf727cadfd..689bccac00 100644 --- a/dvc/commands/remote.py +++ b/dvc/commands/remote.py @@ -233,7 +233,9 @@ def add_parser(subparsers, parent_parser): remote_modify_parser.add_argument( "name", help="Name of the remote" ).complete = completion.REMOTE - remote_modify_parser.add_argument("option", help="Name of the option to modify.") + remote_modify_parser.add_argument( + "option", help="Name of the option to modify." + ).complete = completion.REMOTE_CONFIG_VARS remote_modify_parser.add_argument( "value", nargs="?", help="(optional) Value of the option." ) diff --git a/dvc/config_schema.py b/dvc/config_schema.py index a4e00ff8a2..59844f04d5 100644 --- a/dvc/config_schema.py +++ b/dvc/config_schema.py @@ -60,23 +60,27 @@ def Choices(*choices): # noqa: N802 return Any(*choices, msg="expected one of {}".format(", ".join(choices))) +def _get_schema_from_url(url: str) -> str: + parsed = urlparse(url) + # Windows absolute paths should really have scheme == "" (local) + if os.name == "nt" and len(parsed.scheme) == 1 and not parsed.netloc: + return "" + if not parsed.netloc: + return "" + return parsed.scheme + + def ByUrl(mapping): # noqa: N802 - schemas = walk_values(Schema, mapping) + schemas: dict[str, Schema] = walk_values(Schema, mapping) def validate(data): if "url" not in data: raise Invalid("expected 'url'") - parsed = urlparse(data["url"]) - # Windows absolute paths should really have scheme == "" (local) - if os.name == "nt" and len(parsed.scheme) == 1 and not parsed.netloc: - return schemas[""](data) - if not parsed.netloc: - return schemas[""](data) - if parsed.scheme not in schemas: - raise Invalid(f"Unsupported URL type {parsed.scheme}://") - - return schemas[parsed.scheme](data) + scheme = _get_schema_from_url(data["url"]) + if scheme not in schemas: + raise Invalid(f"Unsupported URL type {scheme}://") + return schemas[scheme](data) return validate @@ -396,9 +400,24 @@ def config_vars_for_completion(d: dict = SCHEMA, path: str = "") -> "Iterator[st k = k.schema if not isinstance(k, str): continue - - keypath = path + k + keypath = f"{path}.{k}" if path else k if isinstance(v, dict): - yield from config_vars_for_completion(v, keypath + ".") + yield from config_vars_for_completion(v, keypath) else: yield keypath + + +def contextual_config_vars_for_completion(config) -> "Iterator[str]": + yield from config_vars_for_completion() + for name, data in config["remote"].items(): + if "url" not in data: + continue + scheme = _get_schema_from_url(data["url"]) + if scheme not in REMOTE_SCHEMAS: + continue + schema = REMOTE_SCHEMAS[scheme] + if schema: + yield from config_vars_for_completion(schema, f"remote.{name}") # type: ignore[arg-type] + + for name in config["db"]: + yield from config_vars_for_completion(SCHEMA["db"], f"db.{name}") # type: ignore[arg-type] From a5ae8b19a403695006522ece02ec929147347630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Saugat=20Pachhai=20=28=E0=A4=B8=E0=A5=8C=E0=A4=97=E0=A4=BE?= =?UTF-8?q?=E0=A4=A4=29?= Date: Tue, 7 Oct 2025 21:06:58 +0545 Subject: [PATCH 3/3] fix db completions result --- dvc/config_schema.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dvc/config_schema.py b/dvc/config_schema.py index 59844f04d5..ca3a559c4f 100644 --- a/dvc/config_schema.py +++ b/dvc/config_schema.py @@ -419,5 +419,6 @@ def contextual_config_vars_for_completion(config) -> "Iterator[str]": if schema: yield from config_vars_for_completion(schema, f"remote.{name}") # type: ignore[arg-type] + db_schema = SCHEMA["db"][str] # type: ignore[index] for name in config["db"]: - yield from config_vars_for_completion(SCHEMA["db"], f"db.{name}") # type: ignore[arg-type] + yield from config_vars_for_completion(db_schema, f"db.{name}") # type: ignore[arg-type]