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>
For "from eco import bernina" with no startup script involved at all --
the same per-session JSONL activity log every interactive entry point
already gets (eco.widgets.kernel_registry.install_shell_logger), exposed
as one explicit opt-in call instead of only happening as a side effect of
going through a startup script:
from eco import bernina
from eco import globallogger
globallogger.start()
Deliberately opt-in, not automatic on `import eco` -- a plain library
import (a short script, a notebook cell) shouldn't get a
~/.eco/kernel_logs/*.jsonl file appearing on disk just from importing.
Defaults the session label to the caller's resolved identity (eco.
elements.access.current_identity) rather than a generic tag, so two
people on a shared account still end up with distinguishable logs -- the
same "global user based" reasoning eco.elements.recent already applies to
the per-user Recent-components list.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CameraBasler/CameraPCO._default_dock_in was True, so the plain
default-widget path (e.g. starting eco and opening a camera viewer) always
docked into a shared EcoDesktopApp workbench, auto-creating one if none
was open -- surprising for anyone not asking for that. Reverted to None
(standalone, matching Assembly's own base default); widget(dock_in=True)
still opts in explicitly and still auto-creates the workbench container
if none exists yet, unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_channel_lists.py: test_compare_reports_missing_and_exceeding asserted
set(result) == set(CHANNEL_LIST_CHANNELTYPES) (expecting channels_BS/
channels_BSCAM present) right next to asserting those same two keys are
*not* in result -- self-contradictory. compare_channel_lists() only ever
reports a list name that was actually present in `recorded`, matching the
module's own docstring; fixed the assertion to match.
test_cameras_swissfel.py: test_camera_basler_viewer_separate_process_passes_
name_and_cam_class called CameraBasler._widget_viewer as an unbound method
against a bare fake `self` that didn't carry _CAM_CLASS_PATH, so
getattr(self, "_CAM_CLASS_PATH", None) read None instead of the real
class's value. Added _CAM_CLASS_PATH to the fake to match what a real
CameraBasler instance would have.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
status() now shows the failed component names (not just the count), a
red/yellow/green state indicator, and a summary of recent request activity
(count, errors, last kind/age/duration) from /stats - previously only
reachable via separate failures()/stats() calls.
recording_mode is now an _AdjustableFSChoice (enum_strs=("all", "throttle",
"sample"), the exact set RecordingSession.__init__ accepts) instead of a
freeform AdjustableFS: rejects an invalid value at set_target_value time,
and exposes enum_strs so it structurally satisfies
eco.elements.protocols.AdjustableEnum for widget-layer dropdown display.
Deliberately not eco.elements.adjustable.AdjustableEnum itself, which
stores an IntEnum's integer rather than the string - storage stays the
plain string the server already parses, no server-side change needed.
Also documents the max_value_elements/max_points_per_channel distinction
inline (per-value-width filter vs. per-channel-length cap over time) -
not obvious from the settings' names alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The settings (recording_mode, recording_min_interval, ...) were already
registered for display -- Assembly._append's own is_display defaults to
True and nothing here overrode it. What was missing is that StatusServer
replaces __repr__ outright with a one-line health summary, which never
called Assembly.get_display_str() (the thing that actually renders
is_display=True components into a table), so the settings never showed up
in repr(bernina.status_server) despite being display-eligible all along.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A brand new namespace object's memory/presets directory doesn't exist yet,
and accounts differ in whether they're in the group the shared memory tree
(eco_cnf_bernina/memory/) is setgid to -- gac-bernina's default group isn't
unx-sf_bernina_bs and that can't easily be changed. ensure_dir() already
warns-and-continues on a permission failure, but AdjustableFS's write then
turned that into an uncaught FileNotFoundError, taking the whole Assembly
(and anything wrapping it, e.g. Namespace.append_obj) down -- hit live when
constructing the new bernina.status_server object.
Memory.setup_path() now falls back to a private, per-account directory under
home (the same pattern AdjustableFS's own group_writable=False already uses
for RecentComponents/ComponentBookmarks) when the shared one can't be
created, so an account like gac-bernina still gets working memory/presets,
just not cross-account-shared for objects it's the first to touch.
Assembly.__init__ additionally degrades to self.memory = None on any other
unexpected failure there, rather than ever letting it take the object down.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three follow-ups to the status_server namespace component and
recording feature:
1. StatusServer's recording settings are now AdjustableFS, not
AdjustableMemory - each a small JSON file under the same shared
eco_cnf_bernina/configuration tree channels_JF/config_JFs already
use. The point: a setting changed in one process is visible to any
other process reading the same filesystem (another session, or the
status server itself) with no REST call or other new communication
- confirmed with a test constructing two independent StatusServer
instances against the same config_dir.
2. RecordingSession.size_report(): per-channel projected bandwidth -
rate (this channel's own point count over elapsed time) times a
sample value's size (element count * dtype itemsize, "frequency *
data shape * bitdepth") - to judge storage/bandwidth cost from a
short trial recording before committing to a long one. Works on a
still-running recording. Exposed via GET /recording/<id>?size=1
(optional &size_top_n=N) and StatusServerClient.recording(...,
size=True).
3. failed_required for recordings: a channel that fails to attach and
belongs to a required namespace component (namespace.required_names())
is now reported separately in a recording's report() - the same
"everyone else failing is expected, this failing is not" distinction
/health's failed_required already makes for init failures. New
client-side warn_recording_failed_required (red, but does not
suggest reinit() the way the health one does - a demoted/
disconnected channel isn't fixed by rebuilding the namespace),
wired into Daq.start_scan_monitoring.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New eco.status_server.namespace_component.StatusServer (Assembly),
wired into bernina_daq.py as a top-level namespace object alongside
daq/scans - always built, independent of whether this session's own
Daq is using the server (ECO_STATUS_SERVER), since the server itself
runs regardless.
- Control: .restart()/.reinit(), delegating to StatusServerClient.
- Inspection: .status() (prints a one-line summary, warns in red about
failed_required, returns the raw /health body), .stats(),
.failures(), .monitor_policy() (new GET /monitor_policy route +
client method, surfacing eco.epics_utils.ca_tuning's adaptive
monitor policy from inside the server's own process - max_rate_hz,
how many channels are monitored/known-fast, sensitive-period state;
read-only, the threshold itself is a module constant today).
- .gui(): launches the Qt monitor as a detached subprocess, same
window `eco-status-server gui` opens (not an embedded widget -
noted as a separate, bigger follow-up if wanted).
- Recording settings (mode, min_interval, sample_interval,
max_value_elements, max_points_per_channel) as real AdjustableMemory
children with is_setting=True, so they show up the way any other
device's configuration does. Not yet consumed by Daq.start_scan_
monitoring (still its own mode="throttle"/min_interval=0.1
defaults) - wiring that through is the natural next step, flagged
explicitly in the module docstring rather than left implicit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hit live on a real test scan: _push_acquiring_scan_status handed
datetime.now() straight to push_status(), which POSTs via
requests.post(json=body) - that serializes with plain stdlib
json.dumps and no encoder hook at all, so it raised TypeError before
the client ever got a response to fall back from.
Two fixes: the actual datetime is now a unix timestamp
(datetime.now().timestamp()) at the source, matching every other
timestamp already flowing through this file; and
StatusServerClient._post() now serializes the body itself
(json.dumps(..., default=_json_default)) instead of using requests'
json= convenience, so any future datetime/numpy value through any
POST this client makes degrades gracefully (numpy scalars/arrays
converted, anything else str()'d) instead of crashing outright.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
DetectorVirtual had no set_current_value_callback() at all, so a
computed Detector could never be live-monitored no matter how
monitorable its own parents were - it landed in a recording's
n_attach_failed the same as a genuinely dead PV, for a structurally
different reason. AdjustableVirtual already solved this generically
(CallbackComposedValue: subscribe to every parent's own callback,
recompute get_current_value() on any update, independent of whether a
parent is CA-backed or itself composed) - give DetectorVirtual the
same treatment via the same class, generalized to take its children
explicitly instead of reaching into AdjustableVirtual's own
._adjustables attribute.
Discovery is automatic: NamespaceMonitorStore already finds every
monitorable status detector via isinstance(ts, MonitorableValueUpdate)
(eco.elements.protocols), a pure duck-typed check - no wiring needed
beyond DetectorVirtual having the method now.
RecordingSession.start()'s recent seed-if-cheap logic (seed a channel
with its current value for free when that costs no CA traffic) now
also recognizes a CallbackComposedValue-backed monitor as free to
seed - it recomputes from already-monitored parents rather than
issuing a get of its own.
Found and fixed along the way: CallbackComposedValue.start() only
accepted add_current_value, not with_ctrlvars/auto_monitor -
RecordingSession.start() (and anything else treating a monitor
uniformly regardless of whether it is CA-backed or composed) calls
.start() with all three, so this raised TypeError for any composed
value that reached this path - previously unreachable for a
DetectorVirtual (no method at all) and apparently never exercised for
an AdjustableVirtual monitored this way either. Both kwargs are now
forwarded to every child, recursively, matching CallbackEpics.start()'s
interface exactly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two-tier fix for a namespace recording's "channel with zero updates
is simply absent from the file" gap:
1. RecordingSession.start() now seeds a channel with its current value
(add_current_value=True) when that's actually free: pyepics'
PV.get_with_metadata() returns the cached value with no CA traffic
when auto_monitor is already True and the channel is connected -
true for the large majority of channels under ca_tuning.make_pv()'s
default policy. A demoted "fast" channel or a disconnected one
would make this a real blocking get, so those are left unseeded, as
before. New n_seeded counter reports how many got the free seed.
2. For the rest, RecordingSession.backfill_from_status() lets a status
snapshot that was already being taken for another reason (a scan's
own status_run_start/status_run_end capture) opportunistically fill
in one value for any channel still at zero points - never a trigger
of new CA traffic on its own. NamespaceMonitorStore gains
pgroup/run_number on start_recording() (purely for this lookup) and
backfill_running_recordings(), wired into both /status/capture and
/status/snapshot right after their own snapshot() call. New
n_backfilled counter.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
start_scan_monitoring/end_scan_monitoring are now in
callbacks_start_scan/callbacks_end_scan - server-only (no local
fallback, unchanged from when these were written), so a scan without a
status server configured simply gets no aux/namespace_monitor.h5, the
same as it already gets no server-backed aliases.json/status.json.
Placement matters: start_scan_monitoring runs after
count_run_number_up_and_attach_to_scan (needs scan.daq_run_number) and
append_start_status_to_scan (shares its per-scan server-in-use
decision); end_scan_monitoring runs before copy_scan_info_to_raw,
which is what actually writes the "monitors" scan parameter into
scan_info_rel.json.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
end_scan_monitoring() now writes to aux/namespace_monitor.h5 (instead
of the general-purpose recording API's default monitors.esc.h5) and
registers it via scan.set_scan_parameter("monitors",
"aux/namespace_monitor.h5") right after dispatching the capture job -
the same "register the path now, the async job finishes later"
pattern copy_aliases_to_scan/append_start_status_to_scan already use
for "aliases"/"status", so copy_scan_info_to_raw's scan_info_rel.json
carries it the same way.
Added eco/status_server/NAMESPACE_MONITOR_FORMAT.md: a handoff spec
for escape_fel describing the file format (one escape.ArrayTimestamps
per channel, keyed by the same dotted alias space as status.json/
aliases.json), how to load it (escape.DataSet(results_file=...)
already dict2structure()s it correctly for free - found by reading
escape_fel 0.2.7's actual source, not guessed), how it relates to and
should replace the existing pickle-based "monitor data hack" in
escape/swissfel/parse.py, and the merge-order needed for a co-loaded
bs/detector dataset to supersede the CA-monitor one for the same
component. Not yet exercised against a real load - flagged as such.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
POST /recording/capture: stops a recording, writes it, and uploads it
to the run - all in the background, mirroring /status/capture and
/aliases/capture. Stopping (detaching callbacks) happens synchronously
before responding, so the recording is unambiguously over by the time
the caller gets a job id; only the write (scales with points recorded)
and the broker upload are backgrounded. Load-tested against the live
server first (start/get_status/stop concurrently): snapshot latency is
unaffected by a concurrent recording (~11s either way), ~38-44% of one
core combined, no thread/memory leak across repeated start/stop.
Daq gains start_scan_monitoring()/end_scan_monitoring() - start a
namespace-wide recording keyed to the run, and capture+upload it -
built to be called at a scan's start/end but deliberately NOT added to
callbacks_start_scan/callbacks_end_scan yet. No local fallback: unlike
status/aliases, a local recording would need this session's own
namespace holding a live monitor per channel for the whole scan, which
is exactly the per-session cost the status server exists to avoid.
Also fixes a latent cross-feature bug found while adding this: GET
/status/job/<id>?include_status=1 merged in any pending
scans.acquiring_scan.* push (from the just-merged push_status
mechanism) by defaulting the job's "key" to "status_run_start" when
absent - but aliases-capture jobs (and now recording-capture jobs)
carry pgroup/run_number for their own reporting without ever setting
"key". Reading such a job with include_status=1 could have silently
stolen a push meant for the real status_run_start job of the same run.
Fixed by requiring an explicit "key" on the job before merging - only
genuine status/capture jobs set one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
scans.acquiring_scan.* (description, scan_command, initial_values, ...) can
never reach the status server's own snapshot: it's built from DetectorMemory
objects with no CA channel, and the server's channel registry is driven
entirely by Alias.get_all(), which only returns aliases that have one. The
run table's "Custom table" sheet already has header cells expecting these
exact column names, so they've been silently blank.
Add a small push mechanism instead of another metadata.*-style workaround:
Daq now hands the values it already resolves (the same ones metadata.* is
built from) to the status server via a new POST /status/push, which merges
them into the matching /status/capture job's status the next time it is
read with include_status=True - the natural point, since that's exactly
when daq_client asks for the run's status to fill the run table.
NamespaceMonitorStore.push_status/pop_pushed_status hold the pushed values
scoped by (pgroup, run_number, key), consumed once and dropped after 5
minutes if nothing ever reads them, so a push whose scan aborts can't
attach itself to a later, unrelated run.
_start_reinit called client.reinit(wait=True), which keeps
_ReinitWorker running for as long as the server-side rebuild takes
(minutes) - and closeEvent refuses to close while that worker is
running, to avoid destroying a QThread mid-flight (a real crash, not
hypothetical). Together that made the window impossible to close for
the whole duration of a reinit, even though the GUI only monitors the
server and the rebuild proceeds independently of whether the window
is even open.
Switch to wait=False: the worker now only has to make the request
itself (bounded by the client's own timeout, normally under a
second), not wait for the rebuild. Progress during the rebuild is
unaffected - it was always coming from the already-running _Poller's
/health polling, not from this worker. closeEvent's guard stays as a
safety net for a slow/unreachable request, just no longer trips for
the reinit itself.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The server already holds a fully-initialized namespace, so its
namespace.alias.get_all() is complete and free (pure in-memory tree
walk, no CA traffic). The client side's own copy_aliases_to_scan
called self.namespace.alias.get_all() unconditionally, including in
status-server mode - against a namespace that mode deliberately never
initializes, so aux/aliases.json was silently missing everything the
local session had not happened to touch.
- namespace_server.py: GET /aliases (synchronous, for direct
inspection) and POST /aliases/capture (async: compute, write
aliases.json, upload to the run - mirrors /status/capture, shares
its job-id space via GET /status/job/<id>).
- storage.py: write_aliases_file(), same group-writable-tree handling
as write_status_snapshot().
- client.py: StatusServerClient.aliases() / .capture_aliases().
- daq_client.py: copy_aliases_to_scan now asks the same per-scan
status-server decision append_start_status_to_scan already makes
and caches, delegating to the server when it's in use and falling
back to the local path (unchanged) otherwise.
Also fixes a latent bug found along the way: Daq.__init__ accepted an
`instrument` kwarg but never stored it as self.instrument, so every
f"/sf/{self.instrument or 'bernina'}/..." path in
_write_status_locally()/write_status() raised AttributeError on any
real Daq - masked because the only test reaching that path tolerates
"any exception" as "fell back correctly".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real incident, 2026-09-06: SLAAR21-LMOT-M521/M524:MOTOR_1.RBV (ordinary
delay-stage motor readbacks) got demoted mid-scan after a single 5s sweep
measured ~14 Hz during that step's move/settle burst. Demotion is
persisted to disk, so a channel that is idle except during moves stayed
wrongly stuck on the slow, direct-CA-get path afterwards.
_sweep_once now requires AUTO_MONITOR_MAX_RATE to be exceeded for
AUTO_MONITOR_SUSTAINED_SWEEPS consecutive sweeps (default 3, ~15s) before
demoting, tracked per-channel in _fast_streak and reset the moment a sweep
sees the channel back at a normal rate - a burst confined to one scan step
no longer counts as evidence of a genuinely fast channel. Also adds
forget_channel(pvname) to undo a single already-mismeasured entry without
wiping the whole learned list via clear_fast_channels().
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Root-cause investigation of a real scan abort (p19734 run 1146,
TimeoutError in Daq.get_pulse_id):
- ca_tuning._sweep_loop() never called epics.ca.use_initial_context()
before demoting channels (pv.auto_monitor = False / remove_callback,
which calls ca_clear_subscription()) - the same CA-context class of
bug this codebase already fixed once for real (see
namespace_store._ca_thread, parallel_init). Fixed, with tests.
- Daq.get_pulse_id() defaulted its wait budget to self.timeout, which
doubles as the broker HTTP request timeout (2s) - a transient ~3.4s
CA gap had no room to resolve. Split into a dedicated
pulse_id_timeout (5s default) so the two can be tuned independently.
- get_pulse_id()'s TimeoutError was a bare "timeout hit" with no
evidence of why. Added ca_tuning.describe_channel() (factored out of
report_none_read's existing connected/history/CA-cache logic) and
wired it into both TimeoutError sites, so a future occurrence
self-diagnoses instead of requiring log archaeology.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously launch_svg_viewer() (eco.utilities.svg_interactor) only ever
opened as a separate top-level Qt window or an inline notebook-cell
IFrame -- no way to tile it into eco desktop's own dock layout or a
JupyterLab Sidecar panel like every other eco Qt/notebook widget can.
Added dock_in (an EcoDesktopApp instance) to embed the viewer as a tiled
QDockWidget via the same _dock_widget_object mechanism the Namespace
launcher already uses for every other widget -- _build_qt_window now
optionally hands off a new _SvgViewerHandle (.window/.stop(), matching
the DisplayQt/AxisPTZStreamQt/CamServerStreamQt convention) instead of
self-showing as a standalone window. Added sidecar_anchor to open the
notebook-side Dash+IFrame viewer inside a JupyterLab Sidecar panel
instead of the current cell's output, reusing the same same-origin Dash
server (verified live: serves the real Bernina beamline SVG with its
click-extraction JS and namespace_prefix wired correctly). Both wired
through to Assembly.show(), so e.g. `namespace.show(sidecar_anchor=
"split-right")` or `namespace.show(in_window=True, dock_in=app)` works
for any Assembly with an SVG panel.
Found and documented (not fixed -- outside repo scope, needs an
environment change) a real gap while testing this: the deployed bpy312
env's qtpy picks PyQt5 as its default binding, which has no
QtWebEngineWidgets installed there, even though PySide6 (also installed)
does -- so in_window=True's native Qt viewer currently can't actually
open in that environment at all. Only one Qt binding can be active per
process, so this needs PyQtWebEngine added to bpy312 (or QT_API pinned
to pyside6), not an eco-side code fix. See
test_launch_svg_viewer_in_window_reports_missing_webengine_clearly.
Added tests/test_svg_interactor.py (previously zero coverage for this
module) covering _SvgViewerHandle, the dock_in wiring in both
launch_svg_viewer and _build_qt_window, and the sidecar_anchor path
(including its RuntimeError message when 'sidecar' isn't installed and
that omitting sidecar_anchor keeps today's plain inline behaviour for
Voila/classic Notebook compatibility).
Previously only eco desktop's console had an activity log (via
eco.widgets.console_kernel's ZMQ-message interception, which only works
for a kernel with a real iopub channel a Qt widget attaches to) -- a
plain terminal `eco console` session and JupyterLab-native/Voila kernels
had none. eco.widgets.kernel_registry.install_shell_logger() closes that
gap using IPython's pre_run_cell/post_run_cell events instead, which work
identically for any real InteractiveShell. Wired into startup_inline.py
(terminal), eco_cli.py's generated kernel profile (JupyterLab-native), and
voila_app.ipynb/jupyterlab_app.ipynb (Voila / bare `eco jupyterlab`).
Narrower than console_kernel's approach by design: only input plus the
resulting value/error are captured, not raw stdout/stderr streaming.
Also proactively set use_jedi=False for the terminal's `eco console`
startup script, matching the reasoning already applied to desktop's
console (see console_kernel.build_console_widget's docstring) -- Jedi's
introspection was measured slower and less correct against eco's dynamic,
lazily-resolving namespace.
Every kernel log's entries are now tagged with which stream they came
from (TimelineEntry.session, a "kind:label · HHMMSS" label derived from
the log's own session_start record) so eco.logs's timeline keeps merging
every session by default but LogTimelineQt gained a second, AND-combined
"Stream:" filter row alongside the existing "Show:" kind row to separate
them back out on demand. Fixed a same-second label collision the first
version of the label had (two sessions of the same kind+label started
within the same second, e.g. under test, produced identical labels) by
falling back to a trailing id fragment from the log filename.
Cross-user session browsing (the log_dir parameter already supports it)
is deliberately deferred, not built this pass.
New capability requested for eco desktop's (and, via eco.logs.widget(),
any) log viewer: filter entries by kind, select a range, and copy that
selection as a runnable Python script -- optionally replaying the
original pacing via inserted time.sleep() calls between lines.
- KernelSession.log_widget_control(code): a new event kind for GUI
actions that never went through the console at all -- e.g. clicking
"Open" in the Namespace launcher. Same "code IS the text" contract as
log_input (not a description -- actual runnable Python), so it's usable
in generated scripts the same way console input already is.
- EcoDesktopApp._open_widget now logs one of these on every successful
open, as a bare name (`<name>.widget()`) -- not `namespace.<name>`,
which per build_namespace's own docstring doesn't actually resolve
(Namespace.append_obj writes devices onto the scope module, not the
Namespace instance); the bare form is exactly what build_namespace_vars
already makes available in that console.
- eco.logs._kernel_record_to_entry maps "widget_control" events to their
own TimelineEntry kind, alongside the existing input/result/stream/
error/session ones.
- LogTimelineQt: a "Show:" row of one checkbox per distinct kind present
(hide/show via setHidden, no rebuild); ExtendedSelection on the list
(Qt's own click/Shift+click/Ctrl+click/drag range selection, nothing
else to wire up); a "Copy Selected as Script" button + "Replicate
timing" checkbox -- input/widget_control entries in the selection
become script lines verbatim, everything else becomes a `# [kind] ...`
comment for context; timing mode inserts time.sleep(dt) between lines
using the real gap between those two entries' timestamps.
Verified via scripted offscreen construction (filter checkboxes present
and working, selection mode set, script content correct both with and
without timing, hidden/filtered rows correctly excluded from a selection
that still nominally contains them) and end-to-end through a real
EcoDesktopApp (_open_widget really writes a widget_control JSONL line,
eco.logs reads it back with the right kind/text) -- plus the full test
suite (361/363, the 2 failures pre-existing in test_config_lazy_init.py,
unrelated).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Native menu bars are frontend-only in JupyterLab (same conclusion reached
earlier this session for auto-opening a Console tab -- no server-side hook
exists), so eco desktop's Tools -> Log Viewer menu action has no direct
equivalent there. A button on the dashboard is the closest achievable
analogue, wired through eco.logs's existing prefer= mechanism so all three
front-ends share one code path instead of three parallel ones:
- eco.logs._open_timeline gained a "sidecar" prefer option: renders the
timeline's HTML into a new eco.widgets.jupyter_sidecar.open_html_in_
sidecar() panel -- a real, separate dockable Sidecar, matching desktop's
menu action opening its own window.
- eco.widgets.jupyter_sidecar.NamespaceDashboard (the JupyterLab
counterpart to EcoDesktopApp) gained a "Log Viewer" button above the
Namespace launcher, calling eco.logs.widget(prefer="sidecar"); reopening
replaces (closes) the previous panel rather than piling up, and it's
torn down in .close() alongside everything else.
- eco.widgets.widget_tray.make_namespace_dashboard (Voila) gained the same
button, calling eco.logs.widget(prefer="jupyter") into a dedicated
ipywidgets.Output() -- Voila has no Sidecar shell to dock into, so this
renders inline instead, same reasoning as its existing WidgetTray.
Verified via scripted construction against fake namespaces (button
present, opens, reopening replaces, torn down on close) and the full test
suite (347/349 -- the 2 failures are pre-existing in
test_config_lazy_init.py, unrelated). Along the way, found and worked
around a real pre-existing test-isolation hazard: IPython's
InteractiveShell.instance() is a process-wide singleton, and an earlier,
unrelated test building a real ipykernel-backed shell leaves a stale one
behind that later display()/Output.clear_output() calls in the same
pytest session crash against -- not something this change caused, but the
first tests to actually exercise that code path, so the new tests
short-circuit the specific stale calls rather than depending on that
global state.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New "Tools" menu -> "Log Viewer" calls the already-existing
eco.logs.widget(prefer="qt") (kernel console history merged across
sessions, on a timeline -- see eco/logs.py and
eco.widgets.log_timeline_qt), which had no menu entry point before this.
Kept referenced on self._log_viewer so it isn't garbage-collected out from
under the user; reopening replaces (stops) the previous one rather than
piling up, and it's torn down alongside everything else in
_on_window_closing.
Verified via a scripted offscreen run (menu present, opens, reopening
replaces, stopped on window close) and the full test suite (342/344 --
the 2 failures are pre-existing in test_config_lazy_init.py, unrelated).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Real bug reported after live testing: `eco jupyterlab` rendered the
Namespace launcher as a large inline block (full notebook width, not a
narrow sidebar like eco desktop's dock), and opening a device apparently
popped a Qt window instead of an ipywidgets view. Root cause: eco jupyterlab
was pointed at the same voila_app.ipynb used by `eco webapp`, which renders
everything via eco.widgets.widget_tray.make_namespace_dashboard -- built
for Voila, which (per eco.widgets.jupyter_sidecar's own existing docstring)
has no Lumino shell to dock into at all, so there was never a "narrow
sidebar" option available there in the first place. JupyterLab DOES have a
Lumino shell, and eco already had eco.widgets.jupyter_sidecar.open_in_sidecar
for exactly this -- it just wasn't wired up to the CLI.
- NamespaceLauncherWidget now takes a generic on_open(obj, label) callback
instead of a hardcoded WidgetTray, so it can drive either a WidgetTray
panel (Voila/webapp, unchanged) or a Sidecar panel (JupyterLab, new).
- New eco.widgets.jupyter_sidecar.open_namespace_dashboard(): docks the
Namespace launcher in its own Sidecar panel, and routes each opened
device into its own Sidecar panel too -- the closest browser-side
analogue to eco desktop's dockable Namespace panel + tiled device docks.
- New eco/jupyterlab_app.ipynb (packaged; added to package-data, learned
that lesson the hard way earlier this session) uses it, with its code
cell collapsed by default (standard nbformat source_hidden metadata) --
JupyterLab has no server-side route to auto-open a real Console tab (it's
a frontend-only command, confirmed by grepping jupyterlab's own source
for any matching handler), so a notebook is still the minimal anchor tab
needed for Sidecar/ipywidgets to have something to render into; this
keeps its visible footprint to just the dashboard.
- New _write_jupyterlab_notebook(): opening an *existing* .ipynb always
uses the kernel named in its own file, not JupyterLab's server-wide
default -- so eco_cli.py now regenerates a copy with its kernelspec
pointed at the just-registered eco-<scope> kernel, same as
_register_eco_kernel's other generated files.
Voila (`eco webapp`) is unchanged -- make_namespace_dashboard/WidgetTray
remains correct there, confirmed working earlier this session.
Verified: the notebook's cell code executes without error against a real
eco-bernina kernel (same mechanism used to verify the kernel registration
earlier), a scripted Sidecar dashboard construction + simulated click
against a fake namespace, and the full test suite (338/340 -- the 2
failures are pre-existing in test_config_lazy_init.py, unrelated).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>