Skip to content

scrolling_log_sink

anomaly_match_ui.utils.scrolling_log_sink

Capped, auto-scrolling loguru sink for ipywidgets Output panes.

Four screens (training / training-setup / prediction / prediction-setup) mirror the loguru stream into an :class:ipywidgets.Output so the user can watch subprocess INFO lines from the UI. Two problems on long runs (#430):

  1. Output.append_stdout retains every line for the lifetime of the widget, so a multi-day run accumulates tens of thousands of entries and slows the kernel / browser.
  2. The Output widget's overflow="auto" does not auto-scroll — new lines appear below the viewport unless the user scrolls.

:class:ScreenLogSink wraps the sink-creation logic and the logger.add / logger.remove lifecycle. The sink maintains a bounded deque of lines and rewrites the widget's outputs payload in place, so memory stays flat. A one-shot MutationObserver keeps any widget tagged with the am-log-sink CSS class pinned to its bottom. The on-disk session.log continues to hold the unabridged history.

ScreenLogSink(screen_label)

Lifecycle wrapper for a screen-bound, capped, auto-scrolling sink.

Each of the four UI screens (training / training_setup / prediction / prediction_setup) mirrors the loguru stream into its own Output widget via the same attach/detach dance: tear down any prior sink, build a fresh capped sink, logger.add; on screen leave, logger.remove while tolerating the ValueError that :meth:Session.set_terminal_out raises after a global sink clear.

This class consolidates that lifecycle so the per-screen wrappers don't have to redefine the same method twice.

Parameters:

Name Type Description Default
screen_label str

Human-readable name used in the debug log when a sink turns out to have already been removed (e.g. "TrainingScreen").

required
Source code in anomaly_match_ui/utils/scrolling_log_sink.py
def __init__(self, screen_label: str) -> None:
    self._screen_label = screen_label
    self._sink_id: int | None = None

attach(out)

Bind a capped, auto-scrolling sink to out.

Idempotent — any previously-attached sink is detached first. Bypasses ipykernel's OutStream (via the underlying :func:_make_scrolling_log_sink) to dodge parent_header-walk hangs on thread-ident reuse.

Source code in anomaly_match_ui/utils/scrolling_log_sink.py
def attach(self, out: widgets.Output) -> None:
    """Bind a capped, auto-scrolling sink to *out*.

    Idempotent — any previously-attached sink is detached first.
    Bypasses ipykernel's ``OutStream`` (via the underlying
    :func:`_make_scrolling_log_sink`) to dodge ``parent_header``-walk
    hangs on thread-ident reuse.
    """
    self.detach()
    sink = _make_scrolling_log_sink(out)
    self._sink_id = logger.add(sink, level="INFO", format="{message}")

detach()

Remove this screen's sink, tolerating session-level clears.

:meth:Session.set_terminal_out may have already removed all sinks between attach and detach, in which case logger.remove raises ValueError. Log at DEBUG so genuine bugs don't hide here.

Source code in anomaly_match_ui/utils/scrolling_log_sink.py
def detach(self) -> None:
    """Remove this screen's sink, tolerating session-level clears.

    :meth:`Session.set_terminal_out` may have already removed all
    sinks between attach and detach, in which case ``logger.remove``
    raises ``ValueError``.  Log at DEBUG so genuine bugs don't hide
    here.
    """
    if self._sink_id is None:
        return
    try:
        logger.remove(self._sink_id)
    except ValueError:
        logger.debug("{} log sink {} was already removed", self._screen_label, self._sink_id)
    self._sink_id = None

install_auto_scroll()

Inject the global auto-scroll observer into the front-end.

Call once from a notebook display context (e.g. the app's show() method). Subsequent calls are harmless: the JS itself is idempotent via window.__amLogScrollObserverInstalled. Calling from inside a sink (i.e. from a loguru dispatcher thread) does not publish to the front-end, which is why this is a separate entry point.

Source code in anomaly_match_ui/utils/scrolling_log_sink.py
def install_auto_scroll() -> None:
    """Inject the global auto-scroll observer into the front-end.

    Call once from a notebook display context (e.g. the app's ``show()``
    method).  Subsequent calls are harmless: the JS itself is idempotent
    via ``window.__amLogScrollObserverInstalled``.  Calling from inside
    a sink (i.e. from a loguru dispatcher thread) does *not* publish to
    the front-end, which is why this is a separate entry point.
    """
    display(Javascript(_AUTO_SCROLL_JS))