diff --git a/eco/ipymagic.py b/eco/ipymagic.py index 670dcf4..0c9b205 100644 --- a/eco/ipymagic.py +++ b/eco/ipymagic.py @@ -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 / ... + .bernina.mono.energy recent + .bernina.attenuator.transmission recommended + .bernina.slit1.width namespace + In [1]: (1 / bernina.mono.energy + +Typing more after the trigger (`...ene`) 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 diff --git a/tests/test_ipymagic.py b/tests/test_ipymagic.py index cf9b286..71d8938 100644 --- a/tests/test_ipymagic.py +++ b/tests/test_ipymagic.py @@ -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"]