Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 120 additions & 3 deletions python/reflex_xy/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,26 @@

import asyncio
import contextlib
import inspect
import warnings
from collections.abc import Coroutine
from typing import Any, Optional
from typing import Any, Optional, cast

from reflex.plugins import Plugin

from .handles import FigureHandle, token_of
from .namespace import XYNamespace
from .registry import registry
from .registry import _figure_of, registry
from .state_bridge import make_rebuild_hook
from .tokens import BUILDER_ATTR, PROBE_ATTR
from .vars import AsyncFigureVar, FigureVar

__all__ = [
"FigureProbeError",
"XYPlugin",
"append",
"clear_selection",
"probe_figure_builders",
"reset_view",
"select",
"set_view",
Expand Down Expand Up @@ -137,18 +143,129 @@ async def _xy_lifespan() -> None:
await registry.sweep_forever()


class FigureProbeError(Exception):
"""A `@reflex_xy.figure` builder failed its compile-time probe."""


def _builder_location(builder: Any) -> str:
try:
filename = inspect.getsourcefile(builder)
_, line = inspect.getsourcelines(builder)
except (OSError, TypeError):
return "<source unavailable>"
return f"{filename}:{line}"


def _touches_session(builder: Any) -> bool:
"""Heuristic escape valve (constraint 2): builders that read the session
(`self.router`) cannot be expected to run against default state — their
probe failures degrade to a warning instead of failing the compile."""
try:
source = inspect.getsource(builder)
except (OSError, TypeError):
return False
return "self.router" in source


def _iter_probed_figure_vars(state_cls: Any, seen: set[Any]):
if state_cls in seen:
return
seen.add(state_cls)
computed = getattr(state_cls, "computed_vars", {})
for name, var in computed.items():
if isinstance(var, (FigureVar, AsyncFigureVar)):
yield state_cls, name, var
for subclass in state_cls.get_substates():
yield from _iter_probed_figure_vars(subclass, seen)


def probe_figure_builders(root_cls: Any = None) -> list[str]:
"""Run every probe-enabled `@reflex_xy.figure` builder against default
state (reflex-integration.md §3.1): the compile gate of the escape-hatch
tier. Returns the probed var full names; raises :class:`FigureProbeError`
(wrapping the original) on the first failing builder.

Level ``"build"`` runs the builder body and type-checks its return —
hallucinated `xy.*` names, wrong kwargs, eager chrome errors, and a
return value that is not a chart (or ``None``) fail here. ``"figure"``
also compiles the returned chart. ``False`` (and, by default, async
builders) are skipped. Builders whose source touches ``self.router`` degrade
failures to a `RuntimeWarning`: they are session-dependent by
declaration and only a live session can validate them.
"""
import reflex as rx

root_cls = root_cls if root_cls is not None else cast("Any", rx.State)
root = root_cls(_reflex_internal_init=True)
probed: list[str] = []
for state_cls, name, var in _iter_probed_figure_vars(root_cls, set()):
fget = var._fget
level = getattr(fget, PROBE_ATTR, False)
builder = getattr(fget, BUILDER_ATTR, None)
if not level or builder is None:
continue
full_name = f"{state_cls.get_full_name()}.{name}"
try:
substate = root.get_substate(tuple(state_cls.get_full_name().split("."))[1:])
except (KeyError, ValueError):
continue # not reachable from this root (e.g. mixin scaffolding)
try:
if inspect.iscoroutinefunction(builder):
chart = asyncio.run(builder(substate))
else:
chart = builder(substate)
if chart is not None and not (
callable(getattr(chart, "figure", None))
or callable(getattr(chart, "build_payload", None))
):
# Every probe level checks the return *type*: a builder that
# returns something no registry publish can accept would
# otherwise reach hydrate before failing.
msg = (
f"builder returned {type(chart).__name__}; expected an "
"xy Chart (or internal Figure), or None for 'no chart'"
)
raise TypeError(msg)
if level == "figure" and chart is not None:
_figure_of(chart)
except Exception as exc:
location = _builder_location(builder)
if _touches_session(builder):
warnings.warn(
f"@reflex_xy.figure probe: {full_name} ({location}) reads the "
f"session and failed against default state: {exc!r}. Probes "
"validate what they can; pass probe=False to silence.",
RuntimeWarning,
stacklevel=2,
)
continue
msg = (
f"@reflex_xy.figure probe failed for {full_name} ({location}): "
f"{type(exc).__name__}: {exc}. The builder ran against default "
"state at compile so this error would not wait for a browser "
"session; pass @reflex_xy.figure(probe=False) if this builder "
"cannot run outside a live session."
)
raise FigureProbeError(msg) from exc
probed.append(full_name)
return probed


class XYPlugin(Plugin):
"""Reflex plugin: `plugins=[reflex_xy.XYPlugin()]` in rxconfig.py.

`post_compile` is the one plugin hook that receives the live App, and it
fires at backend worker startup — after the socket server exists, before
any client connects, and never during frontend-only compiles.
any client connects, and never during frontend-only compiles. It wires
the data plane and then runs the figure-builder compile probes (§3.1) so
escape-hatch builders fail `reflex run`, not the browser.
"""

def post_compile(self, **context: Any) -> None:
app = context.get("app")
if app is not None:
setup(app)
probe_figure_builders()


def _token(source: "str | FigureHandle") -> str:
Expand Down
3 changes: 3 additions & 0 deletions python/reflex_xy/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@
#: It lives on the *function* (not the ComputedVar) so it survives reflex's
#: `_replace` copies, which re-instantiate the var but thread fget through.
BUILDER_ATTR = "__xy_builder__"
#: Attribute stashed beside it carrying the figure var's compile-probe level
#: ("build" | "figure" | False); same placement rationale.
PROBE_ATTR = "__xy_probe__"


@dataclass(frozen=True)
Expand Down
35 changes: 29 additions & 6 deletions python/reflex_xy/vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

from .handles import FigureHandle
from .registry import _figure_of, registry
from .tokens import BUILDER_ATTR, build_state_token
from .tokens import BUILDER_ATTR, PROBE_ATTR, build_state_token

__all__ = ["AsyncFigureVar", "FigureVar", "figure"]

Expand Down Expand Up @@ -136,12 +136,15 @@ def figure(builder: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar": ...

@overload
def figure(
builder: None = None, **var_kwargs: Any
builder: None = None, *, probe: "str | bool | None" = None, **var_kwargs: Any
) -> Callable[[Callable[[Any], Any]], "FigureVar | AsyncFigureVar"]: ...


def figure(
builder: Optional[Callable[[Any], Any]] = None, **var_kwargs: Any
builder: Optional[Callable[[Any], Any]] = None,
*,
probe: "str | bool | None" = None,
**var_kwargs: Any,
) -> "FigureVar | AsyncFigureVar | Callable[[Callable[[Any], Any]], FigureVar | AsyncFigureVar]":
"""Declare a chart on a Reflex state class.

Expand All @@ -168,7 +171,22 @@ async def remote(self) -> xy.Chart:
Keyword arguments pass through to reflex's computed var (``deps=``,
``auto_deps=``, ``interval=``, ...); dependencies are auto-tracked from
the builder's body by default, exactly like a normal ``@rx.var``.

``probe=`` sets the compile-time probe level (spec
reflex-integration.md §3.1): at app compile the plugin runs the builder
once against default state, so hallucinated chart APIs and bad kwargs
fail ``reflex run`` instead of a silent blank mount at hydrate.
``"build"`` (the sync default) runs the body and checks the return is a
chart (or ``None``); ``"figure"`` additionally compiles the result
(full config/shape validation at the price of one real figure);
``False`` opts out — the default for ``async def`` builders, whose data
sources should not be awaited at compile.
"""
# Identity/equality-strict: 0 and 0.0 compare equal to False but are
# not a probe level — reject them instead of silently skipping probes.
if not (probe is None or probe is False or probe == "build" or probe == "figure"):
msg = f"@reflex_xy.figure probe= must be 'build', 'figure', or False, got {probe!r}"
raise ValueError(msg)

def _decorate(fn: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar":
if _fn_name(fn).startswith("_"):
Expand All @@ -180,9 +198,14 @@ def _decorate(fn: Callable[[Any], Any]) -> "FigureVar | AsyncFigureVar":
)
raise ValueError(msg)
var_kwargs.setdefault("cache", True)
if inspect.iscoroutinefunction(fn):
return AsyncFigureVar(fget=_make_async_fget(fn), return_type=FigureHandle, **var_kwargs)
return FigureVar(fget=_make_fget(fn), return_type=FigureHandle, **var_kwargs)
is_async = inspect.iscoroutinefunction(fn)
if is_async:
var = AsyncFigureVar(fget=_make_async_fget(fn), return_type=FigureHandle, **var_kwargs)
else:
var = FigureVar(fget=_make_fget(fn), return_type=FigureHandle, **var_kwargs)
level = probe if probe is not None else (False if is_async else "build")
setattr(var._fget, PROBE_ATTR, level)
return var

if builder is None:
return _decorate
Expand Down
31 changes: 31 additions & 0 deletions spec/design/reflex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,37 @@ points at is exactly the recovery contract). This is §27 applied to
processes: canonical data is Reflex state; every registered figure is a
derived buffer.

**Compile probe.** With the data-bound tier (§3.6) validating everything at
page evaluation, the figure var is the one place chart-building user code
still defers to hydrate — so it gets a compile gate of its own.
`XYPlugin.post_compile` walks the state tree, and for every figure var runs
the builder once against a default state instance
(`@reflex_xy.figure(probe=...)` sets the level):

- `probe="build"` — the default for sync builders: run the body and check
the return is a chart (or `None` for "no chart") — hallucinated `xy.*`
names, wrong kwargs, eager chrome errors, and a return value no registry
publish could accept fail `reflex run` with the state class, var name,
and source location (`FigureProbeError` wrapping the original), instead
of an `err` frame and a silently blank mount at hydrate.
- `probe="figure"` — additionally compile the result: full config/shape
validation at the price of building one real figure per var at startup.
- `probe=False` — opt out; the default for `async def` builders (compile
is sync, and awaiting a data source at compile is what constraint 2
forbids). An async builder may opt in explicitly and runs under
`asyncio.run`.

The three levels are the whole domain and validation is identity-strict:
`probe=0`/`0.0` (which compare equal to `False`) are refused at
decoration, never silently treated as an opt-out.

The escape valve for constraint 2: a builder whose source reads
`self.router` is session-dependent by declaration — its probe failure
degrades to a `RuntimeWarning` instead of failing the compile, because only
a live session can validate it. The probe's cost is the builder's own cost
against *default* state, once per backend worker start — the same order of
work Reflex already accepts evaluating ordinary computed vars at compile.

### 3.2 Registry miss: rebuild from state

`sub` (or `msg`) on an unknown state token parses it, resolves the state
Expand Down
Loading