diff --git a/docs/events/events_overview.md b/docs/events/events_overview.md index fadeba60054..7cf188f1a77 100644 --- a/docs/events/events_overview.md +++ b/docs/events/events_overview.md @@ -46,6 +46,19 @@ Whenever the user hovers over the heading, the `next_word` **event handler** wil Adding the `@rx.event` decorator above the event handler is strongly recommended. This decorator enables proper static type checking, which ensures event handlers receive the correct number and types of arguments. +By default, every public method of a state (one whose name does not start with `_`) is treated as an event handler, whether or not it is decorated. To make `@rx.event` mandatory and keep undecorated public methods as plain Python helpers, enable `state_explicit_event_handlers` in `rxconfig.py`: + +```python +config = rx.Config( + app_name="my_app", + state_explicit_event_handlers=True, +) +``` + +This also applies to states from third-party packages, so they must decorate their event handlers for the option to be usable in your app. + +The option is applied when a state class is created. `reflex run` forwards it to its backend workers; other entry points that import your state modules before the config is loaded, such as a test suite, can set the `REFLEX_STATE_EXPLICIT_EVENT_HANDLERS` environment variable instead. + ## What's in this section? In the event section of the documentation, you will explore the different types of events supported by Reflex, along with the different ways to call them. diff --git a/news/7033.feature.md b/news/7033.feature.md new file mode 100644 index 00000000000..8603ca004a6 --- /dev/null +++ b/news/7033.feature.md @@ -0,0 +1 @@ +Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods. diff --git a/packages/reflex-base/news/7033.feature.md b/packages/reflex-base/news/7033.feature.md new file mode 100644 index 00000000000..8603ca004a6 --- /dev/null +++ b/packages/reflex-base/news/7033.feature.md @@ -0,0 +1 @@ +Add the `state_explicit_event_handlers` config option. When enabled, only methods decorated with `@rx.event` become event handlers; other public state methods stay plain Python methods. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c3861dc4c3d..ff8ee41f072 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -9,6 +9,7 @@ import urllib.parse from collections.abc import Iterable, Iterator, Sequence from contextlib import contextmanager +from contextvars import ContextVar from importlib.machinery import PathFinder from pathlib import Path, PureWindowsPath from types import ModuleType @@ -186,6 +187,7 @@ class BaseConfig: redis_token_expiration: Token expiration time for redis state manager. env_file: Path to file containing key-values pairs to load into the environment; Dotenv format. Multiple files may be separated by os.pathsep. Requires the python-dotenv package. state_auto_setters: Whether to automatically create setters for state base vars. + state_explicit_event_handlers: Whether only methods decorated with `@rx.event` become event handlers. By default every public method of a user-defined state is an event handler. default_color_mode: The default color mode for the app: "system" (follow the OS preference), "light", or "dark". Applies to the built-in color mode switcher and `color_mode_cond` without requiring a radix theme. show_built_with_reflex: Whether to display the sticky "Built with Reflex" badge on all pages. is_reflex_cloud: Whether the app is running in the reflex cloud environment. @@ -269,6 +271,8 @@ class BaseConfig: state_auto_setters: bool = False + state_explicit_event_handlers: bool = False + default_color_mode: LiteralColorMode = "system" show_built_with_reflex: bool | None = None @@ -436,10 +440,10 @@ def _post_init(self, **kwargs): self._non_default_attributes = set(kwargs.keys()) self._replace_defaults(**kwargs) - # Publish for State-class creation so it never re-enters get_config() - # (which AttributeErrors if a State is defined while rxconfig.py is mid-import). - global _state_auto_setters - _state_auto_setters = self.state_auto_setters + # Publish to the in-progress rxconfig.py import, if any, so States defined + # after this Config in rxconfig.py resolve their flags from it. + if (load := _config_load.get()) is not None: + load.config = self if ( self.state_manager_mode == constants.StateManagerMode.REDIS @@ -854,6 +858,14 @@ def _set_persistent(self, **kwargs): self._non_default_attributes.update(kwargs) self._replace_defaults(**kwargs) + def _persist_state_flags(self): + """Export the State-class creation flags to the environment for subprocesses. + + Backend workers import the app module before any config is loaded, so + their user States resolve these flags from the environment. + """ + self._set_persistent(**{name: getattr(self, name) for name in _STATE_FLAGS}) + # Project-local modules first imported while loading rxconfig.py, and the # project root they were recorded under. Evicted before a load from a different @@ -949,29 +961,68 @@ def _record_imports() -> Iterator[_ImportRecorder]: # Protect sys.path from concurrent modification during config loading. _load_config_lock = threading.RLock() -# Cached state_auto_setters so State-class creation never re-enters get_config(). -_state_auto_setters: bool | None = None +# Config fields resolved at State-class creation time via _get_state_flag. +_STATE_FLAGS = ("state_auto_setters", "state_explicit_event_handlers") -def get_state_auto_setters() -> bool: - """Return whether state auto-setters are enabled, without importing rxconfig. +@dataclasses.dataclass +class _ConfigLoad: + """An in-progress rxconfig.py import, holding its Config once constructed.""" + + config: Config | None = None + - Reads the value cached when the Config was built. Before any Config exists - (e.g. a State defined inside rxconfig.py during its import), falls back to the - REFLEX_STATE_AUTO_SETTERS env var, then the default (False). This never calls - get_config() or imports rxconfig, so it cannot re-enter config loading. +# Set for the duration of _get_config() so States defined inside rxconfig.py see +# the Config being loaded (or env/default before it exists), never a stale one. +_config_load: ContextVar[_ConfigLoad | None] = ContextVar("_config_load", default=None) + + +def _get_state_flag(name: str) -> bool: + """Resolve a boolean State-class creation flag without loading rxconfig. + + Uses the Config of the in-progress rxconfig.py import, else the Config cached + on the active RegistrationContext, else the REFLEX_ env var (which + `reflex run` sets for its backend workers), then False. Never loads rxconfig, + since an rxconfig.py may import the very module defining the State. + + Args: + name: The config field name. Returns: - Whether state auto-setters are enabled. + The resolved flag value. """ - if _state_auto_setters is not None: - return _state_auto_setters - env_val = os.environ.get(Config._prefixes[0] + "STATE_AUTO_SETTERS") + load = _config_load.get() + config = ( + load.config + if load is not None + else RegistrationContext.ensure_context()._config + ) + if config is not None: + return getattr(config, name) + env_val = os.environ.get(Config._prefixes[0] + name.upper()) if env_val and env_val.strip(): - return interpret_env_var_value(env_val, bool, "state_auto_setters") + return interpret_env_var_value(env_val, bool, name) return False +def get_state_auto_setters() -> bool: + """Return whether state auto-setters are enabled, without importing rxconfig. + + Returns: + Whether state auto-setters are enabled. + """ + return _get_state_flag("state_auto_setters") + + +def get_state_explicit_event_handlers() -> bool: + """Return whether only `@rx.event` methods become event handlers, without importing rxconfig. + + Returns: + Whether explicit event handlers are required. + """ + return _get_state_flag("state_explicit_event_handlers") + + def _get_config(project_root: Path | None = None) -> Config: """Import rxconfig.py fresh from the project root and return its config. @@ -998,6 +1049,7 @@ def _get_config(project_root: Path | None = None) -> Config: # which removal by value could confuse with caller-owned ones. cwd = str(project_root) sys.path.insert(0, cwd) + load_token = _config_load.set(_ConfigLoad()) try: # Never cache rxconfig itself — each load goes to disk so different # RegistrationContexts hold independent Config instances. @@ -1030,6 +1082,7 @@ def _get_config(project_root: Path | None = None) -> Config: ) return rxconfig.config finally: + _config_load.reset(load_token) for i, entry in enumerate(sys.path): if entry is cwd: del sys.path[i] @@ -1094,13 +1147,16 @@ def get_config(reload: bool = False) -> Config: def reload_config() -> Config: """Force a fresh load of the config into the current RegistrationContext. - Clears any cached config on the current context and reloads rxconfig.py - from disk. + Reloads rxconfig.py from disk and replaces any cached config on the current + context. If the load fails, the context keeps its previous config. Returns: The freshly loaded app config. """ ctx = RegistrationContext.ensure_context() - config = _get_config() - ctx._set_config(config) + # Load and publish under one lock so concurrent reloads of a shared context + # cannot publish an older load over a newer one. + with _load_config_lock: + config = _get_config() + ctx._set_config(config) return config diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 7f6c6ffe43e..832f07081b8 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -297,6 +297,8 @@ def _scan_detach(value: Any, memo: dict[int, Any], active: set[int]) -> Any: BACKGROUND_TASK_MARKER = "_reflex_background_task" SUPERSEDES_MARKER = "_reflex_supersedes" EVENT_ACTIONS_MARKER = "_rx_event_actions" +EVENT_MARKER = "_rx_event" +UNDECORATED_STATE_METHOD_MARKER = "_rx_undecorated_state_method" UPLOAD_FILES_CLIENT_HANDLER = "uploadFiles" # Payload key listing the names of the extra bound handler args in an upload @@ -2395,6 +2397,28 @@ def check_fn_match_arg_spec( raise EventFnArgMismatchError(msg) +def _check_not_undecorated_state_method(value: Any, key: str | None) -> None: + """Reject a state method that explicit event handler mode left undecorated. + + Args: + value: The value wired to the event trigger. + key: The name of the event trigger. + + Raises: + EventHandlerValueError: If value is an undecorated state method. + """ + if getattr(value, UNDECORATED_STATE_METHOD_MARKER, False): + from reflex_base.utils.exceptions import EventHandlerValueError + + msg = ( + f"{value.__qualname__} is wired to {key or 'an event trigger'} but is not an " + "event handler. Decorate it with `@rx.event`: with " + "`state_explicit_event_handlers` enabled, undecorated methods stay plain " + "methods." + ) + raise EventHandlerValueError(msg) + + def event_sequence(events: Any) -> Sequence[Any]: """Get events as a sequence, wrapping a single event-like value in a list. @@ -2436,12 +2460,15 @@ def call_event_fn( The event-like values from calling the function. Raises: - EventHandlerValueError: If the lambda returns an unusable value. + EventHandlerValueError: If fn is an undecorated state method or returns + an unusable value. """ # Import here to avoid circular imports. from reflex_base.event import EventHandler, EventSpec from reflex_base.utils.exceptions import EventHandlerValueError + _check_not_undecorated_state_method(fn, key) + parsed_args, _ = parse_args_spec(arg_spec) parameters = inspect.signature(fn).parameters @@ -2528,6 +2555,7 @@ def _dispatch_mixed_event_var(event_like_var: Var) -> FunctionVar: # Make sure the event spec is valid. if not isinstance(e, (EventSpec, FunctionVar, EventVar)): + _check_not_undecorated_state_method(e, key) hint = "" if isinstance(e, VarOperationCall): hint = " Hint: use `fn.partial(...)` instead of calling the FunctionVar directly." @@ -3163,6 +3191,8 @@ class EventNamespace: BACKGROUND_TASK_MARKER = BACKGROUND_TASK_MARKER SUPERSEDES_MARKER = SUPERSEDES_MARKER EVENT_ACTIONS_MARKER = EVENT_ACTIONS_MARKER + EVENT_MARKER = EVENT_MARKER + UNDECORATED_STATE_METHOD_MARKER = UNDECORATED_STATE_METHOD_MARKER _EVENT_FIELDS = _EVENT_FIELDS FORM_DATA = FORM_DATA FORM_SUBMIT_MAPPING = FORM_SUBMIT_MAPPING @@ -3287,6 +3317,9 @@ def wrapper( if getattr(func, "__name__", "").startswith("_"): msg = "Event handlers cannot be private." raise ValueError(msg) + # Lets State tell decorated methods apart when + # state_explicit_event_handlers is enabled. + setattr(func, EVENT_MARKER, True) qualname: str | None = getattr(func, "__qualname__", None) diff --git a/reflex/state.py b/reflex/state.py index 9859e0d3c2a..c0d622a80fb 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -32,10 +32,13 @@ ) from reflex_base import constants +from reflex_base.config import get_state_auto_setters, get_state_explicit_event_handlers from reflex_base.constants.state import FIELD_MARKER from reflex_base.environment import PerformanceMode, auto_reload_cooldown, environment from reflex_base.event import ( EVENT_ACTIONS_MARKER, + EVENT_MARKER, + UNDECORATED_STATE_METHOD_MARKER, Event, EventHandler, EventSpec, @@ -768,8 +771,11 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): ) raise StateValueError(msg) + explicit = cls.is_user_defined() and get_state_explicit_event_handlers() cls._bind_fields() - cls._bind_mixin_members() + cls._bind_mixin_members(explicit) + if explicit: + cls._mark_undecorated_methods() # Set the base and computed vars. cls.base_vars = { @@ -803,7 +809,7 @@ def __init_subclass__(cls, mixin: bool = False, **kwargs): # Set up the event handlers. for name, fn in list(cls.__dict__.items()): - if cls._item_is_event_handler(name, fn): + if cls._item_is_event_handler(name, fn, explicit): handler = cls._create_event_handler(fn) cls.event_handlers[name] = handler setattr(cls, name, handler) @@ -845,11 +851,14 @@ def _bind_fields(cls) -> None: fields[name] = f @classmethod - def _bind_mixin_members(cls) -> None: + def _bind_mixin_members(cls, explicit: bool) -> None: """Copy the computed vars and event handler functions of mixins onto this class. A member is copied only where this class resolves the name to the mixin's member, so this class or an earlier base overrides it as usual. + + Args: + explicit: Only copy event handler functions decorated with `@rx.event`. """ for mixin_cls in cls._mixins(): for name, value in mixin_cls.__dict__.items(): @@ -864,7 +873,7 @@ def _bind_mixin_members(cls) -> None: _var_data=VarData.from_state(cls), ), ) - elif cls._item_is_event_handler(name, value): + elif cls._item_is_event_handler(name, value, explicit): fn = cls._copy_fn(value) fn.__qualname__ = f"{cls.__name__}.{name}" setattr(cls, name, fn) @@ -909,12 +918,13 @@ def _copy_fn(fn: Callable) -> Callable: return newfn @staticmethod - def _item_is_event_handler(name: str, value: Any) -> bool: + def _item_is_event_handler(name: str, value: Any, explicit: bool = False) -> bool: """Check if the item is an event handler. Args: name: The name of the item. value: The value of the item. + explicit: Only count functions decorated with `@rx.event`. Returns: Whether the item is an event handler. @@ -925,8 +935,27 @@ def _item_is_event_handler(name: str, value: Any) -> bool: and not isinstance(value, EventHandler) and not getattr(value, "__override_base_method__", False) and hasattr(value, "__code__") + and (not explicit or getattr(value, EVENT_MARKER, False)) ) + @classmethod + def _mark_undecorated_methods(cls) -> None: + """Mark the undecorated methods of this class and its mixins. + + Wiring a marked method to an event trigger raises a targeted error. Only + methods defined in the class body are marked: a callback defined + elsewhere may be shared. + """ + for owner in (cls, *cls._mixins()): + prefix = owner.__dict__.get("__original_qualname__", owner.__qualname__) + for name, value in owner.__dict__.items(): + if ( + cls._item_is_event_handler(name, value) + and not getattr(value, EVENT_MARKER, False) + and value.__qualname__ == f"{prefix}.{name}" + ): + setattr(value, UNDECORATED_STATE_METHOD_MARKER, True) + @classmethod def _evaluate(cls, f: Callable[[Self], Any], of_type: type | None = None) -> Var: """Evaluate a function to a ComputedVar. Experimental. @@ -1008,6 +1037,7 @@ def _handle_local_def(cls): proposed_name = f"{cls.__name__}_{ix}" setattr(reflex.istate.dynamic, proposed_name, cls) cls.__original_name__ = cls.__name__ + cls.__original_qualname__ = cls.__qualname__ cls.__original_module__ = cls.__module__ cls.__name__ = cls.__qualname__ = proposed_name cls.__module__ = reflex.istate.dynamic.__name__ @@ -1292,7 +1322,6 @@ def _init_var(cls, name: str, prop: Var): Raises: VarTypeError: if the variable has an incorrect type """ - from reflex_base.config import get_state_auto_setters from reflex_base.utils.exceptions import VarTypeError if not types.is_valid_var_type(prop._var_type): diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 5c4a8fb9726..2d7a5f86200 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -584,6 +584,8 @@ def run_backend( if not frontend_present: notify_backend(host) + get_config()._persist_state_flags() + # Run the backend in development mode. if should_use_granian(): # Fix the start method first so a backend-only run, which skips the @@ -963,6 +965,7 @@ def run_backend_prod( mount_frontend_compiled_app: Whether to mount the compiled frontend app with the backend. """ environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set(mount_frontend_compiled_app) + get_config()._persist_state_flags() if should_use_granian(): run_granian_backend_prod(host, port, loglevel) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 4ec4bc3d68c..aaff7d0b00f 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1701,3 +1701,17 @@ def test_get_config_reload_deprecated(mocker: MockerFixture): # The freshly loaded config stays cached on the context afterwards. assert reflex_base.config.get_config() is second deprecate.assert_called_once() + + +def test_persist_state_flags(monkeypatch): + """_persist_state_flags exports both State-class flags to the environment.""" + mock_os_env = os.environ.copy() + mock_os_env.pop("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", None) + mock_os_env.pop("REFLEX_STATE_AUTO_SETTERS", None) + monkeypatch.setattr(os, "environ", mock_os_env) + config = rx.Config( + app_name="app", state_explicit_event_handlers=True, _skip_plugins_checks=True + ) + config._persist_state_flags() + assert os.environ["REFLEX_STATE_EXPLICIT_EVENT_HANDLERS"] == "True" + assert os.environ["REFLEX_STATE_AUTO_SETTERS"] == "False" diff --git a/tests/units/test_event.py b/tests/units/test_event.py index abc07d9a963..dc2ce05eb0d 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -11,6 +11,7 @@ from reflex_base.constants.compiler import Hooks, Imports from reflex_base.event import ( BACKGROUND_TASK_MARKER, + EVENT_MARKER, Event, EventChain, EventChainVar, @@ -1081,6 +1082,23 @@ async def handle_old_background(self): assert hasattr(bg_handler.fn, BACKGROUND_TASK_MARKER) +def test_event_decorator_marks_function(): + """The decorator marks every function it wraps, with or without options.""" + + def plain(self): + pass + + def with_actions(self): + pass + + async def background(self): + pass + + assert getattr(event(plain), EVENT_MARKER, False) is True + assert getattr(event(stop_propagation=True)(with_actions), EVENT_MARKER, False) + assert getattr(event(background=True)(background), EVENT_MARKER, False) is True + + def test_event_var_in_rx_cond(): """Test that EventVar and EventChainVar cannot be used in rx.cond().""" from reflex_components_core.core.cond import cond as rx_cond diff --git a/tests/units/test_state.py b/tests/units/test_state.py index cc61b0de658..b4be22ff585 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -23,7 +23,7 @@ from hashlib import md5 from pathlib import Path from textwrap import dedent -from types import MethodType, ModuleType +from types import FunctionType, MethodType, ModuleType from typing import Any, ClassVar, ForwardRef, Literal, TypeVar, cast from unittest.mock import AsyncMock, Mock, patch @@ -35,12 +35,13 @@ from reflex_base import constants from reflex_base.constants import CompileVars, RouteVar from reflex_base.constants.state import FIELD_MARKER -from reflex_base.event import Event, EventHandler +from reflex_base.event import Event, EventHandler, EventSpec from reflex_base.event.context import EventContext from reflex_base.event.processor import BaseStateEventProcessor from reflex_base.registry import RegistrationContext from reflex_base.utils import format, types from reflex_base.utils.exceptions import ( + EventHandlerValueError, InvalidLockWarningThresholdError, LockExpiredError, ReflexRuntimeError, @@ -5021,6 +5022,269 @@ class TestState(State): assert "setvar" in TestState.event_handlers +def shared_explicit_callback() -> EventSpec: + """A module-level callback that a state also exposes as an attribute. + + Returns: + The event to run. + """ + return rx.console_log("shared") + + +def test_explicit_event_handlers(tmp_path, forked_registration_context): + """With state_explicit_event_handlers, undecorated methods stay plain methods.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + + config_string = """ +import reflex as rx +config = rx.Config( + app_name="project1", + state_explicit_event_handlers=True, +) + """ + + (proj_root / "rxconfig.py").write_text(dedent(config_string)) + + with chdir(proj_root): + reflex_base.config.reload_config() + from reflex.state import State + + class ExplicitMixin(State, mixin=True): + @rx.event + def mixin_handler(self): + pass + + def mixin_helper(self) -> int: + return 1 + + # Named like the attribute it is exposed as, like a class-body method. + def made_callback() -> EventSpec: + return rx.console_log("made") + + made = made_callback + + class ExplicitCallbacks: + @staticmethod + def refresh() -> EventSpec: + return rx.console_log("refresh") + + class ExplicitState(ExplicitMixin, State): + num: int = 0 + shared_explicit_callback = shared_explicit_callback + made_callback = made + refresh = ExplicitCallbacks.refresh + + @rx.event + def handler(self): + self.num += 1 + + @rx.event(background=True) + async def bg_handler(self): + pass + + def helper(self) -> int: + return self.num + self.mixin_helper() + + assert sorted(ExplicitState.event_handlers) == [ + "bg_handler", + "handler", + "mixin_handler", + "setvar", + ] + assert isinstance(ExplicitState.handler, EventHandler) + assert isinstance(ExplicitState.bg_handler, EventHandler) + assert isinstance(ExplicitState.mixin_handler, EventHandler) + assert isinstance(ExplicitState.helper, FunctionType) + assert isinstance(ExplicitState.mixin_helper, FunctionType) + assert ExplicitState(_reflex_internal_init=True).helper() == 1 # pyright: ignore [reportCallIssue] + + # Wiring an undecorated method to a trigger names the missing decorator + # instead of calling it with the component as `self`. + for trigger in ( + ExplicitState.helper, + ExplicitState.mixin_helper, + lambda: ExplicitState.helper, + lambda: ExplicitState.mixin_helper, + ): + with pytest.raises( + EventHandlerValueError, + match=r"@rx\.event.*state_explicit_event_handlers", + ): + rx.button(on_click=trigger) + + # A callback the state merely exposes stays usable as a trigger elsewhere. + rx.button(on_click=shared_explicit_callback) + rx.button(on_click=made_callback) + rx.button(on_click=ExplicitCallbacks.refresh) + rx.button(on_click=ExplicitState.refresh) + + # Built-in states are unaffected. + assert "on_load_internal" in OnLoadInternalState.event_handlers + + +def test_state_flags_from_env_without_config(monkeypatch): + """A State defined before any config load honors the env var `reflex run` exports. + + Backend workers import the app module in a fresh process, so user States are + created before anything calls get_config(). + """ + monkeypatch.setenv("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", "true") + + with RegistrationContext(): + + class WorkerState(State): + def helper(self): + pass + + assert "helper" not in WorkerState.event_handlers + + +def test_reload_config_resets_state_flags(tmp_path, forked_registration_context): + """Reloading a project does not leak the previous project's State-class flags. + + A State defined in rxconfig.py ahead of its Config must see the default + (implicit) handler mode even if the previously loaded Config enabled + state_explicit_event_handlers. + """ + explicit_root = tmp_path / "explicit" + explicit_root.mkdir() + (explicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="explicit", state_explicit_event_handlers=True) + """ + ) + ) + implicit_root = tmp_path / "implicit" + implicit_root.mkdir() + (implicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + + + class RxconfigImplicitState(rx.State): + def implicit_handler(self): + pass + + + config = rx.Config(app_name="implicit") + """ + ) + ) + + with chdir(explicit_root): + reflex_base.config.reload_config() + assert reflex_base.config.get_state_explicit_event_handlers() is True + + with chdir(implicit_root): + reflex_base.config.reload_config() + state_cls = sys.modules[constants.Config.MODULE].RxconfigImplicitState + assert "implicit_handler" in state_cls.event_handlers + del sys.modules[constants.Config.MODULE] + + +def test_state_in_rxconfig_after_config_honors_flags( + tmp_path, forked_registration_context +): + """A State defined in rxconfig.py after its Config uses that Config's flags.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + (proj_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + + config = rx.Config(app_name="project1", state_explicit_event_handlers=True) + + + class RxconfigPostConfigState(rx.State): + def helper(self): + pass + """ + ) + ) + + with chdir(proj_root): + reflex_base.config.reload_config() + state_cls = sys.modules[constants.Config.MODULE].RxconfigPostConfigState + assert "helper" not in state_cls.event_handlers + del sys.modules[constants.Config.MODULE] + + +def test_reload_config_failure_keeps_previous_config( + tmp_path, forked_registration_context +): + """A failing rxconfig.py reload leaves the context's previous config in place.""" + proj_root = tmp_path / "project1" + proj_root.mkdir() + rxconfig_path = proj_root / "rxconfig.py" + rxconfig_path.write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="project1", state_explicit_event_handlers=True) + """ + ) + ) + + with chdir(proj_root): + good_config = reflex_base.config.reload_config() + rxconfig_path.write_text("raise RuntimeError('broken rxconfig')\n") + with pytest.raises(RuntimeError, match="broken rxconfig"): + reflex_base.config.reload_config() + assert reflex_base.config.get_config() is good_config + assert reflex_base.config.get_state_explicit_event_handlers() is True + + +def test_state_flags_are_per_registration_context( + tmp_path, forked_registration_context +): + """Each RegistrationContext resolves State-class flags from its own Config.""" + explicit_root = tmp_path / "explicit" + explicit_root.mkdir() + (explicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="explicit", state_explicit_event_handlers=True) + """ + ) + ) + implicit_root = tmp_path / "implicit" + implicit_root.mkdir() + (implicit_root / "rxconfig.py").write_text( + dedent( + """ + import reflex as rx + config = rx.Config(app_name="implicit") + """ + ) + ) + + with chdir(explicit_root): + reflex_base.config.reload_config() + + with chdir(implicit_root), RegistrationContext(): + reflex_base.config.reload_config() + + class ImplicitContextState(State): + def handler(self): + pass + + assert "handler" in ImplicitContextState.event_handlers + + # Back on the explicit context: its Config is untouched by the other context. + class ExplicitContextState(State): + def helper(self): + pass + + assert "helper" not in ExplicitContextState.event_handlers + del sys.modules[constants.Config.MODULE] + + def test_state_defined_in_rxconfig_does_not_crash(tmp_path): """A State subclass defined in rxconfig.py must not crash config loading. @@ -5056,11 +5320,9 @@ class RxconfigDefinedState(rx.State): def test_state_in_rxconfig_honors_env_auto_setters(tmp_path, monkeypatch): """A State defined in rxconfig.py (pre-config) honors REFLEX_STATE_AUTO_SETTERS. - During rxconfig import the Config does not exist yet, so the cached value is - unset and get_state_auto_setters falls back to the env var. + During rxconfig import no Config is loaded on the context yet, so + get_state_auto_setters falls back to the env var. """ - # Simulate a fresh process where no Config has been built yet. - monkeypatch.setattr(reflex_base.config, "_state_auto_setters", None) monkeypatch.setenv("REFLEX_STATE_AUTO_SETTERS", "true") proj_root = tmp_path / "project1" @@ -5086,7 +5348,6 @@ class RxconfigEnvSetterState(rx.State): def test_state_in_rxconfig_defaults_to_no_auto_setters(tmp_path, monkeypatch): """A State defined in rxconfig.py gets no auto-setters by default (pre-config).""" - monkeypatch.setattr(reflex_base.config, "_state_auto_setters", None) monkeypatch.delenv("REFLEX_STATE_AUTO_SETTERS", raising=False) proj_root = tmp_path / "project1" diff --git a/tests/units/utils/test_exec.py b/tests/units/utils/test_exec.py index 23ab8c31f42..02e5cf449a3 100644 --- a/tests/units/utils/test_exec.py +++ b/tests/units/utils/test_exec.py @@ -17,7 +17,9 @@ import pytest from pytest_mock import MockerFixture +from reflex_base.config import Config from reflex_base.environment import environment +from reflex_base.registry import RegistrationContext from reflex_base.utils import serializers from reflex.utils import exec as exec_utils @@ -538,6 +540,47 @@ def test_arbitrate_ssr_env_var_wins(monkeypatch: pytest.MonkeyPatch): assert exec_utils.arbitrate_ssr(True) is False +@pytest.mark.parametrize( + ("launcher", "runner"), + [ + ("run_backend", "run_uvicorn_backend"), + ("run_backend_prod", "run_uvicorn_backend_prod"), + ], +) +def test_backend_launchers_persist_state_flags( + launcher: str, + runner: str, + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + forked_registration_context: RegistrationContext, +): + """Backend launchers export the State-class flags before workers import the app.""" + mock_os_env = os.environ.copy() + mock_os_env.pop("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS", None) + mock_os_env.pop("REFLEX_STATE_AUTO_SETTERS", None) + monkeypatch.setattr(os, "environ", mock_os_env) + forked_registration_context._set_config( + Config( + app_name="app", + state_explicit_event_handlers=True, + _skip_plugins_checks=True, + ) + ) + mocker.patch.object(exec_utils, "should_use_granian", return_value=False) + mocker.patch.object(exec_utils, "get_web_dir", return_value=Path("/nonexistent")) + seen: dict[str, str | None] = {} + + def fake_run(*_args, **_kwargs): + seen["explicit"] = os.environ.get("REFLEX_STATE_EXPLICIT_EVENT_HANDLERS") + seen["setters"] = os.environ.get("REFLEX_STATE_AUTO_SETTERS") + + mocker.patch.object(exec_utils, runner, side_effect=fake_run) + + getattr(exec_utils, launcher)("0.0.0.0", 8000, exec_utils.LogLevel.INFO, True) + + assert seen == {"explicit": "True", "setters": "False"} + + def _fake_granian_prod( mocker: MockerFixture, calls: list[str], telemetry_stopped: bool = True ):