Skip to content

Commit 268effe

Browse files
[ENG-4134]Allow specifying custom app module in rxconfig (#4556)
* Allow custom app module in rxconfig * what was that pyscopg mess? * fix another mess * get this working with relative imports and hot reload * typing to named tuple * minor refactor * revert redis knobs positions * fix pyright except 1 * fix pyright hopefully * use the resolved module path * testing workflow * move nba-proxy job to counter job * just cast the type * fix tests for python 3.9 * darglint * CR Suggestions for #4556 (#4644) * reload_dirs: search up from app_module for last directory containing __init__ * Change custom app_module to use an import string * preserve sys.path entries added while loading rxconfig.py --------- Co-authored-by: Masen Furer <[email protected]>
1 parent 4da32a1 commit 268effe

File tree

7 files changed

+132
-30
lines changed

7 files changed

+132
-30
lines changed

.github/workflows/integration_tests.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ env:
3333
PR_TITLE: ${{ github.event.pull_request.title }}
3434

3535
jobs:
36-
example-counter:
36+
example-counter-and-nba-proxy:
3737
env:
3838
OUTPUT_FILE: import_benchmark.json
3939
timeout-minutes: 30
@@ -119,6 +119,26 @@ jobs:
119119
--benchmark-json "./reflex-examples/counter/${{ env.OUTPUT_FILE }}"
120120
--branch-name "${{ github.head_ref || github.ref_name }}" --pr-id "${{ github.event.pull_request.id }}"
121121
--app-name "counter"
122+
- name: Install requirements for nba proxy example
123+
working-directory: ./reflex-examples/nba-proxy
124+
run: |
125+
poetry run uv pip install -r requirements.txt
126+
- name: Install additional dependencies for DB access
127+
run: poetry run uv pip install psycopg
128+
- name: Check export --backend-only before init for nba-proxy example
129+
working-directory: ./reflex-examples/nba-proxy
130+
run: |
131+
poetry run reflex export --backend-only
132+
- name: Init Website for nba-proxy example
133+
working-directory: ./reflex-examples/nba-proxy
134+
run: |
135+
poetry run reflex init --loglevel debug
136+
- name: Run Website and Check for errors
137+
run: |
138+
# Check that npm is home
139+
npm -v
140+
poetry run bash scripts/integration.sh ./reflex-examples/nba-proxy dev
141+
122142
123143
reflex-web:
124144
strategy:

reflex/app_module_for_backend.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,13 @@
77
from reflex import constants
88
from reflex.utils import telemetry
99
from reflex.utils.exec import is_prod_mode
10-
from reflex.utils.prerequisites import get_app
10+
from reflex.utils.prerequisites import get_and_validate_app
1111

1212
if constants.CompileVars.APP != "app":
1313
raise AssertionError("unexpected variable name for 'app'")
1414

1515
telemetry.send("compile")
16-
app_module = get_app(reload=False)
17-
app = getattr(app_module, constants.CompileVars.APP)
16+
app, app_module = get_and_validate_app(reload=False)
1817
# For py3.9 compatibility when redis is used, we MUST add any decorator pages
1918
# before compiling the app in a thread to avoid event loop error (REF-2172).
2019
app._apply_decorated_pages()
@@ -30,7 +29,7 @@
3029
# ensure only "app" is exposed.
3130
del app_module
3231
del compile_future
33-
del get_app
32+
del get_and_validate_app
3433
del is_prod_mode
3534
del telemetry
3635
del constants

reflex/config.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import urllib.parse
1313
from importlib.util import find_spec
1414
from pathlib import Path
15+
from types import ModuleType
1516
from typing import (
1617
TYPE_CHECKING,
1718
Any,
@@ -607,6 +608,9 @@ class Config:
607608
# The name of the app (should match the name of the app directory).
608609
app_name: str
609610

611+
# The path to the app module.
612+
app_module_import: Optional[str] = None
613+
610614
# The log level to use.
611615
loglevel: constants.LogLevel = constants.LogLevel.DEFAULT
612616

@@ -729,13 +733,28 @@ def __init__(self, *args, **kwargs):
729733
"REDIS_URL is required when using the redis state manager."
730734
)
731735

736+
@property
737+
def app_module(self) -> ModuleType | None:
738+
"""Return the app module if `app_module_import` is set.
739+
740+
Returns:
741+
The app module.
742+
"""
743+
return (
744+
importlib.import_module(self.app_module_import)
745+
if self.app_module_import
746+
else None
747+
)
748+
732749
@property
733750
def module(self) -> str:
734751
"""Get the module name of the app.
735752
736753
Returns:
737754
The module name.
738755
"""
756+
if self.app_module is not None:
757+
return self.app_module.__name__
739758
return ".".join([self.app_name, self.app_name])
740759

741760
def update_from_env(self) -> dict[str, Any]:
@@ -874,17 +893,22 @@ def get_config(reload: bool = False) -> Config:
874893
return cached_rxconfig.config
875894

876895
with _config_lock:
877-
sys_path = sys.path.copy()
896+
orig_sys_path = sys.path.copy()
878897
sys.path.clear()
879898
sys.path.append(str(Path.cwd()))
880899
try:
881900
# Try to import the module with only the current directory in the path.
882901
return _get_config()
883902
except Exception:
884903
# If the module import fails, try to import with the original sys.path.
885-
sys.path.extend(sys_path)
904+
sys.path.extend(orig_sys_path)
886905
return _get_config()
887906
finally:
907+
# Find any entries added to sys.path by rxconfig.py itself.
908+
extra_paths = [
909+
p for p in sys.path if p not in orig_sys_path and p != str(Path.cwd())
910+
]
888911
# Restore the original sys.path.
889912
sys.path.clear()
890-
sys.path.extend(sys_path)
913+
sys.path.extend(extra_paths)
914+
sys.path.extend(orig_sys_path)

reflex/event.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1591,7 +1591,7 @@ def get_handler_args(
15911591

15921592

15931593
def fix_events(
1594-
events: list[EventHandler | EventSpec] | None,
1594+
events: list[EventSpec | EventHandler] | None,
15951595
token: str,
15961596
router_data: dict[str, Any] | None = None,
15971597
) -> list[Event]:

reflex/state.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1776,9 +1776,9 @@ def _as_state_update(
17761776
except Exception as ex:
17771777
state._clean()
17781778

1779-
app_instance = getattr(prerequisites.get_app(), constants.CompileVars.APP)
1780-
1781-
event_specs = app_instance.backend_exception_handler(ex)
1779+
event_specs = (
1780+
prerequisites.get_and_validate_app().app.backend_exception_handler(ex)
1781+
)
17821782

17831783
if event_specs is None:
17841784
return StateUpdate()
@@ -1888,9 +1888,9 @@ async def _process_event(
18881888
except Exception as ex:
18891889
telemetry.send_error(ex, context="backend")
18901890

1891-
app_instance = getattr(prerequisites.get_app(), constants.CompileVars.APP)
1892-
1893-
event_specs = app_instance.backend_exception_handler(ex)
1891+
event_specs = (
1892+
prerequisites.get_and_validate_app().app.backend_exception_handler(ex)
1893+
)
18941894

18951895
yield state._as_state_update(
18961896
handler,
@@ -2403,8 +2403,9 @@ def handle_frontend_exception(self, stack: str, component_stack: str) -> None:
24032403
component_stack: The stack trace of the component where the exception occurred.
24042404
24052405
"""
2406-
app_instance = getattr(prerequisites.get_app(), constants.CompileVars.APP)
2407-
app_instance.frontend_exception_handler(Exception(stack))
2406+
prerequisites.get_and_validate_app().app.frontend_exception_handler(
2407+
Exception(stack)
2408+
)
24082409

24092410

24102411
class UpdateVarsInternalState(State):
@@ -2442,15 +2443,16 @@ def on_load_internal(self) -> list[Event | EventSpec] | None:
24422443
The list of events to queue for on load handling.
24432444
"""
24442445
# Do not app._compile()! It should be already compiled by now.
2445-
app = getattr(prerequisites.get_app(), constants.CompileVars.APP)
2446-
load_events = app.get_load_events(self.router.page.path)
2446+
load_events = prerequisites.get_and_validate_app().app.get_load_events(
2447+
self.router.page.path
2448+
)
24472449
if not load_events:
24482450
self.is_hydrated = True
24492451
return # Fast path for navigation with no on_load events defined.
24502452
self.is_hydrated = False
24512453
return [
24522454
*fix_events(
2453-
load_events,
2455+
cast(list[Union[EventSpec, EventHandler]], load_events),
24542456
self.router.session.client_token,
24552457
router_data=self.router_data,
24562458
),
@@ -2609,7 +2611,7 @@ def __init__(
26092611
"""
26102612
super().__init__(state_instance)
26112613
# compile is not relevant to backend logic
2612-
self._self_app = getattr(prerequisites.get_app(), constants.CompileVars.APP)
2614+
self._self_app = prerequisites.get_and_validate_app().app
26132615
self._self_substate_path = tuple(state_instance.get_full_name().split("."))
26142616
self._self_actx = None
26152617
self._self_mutable = False
@@ -3702,8 +3704,7 @@ def get_state_manager() -> StateManager:
37023704
Returns:
37033705
The state manager.
37043706
"""
3705-
app = getattr(prerequisites.get_app(), constants.CompileVars.APP)
3706-
return app.state_manager
3707+
return prerequisites.get_and_validate_app().app.state_manager
37073708

37083709

37093710
class MutableProxy(wrapt.ObjectProxy):

reflex/utils/exec.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,28 @@ def run_backend(
240240
run_uvicorn_backend(host, port, loglevel)
241241

242242

243+
def get_reload_dirs() -> list[str]:
244+
"""Get the reload directories for the backend.
245+
246+
Returns:
247+
The reload directories for the backend.
248+
"""
249+
config = get_config()
250+
reload_dirs = [config.app_name]
251+
if config.app_module is not None and config.app_module.__file__:
252+
module_path = Path(config.app_module.__file__).resolve().parent
253+
while module_path.parent.name:
254+
for parent_file in module_path.parent.iterdir():
255+
if parent_file == "__init__.py":
256+
# go up a level to find dir without `__init__.py`
257+
module_path = module_path.parent
258+
break
259+
else:
260+
break
261+
reload_dirs.append(str(module_path))
262+
return reload_dirs
263+
264+
243265
def run_uvicorn_backend(host, port, loglevel: LogLevel):
244266
"""Run the backend in development mode using Uvicorn.
245267
@@ -256,7 +278,7 @@ def run_uvicorn_backend(host, port, loglevel: LogLevel):
256278
port=port,
257279
log_level=loglevel.value,
258280
reload=True,
259-
reload_dirs=[get_config().app_name],
281+
reload_dirs=get_reload_dirs(),
260282
)
261283

262284

@@ -281,7 +303,7 @@ def run_granian_backend(host, port, loglevel: LogLevel):
281303
interface=Interfaces.ASGI,
282304
log_level=LogLevels(loglevel.value),
283305
reload=True,
284-
reload_paths=[Path(get_config().app_name)],
306+
reload_paths=get_reload_dirs(),
285307
reload_ignore_dirs=[".web"],
286308
).serve()
287309
except ImportError:

reflex/utils/prerequisites.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@
1717
import sys
1818
import tempfile
1919
import time
20+
import typing
2021
import zipfile
2122
from datetime import datetime
2223
from pathlib import Path
2324
from types import ModuleType
24-
from typing import Callable, List, Optional
25+
from typing import Callable, List, NamedTuple, Optional
2526

2627
import httpx
2728
import typer
@@ -42,9 +43,19 @@
4243
from reflex.utils.format import format_library_name
4344
from reflex.utils.registry import _get_npm_registry
4445

46+
if typing.TYPE_CHECKING:
47+
from reflex.app import App
48+
4549
CURRENTLY_INSTALLING_NODE = False
4650

4751

52+
class AppInfo(NamedTuple):
53+
"""A tuple containing the app instance and module."""
54+
55+
app: App
56+
module: ModuleType
57+
58+
4859
@dataclasses.dataclass(frozen=True)
4960
class Template:
5061
"""A template for a Reflex app."""
@@ -291,8 +302,11 @@ def get_app(reload: bool = False) -> ModuleType:
291302
)
292303
module = config.module
293304
sys.path.insert(0, str(Path.cwd()))
294-
app = __import__(module, fromlist=(constants.CompileVars.APP,))
295-
305+
app = (
306+
__import__(module, fromlist=(constants.CompileVars.APP,))
307+
if not config.app_module
308+
else config.app_module
309+
)
296310
if reload:
297311
from reflex.state import reload_state_module
298312

@@ -308,6 +322,29 @@ def get_app(reload: bool = False) -> ModuleType:
308322
raise
309323

310324

325+
def get_and_validate_app(reload: bool = False) -> AppInfo:
326+
"""Get the app instance based on the default config and validate it.
327+
328+
Args:
329+
reload: Re-import the app module from disk
330+
331+
Returns:
332+
The app instance and the app module.
333+
334+
Raises:
335+
RuntimeError: If the app instance is not an instance of rx.App.
336+
"""
337+
from reflex.app import App
338+
339+
app_module = get_app(reload=reload)
340+
app = getattr(app_module, constants.CompileVars.APP)
341+
if not isinstance(app, App):
342+
raise RuntimeError(
343+
"The app instance in the specified app_module_import in rxconfig must be an instance of rx.App."
344+
)
345+
return AppInfo(app=app, module=app_module)
346+
347+
311348
def get_compiled_app(reload: bool = False, export: bool = False) -> ModuleType:
312349
"""Get the app module based on the default config after first compiling it.
313350
@@ -318,8 +355,7 @@ def get_compiled_app(reload: bool = False, export: bool = False) -> ModuleType:
318355
Returns:
319356
The compiled app based on the default config.
320357
"""
321-
app_module = get_app(reload=reload)
322-
app = getattr(app_module, constants.CompileVars.APP)
358+
app, app_module = get_and_validate_app(reload=reload)
323359
# For py3.9 compatibility when redis is used, we MUST add any decorator pages
324360
# before compiling the app in a thread to avoid event loop error (REF-2172).
325361
app._apply_decorated_pages()

0 commit comments

Comments
 (0)