diff --git a/packages/reflex-base/news/7669.bugfix.md b/packages/reflex-base/news/7669.bugfix.md new file mode 100644 index 00000000000..284ab8a6203 --- /dev/null +++ b/packages/reflex-base/news/7669.bugfix.md @@ -0,0 +1 @@ +Calling `rx.asset` at the top level of `rxconfig.py` no longer raises `RecursionError`. Config reads made while `rxconfig.py` is importing get the environment and default config instead of importing `rxconfig.py` again. diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index c3861dc4c3d..193c6e099f3 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -949,6 +949,9 @@ def _record_imports() -> Iterator[_ImportRecorder]: # Protect sys.path from concurrent modification during config loading. _load_config_lock = threading.RLock() +# Set while rxconfig.py is importing, guarded by _load_config_lock. +_loading_config = False + # Cached state_auto_setters so State-class creation never re-enters get_config(). _state_auto_setters: bool | None = None @@ -989,10 +992,20 @@ def _get_config(project_root: Path | None = None) -> Config: Returns: The app config. """ - global _config_module_deps_root + global _config_module_deps_root, _loading_config, _state_auto_setters project_root = (project_root or Path.cwd()).resolve() with _load_config_lock: + if _loading_config: + # rxconfig.py read the config while importing (e.g. via rx.asset); + # importing it again would recurse, so use the env and defaults. + # Building it must not replace the state_auto_setters value a + # Config already created in rxconfig.py published. + state_auto_setters = _state_auto_setters + try: + return Config(app_name="", _skip_plugins_checks=True) + finally: + _state_auto_setters = state_auto_setters # A fresh str object, so the exact inserted entry can be removed by # identity: rxconfig.py may itself add or remove equal cwd entries, # which removal by value could confuse with caller-owned ones. @@ -1017,9 +1030,11 @@ def _get_config(project_root: Path | None = None) -> Config: if PathFinder.find_spec(constants.Config.MODULE, [cwd]) is None: return Config(app_name="", _skip_plugins_checks=True) with _record_imports() as recorder: + _loading_config = True try: rxconfig = importlib.import_module(constants.Config.MODULE) finally: + _loading_config = False # Record even on failure so a later load from another root # evicts what this one imported. Nothing is evicted here: # Python already drops a module whose execution failed, and @@ -1087,7 +1102,11 @@ def get_config(reload: bool = False) -> Config: # Serialize check/load/set so threads sharing a context load once. with _load_config_lock: if ctx._config is None: - ctx._set_config(_get_config()) + config = _get_config() + if _loading_config: + # Don't cache the stand-in returned while rxconfig.py imports. + return config + ctx._set_config(config) return ctx.config diff --git a/tests/units/test_config.py b/tests/units/test_config.py index 4ec4bc3d68c..e45206e640a 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -1127,6 +1127,92 @@ def test_get_config_keeps_caller_owned_cwd_entry( assert sys.path.count(cwd) == caller_owned +def test_get_config_inside_rxconfig_does_not_reload_it( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """rxconfig.py can call rx.asset, which reads the config, without recursing. + + The stand-in config rx.asset sees mid-import must not be cached on the context. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + (tmp_path / "assets").mkdir() + (tmp_path / "assets" / "favicon.ico").write_bytes(b"") + (tmp_path / "rxconfig.py").write_text( + textwrap.dedent( + """ + import reflex as rx + + ASSET = rx.asset("favicon.ico") + config = rx.Config(app_name="asset_in_config") + """ + ) + ) + monkeypatch.chdir(tmp_path) + + with RegistrationContext() as ctx: + assert reflex_base.config._get_config().app_name == "asset_in_config" + assert ctx._config is None + assert reflex_base.config.get_config().app_name == "asset_in_config" + assert sys.modules["rxconfig"].ASSET.startswith("/favicon.ico") + + +def test_get_config_inside_rxconfig_keeps_state_auto_setters( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None +): + """The stand-in config built mid-import does not reset state_auto_setters. + + Args: + tmp_path: The pytest tmp_path fixture. + monkeypatch: The pytest monkeypatch fixture. + clean_config_modules: Cleanup for modules left behind by the load. + """ + from reflex_base.registry import RegistrationContext + + (tmp_path / "assets").mkdir() + (tmp_path / "assets" / "favicon.ico").write_bytes(b"") + (tmp_path / "rxconfig.py").write_text( + textwrap.dedent( + """ + import reflex as rx + + config = rx.Config(app_name="asset_in_config", state_auto_setters=True) + ASSET = rx.asset("favicon.ico") + """ + ) + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(reflex_base.config, "_state_auto_setters", None) + + with RegistrationContext(): + reflex_base.config._get_config() + assert reflex_base.config.get_state_auto_setters() is True + + +def test_get_config_stand_in_failure_keeps_state_auto_setters( + monkeypatch: pytest.MonkeyPatch, +): + """A stand-in config that fails validation does not leave its value published. + + Args: + monkeypatch: The pytest monkeypatch fixture. + """ + monkeypatch.setenv("REFLEX_STATE_MANAGER_MODE", "redis") + monkeypatch.delenv("REFLEX_REDIS_URL", raising=False) + monkeypatch.delenv("REDIS_URL", raising=False) + monkeypatch.setattr(reflex_base.config, "_loading_config", True) + monkeypatch.setattr(reflex_base.config, "_state_auto_setters", True) + + with pytest.raises(ConfigError): + reflex_base.config._get_config() + assert reflex_base.config._state_auto_setters is True + + def test_get_config_accepts_explicit_project_root( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, clean_config_modules: None ):