Skip to content
Feeding a clanker? Grab this page as raw .md

LiveEdit API#

LiveEdit takes one call — LiveEdit.inspect_run(fn, *args, **kwargs) — and lays the run out next to its source: the setup values, a row per loop pass including nested child loops, and the value that came back. Click a numeric column header to chart that column across passes. LiveEdit.from_pytest("tests/test_foo.py::test_bar") does the same for a test body, with a failing assert rendered on the offending line instead of raised, so a broken test becomes something you read rather than something you re-run with print statements.

See also: AsyncFlow for the same idea applied to an async run, ApiDoc for rendering a function's signature and docstring, and Matrix for editing the numbers you feed in by hand.

Bases: AnyWidget

Read-only function trace widget for inspecting one Python run.

LiveEdit.inspect_run(fn, *args, **kwargs) is the primary constructor for v1. The widget stores the original Python args privately and only syncs repr-based trace data to the browser, which keeps future editable code updates possible without serializing arbitrary Python objects.

Source code in wigglystuff/live_edit.py
def __init__(
    self,
    code: str,
    *,
    args: tuple[Any, ...] = (),
    kwargs: dict[str, Any] | None = None,
    editable: bool = False,
    function_name: str | None = None,
    globalns: dict[str, Any] | None = None,
    height: int | None = None,
    float_precision: int | None = None,
    visible_columns: list[str] | None = None,
    **widget_kwargs: Any,
) -> None:
    self._liveedit_args = tuple(args)
    self._liveedit_kwargs = {} if kwargs is None else dict(kwargs)
    self._liveedit_function_name = function_name
    self._liveedit_globalns = dict(globalns or {})
    trace, annotations, error = _trace_code(
        code,
        self._liveedit_args,
        self._liveedit_kwargs,
        function_name=function_name,
        globalns=self._liveedit_globalns,
        float_precision=float_precision,
    )
    if height is None:
        # Fit the source by default: ~21px per rendered line (13px font *
        # 1.55 line-height) plus top/bottom padding, floored at 520px so the
        # trace panel keeps a roomy scroll area. Lines never wrap (they sit
        # in an overflow-x panel), so a line-count estimate is accurate.
        n_lines = len(code.splitlines()) or 1
        height = max(520, n_lines * 21 + 40)
    super().__init__(
        code=code,
        trace=trace,
        annotations=annotations,
        error=error,
        editable=editable,
        height=height,
        float_precision=float_precision,
        visible_columns=list(visible_columns or []),
        **widget_kwargs,
    )

from_pytest classmethod #

from_pytest(nodeid: str, *args: Any, float_precision: int | None = None, visible_columns: list[str] | None = None, **kwargs: Any) -> 'LiveEdit'

Trace one pytest test's body with LiveEdit.

