.md →
StateViewer API#
StateViewer(search_func, view_func) runs search_func once and records every
line it executes, including recursive calls. At each step it calls view_func
with the function's local variables, then plays the whole run back:
- left: whatever
view_funcdrew for that step; - right: the source with the line about to run highlighted, and a table of the function's variables. Rows stay fixed, values that just changed flash, and variables that aren't set yet show not assigned;
- bottom: play/pause/step buttons, a speed slider, a badge with the current timeline value, and a timeline of the whole run. Click or drag the timeline to scrub, or focus the widget and use ← / → (Shift jumps 10), Home / End and Space.
It's made for search problems, where the interesting part is how the state changes on the way to an answer.
Quick start#
Backtracking search for N-Queens, with a view that prints the board:
from wigglystuff import StateViewer
def solve(board, row, n):
if row == n:
return True
for col in range(n):
if all(c != col and abs(c - col) != row - r for r, c in enumerate(board)):
board.append(col)
if solve(board, row + 1, n):
return True
board.pop()
return False
def view(board, n):
rows = ["." * c + "Q" + "." * (n - c - 1) for c in board]
return "<pre>" + "\n".join(rows) + "</pre>"
StateViewer(solve, view, args=([], 0, 8))
args and kwargs are passed to search_func. The run is recorded when the
widget is created; playing it back doesn't run your code again.
How view_func gets its values#
view_func's parameter names pick the locals it receives. def view(board, n)
gets the live board and n from the frame that's running. In a recursive
search that's the innermost call.
- A parameter with a default is optional: it gets the default while that local
doesn't exist yet.
def view(board, n, row=None, col=None)works on every step. - While a required parameter isn't assigned yet, the previous view stays up (and the panel is empty before the first one). So give a default to anything that's assigned partway through the function, or the opening steps show nothing.
- A
**kwargsparameter receives every local.
Each view is drawn the moment its step is recorded, so in-place changes like
board.append(col) show up correctly.
view_func can return anything marimo can display: an HTML or SVG string,
mo.Html/mo.md, a dataframe, even a matplotlib figure. Plain strings are used
as HTML directly. Every distinct view is stored in the widget (repeats are
stored once), so small SVG strings are the best choice for long runs.
Searches without recursion: timeline_func#
By default the timeline plots call depth: how many calls to search_func
haven't returned yet. That shows a backtracking search's dives and retreats, but
a loop-based search never calls itself, so its depth stays at 1 and the timeline
is a flat bar.
A loop-based search keeps its progress in a data structure instead, so tell the
timeline what to plot. timeline_func picks its locals by parameter name, just
like view_func, and returns a number:
def search(maze, start, goal, breadth_first):
frontier = [start]
came_from = {start: None}
while frontier:
node = frontier.pop(0) if breadth_first else frontier.pop()
if node == goal:
break
r, c = node
for nxt in [(r - 1, c), (r, c + 1), (r + 1, c), (r, c - 1)]:
if maze[nxt[0]][nxt[1]] != "#" and nxt not in came_from:
came_from[nxt] = node
frontier.append(nxt)
...
StateViewer(
search,
maze_view,
args=(MAZE, (1, 1), (8, 15), True),
timeline_func=lambda frontier: len(frontier),
timeline_label="frontier size",
)
The badge and the caption show timeline_label. It defaults to the function's
name, or "value" for a lambda. Before the parameters exist the value is 0, and
it must be a finite number. In the demo, the same function plotted this way
shows breadth-first search keeping a narrow band while depth-first search's
stack piles up.
Driving other widgets#
view_func is optional. Without it the widget shows just the code, variables and
timeline, and you draw the state with any other widget instead:
stepis synced back to Python while you play and scrub, at most once everysync_throttle_ms(default 100;0syncs every step).viewer.stateholds the real local values at that step, not reprs. They're copied when the step is recorded, so later in-place changes don't affect them.variables=["board", "row"]limits the table to those names in that order.statestill holds every local.
viewer = StateViewer(solve, args=([], 0, 8), variables=["board", "row", "col"])
viewer.observe(lambda _: redraw(viewer.state["board"]), names="step")
In marimo, wrap the viewer in mo.ui.anywidget and update another widget from a
cell that reads viewer.widget.state. Assign to that widget's traitlets rather
than creating a new one, so it keeps its own state. The demo uses this to drive a
rotatable three.js board.
Reading the trace from Python#
viewer.stepsis the full trace, one record per step:line: the line about to run (1 = thedefline; decorators are left out ofcode);depth: call depth;event:"line","return"or"raise";locals: reprs of the shown variables;view: an index intoviews(-1= nothing drawn yet);value: thetimeline_funcvalue, if you passed one;returned: on return steps, the returned value's repr.
viewer.step,viewer.current(the record atstep),viewer.stateandviewer.n_steps.viewer.returnedis the repr of the result,viewer.truncatedsays whethermax_stepswas reached, andviewer.erroris set ifsearch_func,view_funcortimeline_funcraised. Errors are shown in the widget rather than raised, with the line they happened on.
Limits#
- Only
search_funcitself is recorded. Calls into helper functions run but aren't recorded, and neither are functions defined inside it or the generator expression in anall(...)check. If the interesting work happens in a helper, trace the helper. search_funcneeds Python source you can inspect: a plaindefin a file or notebook cell works best. Builtins and bound methods (obj.solve) raise aTypeError. For a method, passClass.solveand the object inargs.- Generator functions and
async deffunctions record zero steps: calling them only creates the generator or coroutine and doesn't run the body. max_steps(default 20,000) stops recording a runaway search and setstruncated.only_changes=Truekeeps only steps whose view (or, without a view, whose shown values) differs from the previous step.- Threads started by
search_funcaren't recorded; only the thread that creates the widget is. - Every step copies the locals (for
state) and repr's them (for the table), so very large objects make recording slower.
See also: LiveEdit for a table of every loop iteration of one run, and AsyncFlow for async tasks.
Bases: AnyWidget
Animate the state inside one run of a Python function.
search_func is run once under sys.settrace. Every executed line of
it (recursive calls included, helpers stay opaque) becomes one step. At
each step view_func is called with the function's locals matched to
its parameters by name, so def view(board, n): ... receives the live
board and n. Parameters with a default are optional; while a
required one isn't assigned yet the previous view stays up.
view_func can return anything marimo can show: an HTML/SVG string,
mo.Html/mo.md, a dataframe, even a matplotlib figure. Views are
rendered eagerly (so in-place mutations are captured correctly) and
deduplicated. SVG strings are by far the fastest choice for long runs.
view_func is optional. The widget's real job is to publish the state:
step syncs to Python while playing and scrubbing (at most every
sync_throttle_ms), current holds the step's line, depth and value
reprs, and state holds the actual local values at that step. Any other
widget can draw it, e.g. viewer.observe(redraw, names="step") or a
marimo cell that reads viewer.state and updates another widget.
Examples:
from wigglystuff import StateViewer
def solve(board, row, n):
if row == n:
return True
for col in range(n):
if all(c != col and abs(c - col) != row - r for r, c in enumerate(board)):
board.append(col)
if solve(board, row + 1, n):
return True
board.pop()
return False
def view(board, n):
return "<pre>" + "\n".join("." * c + "Q" + "." * (n - c - 1) for c in board) + "</pre>"
StateViewer(solve, view, args=([], 0, 6))
Without a view, drive another widget from the current state:
viewer = StateViewer(solve, args=([], 0, 6), variables=["board", "row"])
viewer.observe(lambda _: print(viewer.state["board"]), names="step")
Create a StateViewer.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
search_func
|
Callable[..., Any]
|
The function to trace. Needs inspectable Python source. |
required |
view_func
|
Callable[..., Any] | None
|
Draws the state; its parameter names pick the locals it gets. Optional: leave it out to only show code, variables and timeline. |
None
|
args
|
tuple[Any, ...]
|
Positional arguments for |
()
|
kwargs
|
dict[str, Any] | None
|
Keyword arguments for |
None
|
max_steps
|
int
|
Stop tracing after this many steps (sets |
20000
|
only_changes
|
bool
|
Drop steps whose view (or, without a view, whose shown values) is identical to the previous step's. |
False
|
interval_ms
|
int
|
Milliseconds per step while playing. |
80
|
loop
|
bool
|
Restart from the first step when playback reaches the end. |
False
|
height
|
int
|
Height cap in pixels (0 = fit content). |
0
|
variables
|
list[str] | None
|
Local names to show in the variables table, in this order.
Defaults to every local. |
None
|
sync_throttle_ms
|
int
|
Cap on how often |
100
|
timeline_func
|
Callable[..., Any] | None
|
Returns the number the timeline plots at each step;
its parameter names pick the locals, like |
None
|
timeline_label
|
str | None
|
Name shown for the timeline value. Defaults to
|
None
|
**widget_kwargs
|
Any
|
Forwarded to |
{}
|
Source code in wigglystuff/state_viewer.py
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 | |
Synced traitlets#
| Traitlet | Type | Notes |
|---|---|---|
code |
str |
Source of search_func (decorators dropped). |
lines |
list[dict] |
Syntax tokens per source line, for highlighting. |
steps |
list[dict] |
One record per step; see Reading the trace from Python. |
views |
list[str] |
Distinct rendered views as HTML; steps[i]["view"] indexes into it. |
variables |
list[str] |
Rows of the variables table: every local the run assigns, or the names passed as variables=. |
step |
int |
Current step. Synced while playing and scrubbing, and settable from Python. |
playing |
bool |
Whether playback is running. |
interval_ms |
int |
Milliseconds per step while playing. |
loop |
bool |
Restart from the first step at the end. |
timeline_label |
str |
What the timeline plots: "call depth", or the label for timeline_func. |
sync_throttle_ms |
int |
Minimum milliseconds between step syncs while playing or scrubbing. |
returned |
str \| None |
Repr of the return value. |
error |
dict \| None |
{type, message, source, lineno} when search_func, view_func or timeline_func raised. |
truncated |
bool |
Whether max_steps was reached. |
height |
int |
Height cap in pixels (0 = fit content). |