This commit is contained in:
Khaleel Al-Adhami 2024-10-28 12:14:19 -07:00
commit 4bfad2c4fa
13 changed files with 355 additions and 218 deletions

View File

@ -122,7 +122,7 @@ jobs:
fail-fast: false
matrix:
# Show OS combos first in GUI
os: [ubuntu-latest, windows-latest]
os: [ubuntu-latest]
python-version: ['3.10.11', '3.11.4']
env:

View File

@ -3,7 +3,7 @@ fail_fast: true
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.7.0
rev: v0.7.1
hooks:
- id: ruff-format
args: [reflex, tests]

50
poetry.lock generated
View File

@ -1348,8 +1348,8 @@ files = [
[package.dependencies]
numpy = [
{version = ">=1.23.2", markers = "python_version == \"3.11\""},
{version = ">=1.26.0", markers = "python_version >= \"3.12\""},
{version = ">=1.23.2", markers = "python_version == \"3.11\""},
{version = ">=1.22.4", markers = "python_version < \"3.11\""},
]
python-dateutil = ">=2.8.2"
@ -1667,8 +1667,8 @@ files = [
annotated-types = ">=0.6.0"
pydantic-core = "2.23.4"
typing-extensions = [
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
{version = ">=4.12.2", markers = "python_version >= \"3.13\""},
{version = ">=4.6.1", markers = "python_version < \"3.13\""},
]
[package.extras]
@ -2164,13 +2164,13 @@ md = ["cmarkgfm (>=0.8.0)"]
[[package]]
name = "redis"
version = "5.1.1"
version = "5.2.0"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.8"
files = [
{file = "redis-5.1.1-py3-none-any.whl", hash = "sha256:f8ea06b7482a668c6475ae202ed8d9bcaa409f6e87fb77ed1043d912afd62e24"},
{file = "redis-5.1.1.tar.gz", hash = "sha256:f6c997521fedbae53387307c5d0bf784d9acc28d9f1d058abeac566ec4dbed72"},
{file = "redis-5.2.0-py3-none-any.whl", hash = "sha256:ae174f2bb3b1bf2b09d54bf3e51fbc1469cf6c10aa03e21141f51969801a7897"},
{file = "redis-5.2.0.tar.gz", hash = "sha256:0b1087665a771b1ff2e003aa5bdd354f15a70c9e25d5a7dbf9c722c16528a7b0"},
]
[package.dependencies]
@ -2287,29 +2287,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "ruff"
version = "0.7.0"
version = "0.7.1"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.7.0-py3-none-linux_armv6l.whl", hash = "sha256:0cdf20c2b6ff98e37df47b2b0bd3a34aaa155f59a11182c1303cce79be715628"},
{file = "ruff-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:496494d350c7fdeb36ca4ef1c9f21d80d182423718782222c29b3e72b3512737"},
{file = "ruff-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:214b88498684e20b6b2b8852c01d50f0651f3cc6118dfa113b4def9f14faaf06"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630fce3fefe9844e91ea5bbf7ceadab4f9981f42b704fae011bb8efcaf5d84be"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:211d877674e9373d4bb0f1c80f97a0201c61bcd1e9d045b6e9726adc42c156aa"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:194d6c46c98c73949a106425ed40a576f52291c12bc21399eb8f13a0f7073495"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:82c2579b82b9973a110fab281860403b397c08c403de92de19568f32f7178598"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9af971fe85dcd5eaed8f585ddbc6bdbe8c217fb8fcf510ea6bca5bdfff56040e"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b641c7f16939b7d24b7bfc0be4102c56562a18281f84f635604e8a6989948914"},
{file = "ruff-0.7.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71672336e46b34e0c90a790afeac8a31954fd42872c1f6adaea1dff76fd44f9"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ab7d98c7eed355166f367597e513a6c82408df4181a937628dbec79abb2a1fe4"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1eb54986f770f49edb14f71d33312d79e00e629a57387382200b1ef12d6a4ef9"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dc452ba6f2bb9cf8726a84aa877061a2462afe9ae0ea1d411c53d226661c601d"},
{file = "ruff-0.7.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:4b406c2dce5be9bad59f2de26139a86017a517e6bcd2688da515481c05a2cb11"},
{file = "ruff-0.7.0-py3-none-win32.whl", hash = "sha256:f6c968509f767776f524a8430426539587d5ec5c662f6addb6aa25bc2e8195ec"},
{file = "ruff-0.7.0-py3-none-win_amd64.whl", hash = "sha256:ff4aabfbaaba880e85d394603b9e75d32b0693152e16fa659a3064a85df7fce2"},
{file = "ruff-0.7.0-py3-none-win_arm64.whl", hash = "sha256:10842f69c245e78d6adec7e1db0a7d9ddc2fff0621d730e61657b64fa36f207e"},
{file = "ruff-0.7.0.tar.gz", hash = "sha256:47a86360cf62d9cd53ebfb0b5eb0e882193fc191c6d717e8bef4462bc3b9ea2b"},
{file = "ruff-0.7.1-py3-none-linux_armv6l.whl", hash = "sha256:cb1bc5ed9403daa7da05475d615739cc0212e861b7306f314379d958592aaa89"},
{file = "ruff-0.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27c1c52a8d199a257ff1e5582d078eab7145129aa02721815ca8fa4f9612dc35"},
{file = "ruff-0.7.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:588a34e1ef2ea55b4ddfec26bbe76bc866e92523d8c6cdec5e8aceefeff02d99"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94fc32f9cdf72dc75c451e5f072758b118ab8100727168a3df58502b43a599ca"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:985818742b833bffa543a84d1cc11b5e6871de1b4e0ac3060a59a2bae3969250"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32f1e8a192e261366c702c5fb2ece9f68d26625f198a25c408861c16dc2dea9c"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:699085bf05819588551b11751eff33e9ca58b1b86a6843e1b082a7de40da1565"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:344cc2b0814047dc8c3a8ff2cd1f3d808bb23c6658db830d25147339d9bf9ea7"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4316bbf69d5a859cc937890c7ac7a6551252b6a01b1d2c97e8fc96e45a7c8b4a"},
{file = "ruff-0.7.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79d3af9dca4c56043e738a4d6dd1e9444b6d6c10598ac52d146e331eb155a8ad"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c5c121b46abde94a505175524e51891f829414e093cd8326d6e741ecfc0a9112"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8422104078324ea250886954e48f1373a8fe7de59283d747c3a7eca050b4e378"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:56aad830af8a9db644e80098fe4984a948e2b6fc2e73891538f43bbe478461b8"},
{file = "ruff-0.7.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:658304f02f68d3a83c998ad8bf91f9b4f53e93e5412b8f2388359d55869727fd"},
{file = "ruff-0.7.1-py3-none-win32.whl", hash = "sha256:b517a2011333eb7ce2d402652ecaa0ac1a30c114fbbd55c6b8ee466a7f600ee9"},
{file = "ruff-0.7.1-py3-none-win_amd64.whl", hash = "sha256:f38c41fcde1728736b4eb2b18850f6d1e3eedd9678c914dede554a70d5241307"},
{file = "ruff-0.7.1-py3-none-win_arm64.whl", hash = "sha256:19aa200ec824c0f36d0c9114c8ec0087082021732979a359d6f3c390a6ff2a37"},
{file = "ruff-0.7.1.tar.gz", hash = "sha256:9d8a41d4aa2dad1575adb98a82870cf5db5f76b2938cf2206c22c940034a36f4"},
]
[[package]]
@ -3048,4 +3048,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "e03374b85bf10f0a7bb857969b2d6714f25affa63e14a48a88be9fa154b24326"
content-hash = "547fdabf7a030c2a7c8d63eb5b2a3c5e821afa86390f08b895db038d30013904"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "reflex"
version = "0.6.4dev1"
version = "0.6.5dev1"
description = "Web apps in pure Python."
license = "Apache-2.0"
authors = [
@ -69,7 +69,7 @@ dill = ">=0.3.8"
toml = ">=0.10.2,<1.0"
pytest-asyncio = ">=0.24.0"
pytest-cov = ">=4.0.0,<6.0"
ruff = "^0.7.0"
ruff = "0.7.1"
pandas = ">=2.1.1,<3.0"
pillow = ">=10.0.0,<12.0"
plotly = ">=5.13.0,<6.0"

View File

@ -6,6 +6,7 @@ import asyncio
import concurrent.futures
import contextlib
import copy
import dataclasses
import functools
import inspect
import io
@ -18,6 +19,7 @@ import traceback
from datetime import datetime
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Callable,
@ -47,7 +49,10 @@ from reflex.app_mixins import AppMixin, LifespanMixin, MiddlewareMixin
from reflex.base import Base
from reflex.compiler import compiler
from reflex.compiler import utils as compiler_utils
from reflex.compiler.compiler import ExecutorSafeFunctions
from reflex.compiler.compiler import (
ExecutorSafeFunctions,
compile_theme,
)
from reflex.components.base.app_wrap import AppWrap
from reflex.components.base.error_boundary import ErrorBoundary
from reflex.components.base.fragment import Fragment
@ -88,6 +93,9 @@ from reflex.utils import codespaces, console, exceptions, format, prerequisites,
from reflex.utils.exec import is_prod_mode, is_testing_env, should_skip_compile
from reflex.utils.imports import ImportVar
if TYPE_CHECKING:
from reflex.vars import Var
# Define custom types.
ComponentCallable = Callable[[], Component]
Reducer = Callable[[Event], Coroutine[Any, Any, StateUpdate]]
@ -170,6 +178,21 @@ class OverlayFragment(Fragment):
pass
@dataclasses.dataclass(
frozen=True,
)
class UnevaluatedPage:
"""An uncompiled page."""
component: Union[Component, ComponentCallable]
route: str
title: Union[Var, str, None]
description: Union[Var, str, None]
image: str
on_load: Union[EventHandler, EventSpec, List[Union[EventHandler, EventSpec]], None]
meta: List[Dict[str, str]]
class App(MiddlewareMixin, LifespanMixin, Base):
"""The main Reflex app that encapsulates the backend and frontend.
@ -220,6 +243,9 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Attributes to add to the html root tag of every page.
html_custom_attrs: Optional[Dict[str, str]] = None
# A map from a route to an unevaluated page. PRIVATE.
unevaluated_pages: Dict[str, UnevaluatedPage] = {}
# A map from a page route to the component to render. Users should use `add_page`. PRIVATE.
pages: Dict[str, Component] = {}
@ -381,8 +407,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
def _add_optional_endpoints(self):
"""Add optional api endpoints (_upload)."""
# To upload files.
if Upload.is_used:
# To upload files.
self.api.post(str(constants.Endpoint.UPLOAD))(upload(self))
# To access uploaded files.
@ -442,8 +468,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
self,
component: Component | ComponentCallable,
route: str | None = None,
title: str | None = None,
description: str | None = None,
title: str | Var | None = None,
description: str | Var | None = None,
image: str = constants.DefaultPage.IMAGE,
on_load: (
EventHandler | EventSpec | list[EventHandler | EventSpec] | None
@ -479,13 +505,13 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Check if the route given is valid
verify_route_validity(route)
if route in self.pages and os.getenv(constants.RELOAD_CONFIG):
if route in self.unevaluated_pages and os.getenv(constants.RELOAD_CONFIG):
# when the app is reloaded(typically for app harness tests), we should maintain
# the latest render function of a route.This applies typically to decorated pages
# since they are only added when app._compile is called.
self.pages.pop(route)
self.unevaluated_pages.pop(route)
if route in self.pages:
if route in self.unevaluated_pages:
route_name = (
f"`{route}` or `/`"
if route == constants.PageNames.INDEX_ROUTE
@ -501,58 +527,38 @@ class App(MiddlewareMixin, LifespanMixin, Base):
state = self.state if self.state else State
state.setup_dynamic_args(get_route_args(route))
# Generate the component if it is a callable.
component = self._generate_component(component)
if on_load:
self.load_events[route] = (
on_load if isinstance(on_load, list) else [on_load]
)
# unpack components that return tuples in an rx.fragment.
if isinstance(component, tuple):
component = Fragment.create(*component)
# Ensure state is enabled if this page uses state.
if self.state is None:
if on_load or component._has_stateful_event_triggers():
self._enable_state()
else:
for var in component._get_vars(include_children=True):
var_data = var._get_all_var_data()
if not var_data:
continue
if not var_data.state:
continue
self._enable_state()
break
component = OverlayFragment.create(component)
meta_args = {
"title": (
title
if title is not None
else format.make_default_page_title(get_config().app_name, route)
),
"image": image,
"meta": meta,
}
if description is not None:
meta_args["description"] = description
# Add meta information to the component.
compiler_utils.add_meta(
component,
**meta_args,
self.unevaluated_pages[route] = UnevaluatedPage(
component=component,
route=route,
title=title,
description=description,
image=image,
on_load=on_load,
meta=meta,
)
def _compile_page(self, route: str):
"""Compile a page.
Args:
route: The route of the page to compile.
"""
component, enable_state = compiler.compile_unevaluated_page(
route, self.unevaluated_pages[route], self.state, self.style, self.theme
)
if enable_state:
self._enable_state()
# Add the page.
self._check_routes_conflict(route)
self.pages[route] = component
# Add the load events.
if on_load:
if not isinstance(on_load, list):
on_load = [on_load]
self.load_events[route] = on_load
def get_load_events(self, route: str) -> list[EventHandler | EventSpec]:
"""Get the load events for a route.
@ -827,13 +833,33 @@ class App(MiddlewareMixin, LifespanMixin, Base):
"""
from reflex.utils.exceptions import ReflexRuntimeError
self.pages = {}
def get_compilation_time() -> str:
return str(datetime.now().time()).split(".")[0]
# Render a default 404 page if the user didn't supply one
if constants.Page404.SLUG not in self.pages:
if constants.Page404.SLUG not in self.unevaluated_pages:
self.add_custom_404_page()
# Fix up the style.
self.style = evaluate_style_namespaces(self.style)
# Add the app wrappers.
app_wrappers: Dict[tuple[int, str], Component] = {
# Default app wrap component renders {children}
(0, "AppWrap"): AppWrap.create()
}
if self.theme is not None:
# If a theme component was provided, wrap the app with it
app_wrappers[(20, "Theme")] = self.theme
# Fix #2992 by removing the top-level appearance prop
self.theme.appearance = None
for route in self.unevaluated_pages:
self._compile_page(route)
# Add the optional endpoints (_upload)
self._add_optional_endpoints()
@ -868,28 +894,15 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Store the compile results.
compile_results = []
# Add the app wrappers.
app_wrappers: Dict[tuple[int, str], Component] = {
# Default app wrap component renders {children}
(0, "AppWrap"): AppWrap.create()
}
if self.theme is not None:
# If a theme component was provided, wrap the app with it
app_wrappers[(20, "Theme")] = self.theme
progress.advance(task)
# Fix up the style.
self.style = evaluate_style_namespaces(self.style)
# Track imports and custom components found.
all_imports = {}
custom_components = set()
for _route, component in self.pages.items():
# Merge the component style with the app style.
component._add_style_recursive(self.style, self.theme)
# This has to happen before compiling stateful components as that
# prevents recursive functions from reaching all components.
for component in self.pages.values():
# Add component._get_all_imports() to all_imports.
all_imports.update(component._get_all_imports())
@ -899,8 +912,6 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Add the custom components from the page to the set.
custom_components |= component._get_all_custom_components()
progress.advance(task)
# Perform auto-memoization of stateful components.
(
stateful_components_path,
@ -927,31 +938,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
)
)
# Compile the contexts before fork.
compile_results.append(
compiler.compile_contexts(self.state, self.theme),
)
# Fix #2992 by removing the top-level appearance prop
if self.theme is not None:
self.theme.appearance = None
app_root = self._app_root(app_wrappers=app_wrappers)
progress.advance(task)
# Prepopulate the global ExecutorSafeFunctions class with input data required by the compile functions.
# This is required for multiprocessing to work, in presence of non-picklable inputs.
for route, component in zip(self.pages, page_components):
ExecutorSafeFunctions.COMPILE_PAGE_ARGS_BY_ROUTE[route] = (
route,
component,
self.state,
)
ExecutorSafeFunctions.COMPILE_APP_APP_ROOT = app_root
ExecutorSafeFunctions.CUSTOM_COMPONENTS = custom_components
ExecutorSafeFunctions.STYLE = self.style
# Use a forking process pool, if possible. Much faster, especially for large sites.
# Fallback to ThreadPoolExecutor as something that will always work.
executor = None
@ -969,36 +957,31 @@ class App(MiddlewareMixin, LifespanMixin, Base):
max_workers=environment.REFLEX_COMPILE_THREADS
)
for route, component in zip(self.pages, page_components):
ExecutorSafeFunctions.COMPONENTS[route] = component
ExecutorSafeFunctions.STATE = self.state
with executor:
result_futures = []
custom_components_future = None
def _mark_complete(_=None):
progress.advance(task)
def _submit_work(fn, *args, **kwargs):
f = executor.submit(fn, *args, **kwargs)
f.add_done_callback(_mark_complete)
# f = executor.apipe(fn, *args, **kwargs)
result_futures.append(f)
# Compile all page components.
# Compile the pre-compiled pages.
for route in self.pages:
_submit_work(ExecutorSafeFunctions.compile_page, route)
# Compile the app wrapper.
_submit_work(ExecutorSafeFunctions.compile_app)
# Compile the custom components.
custom_components_future = executor.submit(
ExecutorSafeFunctions.compile_custom_components,
)
custom_components_future.add_done_callback(_mark_complete)
_submit_work(
ExecutorSafeFunctions.compile_page,
route,
)
# Compile the root stylesheet with base styles.
_submit_work(compiler.compile_root_stylesheet, self.stylesheets)
# Compile the theme.
_submit_work(ExecutorSafeFunctions.compile_theme)
_submit_work(compile_theme, self.style)
# Compile the Tailwind config.
if config.tailwind is not None:
@ -1012,21 +995,34 @@ class App(MiddlewareMixin, LifespanMixin, Base):
# Wait for all compilation tasks to complete.
for future in concurrent.futures.as_completed(result_futures):
compile_results.append(future.result())
progress.advance(task)
# Special case for custom_components, since we need the compiled imports
# to install proper frontend packages.
(
*custom_components_result,
custom_components_imports,
) = custom_components_future.result()
compile_results.append(custom_components_result)
all_imports.update(custom_components_imports)
app_root = self._app_root(app_wrappers=app_wrappers)
# Get imports from AppWrap components.
all_imports.update(app_root._get_all_imports())
progress.advance(task)
# Compile the contexts.
compile_results.append(
compiler.compile_contexts(self.state, self.theme),
)
progress.advance(task)
# Compile the app root.
compile_results.append(
compiler.compile_app(app_root),
)
progress.advance(task)
# Compile custom components.
*custom_components_result, custom_components_imports = (
compiler.compile_components(custom_components)
)
compile_results.append(custom_components_result)
all_imports.update(custom_components_imports)
progress.advance(task)
progress.stop()

View File

@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, Optional, Type, Union
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Tuple, Type, Union
from reflex import constants
from reflex.compiler import templates, utils
from reflex.components.base.fragment import Fragment
from reflex.components.component import (
BaseComponent,
Component,
@ -126,8 +127,8 @@ def _compile_contexts(state: Optional[Type[BaseState]], theme: Component | None)
def _compile_page(
component: Component,
state: Type[BaseState],
component: BaseComponent,
state: Type[BaseState] | None,
) -> str:
"""Compile the component given the app state.
@ -142,7 +143,7 @@ def _compile_page(
imports = utils.compile_imports(imports)
# Compile the code to render the component.
kwargs = {"state_name": state.get_name()} if state else {}
kwargs = {"state_name": state.get_name()} if state is not None else {}
return templates.PAGE.render(
imports=imports,
@ -424,7 +425,7 @@ def compile_contexts(
def compile_page(
path: str, component: Component, state: Type[BaseState]
path: str, component: BaseComponent, state: Type[BaseState] | None
) -> tuple[str, str]:
"""Compile a single page.
@ -534,6 +535,81 @@ def purge_web_pages_dir():
utils.empty_dir(get_web_dir() / constants.Dirs.PAGES, keep_files=["_app.js"])
if TYPE_CHECKING:
from reflex.app import UnevaluatedPage
def compile_unevaluated_page(
route: str,
page: UnevaluatedPage,
state: Type[BaseState] | None = None,
style: ComponentStyle | None = None,
theme: Component | None = None,
) -> Tuple[Component, bool]:
"""Compiles an uncompiled page into a component and adds meta information.
Args:
route: The route of the page.
page: The uncompiled page object.
state: The state of the app.
style: The style of the page.
theme: The theme of the page.
Returns:
The compiled component and whether state should be enabled.
"""
# Generate the component if it is a callable.
component = page.component
component = component if isinstance(component, Component) else component()
# unpack components that return tuples in an rx.fragment.
if isinstance(component, tuple):
component = Fragment.create(*component)
component._add_style_recursive(style or {}, theme)
enable_state = False
# Ensure state is enabled if this page uses state.
if state is None:
if page.on_load or component._has_stateful_event_triggers():
enable_state = True
else:
for var in component._get_vars(include_children=True):
var_data = var._get_all_var_data()
if not var_data:
continue
if not var_data.state:
continue
enable_state = True
break
from reflex.app import OverlayFragment
from reflex.utils.format import make_default_page_title
component = OverlayFragment.create(component)
meta_args = {
"title": (
page.title
if page.title is not None
else make_default_page_title(get_config().app_name, route)
),
"image": page.image,
"meta": page.meta,
}
if page.description is not None:
meta_args["description"] = page.description
# Add meta information to the component.
utils.add_meta(
component,
**meta_args,
)
return component, enable_state
class ExecutorSafeFunctions:
"""Helper class to allow parallelisation of parts of the compilation process.
@ -559,13 +635,12 @@ class ExecutorSafeFunctions:
"""
COMPILE_PAGE_ARGS_BY_ROUTE = {}
COMPILE_APP_APP_ROOT: Component | None = None
CUSTOM_COMPONENTS: set[CustomComponent] | None = None
STYLE: ComponentStyle | None = None
COMPONENTS: Dict[str, BaseComponent] = {}
UNCOMPILED_PAGES: Dict[str, UnevaluatedPage] = {}
STATE: Optional[Type[BaseState]] = None
@classmethod
def compile_page(cls, route: str):
def compile_page(cls, route: str) -> tuple[str, str]:
"""Compile a page.
Args:
@ -574,46 +649,45 @@ class ExecutorSafeFunctions:
Returns:
The path and code of the compiled page.
"""
return compile_page(*cls.COMPILE_PAGE_ARGS_BY_ROUTE[route])
return compile_page(route, cls.COMPONENTS[route], cls.STATE)
@classmethod
def compile_app(cls):
"""Compile the app.
def compile_unevaluated_page(
cls,
route: str,
style: ComponentStyle,
theme: Component | None,
) -> tuple[str, Component, tuple[str, str]]:
"""Compile an unevaluated page.
Args:
route: The route of the page to compile.
style: The style of the page.
theme: The theme of the page.
Returns:
The path and code of the compiled app.
Raises:
ValueError: If the app root is not set.
The route, compiled component, and compiled page.
"""
if cls.COMPILE_APP_APP_ROOT is None:
raise ValueError("COMPILE_APP_APP_ROOT should be set")
return compile_app(cls.COMPILE_APP_APP_ROOT)
component, enable_state = compile_unevaluated_page(
route, cls.UNCOMPILED_PAGES[route]
)
component = component if isinstance(component, Component) else component()
component._add_style_recursive(style, theme)
return route, component, compile_page(route, component, cls.STATE)
@classmethod
def compile_custom_components(cls):
"""Compile the custom components.
Returns:
The path and code of the compiled custom components.
Raises:
ValueError: If the custom components are not set.
"""
if cls.CUSTOM_COMPONENTS is None:
raise ValueError("CUSTOM_COMPONENTS should be set")
return compile_components(cls.CUSTOM_COMPONENTS)
@classmethod
def compile_theme(cls):
def compile_theme(cls, style: ComponentStyle | None) -> tuple[str, str]:
"""Compile the theme.
Args:
style: The style to compile.
Returns:
The path and code of the compiled theme.
Raises:
ValueError: If the style is not set.
"""
if cls.STYLE is None:
if style is None:
raise ValueError("STYLE should be set")
return compile_theme(cls.STYLE)
return compile_theme(style)

View File

@ -3,7 +3,9 @@
from __future__ import annotations
import dataclasses
import enum
import importlib
import inspect
import os
import sys
import urllib.parse
@ -221,6 +223,28 @@ def interpret_path_env(value: str, field_name: str) -> Path:
return path
def interpret_enum_env(value: str, field_type: GenericType, field_name: str) -> Any:
"""Interpret an enum environment variable value.
Args:
value: The environment variable value.
field_type: The field type.
field_name: The field name.
Returns:
The interpreted value.
Raises:
EnvironmentVarValueError: If the value is invalid.
"""
try:
return field_type(value)
except ValueError as ve:
raise EnvironmentVarValueError(
f"Invalid enum value: {value} for {field_name}"
) from ve
def interpret_env_var_value(
value: str, field_type: GenericType, field_name: str
) -> Any:
@ -252,6 +276,8 @@ def interpret_env_var_value(
return interpret_int_env(value, field_name)
elif field_type is Path:
return interpret_path_env(value, field_name)
elif inspect.isclass(field_type) and issubclass(field_type, enum.Enum):
return interpret_enum_env(value, field_type, field_name)
else:
raise ValueError(

View File

@ -35,7 +35,6 @@ ColorType = Literal[
"amber",
"gold",
"bronze",
"gray",
"accent",
"black",
"white",

View File

@ -120,7 +120,7 @@ class Node(SimpleNamespace):
# The Node version.
VERSION = "22.10.0"
# The minimum required node version.
MIN_VERSION = "18.17.0"
MIN_VERSION = "18.18.0"
@classproperty
@classmethod
@ -173,17 +173,17 @@ class PackageJson(SimpleNamespace):
PATH = "package.json"
DEPENDENCIES = {
"@babel/standalone": "7.25.8",
"@babel/standalone": "7.26.0",
"@emotion/react": "11.13.3",
"axios": "1.7.7",
"json5": "2.2.3",
"next": "14.2.15",
"next": "15.0.1",
"next-sitemap": "4.2.3",
"next-themes": "0.3.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-focus-lock": "2.13.2",
"socket.io-client": "4.8.0",
"socket.io-client": "4.8.1",
"universal-cookie": "7.2.1",
}
DEV_DEPENDENCIES = {

View File

@ -182,7 +182,7 @@ class ClientStateVar(Var):
if value is not NoValue:
# This is a hack to make it work like an EventSpec taking an arg
value_var = LiteralVar.create(value)
_var_data = _var_data.merge(value_var._get_all_var_data())
_var_data = VarData.merge(_var_data, value_var._get_all_var_data())
value_str = str(value_var)
if value_str.startswith("_"):

View File

@ -151,31 +151,41 @@ class VarData:
"""
return dict((k, list(v)) for k, v in self.imports)
@classmethod
def merge(cls, *others: VarData | None) -> VarData | None:
def merge(*all: VarData | None) -> VarData | None:
"""Merge multiple var data objects.
Args:
*others: The var data objects to merge.
*all: The var data objects to merge.
Returns:
The merged var data object.
# noqa: DAR102 *all
"""
state = ""
field_name = ""
_imports = {}
hooks = {}
for var_data in others:
if var_data is None:
continue
state = state or var_data.state
field_name = field_name or var_data.field_name
_imports = imports.merge_imports(_imports, var_data.imports)
hooks.update(
var_data.hooks
if isinstance(var_data.hooks, dict)
else {k: None for k in var_data.hooks}
)
all_var_datas = list(filter(None, all))
if not all_var_datas:
return None
if len(all_var_datas) == 1:
return all_var_datas[0]
# Get the first non-empty field name or default to empty string.
field_name = next(
(var_data.field_name for var_data in all_var_datas if var_data.field_name),
"",
)
# Get the first non-empty state or default to empty string.
state = next(
(var_data.state for var_data in all_var_datas if var_data.state), ""
)
hooks = {hook: None for var_data in all_var_datas for hook in var_data.hooks}
_imports = imports.merge_imports(
*(var_data.imports for var_data in all_var_datas)
)
if state or _imports or hooks or field_name:
return VarData(
@ -184,6 +194,7 @@ class VarData:
imports=_imports,
hooks=hooks,
)
return None
def __bool__(self) -> bool:

View File

@ -237,9 +237,12 @@ def test_add_page_default_route(app: App, index_page, about_page):
about_page: The about page.
"""
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page)
app._compile_page("index")
assert app.pages.keys() == {"index"}
app.add_page(about_page)
app._compile_page("about")
assert app.pages.keys() == {"index", "about"}
@ -252,8 +255,9 @@ def test_add_page_set_route(app: App, index_page, windows_platform: bool):
windows_platform: Whether the system is windows.
"""
route = "test" if windows_platform else "/test"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
app._compile_page("test")
assert app.pages.keys() == {"test"}
@ -267,8 +271,9 @@ def test_add_page_set_route_dynamic(index_page, windows_platform: bool):
app = App(state=EmptyState)
assert app.state is not None
route = "/test/[dynamic]"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
app._compile_page("test/[dynamic]")
assert app.pages.keys() == {"test/[dynamic]"}
assert "dynamic" in app.state.computed_vars
assert app.state.computed_vars["dynamic"]._deps(objclass=EmptyState) == {
@ -286,9 +291,9 @@ def test_add_page_set_route_nested(app: App, index_page, windows_platform: bool)
windows_platform: Whether the system is windows.
"""
route = "test\\nested" if windows_platform else "/test/nested"
assert app.pages == {}
assert app.unevaluated_pages == {}
app.add_page(index_page, route=route)
assert app.pages.keys() == {route.strip(os.path.sep)}
assert app.unevaluated_pages.keys() == {route.strip(os.path.sep)}
def test_add_page_invalid_api_route(app: App, index_page):
@ -1238,6 +1243,7 @@ def test_overlay_component(
app.add_page(rx.box("Index"), route="/test")
# overlay components are wrapped during compile only
app._compile_page("test")
app._setup_overlay_component()
page = app.pages["test"]
@ -1365,6 +1371,7 @@ def test_app_state_determination():
# Add a page with `on_load` enables state.
a1.add_page(rx.box("About"), route="/about", on_load=rx.console_log(""))
a1._compile_page("about")
assert a1.state is not None
a2 = App()
@ -1372,6 +1379,7 @@ def test_app_state_determination():
# Referencing a state Var enables state.
a2.add_page(rx.box(rx.text(GenState.value)), route="/")
a2._compile_page("index")
assert a2.state is not None
a3 = App()
@ -1379,6 +1387,7 @@ def test_app_state_determination():
# Referencing router enables state.
a3.add_page(rx.box(rx.text(State.router.page.full_path)), route="/")
a3._compile_page("index")
assert a3.state is not None
a4 = App()
@ -1390,6 +1399,7 @@ def test_app_state_determination():
a4.add_page(
rx.box(rx.button("Click", on_click=DynamicState.on_counter)), route="/page2"
)
a4._compile_page("page2")
assert a4.state is not None
@ -1469,6 +1479,9 @@ def test_add_page_component_returning_tuple():
app.add_page(index) # type: ignore
app.add_page(page2) # type: ignore
app._compile_page("index")
app._compile_page("page2")
assert isinstance((fragment_wrapper := app.pages["index"].children[0]), Fragment)
assert isinstance((first_text := fragment_wrapper.children[0]), Text)
assert str(first_text.children[0].contents) == '"first"' # type: ignore

View File

@ -7,8 +7,13 @@ import pytest
import reflex as rx
import reflex.config
from reflex.config import environment
from reflex.constants import Endpoint
from reflex.config import (
environment,
interpret_boolean_env,
interpret_enum_env,
interpret_int_env,
)
from reflex.constants import Endpoint, Env
def test_requires_app_name():
@ -208,11 +213,11 @@ def test_replace_defaults(
assert getattr(c, key) == value
def reflex_dir_constant():
def reflex_dir_constant() -> Path:
return environment.REFLEX_DIR
def test_reflex_dir_env_var(monkeypatch, tmp_path):
def test_reflex_dir_env_var(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Test that the REFLEX_DIR environment variable is used to set the Reflex.DIR constant.
Args:
@ -224,3 +229,16 @@ def test_reflex_dir_env_var(monkeypatch, tmp_path):
mp_ctx = multiprocessing.get_context(method="spawn")
with mp_ctx.Pool(processes=1) as pool:
assert pool.apply(reflex_dir_constant) == tmp_path
def test_interpret_enum_env() -> None:
assert interpret_enum_env(Env.PROD.value, Env, "REFLEX_ENV") == Env.PROD
def test_interpret_int_env() -> None:
assert interpret_int_env("3001", "FRONTEND_PORT") == 3001
@pytest.mark.parametrize("value, expected", [("true", True), ("false", False)])
def test_interpret_bool_env(value: str, expected: bool) -> None:
assert interpret_boolean_env(value, "TELEMETRY_ENABLED") == expected