nodeid is a pytest node id like "tests/test_foo.py::test_bar" (the same string you would pass on the command line). LiveEdit then traces the test function body exactly as inspect_run would. Calls into other functions stay opaque (you see a call's return value, not its internals). A failing assert is rendered on the offending source line rather than raised.

Arguments come from one of two places:

  • Nothing passed (default): pytest resolves the test's fixtures, parametrization, and conftest.py and the test runs once. If the node id matches several tests (e.g. a parametrized test given by its bare name), no test is traced and an error asks you to pass a specific ...::test_bar[case] id or supply arguments yourself.
  • Args/keyword args passed (e.g. from_pytest(nodeid, x=3)): those values are used directly and fixtures are bypassed entirely. This is the escape hatch for parametrized tests or tests whose fixtures are too heavy to spin up just to watch the logic.

Only module-level def test_* functions are supported: class-based tests (TestClass::test_x) and async def tests raise a clear error. Tests wrapped in a behaviour-changing decorator (e.g. @mock.patch(...)) aren't traced either, because the trace runs a re-exec'd copy of the source; use the fixture forms (monkeypatch, mocker) — which are resolved normally — or pass args manually.

Note: pytest imports each test module once per process, so editing the test file and calling from_pytest again in the same kernel may reuse the stale module.

Source code in wigglystuff/live_edit.py
@classmethod
def from_pytest(
    cls,
    nodeid: str,
    *args: Any,
    float_precision: int | None = None,
    visible_columns: list[str] | None = None,
    **kwargs: Any,
) -> "LiveEdit":
    """Trace one pytest test's body with ``LiveEdit``.

    ``nodeid`` is a pytest node id like ``"tests/test_foo.py::test_bar"``
    (the same string you would pass on the command line). ``LiveEdit`` then
    traces the **test function body** exactly as ``inspect_run`` would.
    Calls into other functions stay opaque (you see a call's return value,
    not its internals). A failing ``assert`` is rendered on the offending
    source line rather than raised.

    Arguments come from one of two places:

    - **Nothing passed** (default): pytest resolves the test's fixtures,
      parametrization, and ``conftest.py`` and the test runs once. If the
      node id matches several tests (e.g. a parametrized test given by its
      bare name), no test is traced and an error asks you to pass a specific
      ``...::test_bar[case]`` id or supply arguments yourself.
    - **Args/keyword args passed** (e.g. ``from_pytest(nodeid, x=3)``):
      those values are used directly and fixtures are bypassed entirely.
      This is the escape hatch for parametrized tests or tests whose
      fixtures are too heavy to spin up just to watch the logic.

    Only module-level ``def test_*`` functions are supported: class-based
    tests (``TestClass::test_x``) and ``async def`` tests raise a clear
    error. Tests wrapped in a *behaviour-changing* decorator (e.g.
    ``@mock.patch(...)``) aren't traced either, because the trace runs a
    re-`exec`'d copy of the source; use the fixture forms (``monkeypatch``,
    ``mocker``) — which are resolved normally — or pass args manually.

    Note: pytest imports each test module once per process, so editing the
    test file and calling ``from_pytest`` again in the same kernel may reuse
    the stale module.
    """
    try:
        import pytest
    except ImportError as exc:  # pytest is not a runtime dependency.
        raise ImportError(
            "LiveEdit.from_pytest requires pytest. Install it with "
            "`pip install wigglystuff[pytest]`."
        ) from exc

    collector = _PytestCollector(
        cls, args, kwargs, float_precision, visible_columns
    )
    path = nodeid.split("::", 1)[0]
    rootdir = str(Path(path).resolve().parent) if path else "."
    argv = [
        nodeid,
        "-p",
        "no:cacheprovider",
        "--assert=plain",
        # importlib import mode keys modules by full path rather than
        # basename, so tracing two test files that share a basename (or
        # re-running against a fresh temp file of the same name) doesn't
        # trip pytest's "import file mismatch" collection error.
        "--import-mode=importlib",
        "-q",
        f"--rootdir={rootdir}",
    ]
    # importlib import mode keys modules by full path (see argv), but unlike
    # pytest's default it does NOT put the test's directory on sys.path, so a
    # test that imports a sibling/helper module (or whose conftest does)
    # would fail to collect. Add the rootdir the way prepend mode would, and
    # restore sys.path afterwards. Swallow pytest's console chatter (e.g.
    # "no tests ran") so the widget is the only cell output; errors still
    # reach us via the collector's hooks.
    sink = io.StringIO()
    added_to_path = rootdir not in sys.path
    if added_to_path:
        sys.path.insert(0, rootdir)
    try:
        with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
            exit_code = pytest.main(argv, plugins=[collector])
    finally:
        if added_to_path and rootdir in sys.path:
            sys.path.remove(rootdir)

    if collector.collect_error is not None:
        raise RuntimeError(
            f"LiveEdit.from_pytest: pytest could not collect `{nodeid}`:\n"
            f"{collector.collect_error}"
        )
    if collector.select_error is not None:
        raise ValueError(f"LiveEdit.from_pytest: `{nodeid}` {collector.select_error}")
    if collector.guard_error is not None:
        raise ValueError(collector.guard_error)
    if collector.widget is not None:
        return collector.widget
    if collector.setup_error is not None:
        raise RuntimeError(
            f"LiveEdit.from_pytest: fixture setup failed for `{nodeid}`:\n"
            f"{collector.setup_error}"
        )
    raise ValueError(
        f"LiveEdit.from_pytest: no test matched `{nodeid}` "
        f"(pytest exit code {int(exit_code)})."
    )

Inspect one run of a Python function with LiveEdit.

Source code in wigglystuff/live_edit.py
def inspect_run(fn: Any, *args: Any, **kwargs: Any) -> LiveEdit:
    """Inspect one run of a Python function with ``LiveEdit``."""

    return LiveEdit.inspect_run(fn, *args, **kwargs)

Synced traitlets#

Traitlet Type Notes
code str Source code for the traced function. This is the future live-edit source of truth.
trace dict Structured setup values, loop passes, nested child loops, and returned value.
annotations dict Static line/token metadata used by the browser for hover linking.
error dict or None Parse, runtime, or argument mismatch error payload; None when the run succeeds.
editable bool Reserved for the future browser editor mode. Defaults to False.
theme str "auto", "light", or "dark".
width int Widget width in pixels.
height int Maximum widget height in pixels.