Skip to content
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
14 changes: 10 additions & 4 deletions docs/source/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,16 @@ eval "$(libby completion bash)"

Completing an address lists live daemons the same way `list` does, so TAB
offers daemons after `<group>.` and that daemon's keywords after
`<group>.<daemon>.`. Results are cached for 10s in
`~/.libby/completion_cache.json` so a burst of TABs costs one broadcast, and
the lookup is bounded at 0.5s so TAB never hangs. An unreachable broker
completes nothing rather than erroring.
`<group>.<daemon>.`. `list` completes its own shape: a daemon can stand on
its own there, and every level also offers its `%` form, so
`libby list <TAB>` starts at `%.%` and `hsfei.%`.

Flags are not offered until you type `-`, so TAB on an empty argument shows
verbs or addresses rather than burying them in `--options`.

Results are cached for 10s in `~/.libby/completion_cache.json` so a burst of
TABs costs one broadcast, and the lookup is bounded at 0.5s so TAB never
hangs. An unreachable broker completes nothing rather than erroring.

## Modify syntax

Expand Down
32 changes: 31 additions & 1 deletion libby/cli/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from pathlib import Path
from typing import Callable, Dict, List, Optional

from libby.keyword import match_pattern

DEFAULT_CACHE_PATH = Path.home() / ".libby" / "completion_cache.json"
CACHE_TTL_S = 10.0
COMPLETE_TIMEOUT_S = 0.5
Expand Down Expand Up @@ -65,12 +67,20 @@ def peer_candidates(prefix: str, listings: Listings) -> List[str]:
return sorted(peer for peer in listings if peer.startswith(prefix.lower()))


def _peer_lowered(prefix: str) -> str:
"""Lowercase the group and daemon segments, which are case-insensitive."""
group, dot, rest = prefix.partition(".")
daemon, dot2, keyword = rest.partition(".")
return f"{group.lower()}{dot}{daemon.lower()}{dot2}{keyword}"


def address_candidates(prefix: str, listings: Listings) -> List[str]:
"""Complete a partial ``<group>.<daemon>.<keyword>``.

Until the daemon segment is complete the candidates end in ``.``, so the
shell stops there and the next TAB moves on to the keywords.
"""
prefix = _peer_lowered(prefix)
if prefix.count(".") < 2:
peers = peer_candidates(prefix, listings)
# The shell appends a space to a lone candidate unless it ends in
Expand All @@ -79,9 +89,29 @@ def address_candidates(prefix: str, listings: Listings) -> List[str]:
return [f"{peer}." for peer in peers]
prefix = f"{peers[0]}."
peer, _, keyword_prefix = prefix.rpartition(".")
peer = peer.lower()
return sorted(
f"{peer}.{name}"
for name in listings.get(peer, [])
if name.startswith(keyword_prefix)
)


def list_candidates(prefix: str, listings: Listings) -> List[str]:
"""Complete a ``libby list`` pattern.

Unlike an address, the daemon segment may stand alone and any segment may
be ``%``, so each level offers its wildcard next to the concrete names.
"""
prefix = _peer_lowered(prefix)
if prefix.count(".") < 2:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this cover this case? hsfei.% takes the daemon path and asks "which daemon starts with the text hsfei.%?" I think the current if condition is doing a startswith check so we aren't able to autocomplete when attempting to see the possible daemons associated with the group hsfei

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only the TAB completer, not the list command, which already handles hsfei.% on main.

Typing hsfei. and pressing TAB offers hsfei.% plus every daemon in the group, so the daemons are reachable one keystroke earlier. TAB after a typed % returns nothing on purpose: the shell replaces the whole word, so offering hsfei.adc there would delete the %.

