diff --git a/python/reflex_xy/app.py b/python/reflex_xy/app.py index 52ab1d10..2fd276d8 100644 --- a/python/reflex_xy/app.py +++ b/python/reflex_xy/app.py @@ -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", @@ -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 "" + 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: diff --git a/python/reflex_xy/tokens.py b/python/reflex_xy/tokens.py index 9aed0308..270a2662 100644 --- a/python/reflex_xy/tokens.py +++ b/python/reflex_xy/tokens.py @@ -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) diff --git a/python/reflex_xy/vars.py b/python/reflex_xy/vars.py index 4898956f..fb2d08fb 100644 --- a/python/reflex_xy/vars.py +++ b/python/reflex_xy/vars.py @@ -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"] @@ -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. @@ -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("_"): @@ -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 diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index f52a536a..4448cdbc 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -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 diff --git a/tests/reflex_adapter/test_figure_probe.py b/tests/reflex_adapter/test_figure_probe.py new file mode 100644 index 00000000..1bf50491 --- /dev/null +++ b/tests/reflex_adapter/test_figure_probe.py @@ -0,0 +1,149 @@ +"""The @reflex_xy.figure compile probe: escape-hatch builders fail at compile. + +The probed states below raise only while their module flag is armed, so the +process-wide state walk stays healthy for every other test in the session. +""" + +from __future__ import annotations + +import asyncio + +import numpy as np +import pytest +import reflex as rx + +import reflex_xy +import xy +from reflex_xy.app import FigureProbeError, probe_figure_builders + +#: Armed per-test; every probed builder below is a no-op otherwise. +_ARM = { + "hallucinated": False, + "bad_config": False, + "bad_return": False, + "session": False, + "async_ran": False, +} + + +class ProbeDemo(rx.State): + n: int = 8 + + @reflex_xy.figure + def healthy(self) -> xy.Chart: + xs = np.linspace(0.0, 1.0, self.n) + return xy.scatter_chart(xy.scatter(xs, xs)) + + @reflex_xy.figure + def hallucinated(self): + if not _ARM["hallucinated"]: + return None + return xy.polar_scatter([1.0], [1.0]) # no such factory + + @reflex_xy.figure(probe="figure") + def bad_config(self): + if not _ARM["bad_config"]: + return None + return xy.scatter_chart(xy.scatter([1.0], [1.0], colormap="virids")) + + @reflex_xy.figure + def session_bound(self): + if not _ARM["session"]: + return None + # Session-dependent by declaration: reads self.router. Against the + # probe's default state this raises; the probe must downgrade. + raise RuntimeError(f"no session for {self.router.session.client_token!r}") + + @reflex_xy.figure + def bad_return(self): + if not _ARM["bad_return"]: + return None + return {"x": [1.0], "y": [2.0]} # not a chart: caught at "build" level + + @reflex_xy.figure(probe=False) + def opted_out(self): + raise AssertionError("probe=False builders must never run at compile") + + @reflex_xy.figure + async def async_default(self): + raise AssertionError("async builders are not probed by default") + + @reflex_xy.figure(probe="build") + async def async_opted_in(self): + _ARM["async_ran"] = True + await asyncio.sleep(0) + return None + + +@pytest.fixture(autouse=True) +def _disarm(): + yield + for key in _ARM: + _ARM[key] = False + + +def test_probe_runs_sync_builders_and_skips_optouts(): + # Deliberately unscoped: this one test exercises the production shape — + # the whole-state-tree walk post_compile runs (every other builder in the + # session must probe clean under default state, the contract it enforces). + probed = probe_figure_builders() + names = {name.rsplit(".", 1)[-1] for name in probed if "probe_demo" in name} + assert "healthy" in names + assert "opted_out" not in names + assert "async_default" not in names + assert "async_opted_in" in names # explicit opt-in runs under asyncio.run + assert _ARM["async_ran"] + + +def test_hallucinated_chart_api_fails_the_compile(): + """Problem 2 of the options doc, closed for the escape hatch: the + builder body is no longer dead code until hydrate.""" + _ARM["hallucinated"] = True + with pytest.raises(FigureProbeError, match="hallucinated") as excinfo: + probe_figure_builders(ProbeDemo) + assert "polar_scatter" in str(excinfo.value) + assert isinstance(excinfo.value.__cause__, AttributeError) + + +def test_probe_figure_level_compiles_the_result(): + _ARM["bad_config"] = True + with pytest.raises(FigureProbeError, match="bad_config") as excinfo: + probe_figure_builders(ProbeDemo) + assert "colormap" in str(excinfo.value) + + +def test_session_dependent_builder_downgrades_to_warning(): + _ARM["session"] = True + with pytest.warns(RuntimeWarning, match="session_bound.*reads the session"): + probed = probe_figure_builders(ProbeDemo) + assert not any(name.endswith("session_bound") for name in probed) + + +def test_non_chart_return_fails_the_default_probe_level(): + """The default "build" level type-checks the return: a value no registry + publish can accept must not survive to hydrate.""" + _ARM["bad_return"] = True + with pytest.raises(FigureProbeError, match="bad_return") as excinfo: + probe_figure_builders(ProbeDemo) + assert "dict" in str(excinfo.value) + + +def test_invalid_probe_level_is_refused_at_decoration(): + with pytest.raises(ValueError, match="probe="): + + class BadProbe(rx.State): # noqa: F841 - definition is the assertion + @reflex_xy.figure(probe="everything") + def chart(self): + return None + + +@pytest.mark.parametrize("level", [0, 0.0, 1, True]) +def test_probe_levels_are_identity_strict(level): + """0/0.0 compare equal to False (and 1/True to each other) but are not + probe levels; equality-based membership silently accepted them.""" + with pytest.raises(ValueError, match="probe="): + + class BadProbe(rx.State): # noqa: F841 - definition is the assertion + @reflex_xy.figure(probe=level) + def chart(self): + return None