diff --git a/dvc/cli/completion.py b/dvc/cli/completion.py index b257bc20b3..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,27 +90,111 @@ _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 = """ +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 + +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 = { "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": '-f -a "(__fish_complete_dvc_files)"', +} +STAGE = { + "bash": "_dvc_compgen_stages", + "zsh": "_dvc_compadd_stages", + "fish": '-f -a "(__fish_complete_dvc_stages)"', +} DVCFILES_AND_STAGE = { "bash": "_dvc_compgen_stages_and_files", "zsh": "_dvc_compadd_stages_and_files", + "fish": '-f -a "(__fish_complete_dvc_stages_and_files)"', +} +EXPERIMENT = { + "bash": "_dvc_compgen_exps", + "zsh": "_dvc_compadd_exps", + "fish": '-f -a "(__fish_complete_dvc_experiments)"', +} +REMOTE = { + "bash": "_dvc_compgen_remotes", + "zsh": "_dvc_compadd_remotes", + "fish": '-f -a "(__fish_complete_dvc_remotes)"', +} +CONFIG_VARS = { + "bash": "_dvc_compgen_config_vars", + "zsh": "_dvc_compadd_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)"', } -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 +208,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/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..ca3a559c4f 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,25 @@ 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] + + db_schema = SCHEMA["db"][str] # type: ignore[index] + for name in config["db"]: + yield from config_vars_for_completion(db_schema, f"db.{name}") # type: ignore[arg-type] 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",