From 5e97d47cd9fdaf2755473751fbac4c86726bc4ac Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:45:13 -0700 Subject: [PATCH 1/2] Complete list patterns and stop offering flags before a dash --- docs/source/cli.md | 14 ++++++++++---- libby/cli/completion.py | 30 ++++++++++++++++++++++++++++++ libby/cli/libby_cli.py | 14 ++++++++++++-- tests/test_completion.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/source/cli.md b/docs/source/cli.md index fd9b653..933e962 100644 --- a/docs/source/cli.md +++ b/docs/source/cli.md @@ -104,10 +104,16 @@ eval "$(libby completion bash)" Completing an address lists live daemons the same way `list` does, so TAB offers daemons after `.` and that daemon's keywords after -`..`. 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. +`..`. `list` completes its own shape: a daemon can stand on +its own there, and every level also offers its `%` form, so +`libby list ` 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 diff --git a/libby/cli/completion.py b/libby/cli/completion.py index 309a7a6..9523f9b 100644 --- a/libby/cli/completion.py +++ b/libby/cli/completion.py @@ -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 @@ -85,3 +87,31 @@ def address_candidates(prefix: str, listings: Listings) -> List[str]: for name in listings.get(peer, []) if name.startswith(keyword_prefix) ) + + +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 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: + 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)}) diff --git a/libby/cli/libby_cli.py b/libby/cli/libby_cli.py index 604201e..de2d792 100644 --- a/libby/cli/libby_cli.py +++ b/libby/cli/libby_cli.py @@ -22,6 +22,7 @@ Listings, address_candidates, cached_listings, + list_candidates, peer_candidates, ) from libby.config_resolve import ( @@ -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, @@ -700,7 +709,7 @@ def add_common(p): "pattern", help=".[.] (%% 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") @@ -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: diff --git a/tests/test_completion.py b/tests/test_completion.py index 0a715ee..18cd2d8 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -8,6 +8,7 @@ CompletionCache, address_candidates, cached_listings, + list_candidates, peer_candidates, ) @@ -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"]) From 1cfd3accfadff9cc7ef293cab8841642a9feb043 Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:24:55 -0700 Subject: [PATCH 2/2] Share the case-folding helper between both completers --- libby/cli/completion.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libby/cli/completion.py b/libby/cli/completion.py index 9523f9b..e618aa3 100644 --- a/libby/cli/completion.py +++ b/libby/cli/completion.py @@ -67,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 ``..``. 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 @@ -81,7 +89,6 @@ 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, []) @@ -89,13 +96,6 @@ def address_candidates(prefix: str, listings: Listings) -> List[str]: ) -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 list_candidates(prefix: str, listings: Listings) -> List[str]: """Complete a ``libby list`` pattern.