From f76663f7980ef5e18deff87d212e2f0fddce4d3e Mon Sep 17 00:00:00 2001 From: michael Date: Tue, 14 Jul 2026 12:19:42 -0700 Subject: [PATCH] feat(rawcli): ipython-style completion (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - completer.py: RawCompleter completes the leading command token — control verbs + the identified transponder's catalog commands — with each command's help as the completion meta (tooltip). `help ` completes catalog command names. - app: wire the completer with complete_while_typing (live menu) + AutoSuggestFromHistory (inline hints) for the ipython feel. Completes the 6-phase rawcli plan. 4 new tests (verb/catalog completion, tooltips, help-arg completion). Co-Authored-By: Claude Opus 4.8 --- pm3py/cli/rawcli/app.py | 10 ++++++- pm3py/cli/rawcli/completer.py | 54 +++++++++++++++++++++++++++++++++++ tests/test_rawcli.py | 30 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 pm3py/cli/rawcli/completer.py diff --git a/pm3py/cli/rawcli/app.py b/pm3py/cli/rawcli/app.py index aa049e6..cd79239 100644 --- a/pm3py/cli/rawcli/app.py +++ b/pm3py/cli/rawcli/app.py @@ -148,13 +148,21 @@ def _handle_help(state: RawSession, args) -> None: def run(port=None) -> int: from prompt_toolkit import PromptSession + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.patch_stdout import patch_stdout + from .completer import RawCompleter + state = RawSession(device=_connect(port)) kb = KeyBindings() install_key_bindings(kb, state) - session = PromptSession(key_bindings=kb) + session = PromptSession( + key_bindings=kb, + completer=RawCompleter(state), + complete_while_typing=True, # ipython-style live menu + auto_suggest=AutoSuggestFromHistory(), # inline suggestion hints + ) def message(): return state.breadcrumb() + " " diff --git a/pm3py/cli/rawcli/completer.py b/pm3py/cli/rawcli/completer.py new file mode 100644 index 0000000..0ddf9ad --- /dev/null +++ b/pm3py/cli/rawcli/completer.py @@ -0,0 +1,54 @@ +"""ipython-style completion for rawcli. + +Completes the leading command token — control verbs plus the identified transponder's catalog +commands — with each command's help as the completion **meta** (the tooltip). ``help `` +completes catalog command names too. Paired in the app with menu completion while typing and +history auto-suggestion for the ipython feel. +""" +from __future__ import annotations + +from prompt_toolkit.completion import Completer, Completion + +from .parser import CONTROL_VERBS +from .catalog import catalog_for + + +class RawCompleter(Completer): + def __init__(self, session): + self._session = session + + def get_completions(self, document, complete_event): + text = document.text_before_cursor + stripped = text.lstrip() + tokens = stripped.split() + at_word = bool(tokens) and not stripped.endswith(" ") + word = tokens[-1] if at_word else "" + + completing_first = (" " not in stripped) or (len(tokens) == 1 and at_word) + if completing_first: + yield from self._commands(word) + elif tokens and tokens[0].lower() == "help": + yield from self._catalog_names(word) + + def _commands(self, word: str): + low = word.lower() + for verb in sorted(CONTROL_VERBS): + if verb.startswith(low): + yield Completion(verb, start_position=-len(word), display_meta="command") + catalog = catalog_for(self._session) + if catalog is not None: + for name in catalog.names(): + if name.lower().startswith(low): + tc = catalog.get(name) + yield Completion(f"{name}(", start_position=-len(word), + display=tc.usage(), display_meta=tc.help) + + def _catalog_names(self, word: str): + catalog = catalog_for(self._session) + if catalog is None: + return + low = word.lower() + for name in catalog.names(): + if name.lower().startswith(low): + yield Completion(name, start_position=-len(word), + display_meta=catalog.get(name).help) diff --git a/tests/test_rawcli.py b/tests/test_rawcli.py index 8d7f2db..efb112b 100644 --- a/tests/test_rawcli.py +++ b/tests/test_rawcli.py @@ -13,6 +13,7 @@ from pm3py.cli.rawcli.entry import byte_space from pm3py.cli.rawcli.identify import identify, clear, name_14a from pm3py.cli.rawcli.catalog import TYPE2, ISO15, catalog_for, catalog_for as _cf from pm3py.cli.rawcli.tlv import parse_tlv, format_tlv +from pm3py.cli.rawcli.completer import RawCompleter from pm3py.cli.rawcli.app import dispatch _ANSI = re.compile(r"\x1b\[[0-9;]*m") @@ -296,3 +297,32 @@ class TestTLV: def test_tlv_command(self, capsys): dispatch(RawSession(), "tlv " + self.BLOCK.hex()) assert "NDEF MESSAGE" in capsys.readouterr().out + + +class TestCompleter: + def _complete(self, session, text): + from prompt_toolkit.document import Document + return list(RawCompleter(session).get_completions(Document(text), None)) + + def test_completes_control_verbs(self): + cs = self._complete(RawSession(), "ide") + assert any(c.text == "identify" for c in cs) + + def test_completes_catalog_commands_with_meta(self): + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG215" # -> Type 2 catalog + cs = self._complete(s, "REA") + texts = [c.text for c in cs] + assert "READ(" in texts + read = next(c for c in cs if c.text == "READ(") + assert "0x30" in read.display_meta_text # tooltip = help + + def test_no_catalog_before_identify(self): + cs = self._complete(RawSession(), "REA") + assert all(c.text != "READ(" for c in cs) # no tag => no catalog commands + + def test_help_argument_completes_command_names(self): + s = RawSession() + s.protocol, s.transponder = "hf14a", "NTAG215" + cs = self._complete(s, "help GET") + assert any(c.text == "GET_VERSION" for c in cs)