Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bash complete #2259

Merged
merged 5 commits into from
Aug 19, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.D/2259.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Implement URI autocompletion for bash shell.
169 changes: 120 additions & 49 deletions neuro-cli/src/neuro_cli/click_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import os
import re
from datetime import datetime, timedelta
from pathlib import Path
from typing import (
AsyncIterator,
Generic,
Expand All @@ -18,7 +17,12 @@

import click
from click import BadParameter
from click.shell_completion import CompletionItem, ZshComplete, add_completion_class
from click.shell_completion import (
BashComplete,
CompletionItem,
ZshComplete,
add_completion_class,
)
from yarl import URL

from neuro_sdk import (
Expand All @@ -29,7 +33,6 @@
ResourceNotFound,
TagOption,
)
from neuro_sdk.url_utils import _extract_path, uri_from_cli

from .parse_utils import (
JobTableFormat,
Expand All @@ -38,7 +41,6 @@
to_megabytes,
)
from .root import Root
from .utils import _calc_relative_uri

# NOTE: these job name defaults are taken from `platform_api` file `validators.py`
JOB_NAME_MIN_LENGTH = 3
Expand Down Expand Up @@ -462,36 +464,25 @@ async def async_convert(
ctx: Optional[click.Context],
) -> URL:
await root.init_client()
return self._parse_uri(value, root)

def _parse_uri(self, value: str, root: Root) -> URL:
return uri_from_cli(
return root.client.parse.str_to_uri(
value,
root.client.username,
root.client.cluster_name,
allowed_schemes=self._allowed_schemes,
short=False,
)

def _make_item(
self,
parent: URL,
name: str,
is_dir: bool,
prefix: str,
) -> CompletionItem:
uri = _calc_relative_uri(parent, name, prefix)
if is_dir:
return CompletionItem(
uri + "/",
uri="1",
display_name=name + "/",
)
else:
return CompletionItem(
uri,
uri="1",
display_name=name,
)
name += "/"
return CompletionItem(
name,
type="uri",
prefix=str(parent) + "/" if parent.path else str(parent),
)

async def _collect_names(
self,
Expand All @@ -500,12 +491,11 @@ async def _collect_names(
incomplete: str,
) -> AsyncIterator[CompletionItem]:
if uri.scheme == "file":
path = _extract_path(uri)
path = root.client.parse.uri_to_path(uri)
if not path.is_dir():
raise NotADirectoryError
cwd = Path.cwd().as_uri()
for item in path.iterdir():
if str(item.name).startswith(incomplete):
if str(uri / item.name).startswith(incomplete):
is_dir = item.is_dir()
if is_dir and not self._complete_dir:
continue
Expand All @@ -515,13 +505,11 @@ async def _collect_names(
uri,
item.name,
is_dir,
str(cwd),
)
else:
home = self._parse_uri("storage:", root)
async with root.client.storage.ls(uri) as it:
async for fstat in it:
if str(fstat.name).startswith(incomplete):
if str(uri / fstat.name).startswith(incomplete):
is_dir = fstat.is_dir()
if is_dir and not self._complete_dir:
continue
Expand All @@ -531,7 +519,6 @@ async def _collect_names(
uri,
fstat.name,
is_dir,
str(home),
)

async def _find_matches(self, incomplete: str, root: Root) -> List[CompletionItem]:
Expand All @@ -542,18 +529,25 @@ async def _find_matches(self, incomplete: str, root: Root) -> List[CompletionIte
# found valid scheme, try to resolve path
break
if scheme.startswith(incomplete):
ret.append(CompletionItem(scheme, uri="1", display_name=scheme))
ret.append(CompletionItem(scheme, type="uri", prefix=""))
else:
return ret

uri = self._parse_uri(incomplete, root)
# while incomplete.endswith("/"):
# incomplete = incomplete[:-1]

uri = root.client.parse.str_to_uri(
incomplete,
allowed_schemes=self._allowed_schemes,
short=True,
)
try:
return [item async for item in self._collect_names(uri, root, "")]
return [item async for item in self._collect_names(uri, root, incomplete)]
except (ResourceNotFound, NotADirectoryError):
try:
return [
item
async for item in self._collect_names(uri.parent, root, uri.name)
async for item in self._collect_names(uri.parent, root, incomplete)
]
except (ResourceNotFound, NotADirectoryError):
return []
Expand All @@ -562,7 +556,8 @@ async def async_shell_complete(
self, root: Root, ctx: click.Context, param: click.Parameter, incomplete: str
) -> List[CompletionItem]:
async with await root.init_client():
return await self._find_matches(incomplete, root)
ret = await self._find_matches(incomplete, root)
return ret


_SOURCE_ZSH = """\
Expand All @@ -573,23 +568,21 @@ async def async_shell_complete(
local -a completions_with_descriptions
local -a response
local -a uris
local -a display_names
local prefix
(( ! $+commands[%(prog_name)s] )) && return 1

response=("${(@f)$(env COMP_WORDS="${words[*]}" COMP_CWORD=$((CURRENT-1)) \
%(complete_var)s=zsh_complete %(prog_name)s)}")

for type key descr uri display_name in ${response}; do
if [[ "$type" == "plain" ]]; then
if [[ "$uri" == "1" ]]; then
uris+=("$key")
display_names+=("$display_name")
for type key descr pre in ${response}; do
if [[ "$type" == "uri" ]]; then
uris+=("$key")
prefix="$pre"
elif [[ "$type" == "plain" ]]; then
if [[ "$descr" == "_" ]]; then
completions+=("$key")
else
if [[ "$descr" == "_" ]]; then
completions+=("$key")
else
completions_with_descriptions+=("$key":"$descr")
fi
completions_with_descriptions+=("$key":"$descr")
fi
elif [[ "$type" == "dir" ]]; then
_path_files -/
Expand All @@ -608,11 +601,10 @@ async def async_shell_complete(

if [ -n "$uris" ]; then
compset -S '[^:/]*' && compstate[to_end]=''
compadd -Q -S '' -d display_names -U -V unsorted -a uris
compadd -P "$prefix" -S '' -U -V unsorted -a uris
fi
}


compdef %(complete_func)s %(prog_name)s;
"""

Expand All @@ -623,8 +615,87 @@ class NewZshComplete(ZshComplete):
def format_completion(self, item: CompletionItem) -> str:
return (
f"{item.type}\n{item.value}\n{item.help if item.help else '_'}\n"
f"{item.uri}\n{item.display_name}"
f"{item.prefix}"
)


add_completion_class(NewZshComplete)


_SOURCE_BASH = """\
%(complete_func)s() {
local IFS=$'\\n'
local response

response=$(env COMP_WORDS="${COMP_WORDS[*]}" COMP_CWORD=$COMP_CWORD \
%(complete_var)s=bash_complete $1)

for completion in $response; do
IFS=',' read type value prefix <<< "$completion"

if [[ $type == 'uri' ]]; then
COMPREPLY+=("$prefix$value")
compopt -o nospace
elif [[ $type == 'dir' ]]; then
COMREPLY=()
compopt -o dirnames
elif [[ $type == 'file' ]]; then
COMREPLY=()
compopt -o default
elif [[ $type == 'plain' ]]; then
COMPREPLY+=($value)
fi
done

return 0
}

%(complete_func)s_setup() {
complete -o nosort -F %(complete_func)s %(prog_name)s
}

%(complete_func)s_setup;
"""


def _merge_autocompletion_args(
args: List[str], incomplete: str
) -> Tuple[List[str], str]:
new_args: List[str] = []
for arg in args:
if arg == ":":
if new_args:
new_args[-1] += ":"
else:
new_args.append(":")
else:
if new_args and new_args[-1].endswith(":"):
new_args[-1] += arg
else:
new_args.append(arg)

if new_args:
if new_args[-1].endswith(":"):
incomplete = new_args[-1] + incomplete
del new_args[-1]
elif incomplete == ":":
incomplete = new_args[-1] + ":"
del new_args[-1]
return new_args, incomplete


class NewBashComplete(BashComplete):
source_template = _SOURCE_BASH

def get_completion_args(self) -> Tuple[List[str], str]:
args, incomplete = super().get_completion_args()
args, incomplete = _merge_autocompletion_args(args, incomplete)
return args, incomplete

def format_completion(self, item: CompletionItem) -> str:
# bash assumes ':' as a word separator along with ' '
pre, sep, prefix = (item.prefix or "").rpartition(":")
return f"{item.type},{item.value},{prefix}"


add_completion_class(NewBashComplete)
16 changes: 4 additions & 12 deletions neuro-cli/src/neuro_cli/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,7 @@
get_painter,
)
from .root import Root
from .utils import (
Option,
_calc_relative_uri,
argument,
command,
group,
option,
parse_file_resource,
)
from .utils import Option, argument, command, group, option, parse_file_resource

NEUROIGNORE_FILENAME = ".neuroignore"

Expand Down Expand Up @@ -709,9 +701,9 @@ async def tree(
errors = False
try:
tree = await fetch_tree(root.client, path, show_all)
name = str(
_calc_relative_uri(path, "", str(parse_file_resource("storage:", root)))
).rstrip("\\/")
name = root.client.parse.uri_to_str(
root.client.parse.normalize_uri(path, short=True)
)
tree = dataclasses.replace(
tree,
name=name,
Expand Down
11 changes: 0 additions & 11 deletions neuro-cli/src/neuro_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -675,14 +675,3 @@ async def calc_timeout_unused(
return await _calc_timedelta_key(
client, value, default, config_section, "timeout-unused"
)


def _calc_relative_uri(parent: URL, name: str, prefix: str) -> str:
uri = str(parent / name)
if uri.startswith(prefix):
relative = uri[len(prefix) :]
if relative[0] == "/":
# drop trailing slash
relative = relative[1:]
uri = parent.scheme + ":" + relative
return uri
Loading