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()
<plot appears>
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 <noreply@anthropic.com>
This commit is contained in:
+152
@@ -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()
|
||||
<plot appears>
|
||||
|
||||
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
|
||||
@@ -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")
|
||||
|
||||
@@ -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()"
|
||||
Reference in New Issue
Block a user