ipymagic: terminal Tab-completion overlay (Option A), and a real path bug

Adds the terminal-native picker: typing the trigger and pressing Tab pops
IPython's own completion menu (same widget, works in a real terminal or a
notebook -- ipykernel's completion goes through the same IPCompleter
matcher pipeline) instead of the Qt modal. Two triggers:

  "..."   component mode: Recent, Recommended (FrequencyCounts-ranked),
          then a live substring-filterable walk of the namespace tree.
  "...h"  command mode (mnemonic: history): Recent/Recommended prior
          *commands* (eco.logs.recent_commands/frequent_commands) -- no
          namespace section, nothing to browse there.

Selecting an entry only ever inserts text at the cursor -- it never
executes, in either mode -- so the rest of the line can still be composed/
edited before submitting. This composes with the existing Qt-modal `...`
mechanism rather than conflicting with it: accepting a completion replaces
`...` in the buffer before the line is ever submitted, so if you don't use
Tab and just hit Enter, the AST-transform fallback still applies to
whatever literal `...` is left.

Real bug found while building this and wiring it through a live
IPCompleter (not just unit tests): eco.widgets.component_selector's
ComponentNode.name (and so pick_component_modal's return value) is
relative to `root` (confirmed against Alias.get_full_name(base=...), which
stops at base) -- not a standalone, globally-evaluable expression the way
this module had assumed everywhere. Both the Qt-modal path (_EllipsisPicker)
and this new completer were splicing that relative fragment in directly,
silently dropping the root's own name from the printed/inserted code.
Fixed with _full_path(root, relative_path), which reattaches the name
`root` is actually bound to (its own `.name`, matching this codebase's
`import eco.bernina as bernina` convention) -- verified against a real
IPCompleter.complete() call, not just the mocked unit tests, which is
exactly what surfaced the bug in the first place (a hand-fed "already
correct" path in the earlier Qt smoke test had been masking it).

Also fixed the smoke-test script that had encoded the same wrong
assumption, for anyone re-running it later.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 17:05:59 +02:00
co-authored by Claude Sonnet 5
parent 2043340467
commit d0606e2b89
2 changed files with 326 additions and 18 deletions
+169 -12
View File
@@ -38,12 +38,55 @@ literal ``...`` in place, so it fails exactly the way typing a real
``Ellipsis`` there would (a clear `TypeError` if it's actually used, no
silent auto-pick).
Deliberately namespace-tree-only for now -- see the module TODO below for
picking from prior *commands* (eco.logs' kernel history) as a planned
second mode, likely via a different trigger so the two don't collide.
Terminal overlay (Tab-completion mode)
--------------------------------------
In a real terminal (or a notebook -- ipykernel's completion goes through
the same IPCompleter matcher pipeline), typing the trigger and pressing
Tab pops IPython's own native completion menu -- the same widget/rendering
Python attribute completion already uses -- populated with three sections
instead of attributes: Recent, Recommended (ranked by actual use-count via
`eco.elements.recent.FrequencyCounts`, not just recency), and (component
mode only) a live, filterable walk of the whole namespace tree. This is
the preferred way to pick something in a terminal: it never blocks on a
Qt window, and -- critically -- it only ever *inserts text at the cursor*,
never executes anything, so you can keep composing/editing the line and
submit whenever you're ready yourself:
In [1]: (1 / ...<TAB>
.bernina.mono.energy recent
.bernina.attenuator.transmission recommended
.bernina.slit1.width namespace
In [1]: (1 / bernina.mono.energy
Typing more after the trigger (`...ene<TAB>`) narrows the namespace
section by substring match; Recent/Recommended stay unfiltered (picking
something you use constantly shouldn't require spelling it first).
A second trigger, `...h` (mnemonic: history), does the same for prior
*commands* (`eco.logs.recent_commands`/`frequent_commands`) instead of
components -- no namespace section (commands have no tree to browse), and
picking one always just inserts its text for further editing, the same
"never auto-executes" rule as components. Both share one on/off switch
(`start()`/`stop()`); there's no separate flag for the completion path
since it always inserts rather than executes, so there's nothing about it
that needs its own opt-out.
If you never press Tab and just hit Enter with a literal `...` still in
the line, the `...`-in-an-expression mechanism above (the Qt modal picker)
still applies as a fallback -- the two compose rather than conflict, since
accepting a Tab-completion replaces the `...` in the buffer before the
line is ever submitted, leaving nothing for the AST transform to find.
"""
import ast
from IPython.core.completer import SimpleCompletion, context_matcher
COMPONENT_TRIGGER = "..."
COMMAND_TRIGGER = "...h"
_RECENT_COUNT = 8
_RECOMMENDED_COUNT = 8
_NAMESPACE_LIMIT = 30
_state = {}
@@ -51,6 +94,31 @@ def _is_marker(node):
return isinstance(node, ast.Constant) and node.value is Ellipsis
def _full_path(root, relative_path):
"""`component_selector.build_tree`/`resolve_path` paths (and so
`pick_component_modal`'s return value) are relative to `root` --
plain `getattr` chains starting *from* root, never including root's
own name (confirmed against `Alias.get_full_name(base=...)`, which
stops at `base` and returns "" for `base` itself) -- not a standalone
expression that means anything on its own. This combines it with the
name `root` is actually bound to in the executing namespace (its own
`.name`, matching this codebase's convention that a namespace object's
`.name` already matches how it's imported at the top level -- e.g.
`import eco.bernina as bernina` gives an object whose `.name` is
already "bernina", and `eco.ipymagic.start()`'s own docstring assumes
exactly this) so the printed/inserted code is directly executable and
re-typeable on its own, not silently relative to some invisible root.
Contrast with `eco.elements.recent.RecentComponents`/`FrequencyCounts`
paths, which come from `Alias.get_full_name()` with *no* base -- those
are already absolute and must NOT be run through this again.
"""
root_name = getattr(root, "name", None) or "root"
if not relative_path:
return root_name
return f"{root_name}.{relative_path}"
class _EllipsisPicker(ast.NodeTransformer):
"""Replaces every bare (non-subscript-slice) ``...`` in a parsed AST
with the dotted-path expression of whatever the user picks, depth/
@@ -87,7 +155,7 @@ class _EllipsisPicker(ast.NodeTransformer):
print("eco.ipymagic: selection cancelled -- left `...` in place")
return node
self.picked_any = True
replacement = ast.parse(path, mode="eval").body
replacement = ast.parse(_full_path(self.root, path), mode="eval").body
return ast.copy_location(replacement, node)
@@ -116,17 +184,102 @@ class _AstTransformer:
_ast_transformer = _AstTransformer()
def _flatten_paths(node, out=None):
"""All dotted paths in a `component_selector.ComponentNode` tree,
depth-first (the root itself included, if it has a name)."""
if out is None:
out = []
if node.name:
out.append(node.name)
for child in node.children:
_flatten_paths(child, out)
return out
def _ranked_completions(recent_items, recommended_items, extra_items, query):
"""Recent, then Recommended (recommended items already in Recent are
dropped -- no point suggesting the same thing twice), then whatever
`extra_items` are left (already excludes both, and already filtered
by `query` by the caller -- Recent/Recommended stay unfiltered)."""
completions = [SimpleCompletion(text=p, type="recent") for p in recent_items]
recommended_items = [p for p in recommended_items if p not in recent_items]
completions += [SimpleCompletion(text=p, type="recommended") for p in recommended_items]
completions += [SimpleCompletion(text=p, type="namespace") for p in extra_items]
return completions
@context_matcher()
def _component_matcher(context):
token = context.token
root = _state.get("root")
if root is None or not token.startswith(COMPONENT_TRIGGER) or token.startswith(
COMMAND_TRIGGER
):
return {"completions": []}
from eco.elements.recent import FrequencyCounts, RecentComponents
from eco.widgets.component_selector import build_tree
namespace_name = getattr(root, "name", None)
recent_paths = RecentComponents(namespace_name=namespace_name).all()[:_RECENT_COUNT]
recommended_paths = FrequencyCounts(namespace_name=namespace_name).top(
_RECOMMENDED_COUNT * 2
)[:_RECOMMENDED_COUNT]
query = token[len(COMPONENT_TRIGGER) :].lower()
exclude = set(recent_paths) | set(recommended_paths)
# build_tree's paths are relative to root (see _full_path's docstring)
# -- convert to absolute *before* dedup/query matching, so they compare
# correctly against Recent/Recommended's already-absolute paths.
namespace_paths = [
full
for full in (_full_path(root, p) for p in _flatten_paths(build_tree(root)))
if full not in exclude and (not query or query in full.lower())
][:_NAMESPACE_LIMIT]
completions = _ranked_completions(recent_paths, recommended_paths, namespace_paths, query)
return {"completions": completions, "suppress": True}
@context_matcher()
def _command_matcher(context):
token = context.token
if _state.get("root") is None or not token.startswith(COMMAND_TRIGGER):
return {"completions": []}
from eco import logs
recent_cmds = logs.recent_commands(n=_RECENT_COUNT)
recommended_cmds = logs.frequent_commands(n=_RECOMMENDED_COUNT * 2)[:_RECOMMENDED_COUNT]
query = token[len(COMMAND_TRIGGER) :].lower()
def _keep(cmd):
return not query or query in cmd.lower()
recent_cmds = [c for c in recent_cmds if _keep(c)]
recommended_cmds = [c for c in recommended_cmds if _keep(c)]
completions = _ranked_completions(recent_cmds, recommended_cmds, [], query)
return {"completions": completions, "suppress": True}
def start(root, kind_filter="All", bookmarks=None, recent=None):
"""Register the `...` picker on the *current* IPython shell.
"""Register the `...` picker on the *current* IPython shell: both the
Tab-completion overlay (`...`/`...h`, terminal and notebook alike) and
the Qt-modal fallback for a `...` that reaches Enter unexpanded.
`root`: the namespace/Assembly tree to pick from (e.g. `bernina`).
`kind_filter`/`bookmarks`/`recent`: passed straight through to the
picker window each time (see `eco.widgets.component_selector_qt.
pick_component_modal`) -- left as `None` picks up that frontend's own
per-user, per-namespace defaults, same as opening it any other way.
Qt-modal picker each time it opens (see `eco.widgets.
component_selector_qt.pick_component_modal`) -- left as `None` picks up
that frontend's own per-user, per-namespace defaults, same as opening
it any other way. The Tab-completion overlay always uses its own
per-namespace Recent/Recommended (see `eco.elements.recent`), not
these -- there's no window to hand a shared instance to.
Calling this again just repoints `root` (etc.) for the next pick --
it does not install a second hook, so re-running a startup cell is
it does not install the hooks twice, so re-running a startup cell is
safe.
"""
from IPython import get_ipython
@@ -142,11 +295,15 @@ def start(root, kind_filter="All", bookmarks=None, recent=None):
if not getattr(ip, "_eco_ipymagic_installed", False):
ip.ast_transformers.append(_ast_transformer)
ip.Completer.custom_matchers.extend([_component_matcher, _command_matcher])
ip._eco_ipymagic_installed = True
def stop():
"""Turn picking off (`...` goes back to being a plain literal) without
needing a shell restart -- e.g. a notebook cell that wants to type a
real Ellipsis afterward."""
"""Turn picking off (`...`/`...h` go back to being plain text/a plain
literal) without needing a shell restart -- e.g. a notebook cell that
wants to type a real Ellipsis afterward. The completion matchers stay
registered (harmless: both already no-op whenever `_state["root"]` is
`None`, checked first) -- only `_state["root"]` is cleared, same as
the Qt-modal fallback's own gate."""
_state["root"] = None
+157 -6
View File
@@ -18,9 +18,15 @@ class _FakeEvents:
pass
class _FakeCompleter:
def __init__(self):
self.custom_matchers = []
class _FakeShell:
def __init__(self):
self.ast_transformers = []
self.Completer = _FakeCompleter()
@pytest.fixture(autouse=True)
@@ -30,7 +36,20 @@ def _reset_state():
ipymagic._state.clear()
def _transform_source(source, root="the_root"):
class _NamedRoot:
"""A pick_component_modal path is relative to root (see
ipymagic._full_path's docstring) -- these tests need a `root` with a
real `.name` for the resulting expanded code to come out right, not
a bare string (which has no `.name` and would fall back to a dummy
"root" prefix)."""
def __init__(self, name):
self.name = name
def _transform_source(source, root=None):
if root is None:
root = _NamedRoot("bernina")
tree = ast.parse(source)
picker = ipymagic._EllipsisPicker(root, "All", None, None)
new_tree = picker.visit(tree)
@@ -41,7 +60,7 @@ def _transform_source(source, root="the_root"):
def test_bare_ellipsis_expression_is_replaced_with_the_picked_path(monkeypatch):
monkeypatch.setattr(
"eco.widgets.component_selector_qt.pick_component_modal",
lambda root, **kw: ("bernina.mono.energy", object()),
lambda root, **kw: ("mono.energy", object()), # relative to root, like the real picker
)
out, picked_any = _transform_source("(1 / ...).plot()")
assert out == "(1 / bernina.mono.energy).plot()"
@@ -49,7 +68,7 @@ def test_bare_ellipsis_expression_is_replaced_with_the_picked_path(monkeypatch):
def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch):
picks = iter(["bernina.mono.energy", "bernina.attenuator.transmission"])
picks = iter(["mono.energy", "attenuator.transmission"])
seen_roots = []
def fake_pick(root, **kw):
@@ -57,12 +76,13 @@ def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch):
return next(picks), object()
monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fake_pick)
root = _NamedRoot("bernina")
out, picked_any = _transform_source(
"cen = (left := ...) - (right := ...)", root="bernina"
"cen = (left := ...) - (right := ...)", root=root
)
assert out == "cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission)"
assert picked_any is True
assert seen_roots == ["bernina", "bernina"]
assert seen_roots == [root, root]
def test_subscript_slice_ellipsis_is_left_alone(monkeypatch):
@@ -94,7 +114,7 @@ def test_transform_is_a_noop_when_start_has_not_been_called():
assert out == "(1 / ...).plot()"
def test_start_registers_the_transformer_once(monkeypatch):
def test_start_registers_the_transformer_and_matchers_once(monkeypatch):
shell = _FakeShell()
monkeypatch.setattr("IPython.get_ipython", lambda: shell)
@@ -102,6 +122,10 @@ def test_start_registers_the_transformer_once(monkeypatch):
ipymagic.start("bernina") # idempotent -- must not install a second hook
assert shell.ast_transformers == [ipymagic._ast_transformer]
assert shell.Completer.custom_matchers == [
ipymagic._component_matcher,
ipymagic._command_matcher,
]
assert ipymagic._state["root"] == "bernina"
@@ -121,3 +145,130 @@ def test_stop_makes_the_transform_a_noop_again(monkeypatch):
tree = ast.parse("(1 / ...).plot()")
out = ast.unparse(ipymagic._ast_transformer.visit(tree))
assert out == "(1 / ...).plot()"
# -- Tab-completion overlay (_component_matcher / _command_matcher) --------
class _Ctx:
"""Just enough of CompletionContext for these matchers -- both only
ever read `.token`."""
def __init__(self, token):
self.token = token
class _FakeRoot:
name = "bernina"
def _texts(result):
return [c.text for c in result["completions"]]
def _types(result):
return [c.type for c in result["completions"]]
def test_component_matcher_returns_nothing_when_not_started():
assert ipymagic._component_matcher(_Ctx("...")) == {"completions": []}
def test_component_matcher_ignores_tokens_without_the_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._component_matcher(_Ctx("bernina")) == {"completions": []}
def test_component_matcher_defers_to_command_matcher_for_the_history_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._component_matcher(_Ctx("...h")) == {"completions": []}
def _fake_node(name, children=()):
return type("N", (), {"name": name, "children": list(children)})()
def _fake_tree_relative_to_bernina():
# build_tree's own node names are relative to root (see _full_path's
# docstring) -- root's own name comes back "" (Alias.get_full_name
# stops at base), children are unprefixed ("mono.energy", not
# "bernina.mono.energy").
return _fake_node(
"",
children=[
_fake_node("mono.energy"),
_fake_node("attenuator.transmission"),
],
)
def test_component_matcher_sections_are_recent_then_recommended_then_namespace(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr(
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
)
monkeypatch.setattr(
"eco.elements.recent.FrequencyCounts.top",
lambda self, n: ["bernina.slit1.width", "bernina.mono.energy"],
)
monkeypatch.setattr(
"eco.widgets.component_selector.build_tree",
lambda root: _fake_tree_relative_to_bernina(),
)
result = ipymagic._component_matcher(_Ctx("..."))
# bernina.slit1.width: recent, wins over also being "recommended".
# bernina.mono.energy: recommended (already absolute -- RecentComponents/
# FrequencyCounts paths come from Alias.get_full_name() with no base,
# unlike build_tree's root-relative ones) -- also excluded from the
# namespace section below since it's already shown once.
# bernina.attenuator.transmission: plain namespace, root-prefixed since
# build_tree gave back the relative "attenuator.transmission".
assert _texts(result) == [
"bernina.slit1.width",
"bernina.mono.energy",
"bernina.attenuator.transmission",
]
assert _types(result) == ["recent", "recommended", "namespace"]
assert result["suppress"] is True
def test_component_matcher_query_narrows_namespace_but_not_recent(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr(
"eco.elements.recent.RecentComponents.all", lambda self: ["bernina.slit1.width"]
)
monkeypatch.setattr("eco.elements.recent.FrequencyCounts.top", lambda self, n: [])
monkeypatch.setattr(
"eco.widgets.component_selector.build_tree",
lambda root: _fake_tree_relative_to_bernina(),
)
result = ipymagic._component_matcher(_Ctx("...ene"))
# "slit1.width" doesn't match "ene" but stays -- Recent is unfiltered.
# Of the namespace section, only "bernina.mono.energy" (via "ene") matches.
assert _texts(result) == ["bernina.slit1.width", "bernina.mono.energy"]
def test_command_matcher_returns_nothing_when_not_started():
assert ipymagic._command_matcher(_Ctx("...h")) == {"completions": []}
def test_command_matcher_ignores_the_plain_component_trigger(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
assert ipymagic._command_matcher(_Ctx("...")) == {"completions": []}
def test_command_matcher_has_recent_and_recommended_but_no_namespace_section(monkeypatch):
ipymagic._state["root"] = _FakeRoot()
monkeypatch.setattr("eco.logs.recent_commands", lambda n: ["mono.mv(5)"])
monkeypatch.setattr(
"eco.logs.frequent_commands", lambda n: ["daq.compare_channels()", "mono.mv(5)"]
)
result = ipymagic._command_matcher(_Ctx("...h"))
assert _texts(result) == ["mono.mv(5)", "daq.compare_channels()"]
assert _types(result) == ["recent", "recommended"]