peers = peer_candidates(prefix, listings)
groups = sorted({peer.split(".", 1)[0] for peer in peers})
candidates = ["%.%", *(f"{group}.%" for group in groups), *peers]
else:
peer_pattern, _, _ = prefix.rpartition(".")
candidates = [f"{peer_pattern}.%"] + [
f"{peer_pattern}.{name}"
for peer in match_pattern(peer_pattern, listings)
for name in listings[peer]
]
return sorted({c for c in candidates if c.startswith(prefix)})
14 changes: 12 additions & 2 deletions libby/cli/libby_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
Listings,
address_candidates,
cached_listings,
list_candidates,
peer_candidates,
)
from libby.config_resolve import (
Expand Down Expand Up @@ -626,6 +627,14 @@ def _complete_peer(prefix: str, parsed_args: argparse.Namespace, **_: Any) -> Li
return []


def _complete_list(prefix: str, parsed_args: argparse.Namespace, **_: Any) -> List[str]:
"""Complete a partial list pattern, which may name daemons or keywords."""
try:
return list_candidates(prefix, _fetch_listings(parsed_args))
except Exception: # pylint: disable=broad-exception-caught
return []


def _completed_by(
action: argparse.Action,
completer: Callable[..., List[str]] = _complete_address,
Expand Down Expand Up @@ -700,7 +709,7 @@ def add_common(p):
"pattern",
help="<group>.<daemon>[.<keyword-pattern>] (%% wildcard in any segment; "
"without a keyword segment, list the matching daemons)",
))
), _complete_list)
p_list.set_defaults(func=cmd_list)

p_describe = sub.add_parser("describe", help="Show metadata for a keyword")
Expand Down Expand Up @@ -766,7 +775,8 @@ def add_common(p):

def main(argv: Optional[List[str]] = None) -> int:
parser = build_parser()
argcomplete.autocomplete(parser)
# Without this every TAB also lists --flags, burying the addresses
argcomplete.autocomplete(parser, always_complete_options=False)
namespace = parser.parse_args(argv)

try:
Expand Down
31 changes: 31 additions & 0 deletions tests/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
CompletionCache,
address_candidates,
cached_listings,
list_candidates,
peer_candidates,
)

Expand Down Expand Up @@ -42,6 +43,36 @@ def test_unknown_daemon_offers_nothing(self):
self.assertEqual(address_candidates("hsfei.nosuch.", LISTINGS), [])


class ListCandidatesTests(unittest.TestCase):
"""Completing a list pattern, where a daemon may stand alone."""

def test_empty_prefix_offers_wildcards_and_bare_peers(self):
self.assertEqual(list_candidates("", LISTINGS),
["%.%", "hscal.%", "hscal.hkettherm",
"hsfei.%", "hsfei.adc", "hsfei.atcfw"])

def test_candidates_never_fail_to_match_the_prefix(self):
# "%.%" must not be offered once a literal group is typed
for candidate in list_candidates("hs", LISTINGS):
self.assertTrue(candidate.startswith("hs"), candidate)

def test_group_prefix_offers_its_wildcard_and_daemons(self):
self.assertEqual(list_candidates("hsfei.", LISTINGS),
["hsfei.%", "hsfei.adc", "hsfei.atcfw"])

def test_daemon_prefix_offers_its_keywords_and_wildcard(self):
self.assertEqual(list_candidates("hsfei.adc.", LISTINGS),
["hsfei.adc.%", "hsfei.adc.isconnected", "hsfei.adc.position"])

def test_wildcard_daemon_spans_the_group(self):
self.assertEqual(list_candidates("hsfei.%.", LISTINGS),
["hsfei.%.%", "hsfei.%.isconnected", "hsfei.%.position"])

def test_partial_keyword_narrows_and_drops_the_wildcard(self):
self.assertEqual(list_candidates("hsfei.adc.is", LISTINGS),
["hsfei.adc.isconnected"])


class PeerCandidatesTests(unittest.TestCase):
def test_offers_matching_daemons_without_a_trailing_dot(self):
self.assertEqual(peer_candidates("hsfei.", LISTINGS), ["hsfei.adc", "hsfei.atcfw"])
Expand Down
Loading