From 35c4e6bbd58e4bf785cec8a5672ca75ff57d3ddf Mon Sep 17 00:00:00 2001 From: lemke_h Date: Sat, 12 Sep 2026 16:34:41 +0200 Subject: [PATCH] Add eco.ipymagic: opt-in inline component picker triggered by `...` from eco import bernina from eco import ipymagic ipymagic.start(bernina) In [1]: (1 / ...).plot() (1 / bernina.mono.energy).plot() In [2]: cen = (left := ...) - (right := ...) cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission) A bare `...` (the builtin Ellipsis literal -- not an IPython special form, so this works anywhere IPython's own ast_transformers hook runs: terminal, notebook/JupyterLab, qtconsole) is replaced, before execution, by the picked component's real dotted path -- the expanded code is printed, then run, so what actually executed stays visible and re-typeable. Multiple `...` in one statement resolve left to right. A `...` used as a numpy subscript (`arr[...]`) is left alone. Reuses eco.widgets.component_selector for the actual tree/fold-state/Recent-list machinery -- nothing new there. New eco.widgets.component_selector_qt.pick_component_modal(): the existing ComponentSelectorQtWindow is built for embedding/async display (start() hands off to IPython's own Qt pumping, or run() blocks the whole process forever); an AST transform needs an actual answer before it can even return control to IPython, so this drives the same window with its own nested QEventLoop that quits the instant a pick (or a close) happens. Also fixed a real bug found while testing this live: IPython's ast_transformers calls `.visit(node)` on each registered entry (expects an ast.NodeTransformer-shaped object) -- a bare function registered there throws 'function' object has no attribute 'visit' once, and IPython *silently* unregisters it with only a warning, no visible error. Verified end-to-end against a real Qt window on a real X11 display (Xvfb) plus a real IPython shell: spawned the picker, drove a real selection via QTimer, confirmed the printed/executed code and result were both correct. Deliberately namespace-tree-only for now -- picking from prior *commands* (eco.logs' kernel history) is a planned second mode, not built here. Co-Authored-By: Claude Sonnet 5 --- eco/ipymagic.py | 152 +++++++++++++++++++++++++++ eco/widgets/component_selector_qt.py | 47 +++++++++ tests/test_ipymagic.py | 123 ++++++++++++++++++++++ 3 files changed, 322 insertions(+) create mode 100644 eco/ipymagic.py create mode 100644 tests/test_ipymagic.py diff --git a/eco/ipymagic.py b/eco/ipymagic.py new file mode 100644 index 0000000..670dcf4 --- /dev/null +++ b/eco/ipymagic.py @@ -0,0 +1,152 @@ +"""Opt-in inline component picker for IPython: write a bare ``...`` +(the builtin ``Ellipsis`` literal) anywhere an expression is expected, and +it's replaced -- before execution -- by whatever component you pick from +the namespace tree. The *expanded* code (with the picked component's real +dotted path spliced in, not a placeholder) is printed first, then run -- +so what actually executed is always visible and re-typeable:: + + from eco import bernina + from eco import ipymagic + ipymagic.start(bernina) + + In [1]: (1 / ...).plot() + (1 / bernina.mono.energy).plot() + + + In [2]: cen = (left := ...) - (right := ...) + cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission) + +Each ``...`` prompts once, in left-to-right order, and the picker itself +(``eco.widgets.component_selector_qt``) remembers its own fold/search state +and a per-user Recent list across invocations already -- nothing extra +needed here for that. + +Why ``...`` and not a named sentinel object: it's already a real Python +literal (the builtin ``Ellipsis`` singleton -- ``arr[...]``, type-stub +bodies, "fill this in" generally), not an IPython special form, so the +interception happens the same way anywhere IPython's own +``ast_transformers`` hook runs -- a terminal session, a Jupyter notebook/ +JupyterLab, qtconsole -- with nothing IPython-specific about the trigger +itself, only about how it's intercepted. No name to import, no risk of +shadowing a real variable, nothing to rename if `ipymagic` itself gets +renamed later. + +A bare ``...`` used as a numpy/array subscript (``arr[...]``) is left +alone -- only a ``...`` used as its own expression is treated as "pick +something here". Closing the picker without choosing anything leaves the +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. +""" +import ast + +_state = {} + + +def _is_marker(node): + return isinstance(node, ast.Constant) and node.value is Ellipsis + + +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/ + left-to-right in source order (so `(left := ...) - (right := ...)` + prompts for `left` before `right`). Leaves a cancelled pick's literal + `...` untouched.""" + + def __init__(self, root, kind_filter, bookmarks, recent): + self.root = root + self.kind_filter = kind_filter + self.bookmarks = bookmarks + self.recent = recent + self.picked_any = False + + def visit_Subscript(self, node): + # descend into everything except the slice itself, so `arr[...]` + # is left alone -- only node.value (e.g. a pick used as part of + # what's being subscripted, `(...).plot()[0]`) is still eligible. + node.value = self.visit(node.value) + return node + + def visit_Constant(self, node): + if not _is_marker(node): + return node + from eco.widgets.component_selector_qt import pick_component_modal + + path, obj = pick_component_modal( + self.root, + kind_filter=self.kind_filter, + bookmarks=self.bookmarks, + recent=self.recent, + ) + if path is None: + print("eco.ipymagic: selection cancelled -- left `...` in place") + return node + self.picked_any = True + replacement = ast.parse(path, mode="eval").body + return ast.copy_location(replacement, node) + + +class _AstTransformer: + """Registered directly into IPython's `ast_transformers` list. + `InteractiveShell.transform_ast` calls `.visit(node)` on each entry + itself -- it expects an `ast.NodeTransformer`-shaped object, not a + plain function (confirmed the hard way: a bare function registered + there gets one `AttributeError: 'function' object has no attribute + 'visit'`, then IPython silently unregisters it and moves on -- no + exception surfaces to the caller, `...` just quietly stops being + intercepted).""" + + def visit(self, node): + if _state.get("root") is None: + return node + picker = _EllipsisPicker( + _state["root"], _state["kind_filter"], _state["bookmarks"], _state["recent"] + ) + node = picker.visit(node) + if picker.picked_any: + print(ast.unparse(node)) + return node + + +_ast_transformer = _AstTransformer() + + +def start(root, kind_filter="All", bookmarks=None, recent=None): + """Register the `...` picker on the *current* IPython shell. + + `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. + + 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 + safe. + """ + from IPython import get_ipython + + ip = get_ipython() + if ip is None: + raise RuntimeError("eco.ipymagic.start() needs a running IPython session") + + _state["root"] = root + _state["kind_filter"] = kind_filter + _state["bookmarks"] = bookmarks + _state["recent"] = recent + + if not getattr(ip, "_eco_ipymagic_installed", False): + ip.ast_transformers.append(_ast_transformer) + 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.""" + _state["root"] = None diff --git a/eco/widgets/component_selector_qt.py b/eco/widgets/component_selector_qt.py index 90adb16..cda0769 100644 --- a/eco/widgets/component_selector_qt.py +++ b/eco/widgets/component_selector_qt.py @@ -459,3 +459,50 @@ def make_component_selector_qt_window( max_depth=max_depth, auto_start=auto_start, ) + + +def pick_component_modal( + root: Any, + kind_filter: str = "All", + bookmarks: Optional[ComponentBookmarks] = None, + recent: Optional[RecentComponents] = None, +): + """Block until the user picks a component (or closes the window without + picking), regardless of any active IPython Qt event-loop integration -- + unlike `ComponentSelectorQtWindow.start()`/`run()`, which either hands + off to IPython's async pumping (returning immediately, selection still + pending) or blocks the whole process forever via `app.exec_()`. This + runs its own nested `QEventLoop` that quits the instant a pick (or a + close) happens, then hands control straight back -- for a caller that + needs an actual answer before it can do anything else, like eco. + ipymagic's inline `...` picker (an AST transform can't return control to + IPython and get an answer later; it has to have one before it returns + at all). + + Returns `(dotted_path, obj)`, or `(None, None)` if the window was + closed without picking anything. + """ + global _app_ref + app = QtWidgets.QApplication.instance() + if app is None: + app = QtWidgets.QApplication([]) + _app_ref = app + + win = ComponentSelectorQtWindow( + root, kind_filter=kind_filter, bookmarks=bookmarks, recent=recent, auto_start=False + ) + win._build_window() + + loop = QtCore.QEventLoop() + picked = {} + + def _on_select(path, obj): + picked["path"], picked["obj"] = path, obj + loop.quit() + + win.on_select(_on_select) + win.window.destroyed.connect(loop.quit) + loop.exec_() + win.stop() + + return picked.get("path"), picked.get("obj") diff --git a/tests/test_ipymagic.py b/tests/test_ipymagic.py new file mode 100644 index 0000000..cf9b286 --- /dev/null +++ b/tests/test_ipymagic.py @@ -0,0 +1,123 @@ +"""eco.ipymagic -- the `...`-triggered inline component picker. + +Qt itself (the actual picker window) is not exercised here -- these tests +monkeypatch eco.widgets.component_selector_qt.pick_component_modal (the one +function ipymagic._EllipsisPicker calls) and check the pure-AST behavior: +which `...` occurrences get replaced, in what order, with what, and which +are correctly left alone. +""" +import ast + +import pytest + +from eco import ipymagic + + +class _FakeEvents: + def register(self, *a, **k): + pass + + +class _FakeShell: + def __init__(self): + self.ast_transformers = [] + + +@pytest.fixture(autouse=True) +def _reset_state(): + ipymagic._state.clear() + yield + ipymagic._state.clear() + + +def _transform_source(source, root="the_root"): + tree = ast.parse(source) + picker = ipymagic._EllipsisPicker(root, "All", None, None) + new_tree = picker.visit(tree) + ast.fix_missing_locations(new_tree) + return ast.unparse(new_tree), picker.picked_any + + +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()), + ) + out, picked_any = _transform_source("(1 / ...).plot()") + assert out == "(1 / bernina.mono.energy).plot()" + assert picked_any is True + + +def test_two_markers_are_resolved_left_to_right_in_source_order(monkeypatch): + picks = iter(["bernina.mono.energy", "bernina.attenuator.transmission"]) + seen_roots = [] + + def fake_pick(root, **kw): + seen_roots.append(root) + return next(picks), object() + + monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fake_pick) + out, picked_any = _transform_source( + "cen = (left := ...) - (right := ...)", root="bernina" + ) + assert out == "cen = (left := bernina.mono.energy) - (right := bernina.attenuator.transmission)" + assert picked_any is True + assert seen_roots == ["bernina", "bernina"] + + +def test_subscript_slice_ellipsis_is_left_alone(monkeypatch): + def fail_if_called(root, **kw): + raise AssertionError("picker should not be invoked for arr[...]") + + monkeypatch.setattr("eco.widgets.component_selector_qt.pick_component_modal", fail_if_called) + out, picked_any = _transform_source("arr[...].sum()") + assert out == "arr[...].sum()" + assert picked_any is False + + +def test_cancelled_pick_leaves_the_literal_ellipsis_in_place(monkeypatch, capsys): + monkeypatch.setattr( + "eco.widgets.component_selector_qt.pick_component_modal", + lambda root, **kw: (None, None), + ) + out, picked_any = _transform_source("(1 / ...).plot()") + assert out == "(1 / ...).plot()" + assert picked_any is False + assert "cancelled" in capsys.readouterr().out + + +def test_transform_is_a_noop_when_start_has_not_been_called(): + # ipymagic._state is empty (autouse fixture clears it) -- _transform + # itself (not just the picker) must not try to pick anything. + tree = ast.parse("(1 / ...).plot()") + out = ast.unparse(ipymagic._ast_transformer.visit(tree)) + assert out == "(1 / ...).plot()" + + +def test_start_registers_the_transformer_once(monkeypatch): + shell = _FakeShell() + monkeypatch.setattr("IPython.get_ipython", lambda: shell) + + ipymagic.start("bernina") + ipymagic.start("bernina") # idempotent -- must not install a second hook + + assert shell.ast_transformers == [ipymagic._ast_transformer] + assert ipymagic._state["root"] == "bernina" + + +def test_start_raises_without_an_ipython_session(monkeypatch): + monkeypatch.setattr("IPython.get_ipython", lambda: None) + with pytest.raises(RuntimeError): + ipymagic.start("bernina") + + +def test_stop_makes_the_transform_a_noop_again(monkeypatch): + shell = _FakeShell() + monkeypatch.setattr("IPython.get_ipython", lambda: shell) + ipymagic.start("bernina") + + ipymagic.stop() + + tree = ast.parse("(1 / ...).plot()") + out = ast.unparse(ipymagic._ast_transformer.visit(tree)) + assert out == "(1 / ...).plot()"