merging
This commit is contained in:
commit
dd1ddd653f
@ -51,6 +51,7 @@ jobs:
|
||||
SCREENSHOT_DIR: /tmp/screenshots
|
||||
REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }}
|
||||
run: |
|
||||
poetry run playwright install --with-deps
|
||||
poetry run pytest tests/integration
|
||||
- uses: actions/upload-artifact@v4
|
||||
name: Upload failed test screenshots
|
||||
|
2
.github/workflows/integration_tests.yml
vendored
2
.github/workflows/integration_tests.yml
vendored
@ -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:
|
||||
|
@ -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]
|
||||
|
75
poetry.lock
generated
75
poetry.lock
generated
@ -521,6 +521,21 @@ files = [
|
||||
{file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dill"
|
||||
version = "0.3.9"
|
||||
description = "serialize all of Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "dill-0.3.9-py3-none-any.whl", hash = "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a"},
|
||||
{file = "dill-0.3.9.tar.gz", hash = "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
graph = ["objgraph (>=1.7.2)"]
|
||||
profile = ["gprof2dot (>=2022.7.29)"]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.9"
|
||||
@ -1977,20 +1992,6 @@ files = [
|
||||
[package.dependencies]
|
||||
six = ">=1.5"
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.0.1"
|
||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"},
|
||||
{file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-engineio"
|
||||
version = "4.10.1"
|
||||
@ -2163,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]
|
||||
@ -2286,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]]
|
||||
@ -3047,4 +3048,4 @@ type = ["pytest-mypy"]
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.9"
|
||||
content-hash = "c5da15520cef58124f6699007c81158036840469d4f9972592d72bd456c45e7e"
|
||||
content-hash = "547fdabf7a030c2a7c8d63eb5b2a3c5e821afa86390f08b895db038d30013904"
|
||||
|
@ -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 = [
|
||||
@ -33,7 +33,6 @@ jinja2 = ">=3.1.2,<4.0"
|
||||
psutil = ">=5.9.4,<7.0"
|
||||
pydantic = ">=1.10.2,<3.0"
|
||||
python-multipart = ">=0.0.5,<0.1"
|
||||
python-dotenv = ">=1.0.1"
|
||||
python-socketio = ">=5.7.0,<6.0"
|
||||
redis = ">=4.3.5,<6.0"
|
||||
rich = ">=13.0.0,<14.0"
|
||||
@ -66,10 +65,11 @@ pytest = ">=7.1.2,<9.0"
|
||||
pytest-mock = ">=3.10.0,<4.0"
|
||||
pyright = ">=1.1.229,<1.1.335"
|
||||
darglint = ">=1.8.1,<2.0"
|
||||
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"
|
||||
|
@ -1,11 +1,11 @@
|
||||
{% extends "web/pages/base_page.js.jinja2" %}
|
||||
|
||||
{% block early_imports %}
|
||||
import '/styles/styles.css'
|
||||
import '$/styles/styles.css'
|
||||
{% endblock %}
|
||||
|
||||
{% block declaration %}
|
||||
import { EventLoopProvider, StateProvider, defaultColorMode } from "/utils/context.js";
|
||||
import { EventLoopProvider, StateProvider, defaultColorMode } from "$/utils/context.js";
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
{% for library_alias, library_path in window_libraries %}
|
||||
import * as {{library_alias}} from "{{library_path}}";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useMemo, useReducer, useState } from "react"
|
||||
import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "/utils/state.js"
|
||||
import { applyDelta, Event, hydrateClientStorage, useEventLoop, refs } from "$/utils/state.js"
|
||||
|
||||
{% if initial_state %}
|
||||
export const initialState = {{ initial_state|json_dumps }}
|
||||
@ -59,6 +59,8 @@ export const initialEvents = () => [
|
||||
{% else %}
|
||||
export const state_name = undefined
|
||||
|
||||
export const exception_state_name = undefined
|
||||
|
||||
export const onLoadInternalEvent = () => []
|
||||
|
||||
export const initialEvents = () => []
|
||||
|
@ -4,8 +4,8 @@ import {
|
||||
ColorModeContext,
|
||||
defaultColorMode,
|
||||
isDevMode,
|
||||
lastCompiledTimeStamp
|
||||
} from "/utils/context.js";
|
||||
lastCompiledTimeStamp,
|
||||
} from "$/utils/context.js";
|
||||
|
||||
export default function RadixThemesColorModeProvider({ children }) {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
@ -37,7 +37,7 @@ export default function RadixThemesColorModeProvider({ children }) {
|
||||
const allowedModes = ["light", "dark", "system"];
|
||||
if (!allowedModes.includes(mode)) {
|
||||
console.error(
|
||||
`Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`,
|
||||
`Invalid color mode "${mode}". Defaulting to "${defaultColorMode}".`
|
||||
);
|
||||
mode = defaultColorMode;
|
||||
}
|
||||
|
@ -2,6 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"$/*": ["*"],
|
||||
"@/*": ["public/*"]
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@
|
||||
import axios from "axios";
|
||||
import io from "socket.io-client";
|
||||
import JSON5 from "json5";
|
||||
import env from "/env.json";
|
||||
import env from "$/env.json";
|
||||
import Cookies from "universal-cookie";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Router, { useRouter } from "next/router";
|
||||
@ -12,9 +12,9 @@ import {
|
||||
onLoadInternalEvent,
|
||||
state_name,
|
||||
exception_state_name,
|
||||
} from "utils/context.js";
|
||||
import debounce from "/utils/helpers/debounce";
|
||||
import throttle from "/utils/helpers/throttle";
|
||||
} from "$/utils/context.js";
|
||||
import debounce from "$/utils/helpers/debounce";
|
||||
import throttle from "$/utils/helpers/throttle";
|
||||
import * as Babel from "@babel/standalone";
|
||||
|
||||
// Endpoint URLs.
|
||||
|
@ -331,6 +331,7 @@ _MAPPING: dict = {
|
||||
"var",
|
||||
"ComponentState",
|
||||
"State",
|
||||
"dynamic",
|
||||
],
|
||||
"style": ["Style", "toggle_color_mode"],
|
||||
"utils.imports": ["ImportVar"],
|
||||
|
@ -184,6 +184,7 @@ from .model import session as session
|
||||
from .page import page as page
|
||||
from .state import ComponentState as ComponentState
|
||||
from .state import State as State
|
||||
from .state import dynamic as dynamic
|
||||
from .state import var as var
|
||||
from .style import Style as Style
|
||||
from .style import toggle_color_mode as toggle_color_mode
|
||||
|
286
reflex/app.py
286
reflex/app.py
@ -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
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
@ -679,7 +685,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
||||
for i, tags in imports.items()
|
||||
if i not in constants.PackageJson.DEPENDENCIES
|
||||
and i not in constants.PackageJson.DEV_DEPENDENCIES
|
||||
and not any(i.startswith(prefix) for prefix in ["/", ".", "next/"])
|
||||
and not any(i.startswith(prefix) for prefix in ["/", "$/", ".", "next/"])
|
||||
and i != ""
|
||||
and any(tag.install for tag in tags)
|
||||
}
|
||||
@ -827,13 +833,18 @@ 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()
|
||||
|
||||
for route in self.unevaluated_pages:
|
||||
self._compile_page(route)
|
||||
|
||||
# Add the optional endpoints (_upload)
|
||||
self._add_optional_endpoints()
|
||||
|
||||
@ -857,7 +868,7 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
||||
progress.start()
|
||||
task = progress.add_task(
|
||||
f"[{get_compilation_time()}] Compiling:",
|
||||
total=len(self.pages)
|
||||
total=len(self.unevaluated_pages)
|
||||
+ fixed_pages_within_executor
|
||||
+ adhoc_steps_without_executor,
|
||||
)
|
||||
@ -886,38 +897,8 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
||||
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)
|
||||
|
||||
# Add component._get_all_imports() to all_imports.
|
||||
all_imports.update(component._get_all_imports())
|
||||
|
||||
# Add the app wrappers from this component.
|
||||
app_wrappers.update(component._get_all_app_wrap_components())
|
||||
|
||||
# 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,
|
||||
stateful_components_code,
|
||||
page_components,
|
||||
) = compiler.compile_stateful_components(self.pages.values())
|
||||
|
||||
progress.advance(task)
|
||||
|
||||
# Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State.
|
||||
if code_uses_state_contexts(stateful_components_code) and self.state is None:
|
||||
raise ReflexRuntimeError(
|
||||
"To access rx.State in frontend components, at least one "
|
||||
"subclass of rx.State must be defined in the app."
|
||||
)
|
||||
compile_results.append((stateful_components_path, stateful_components_code))
|
||||
|
||||
# Compile the root document before fork.
|
||||
compile_results.append(
|
||||
compiler.compile_document_root(
|
||||
@ -927,31 +908,12 @@ 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 +931,55 @@ class App(MiddlewareMixin, LifespanMixin, Base):
|
||||
max_workers=environment.REFLEX_COMPILE_THREADS
|
||||
)
|
||||
|
||||
for route, component in self.pages.items():
|
||||
component._add_style_recursive(self.style, self.theme)
|
||||
|
||||
ExecutorSafeFunctions.COMPONENTS[route] = component
|
||||
|
||||
for route, page in self.unevaluated_pages.items():
|
||||
if route in self.pages:
|
||||
continue
|
||||
|
||||
ExecutorSafeFunctions.UNCOMPILED_PAGES[route] = page
|
||||
|
||||
ExecutorSafeFunctions.STATE = self.state
|
||||
|
||||
pages_results = []
|
||||
|
||||
with executor:
|
||||
result_futures = []
|
||||
custom_components_future = None
|
||||
|
||||
def _mark_complete(_=None):
|
||||
progress.advance(task)
|
||||
pages_futures = []
|
||||
|
||||
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.
|
||||
for route in self.unevaluated_pages:
|
||||
if route in self.pages:
|
||||
continue
|
||||
|
||||
f = executor.submit(
|
||||
ExecutorSafeFunctions.compile_unevaluated_page,
|
||||
route,
|
||||
self.style,
|
||||
self.theme,
|
||||
)
|
||||
pages_futures.append(f)
|
||||
|
||||
# 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 +993,70 @@ 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)
|
||||
for future in concurrent.futures.as_completed(pages_futures):
|
||||
pages_results.append(future.result())
|
||||
progress.advance(task)
|
||||
|
||||
for route, component, compiled_page in pages_results:
|
||||
self._check_routes_conflict(route)
|
||||
self.pages[route] = component
|
||||
compile_results.append(compiled_page)
|
||||
|
||||
for _, component in self.pages.items():
|
||||
# Add component._get_all_imports() to all_imports.
|
||||
all_imports.update(component._get_all_imports())
|
||||
|
||||
# Add the app wrappers from this component.
|
||||
app_wrappers.update(component._get_all_app_wrap_components())
|
||||
|
||||
# Add the custom components from the page to the set.
|
||||
custom_components |= component._get_all_custom_components()
|
||||
|
||||
# Perform auto-memoization of stateful components.
|
||||
(
|
||||
stateful_components_path,
|
||||
stateful_components_code,
|
||||
page_components,
|
||||
) = compiler.compile_stateful_components(self.pages.values())
|
||||
|
||||
progress.advance(task)
|
||||
|
||||
# Catch "static" apps (that do not define a rx.State subclass) which are trying to access rx.State.
|
||||
if code_uses_state_contexts(stateful_components_code) and self.state is None:
|
||||
raise ReflexRuntimeError(
|
||||
"To access rx.State in frontend components, at least one "
|
||||
"subclass of rx.State must be defined in the app."
|
||||
)
|
||||
compile_results.append((stateful_components_path, stateful_components_code))
|
||||
|
||||
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()
|
||||
|
||||
|
@ -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,
|
||||
@ -67,8 +68,8 @@ def _compile_app(app_root: Component) -> str:
|
||||
window_libraries = [
|
||||
(_normalize_library_name(name), name) for name in bundled_libraries
|
||||
] + [
|
||||
("utils_context", f"/{constants.Dirs.UTILS}/context"),
|
||||
("utils_state", f"/{constants.Dirs.UTILS}/state"),
|
||||
("utils_context", f"$/{constants.Dirs.UTILS}/context"),
|
||||
("utils_state", f"$/{constants.Dirs.UTILS}/state"),
|
||||
]
|
||||
|
||||
return templates.APP_ROOT.render(
|
||||
@ -127,7 +128,7 @@ def _compile_contexts(state: Optional[Type[BaseState]], theme: Component | None)
|
||||
|
||||
def _compile_page(
|
||||
component: Component,
|
||||
state: Type[BaseState],
|
||||
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,
|
||||
@ -228,7 +229,7 @@ def _compile_components(
|
||||
"""
|
||||
imports = {
|
||||
"react": [ImportVar(tag="memo")],
|
||||
f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
|
||||
f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="E"), ImportVar(tag="isTrue")],
|
||||
}
|
||||
component_renders = []
|
||||
|
||||
@ -315,7 +316,7 @@ def _compile_stateful_components(
|
||||
# Don't import from the file that we're about to create.
|
||||
all_imports = utils.merge_imports(*all_import_dicts)
|
||||
all_imports.pop(
|
||||
f"/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
|
||||
f"$/{constants.Dirs.UTILS}/{constants.PageNames.STATEFUL_COMPONENTS}", None
|
||||
)
|
||||
|
||||
return templates.STATEFUL_COMPONENTS.render(
|
||||
@ -424,7 +425,7 @@ def compile_contexts(
|
||||
|
||||
|
||||
def compile_page(
|
||||
path: str, component: Component, state: Type[BaseState]
|
||||
path: str, component: Component, state: Type[BaseState] | None
|
||||
) -> tuple[str, str]:
|
||||
"""Compile a single page.
|
||||
|
||||
@ -534,6 +535,73 @@ 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
|
||||
) -> 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.
|
||||
|
||||
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)
|
||||
|
||||
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 +627,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, Component] = {}
|
||||
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 +641,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)
|
||||
|
@ -83,6 +83,12 @@ def validate_imports(import_dict: ParsedImportDict):
|
||||
f"{_import.tag}/{_import.alias}" if _import.alias else _import.tag
|
||||
)
|
||||
if import_name in used_tags:
|
||||
already_imported = used_tags[import_name]
|
||||
if (already_imported[0] == "$" and already_imported[1:] == lib) or (
|
||||
lib[0] == "$" and lib[1:] == already_imported
|
||||
):
|
||||
used_tags[import_name] = lib if lib[0] == "$" else already_imported
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Can not compile, the tag {import_name} is used multiple time from {lib} and {used_tags[import_name]}"
|
||||
)
|
||||
|
@ -38,6 +38,7 @@ from reflex.constants import (
|
||||
)
|
||||
from reflex.constants.compiler import SpecialAttributes
|
||||
from reflex.event import (
|
||||
EventCallback,
|
||||
EventChain,
|
||||
EventChainVar,
|
||||
EventHandler,
|
||||
@ -1129,6 +1130,8 @@ class Component(BaseComponent, ABC):
|
||||
for trigger in self.event_triggers.values():
|
||||
if isinstance(trigger, EventChain):
|
||||
for event in trigger.events:
|
||||
if isinstance(event, EventCallback):
|
||||
continue
|
||||
if isinstance(event, EventSpec):
|
||||
if event.handler.state_full_name:
|
||||
return True
|
||||
@ -1308,7 +1311,9 @@ class Component(BaseComponent, ABC):
|
||||
if self._get_ref_hook():
|
||||
# Handle hooks needed for attaching react refs to DOM nodes.
|
||||
_imports.setdefault("react", set()).add(ImportVar(tag="useRef"))
|
||||
_imports.setdefault(f"/{Dirs.STATE_PATH}", set()).add(ImportVar(tag="refs"))
|
||||
_imports.setdefault(f"$/{Dirs.STATE_PATH}", set()).add(
|
||||
ImportVar(tag="refs")
|
||||
)
|
||||
|
||||
if self._get_mount_lifecycle_hook():
|
||||
# Handle hooks for `on_mount` / `on_unmount`.
|
||||
@ -1665,7 +1670,7 @@ class CustomComponent(Component):
|
||||
"""A custom user-defined component."""
|
||||
|
||||
# Use the components library.
|
||||
library = f"/{Dirs.COMPONENTS_PATH}"
|
||||
library = f"$/{Dirs.COMPONENTS_PATH}"
|
||||
|
||||
# The function that creates the component.
|
||||
component_fn: Callable[..., Component] = Component.create
|
||||
@ -2234,7 +2239,7 @@ class StatefulComponent(BaseComponent):
|
||||
"""
|
||||
if self.rendered_as_shared:
|
||||
return {
|
||||
f"/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
|
||||
f"$/{Dirs.UTILS}/{PageNames.STATEFUL_COMPONENTS}": [
|
||||
ImportVar(tag=self.tag)
|
||||
]
|
||||
}
|
||||
|
@ -66,8 +66,8 @@ class WebsocketTargetURL(Var):
|
||||
_js_expr="getBackendURL(env.EVENT).href",
|
||||
_var_data=VarData(
|
||||
imports={
|
||||
"/env.json": [ImportVar(tag="env", is_default=True)],
|
||||
f"/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
|
||||
"$/env.json": [ImportVar(tag="env", is_default=True)],
|
||||
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="getBackendURL")],
|
||||
},
|
||||
),
|
||||
_var_type=WebsocketTargetURL,
|
||||
|
@ -21,7 +21,7 @@ route_not_found: Var = Var(_js_expr=constants.ROUTE_NOT_FOUND)
|
||||
class ClientSideRouting(Component):
|
||||
"""The client-side routing component."""
|
||||
|
||||
library = "/utils/client_side_routing"
|
||||
library = "$/utils/client_side_routing"
|
||||
tag = "useClientSideRouting"
|
||||
|
||||
def add_hooks(self) -> list[str]:
|
||||
|
@ -67,7 +67,7 @@ class Clipboard(Fragment):
|
||||
The import dict for the component.
|
||||
"""
|
||||
return {
|
||||
"/utils/helpers/paste.js": ImportVar(
|
||||
"$/utils/helpers/paste.js": ImportVar(
|
||||
tag="usePasteHandler", is_default=True
|
||||
),
|
||||
}
|
||||
|
@ -15,7 +15,7 @@ from reflex.vars.base import LiteralVar, Var
|
||||
from reflex.vars.number import ternary_operation
|
||||
|
||||
_IS_TRUE_IMPORT: ImportDict = {
|
||||
f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
|
||||
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
|
||||
}
|
||||
|
||||
|
||||
|
@ -118,7 +118,7 @@ class DebounceInput(Component):
|
||||
_var_type=Type[Component],
|
||||
_var_data=VarData(
|
||||
imports=child._get_imports(),
|
||||
hooks=child._get_hooks_internal(),
|
||||
hooks=child._get_all_hooks(),
|
||||
),
|
||||
),
|
||||
)
|
||||
@ -128,6 +128,10 @@ class DebounceInput(Component):
|
||||
component.event_triggers.update(child.event_triggers)
|
||||
component.children = child.children
|
||||
component._rename_props = child._rename_props
|
||||
outer_get_all_custom_code = component._get_all_custom_code
|
||||
component._get_all_custom_code = lambda: outer_get_all_custom_code().union(
|
||||
child._get_all_custom_code()
|
||||
)
|
||||
return component
|
||||
|
||||
def _render(self):
|
||||
|
@ -29,7 +29,7 @@ DEFAULT_UPLOAD_ID: str = "default"
|
||||
upload_files_context_var_data: VarData = VarData(
|
||||
imports={
|
||||
"react": "useContext",
|
||||
f"/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
|
||||
f"$/{Dirs.CONTEXTS_PATH}": "UploadFilesContext",
|
||||
},
|
||||
hooks={
|
||||
"const [filesById, setFilesById] = useContext(UploadFilesContext);": None,
|
||||
@ -134,8 +134,8 @@ uploaded_files_url_prefix = Var(
|
||||
_js_expr="getBackendURL(env.UPLOAD)",
|
||||
_var_data=VarData(
|
||||
imports={
|
||||
f"/{Dirs.STATE_PATH}": "getBackendURL",
|
||||
"/env.json": ImportVar(tag="env", is_default=True),
|
||||
f"$/{Dirs.STATE_PATH}": "getBackendURL",
|
||||
"$/env.json": ImportVar(tag="env", is_default=True),
|
||||
}
|
||||
),
|
||||
).to(str)
|
||||
@ -170,7 +170,7 @@ def _on_drop_spec(files: Var) -> Tuple[Var[Any]]:
|
||||
class UploadFilesProvider(Component):
|
||||
"""AppWrap component that provides a dict of selected files by ID via useContext."""
|
||||
|
||||
library = f"/{Dirs.CONTEXTS_PATH}"
|
||||
library = f"$/{Dirs.CONTEXTS_PATH}"
|
||||
tag = "UploadFilesProvider"
|
||||
|
||||
|
||||
|
@ -34,8 +34,8 @@ uploaded_files_url_prefix = Var(
|
||||
_js_expr="getBackendURL(env.UPLOAD)",
|
||||
_var_data=VarData(
|
||||
imports={
|
||||
f"/{Dirs.STATE_PATH}": "getBackendURL",
|
||||
"/env.json": ImportVar(tag="env", is_default=True),
|
||||
f"$/{Dirs.STATE_PATH}": "getBackendURL",
|
||||
"$/env.json": ImportVar(tag="env", is_default=True),
|
||||
}
|
||||
),
|
||||
).to(str)
|
||||
|
@ -344,7 +344,7 @@ class DataEditor(NoSSRComponent):
|
||||
return {
|
||||
"": f"{format.format_library_name(self.library)}/dist/index.css",
|
||||
self.library: "GridCellKind",
|
||||
"/utils/helpers/dataeditor.js": ImportVar(
|
||||
"$/utils/helpers/dataeditor.js": ImportVar(
|
||||
tag="formatDataEditorCells", is_default=False, install=False
|
||||
),
|
||||
}
|
||||
|
@ -90,7 +90,7 @@ def load_dynamic_serializer():
|
||||
for lib, names in component._get_all_imports().items():
|
||||
formatted_lib_name = format_library_name(lib)
|
||||
if (
|
||||
not lib.startswith((".", "/"))
|
||||
not lib.startswith((".", "/", "$/"))
|
||||
and not lib.startswith("http")
|
||||
and formatted_lib_name not in libs_in_window
|
||||
):
|
||||
@ -106,7 +106,7 @@ def load_dynamic_serializer():
|
||||
# Rewrite imports from `/` to destructure from window
|
||||
for ix, line in enumerate(module_code_lines[:]):
|
||||
if line.startswith("import "):
|
||||
if 'from "/' in line:
|
||||
if 'from "$/' in line or 'from "/' in line:
|
||||
module_code_lines[ix] = (
|
||||
line.replace("import ", "const ", 1).replace(
|
||||
" from ", " = window['__reflex'][", 1
|
||||
@ -157,7 +157,7 @@ def load_dynamic_serializer():
|
||||
merge_var_data=VarData.merge(
|
||||
VarData(
|
||||
imports={
|
||||
f"/{constants.Dirs.STATE_PATH}": [
|
||||
f"$/{constants.Dirs.STATE_PATH}": [
|
||||
imports.ImportVar(tag="evalReactComponent"),
|
||||
],
|
||||
"react": [
|
||||
|
@ -187,7 +187,7 @@ class Form(BaseHTML):
|
||||
"""
|
||||
return {
|
||||
"react": "useCallback",
|
||||
f"/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
|
||||
f"$/{Dirs.STATE_PATH}": ["getRefValue", "getRefValues"],
|
||||
}
|
||||
|
||||
def add_hooks(self) -> list[str]:
|
||||
@ -615,6 +615,42 @@ class Textarea(BaseHTML):
|
||||
# Fired when a key is released
|
||||
on_key_up: EventHandler[key_event]
|
||||
|
||||
@classmethod
|
||||
def create(cls, *children, **props):
|
||||
"""Create a textarea component.
|
||||
|
||||
Args:
|
||||
*children: The children of the textarea.
|
||||
**props: The properties of the textarea.
|
||||
|
||||
Returns:
|
||||
The textarea component.
|
||||
|
||||
Raises:
|
||||
ValueError: when `enter_key_submit` is combined with `on_key_down`.
|
||||
"""
|
||||
enter_key_submit = props.get("enter_key_submit")
|
||||
auto_height = props.get("auto_height")
|
||||
custom_attrs = props.setdefault("custom_attrs", {})
|
||||
|
||||
if enter_key_submit is not None:
|
||||
enter_key_submit = Var.create(enter_key_submit)
|
||||
if "on_key_down" in props:
|
||||
raise ValueError(
|
||||
"Cannot combine `enter_key_submit` with `on_key_down`.",
|
||||
)
|
||||
custom_attrs["on_key_down"] = Var(
|
||||
_js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(enter_key_submit)})",
|
||||
_var_data=VarData.merge(enter_key_submit._get_all_var_data()),
|
||||
)
|
||||
if auto_height is not None:
|
||||
auto_height = Var.create(auto_height)
|
||||
custom_attrs["on_input"] = Var(
|
||||
_js_expr=f"(e) => autoHeightOnInput(e, {str(auto_height)})",
|
||||
_var_data=VarData.merge(auto_height._get_all_var_data()),
|
||||
)
|
||||
return super().create(*children, **props)
|
||||
|
||||
def _exclude_props(self) -> list[str]:
|
||||
return super()._exclude_props() + [
|
||||
"auto_height",
|
||||
@ -634,28 +670,6 @@ class Textarea(BaseHTML):
|
||||
custom_code.add(ENTER_KEY_SUBMIT_JS)
|
||||
return custom_code
|
||||
|
||||
def _render(self) -> Tag:
|
||||
tag = super()._render()
|
||||
if self.enter_key_submit is not None:
|
||||
if "on_key_down" in self.event_triggers:
|
||||
raise ValueError(
|
||||
"Cannot combine `enter_key_submit` with `on_key_down`.",
|
||||
)
|
||||
tag.add_props(
|
||||
on_key_down=Var(
|
||||
_js_expr=f"(e) => enterKeySubmitOnKeyDown(e, {str(self.enter_key_submit)})",
|
||||
_var_data=VarData.merge(self.enter_key_submit._get_all_var_data()),
|
||||
)
|
||||
)
|
||||
if self.auto_height is not None:
|
||||
tag.add_props(
|
||||
on_input=Var(
|
||||
_js_expr=f"(e) => autoHeightOnInput(e, {str(self.auto_height)})",
|
||||
_var_data=VarData.merge(self.auto_height._get_all_var_data()),
|
||||
)
|
||||
)
|
||||
return tag
|
||||
|
||||
|
||||
button = Button.create
|
||||
fieldset = Fieldset.create
|
||||
|
@ -1376,10 +1376,10 @@ class Textarea(BaseHTML):
|
||||
on_unmount: Optional[EventType[[]]] = None,
|
||||
**props,
|
||||
) -> "Textarea":
|
||||
"""Create the component.
|
||||
"""Create a textarea component.
|
||||
|
||||
Args:
|
||||
*children: The children of the component.
|
||||
*children: The children of the textarea.
|
||||
auto_complete: Whether the form control should have autocomplete enabled
|
||||
auto_focus: Automatically focuses the textarea when the page loads
|
||||
auto_height: Automatically fit the content height to the text (use min-height with this prop)
|
||||
@ -1419,10 +1419,13 @@ class Textarea(BaseHTML):
|
||||
class_name: The class name for the component.
|
||||
autofocus: Whether the component should take the focus once the page is loaded
|
||||
custom_attrs: custom attribute
|
||||
**props: The props of the component.
|
||||
**props: The properties of the textarea.
|
||||
|
||||
Returns:
|
||||
The component.
|
||||
The textarea component.
|
||||
|
||||
Raises:
|
||||
ValueError: when `enter_key_submit` is combined with `on_key_down`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
@ -3,10 +3,10 @@
|
||||
import dataclasses
|
||||
from typing import List, Optional
|
||||
|
||||
from reflex.components.component import Component, NoSSRComponent
|
||||
from reflex.components.component import NoSSRComponent
|
||||
from reflex.event import EventHandler, identity_event
|
||||
from reflex.utils.imports import ImportDict
|
||||
from reflex.vars.base import Var
|
||||
from reflex.vars.base import LiteralVar, Var
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
@ -92,6 +92,9 @@ class Moment(NoSSRComponent):
|
||||
# Display the date in the given timezone.
|
||||
tz: Var[str]
|
||||
|
||||
# The locale to use when rendering.
|
||||
locale: Var[str]
|
||||
|
||||
# Fires when the date changes.
|
||||
on_change: EventHandler[identity_event(str)]
|
||||
|
||||
@ -101,22 +104,15 @@ class Moment(NoSSRComponent):
|
||||
Returns:
|
||||
The import dict for the component.
|
||||
"""
|
||||
imports = {}
|
||||
|
||||
if isinstance(self.locale, LiteralVar):
|
||||
imports[""] = f"moment/locale/{self.locale._var_value}"
|
||||
elif self.locale is not None:
|
||||
# If the user is using a variable for the locale, we can't know the
|
||||
# value at compile time so import all locales available.
|
||||
imports[""] = "moment/min/locales"
|
||||
if self.tz is not None:
|
||||
return {"moment-timezone": ""}
|
||||
return {}
|
||||
imports["moment-timezone"] = ""
|
||||
|
||||
@classmethod
|
||||
def create(cls, *children, **props) -> Component:
|
||||
"""Create a Moment component.
|
||||
|
||||
Args:
|
||||
*children: The children of the component.
|
||||
**props: The properties of the component.
|
||||
|
||||
Returns:
|
||||
The Moment Component.
|
||||
"""
|
||||
comp = super().create(*children, **props)
|
||||
if "tz" in props:
|
||||
comp.lib_dependencies.append("moment-timezone")
|
||||
return comp
|
||||
return imports
|
||||
|
@ -51,6 +51,7 @@ class Moment(NoSSRComponent):
|
||||
unix: Optional[Union[Var[bool], bool]] = None,
|
||||
local: Optional[Union[Var[bool], bool]] = None,
|
||||
tz: Optional[Union[Var[str], str]] = None,
|
||||
locale: Optional[Union[Var[str], str]] = None,
|
||||
style: Optional[Style] = None,
|
||||
key: Optional[Any] = None,
|
||||
id: Optional[Any] = None,
|
||||
@ -75,7 +76,7 @@ class Moment(NoSSRComponent):
|
||||
on_unmount: Optional[EventType[[]]] = None,
|
||||
**props,
|
||||
) -> "Moment":
|
||||
"""Create a Moment component.
|
||||
"""Create the component.
|
||||
|
||||
Args:
|
||||
*children: The children of the component.
|
||||
@ -99,15 +100,16 @@ class Moment(NoSSRComponent):
|
||||
unix: Tells Moment to parse the given date value as a unix timestamp.
|
||||
local: Outputs the result in local time.
|
||||
tz: Display the date in the given timezone.
|
||||
locale: The locale to use when rendering.
|
||||
style: The style of the component.
|
||||
key: A unique key for the component.
|
||||
id: The id for the component.
|
||||
class_name: The class name for the component.
|
||||
autofocus: Whether the component should take the focus once the page is loaded
|
||||
custom_attrs: custom attribute
|
||||
**props: The properties of the component.
|
||||
**props: The props of the component.
|
||||
|
||||
Returns:
|
||||
The Moment Component.
|
||||
The component.
|
||||
"""
|
||||
...
|
||||
|
@ -221,7 +221,7 @@ class Theme(RadixThemesComponent):
|
||||
The import dict.
|
||||
"""
|
||||
_imports: ImportDict = {
|
||||
"/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
|
||||
"$/utils/theme.js": [ImportVar(tag="theme", is_default=True)],
|
||||
}
|
||||
if get_config().tailwind is None:
|
||||
# When tailwind is disabled, import the radix-ui styles directly because they will
|
||||
@ -265,7 +265,7 @@ class ThemePanel(RadixThemesComponent):
|
||||
class RadixThemesColorModeProvider(Component):
|
||||
"""Next-themes integration for radix themes components."""
|
||||
|
||||
library = "/components/reflex/radix_themes_color_mode_provider.js"
|
||||
library = "$/components/reflex/radix_themes_color_mode_provider.js"
|
||||
tag = "RadixThemesColorModeProvider"
|
||||
is_default = True
|
||||
|
||||
|
@ -251,7 +251,7 @@ class Toaster(Component):
|
||||
_js_expr=f"{toast_ref} = toast",
|
||||
_var_data=VarData(
|
||||
imports={
|
||||
"/utils/state": [ImportVar(tag="refs")],
|
||||
"$/utils/state": [ImportVar(tag="refs")],
|
||||
self.library: [ImportVar(tag="toast", install=False)],
|
||||
}
|
||||
),
|
||||
|
@ -8,12 +8,12 @@ import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Union
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from typing_extensions import get_type_hints
|
||||
|
||||
from reflex.utils.exceptions import ConfigError, EnvironmentVarValueError
|
||||
from reflex.utils.types import value_inside_optional
|
||||
from reflex.utils.types import GenericType, is_union, value_inside_optional
|
||||
|
||||
try:
|
||||
import pydantic.v1 as pydantic
|
||||
@ -157,11 +157,13 @@ def get_default_value_for_field(field: dataclasses.Field) -> Any:
|
||||
)
|
||||
|
||||
|
||||
def interpret_boolean_env(value: str) -> bool:
|
||||
# TODO: Change all interpret_.* signatures to value: str, field: dataclasses.Field once we migrate rx.Config to dataclasses
|
||||
def interpret_boolean_env(value: str, field_name: str) -> bool:
|
||||
"""Interpret a boolean environment variable value.
|
||||
|
||||
Args:
|
||||
value: The environment variable value.
|
||||
field_name: The field name.
|
||||
|
||||
Returns:
|
||||
The interpreted value.
|
||||
@ -176,14 +178,15 @@ def interpret_boolean_env(value: str) -> bool:
|
||||
return True
|
||||
elif value.lower() in false_values:
|
||||
return False
|
||||
raise EnvironmentVarValueError(f"Invalid boolean value: {value}")
|
||||
raise EnvironmentVarValueError(f"Invalid boolean value: {value} for {field_name}")
|
||||
|
||||
|
||||
def interpret_int_env(value: str) -> int:
|
||||
def interpret_int_env(value: str, field_name: str) -> int:
|
||||
"""Interpret an integer environment variable value.
|
||||
|
||||
Args:
|
||||
value: The environment variable value.
|
||||
field_name: The field name.
|
||||
|
||||
Returns:
|
||||
The interpreted value.
|
||||
@ -194,14 +197,17 @@ def interpret_int_env(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError as ve:
|
||||
raise EnvironmentVarValueError(f"Invalid integer value: {value}") from ve
|
||||
raise EnvironmentVarValueError(
|
||||
f"Invalid integer value: {value} for {field_name}"
|
||||
) from ve
|
||||
|
||||
|
||||
def interpret_path_env(value: str) -> Path:
|
||||
def interpret_path_env(value: str, field_name: str) -> Path:
|
||||
"""Interpret a path environment variable value.
|
||||
|
||||
Args:
|
||||
value: The environment variable value.
|
||||
field_name: The field name.
|
||||
|
||||
Returns:
|
||||
The interpreted value.
|
||||
@ -211,16 +217,19 @@ def interpret_path_env(value: str) -> Path:
|
||||
"""
|
||||
path = Path(value)
|
||||
if not path.exists():
|
||||
raise EnvironmentVarValueError(f"Path does not exist: {path}")
|
||||
raise EnvironmentVarValueError(f"Path does not exist: {path} for {field_name}")
|
||||
return path
|
||||
|
||||
|
||||
def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
|
||||
def interpret_env_var_value(
|
||||
value: str, field_type: GenericType, field_name: str
|
||||
) -> Any:
|
||||
"""Interpret an environment variable value based on the field type.
|
||||
|
||||
Args:
|
||||
value: The environment variable value.
|
||||
field: The field.
|
||||
field_type: The field type.
|
||||
field_name: The field name.
|
||||
|
||||
Returns:
|
||||
The interpreted value.
|
||||
@ -228,20 +237,25 @@ def interpret_env_var_value(value: str, field: dataclasses.Field) -> Any:
|
||||
Raises:
|
||||
ValueError: If the value is invalid.
|
||||
"""
|
||||
field_type = value_inside_optional(field.type)
|
||||
field_type = value_inside_optional(field_type)
|
||||
|
||||
if is_union(field_type):
|
||||
raise ValueError(
|
||||
f"Union types are not supported for environment variables: {field_name}."
|
||||
)
|
||||
|
||||
if field_type is bool:
|
||||
return interpret_boolean_env(value)
|
||||
return interpret_boolean_env(value, field_name)
|
||||
elif field_type is str:
|
||||
return value
|
||||
elif field_type is int:
|
||||
return interpret_int_env(value)
|
||||
return interpret_int_env(value, field_name)
|
||||
elif field_type is Path:
|
||||
return interpret_path_env(value)
|
||||
return interpret_path_env(value, field_name)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid type for environment variable {field.name}: {field_type}. This is probably an issue in Reflex."
|
||||
f"Invalid type for environment variable {field_name}: {field_type}. This is probably an issue in Reflex."
|
||||
)
|
||||
|
||||
|
||||
@ -316,7 +330,7 @@ class EnvironmentVariables:
|
||||
field.type = type_hints.get(field.name) or field.type
|
||||
|
||||
value = (
|
||||
interpret_env_var_value(raw_value, field)
|
||||
interpret_env_var_value(raw_value, field.type, field.name)
|
||||
if raw_value is not None
|
||||
else get_default_value_for_field(field)
|
||||
)
|
||||
@ -387,7 +401,7 @@ class Config(Base):
|
||||
telemetry_enabled: bool = True
|
||||
|
||||
# The bun path
|
||||
bun_path: Union[str, Path] = constants.Bun.DEFAULT_PATH
|
||||
bun_path: Path = constants.Bun.DEFAULT_PATH
|
||||
|
||||
# List of origins that are allowed to connect to the backend API.
|
||||
cors_allowed_origins: List[str] = ["*"]
|
||||
@ -484,17 +498,17 @@ class Config(Base):
|
||||
|
||||
Returns:
|
||||
The updated config values.
|
||||
|
||||
Raises:
|
||||
EnvVarValueError: If an environment variable is set to an invalid type.
|
||||
"""
|
||||
from reflex.utils.exceptions import EnvVarValueError
|
||||
|
||||
if self.env_file:
|
||||
from dotenv import load_dotenv
|
||||
try:
|
||||
from dotenv import load_dotenv # type: ignore
|
||||
|
||||
# load env file if exists
|
||||
load_dotenv(self.env_file, override=True)
|
||||
# load env file if exists
|
||||
load_dotenv(self.env_file, override=True)
|
||||
except ImportError:
|
||||
console.error(
|
||||
"""The `python-dotenv` package is required to load environment variables from a file. Run `pip install "python-dotenv>=1.0.1"`."""
|
||||
)
|
||||
|
||||
updated_values = {}
|
||||
# Iterate over the fields.
|
||||
@ -510,21 +524,11 @@ class Config(Base):
|
||||
dedupe=True,
|
||||
)
|
||||
|
||||
# Convert the env var to the expected type.
|
||||
try:
|
||||
if issubclass(field.type_, bool):
|
||||
# special handling for bool values
|
||||
env_var = env_var.lower() in ["true", "1", "yes"]
|
||||
else:
|
||||
env_var = field.type_(env_var)
|
||||
except ValueError as ve:
|
||||
console.error(
|
||||
f"Could not convert {key.upper()}={env_var} to type {field.type_}"
|
||||
)
|
||||
raise EnvVarValueError from ve
|
||||
# Interpret the value.
|
||||
value = interpret_env_var_value(env_var, field.type_, field.name)
|
||||
|
||||
# Set the value.
|
||||
updated_values[key] = env_var
|
||||
updated_values[key] = value
|
||||
|
||||
return updated_values
|
||||
|
||||
|
@ -35,7 +35,6 @@ ColorType = Literal[
|
||||
"amber",
|
||||
"gold",
|
||||
"bronze",
|
||||
"gray",
|
||||
"accent",
|
||||
"black",
|
||||
"white",
|
||||
|
@ -114,8 +114,8 @@ class Imports(SimpleNamespace):
|
||||
|
||||
EVENTS = {
|
||||
"react": [ImportVar(tag="useContext")],
|
||||
f"/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
|
||||
f"/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
|
||||
f"$/{Dirs.CONTEXTS_PATH}": [ImportVar(tag="EventLoopContext")],
|
||||
f"$/{Dirs.STATE_PATH}": [ImportVar(tag=CompileVars.TO_EVENT)],
|
||||
}
|
||||
|
||||
|
||||
|
@ -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 = {
|
||||
|
125
reflex/event.py
125
reflex/event.py
@ -394,7 +394,9 @@ class CallableEventSpec(EventSpec):
|
||||
class EventChain(EventActionsMixin):
|
||||
"""Container for a chain of events that will be executed in order."""
|
||||
|
||||
events: List[Union[EventSpec, EventVar]] = dataclasses.field(default_factory=list)
|
||||
events: Sequence[Union[EventSpec, EventVar, EventCallback]] = dataclasses.field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
args_spec: Optional[Union[Callable, Sequence[Callable]]] = dataclasses.field(
|
||||
default=None
|
||||
@ -1540,13 +1542,8 @@ class LiteralEventChainVar(ArgsFunctionOperation, LiteralVar, EventChainVar):
|
||||
)
|
||||
|
||||
|
||||
G = ParamSpec("G")
|
||||
|
||||
IndividualEventType = Union[EventSpec, EventHandler, Callable[G, Any], Var[Any]]
|
||||
|
||||
EventType = Union[IndividualEventType[G], List[IndividualEventType[G]]]
|
||||
|
||||
P = ParamSpec("P")
|
||||
Q = ParamSpec("Q")
|
||||
T = TypeVar("T")
|
||||
V = TypeVar("V")
|
||||
V2 = TypeVar("V2")
|
||||
@ -1568,55 +1565,73 @@ if sys.version_info >= (3, 10):
|
||||
"""
|
||||
self.func = func
|
||||
|
||||
@property
|
||||
def prevent_default(self):
|
||||
"""Prevent default behavior.
|
||||
|
||||
Returns:
|
||||
The event callback with prevent default behavior.
|
||||
"""
|
||||
return self
|
||||
|
||||
@property
|
||||
def stop_propagation(self):
|
||||
"""Stop event propagation.
|
||||
|
||||
Returns:
|
||||
The event callback with stop propagation behavior.
|
||||
"""
|
||||
return self
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self: EventCallback[[V], T], instance: None, owner
|
||||
) -> Callable[[Union[Var[V], V]], EventSpec]: ...
|
||||
def __call__(
|
||||
self: EventCallback[Concatenate[V, Q], T], value: V | Var[V]
|
||||
) -> EventCallback[Q, T]: ...
|
||||
|
||||
@overload
|
||||
def __call__(
|
||||
self: EventCallback[Concatenate[V, V2, Q], T],
|
||||
value: V | Var[V],
|
||||
value2: V2 | Var[V2],
|
||||
) -> EventCallback[Q, T]: ...
|
||||
|
||||
@overload
|
||||
def __call__(
|
||||
self: EventCallback[Concatenate[V, V2, V3, Q], T],
|
||||
value: V | Var[V],
|
||||
value2: V2 | Var[V2],
|
||||
value3: V3 | Var[V3],
|
||||
) -> EventCallback[Q, T]: ...
|
||||
|
||||
@overload
|
||||
def __call__(
|
||||
self: EventCallback[Concatenate[V, V2, V3, V4, Q], T],
|
||||
value: V | Var[V],
|
||||
value2: V2 | Var[V2],
|
||||
value3: V3 | Var[V3],
|
||||
value4: V4 | Var[V4],
|
||||
) -> EventCallback[Q, T]: ...
|
||||
|
||||
def __call__(self, *values) -> EventCallback: # type: ignore
|
||||
"""Call the function with the values.
|
||||
|
||||
Args:
|
||||
*values: The values to call the function with.
|
||||
|
||||
Returns:
|
||||
The function with the values.
|
||||
"""
|
||||
return self.func(*values) # type: ignore
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self: EventCallback[[V, V2], T], instance: None, owner
|
||||
) -> Callable[[Union[Var[V], V], Union[Var[V2], V2]], EventSpec]: ...
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self: EventCallback[[V, V2, V3], T], instance: None, owner
|
||||
) -> Callable[
|
||||
[Union[Var[V], V], Union[Var[V2], V2], Union[Var[V3], V3]],
|
||||
EventSpec,
|
||||
]: ...
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self: EventCallback[[V, V2, V3, V4], T], instance: None, owner
|
||||
) -> Callable[
|
||||
[
|
||||
Union[Var[V], V],
|
||||
Union[Var[V2], V2],
|
||||
Union[Var[V3], V3],
|
||||
Union[Var[V4], V4],
|
||||
],
|
||||
EventSpec,
|
||||
]: ...
|
||||
|
||||
@overload
|
||||
def __get__(
|
||||
self: EventCallback[[V, V2, V3, V4, V5], T], instance: None, owner
|
||||
) -> Callable[
|
||||
[
|
||||
Union[Var[V], V],
|
||||
Union[Var[V2], V2],
|
||||
Union[Var[V3], V3],
|
||||
Union[Var[V4], V4],
|
||||
Union[Var[V5], V5],
|
||||
],
|
||||
EventSpec,
|
||||
]: ...
|
||||
self: EventCallback[P, T], instance: None, owner
|
||||
) -> EventCallback[P, T]: ...
|
||||
|
||||
@overload
|
||||
def __get__(self, instance, owner) -> Callable[P, T]: ...
|
||||
|
||||
def __get__(self, instance, owner) -> Callable:
|
||||
def __get__(self, instance, owner) -> Callable: # type: ignore
|
||||
"""Get the function with the instance bound to it.
|
||||
|
||||
Args:
|
||||
@ -1643,6 +1658,9 @@ if sys.version_info >= (3, 10):
|
||||
return func # type: ignore
|
||||
else:
|
||||
|
||||
class EventCallback(Generic[P, T]):
|
||||
"""A descriptor that wraps a function to be used as an event."""
|
||||
|
||||
def event_handler(func: Callable[P, T]) -> Callable[P, T]:
|
||||
"""Wrap a function to be used as an event.
|
||||
|
||||
@ -1655,6 +1673,17 @@ else:
|
||||
return func
|
||||
|
||||
|
||||
G = ParamSpec("G")
|
||||
|
||||
IndividualEventType = Union[
|
||||
EventSpec, EventHandler, Callable[G, Any], EventCallback[G, Any], Var[Any]
|
||||
]
|
||||
|
||||
ItemOrList = Union[V, List[V]]
|
||||
|
||||
EventType = ItemOrList[IndividualEventType[G]]
|
||||
|
||||
|
||||
class EventNamespace(types.SimpleNamespace):
|
||||
"""A namespace for event related classes."""
|
||||
|
||||
|
@ -21,7 +21,7 @@ NoValue = object()
|
||||
|
||||
|
||||
_refs_import = {
|
||||
f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
|
||||
f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")],
|
||||
}
|
||||
|
||||
|
||||
@ -178,9 +178,12 @@ class ClientStateVar(Var):
|
||||
if self._global_ref
|
||||
else self._setter_name
|
||||
)
|
||||
_var_data = VarData(imports=_refs_import if self._global_ref else {})
|
||||
if value is not NoValue:
|
||||
# This is a hack to make it work like an EventSpec taking an arg
|
||||
value_str = str(LiteralVar.create(value))
|
||||
value_var = LiteralVar.create(value)
|
||||
_var_data = VarData.merge(_var_data, value_var._get_all_var_data())
|
||||
value_str = str(value_var)
|
||||
|
||||
if value_str.startswith("_"):
|
||||
# remove patterns of ["*"] from the value_str using regex
|
||||
@ -190,7 +193,7 @@ class ClientStateVar(Var):
|
||||
setter = f"(() => {setter}({value_str}))"
|
||||
return Var(
|
||||
_js_expr=setter,
|
||||
_var_data=VarData(imports=_refs_import if self._global_ref else {}),
|
||||
_var_data=_var_data,
|
||||
).to(FunctionVar, EventChain)
|
||||
|
||||
@property
|
||||
|
@ -38,7 +38,7 @@ def get_engine(url: str | None = None) -> sqlalchemy.engine.Engine:
|
||||
url = url or conf.db_url
|
||||
if url is None:
|
||||
raise ValueError("No database url configured")
|
||||
if environment.ALEMBIC_CONFIG.exists():
|
||||
if not environment.ALEMBIC_CONFIG.exists():
|
||||
console.warn(
|
||||
"Database is not initialized, run [bold]reflex db init[/bold] first."
|
||||
)
|
||||
|
@ -30,6 +30,7 @@ from typing import (
|
||||
Set,
|
||||
Tuple,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
get_args,
|
||||
@ -79,6 +80,7 @@ from reflex.utils import console, format, path_ops, prerequisites, types
|
||||
from reflex.utils.exceptions import (
|
||||
ComputedVarShadowsBaseVars,
|
||||
ComputedVarShadowsStateVar,
|
||||
DynamicComponentInvalidSignature,
|
||||
DynamicRouteArgShadowsStateVar,
|
||||
EventHandlerShadowsBuiltInStateMethod,
|
||||
ImmutableStateError,
|
||||
@ -218,6 +220,7 @@ class EventHandlerSetVar(EventHandler):
|
||||
Raises:
|
||||
AttributeError: If the given Var name does not exist on the state.
|
||||
EventHandlerValueError: If the given Var name is not a str
|
||||
NotImplementedError: If the setter for the given Var is async
|
||||
"""
|
||||
from reflex.utils.exceptions import EventHandlerValueError
|
||||
|
||||
@ -226,11 +229,20 @@ class EventHandlerSetVar(EventHandler):
|
||||
raise EventHandlerValueError(
|
||||
f"Var name must be passed as a string, got {args[0]!r}"
|
||||
)
|
||||
|
||||
handler = getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None)
|
||||
|
||||
# Check that the requested Var setter exists on the State at compile time.
|
||||
if getattr(self.state_cls, constants.SETTER_PREFIX + args[0], None) is None:
|
||||
if handler is None:
|
||||
raise AttributeError(
|
||||
f"Variable `{args[0]}` cannot be set on `{self.state_cls.get_full_name()}`"
|
||||
)
|
||||
|
||||
if asyncio.iscoroutinefunction(handler.fn):
|
||||
raise NotImplementedError(
|
||||
f"Setter for {args[0]} is async, which is not supported."
|
||||
)
|
||||
|
||||
return super().__call__(*args)
|
||||
|
||||
|
||||
@ -2051,12 +2063,24 @@ class BaseState(Base, ABC, extra=pydantic.Extra.allow):
|
||||
"""
|
||||
try:
|
||||
return pickle.dumps((self._to_schema(), self))
|
||||
except pickle.PicklingError:
|
||||
console.warn(
|
||||
except (pickle.PicklingError, AttributeError) as og_pickle_error:
|
||||
error = (
|
||||
f"Failed to serialize state {self.get_full_name()} due to unpicklable object. "
|
||||
"This state will not be persisted."
|
||||
"This state will not be persisted. "
|
||||
)
|
||||
return b""
|
||||
try:
|
||||
import dill
|
||||
|
||||
return dill.dumps((self._to_schema(), self))
|
||||
except ImportError:
|
||||
error += (
|
||||
f"Pickle error: {og_pickle_error}. "
|
||||
"Consider `pip install 'dill>=0.3.8'` for more exotic serialization support."
|
||||
)
|
||||
except (pickle.PicklingError, TypeError, ValueError) as ex:
|
||||
error += f"Dill was also unable to pickle the state: {ex}"
|
||||
console.warn(error)
|
||||
return b""
|
||||
|
||||
@classmethod
|
||||
def _deserialize(
|
||||
@ -2095,6 +2119,51 @@ class State(BaseState):
|
||||
is_hydrated: bool = False
|
||||
|
||||
|
||||
T = TypeVar("T", bound=BaseState)
|
||||
|
||||
|
||||
def dynamic(func: Callable[[T], Component]):
|
||||
"""Create a dynamically generated components from a state class.
|
||||
|
||||
Args:
|
||||
func: The function to generate the component.
|
||||
|
||||
Returns:
|
||||
The dynamically generated component.
|
||||
|
||||
Raises:
|
||||
DynamicComponentInvalidSignature: If the function does not have exactly one parameter.
|
||||
DynamicComponentInvalidSignature: If the function does not have a type hint for the state class.
|
||||
"""
|
||||
number_of_parameters = len(inspect.signature(func).parameters)
|
||||
|
||||
func_signature = get_type_hints(func)
|
||||
|
||||
if "return" in func_signature:
|
||||
func_signature.pop("return")
|
||||
|
||||
values = list(func_signature.values())
|
||||
|
||||
if number_of_parameters != 1:
|
||||
raise DynamicComponentInvalidSignature(
|
||||
"The function must have exactly one parameter, which is the state class."
|
||||
)
|
||||
|
||||
if len(values) != 1:
|
||||
raise DynamicComponentInvalidSignature(
|
||||
"You must provide a type hint for the state class in the function."
|
||||
)
|
||||
|
||||
state_class: Type[T] = values[0]
|
||||
|
||||
def wrapper() -> Component:
|
||||
from reflex.components.base.fragment import fragment
|
||||
|
||||
return fragment(state_class._evaluate(lambda state: func(state)))
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class FrontendEventExceptionState(State):
|
||||
"""Substate for handling frontend exceptions."""
|
||||
|
||||
@ -2848,9 +2917,13 @@ class StateManagerDisk(StateManager):
|
||||
for substate in state.get_substates():
|
||||
substate_token = _substate_key(client_token, substate)
|
||||
|
||||
fresh_instance = await root_state.get_state(substate)
|
||||
instance = await self.load_state(substate_token)
|
||||
if instance is None:
|
||||
instance = await root_state.get_state(substate)
|
||||
if instance is not None:
|
||||
# Ensure all substates exist, even if they weren't serialized previously.
|
||||
instance.substates = fresh_instance.substates
|
||||
else:
|
||||
instance = fresh_instance
|
||||
state.substates[substate.get_name()] = instance
|
||||
instance.parent_state = state
|
||||
|
||||
|
@ -23,7 +23,7 @@ LiteralColorMode = Literal["system", "light", "dark"]
|
||||
|
||||
# Reference the global ColorModeContext
|
||||
color_mode_imports = {
|
||||
f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
|
||||
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="ColorModeContext")],
|
||||
"react": [ImportVar(tag="useContext")],
|
||||
}
|
||||
|
||||
|
@ -249,7 +249,8 @@ class AppHarness:
|
||||
return textwrap.dedent(source)
|
||||
|
||||
def _initialize_app(self):
|
||||
os.environ["TELEMETRY_ENABLED"] = "" # disable telemetry reporting for tests
|
||||
# disable telemetry reporting for tests
|
||||
os.environ["TELEMETRY_ENABLED"] = "false"
|
||||
self.app_path.mkdir(parents=True, exist_ok=True)
|
||||
if self.app_source is not None:
|
||||
app_globals = self._get_globals_from_signature(self.app_source)
|
||||
|
@ -143,3 +143,7 @@ class StateSchemaMismatchError(ReflexError, TypeError):
|
||||
|
||||
class EnvironmentVarValueError(ReflexError, ValueError):
|
||||
"""Raised when an environment variable is set to an invalid value."""
|
||||
|
||||
|
||||
class DynamicComponentInvalidSignature(ReflexError, TypeError):
|
||||
"""Raised when a dynamic component has an invalid signature."""
|
||||
|
@ -23,6 +23,12 @@ def merge_imports(
|
||||
for lib, fields in (
|
||||
import_dict if isinstance(import_dict, tuple) else import_dict.items()
|
||||
):
|
||||
# If the lib is an absolute path, we need to prefix it with a $
|
||||
lib = (
|
||||
"$" + lib
|
||||
if lib.startswith(("/utils/", "/components/", "/styles/", "/public/"))
|
||||
else lib
|
||||
)
|
||||
if isinstance(fields, (list, tuple, set)):
|
||||
all_imports[lib].extend(
|
||||
(
|
||||
|
@ -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:
|
||||
@ -217,7 +228,7 @@ class VarData:
|
||||
): None
|
||||
},
|
||||
imports={
|
||||
f"/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
|
||||
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
|
||||
"react": [ImportVar(tag="useContext")],
|
||||
},
|
||||
)
|
||||
@ -956,7 +967,7 @@ class Var(Generic[VAR_TYPE]):
|
||||
_js_expr="refs",
|
||||
_var_data=VarData(
|
||||
imports={
|
||||
f"/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
|
||||
f"$/{constants.Dirs.STATE_PATH}": [imports.ImportVar(tag="refs")]
|
||||
}
|
||||
),
|
||||
).to(ObjectVar, Dict[str, str])
|
||||
@ -2530,7 +2541,7 @@ def get_uuid_string_var() -> Var:
|
||||
unique_uuid_var = get_unique_variable_name()
|
||||
unique_uuid_var_data = VarData(
|
||||
imports={
|
||||
f"/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")}, # type: ignore
|
||||
f"$/{constants.Dirs.STATE_PATH}": {ImportVar(tag="generateUUID")}, # type: ignore
|
||||
"react": "useMemo",
|
||||
},
|
||||
hooks={f"const {unique_uuid_var} = useMemo(generateUUID, [])": None},
|
||||
|
@ -1090,7 +1090,7 @@ boolean_types = Union[BooleanVar, bool]
|
||||
|
||||
|
||||
_IS_TRUE_IMPORT: ImportDict = {
|
||||
f"/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
|
||||
f"$/{Dirs.STATE_PATH}": [ImportVar(tag="isTrue")],
|
||||
}
|
||||
|
||||
|
||||
|
59
tests/integration/tests_playwright/test_stateless_app.py
Normal file
59
tests/integration/tests_playwright/test_stateless_app.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""Integration tests for a stateless app."""
|
||||
|
||||
from typing import Generator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
import reflex as rx
|
||||
from reflex.testing import AppHarness
|
||||
|
||||
|
||||
def StatelessApp():
|
||||
"""A stateless app that renders a heading."""
|
||||
import reflex as rx
|
||||
|
||||
def index():
|
||||
return rx.heading("This is a stateless app")
|
||||
|
||||
app = rx.App()
|
||||
app.add_page(index)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def stateless_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
|
||||
"""Create a stateless app AppHarness.
|
||||
|
||||
Args:
|
||||
tmp_path_factory: pytest fixture for creating temporary directories.
|
||||
|
||||
Yields:
|
||||
AppHarness: A harness for testing the stateless app.
|
||||
"""
|
||||
with AppHarness.create(
|
||||
root=tmp_path_factory.mktemp("stateless_app"),
|
||||
app_source=StatelessApp, # type: ignore
|
||||
) as harness:
|
||||
yield harness
|
||||
|
||||
|
||||
def test_statelessness(stateless_app: AppHarness, page: Page):
|
||||
"""Test that the stateless app renders a heading but backend/_event is not mounted.
|
||||
|
||||
Args:
|
||||
stateless_app: A harness for testing the stateless app.
|
||||
page: A Playwright page.
|
||||
"""
|
||||
assert stateless_app.frontend_url is not None
|
||||
assert stateless_app.backend is not None
|
||||
assert stateless_app.backend.started
|
||||
|
||||
res = httpx.get(rx.config.get_config().api_url + "/_event")
|
||||
assert res.status_code == 404
|
||||
|
||||
res2 = httpx.get(rx.config.get_config().api_url + "/ping")
|
||||
assert res2.status_code == 200
|
||||
|
||||
page.goto(stateless_app.frontend_url)
|
||||
expect(page.get_by_role("heading")).to_have_text("This is a stateless app")
|
@ -3,10 +3,18 @@
|
||||
from typing import Generator
|
||||
|
||||
import pytest
|
||||
from selenium.webdriver.common.by import By
|
||||
from playwright.sync_api import Page
|
||||
|
||||
from reflex.testing import AppHarness
|
||||
|
||||
expected_col_headers = ["Name", "Age", "Location"]
|
||||
expected_row_headers = ["John", "Jane", "Joe"]
|
||||
expected_cells_data = [
|
||||
["30", "New York"],
|
||||
["31", "San Fransisco"],
|
||||
["32", "Los Angeles"],
|
||||
]
|
||||
|
||||
|
||||
def Table():
|
||||
"""App using table component."""
|
||||
@ -17,11 +25,6 @@ def Table():
|
||||
@app.add_page
|
||||
def index():
|
||||
return rx.center(
|
||||
rx.input(
|
||||
id="token",
|
||||
value=rx.State.router.session.client_token,
|
||||
is_read_only=True,
|
||||
),
|
||||
rx.table.root(
|
||||
rx.table.header(
|
||||
rx.table.row(
|
||||
@ -53,7 +56,7 @@ def Table():
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def table(tmp_path_factory) -> Generator[AppHarness, None, None]:
|
||||
def table_app(tmp_path_factory) -> Generator[AppHarness, None, None]:
|
||||
"""Start Table app at tmp_path via AppHarness.
|
||||
|
||||
Args:
|
||||
@ -71,47 +74,27 @@ def table(tmp_path_factory) -> Generator[AppHarness, None, None]:
|
||||
yield harness
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def driver(table: AppHarness):
|
||||
"""GEt an instance of the browser open to the table app.
|
||||
|
||||
Args:
|
||||
table: harness for Table app
|
||||
|
||||
Yields:
|
||||
WebDriver instance.
|
||||
"""
|
||||
driver = table.frontend()
|
||||
try:
|
||||
token_input = driver.find_element(By.ID, "token")
|
||||
assert token_input
|
||||
# wait for the backend connection to send the token
|
||||
token = table.poll_for_value(token_input)
|
||||
assert token is not None
|
||||
|
||||
yield driver
|
||||
finally:
|
||||
driver.quit()
|
||||
|
||||
|
||||
def test_table(driver, table: AppHarness):
|
||||
def test_table(page: Page, table_app: AppHarness):
|
||||
"""Test that a table component is rendered properly.
|
||||
|
||||
Args:
|
||||
driver: Selenium WebDriver open to the app
|
||||
table: Harness for Table app
|
||||
table_app: Harness for Table app
|
||||
page: Playwright page instance
|
||||
"""
|
||||
assert table.app_instance is not None, "app is not running"
|
||||
assert table_app.frontend_url is not None, "frontend url is not available"
|
||||
|
||||
thead = driver.find_element(By.TAG_NAME, "thead")
|
||||
# poll till page is fully loaded.
|
||||
table.poll_for_content(element=thead)
|
||||
# check headers
|
||||
assert thead.find_element(By.TAG_NAME, "tr").text == "Name Age Location"
|
||||
# check first row value
|
||||
assert (
|
||||
driver.find_element(By.TAG_NAME, "tbody")
|
||||
.find_elements(By.TAG_NAME, "tr")[0]
|
||||
.text
|
||||
== "John 30 New York"
|
||||
)
|
||||
page.goto(table_app.frontend_url)
|
||||
table = page.get_by_role("table")
|
||||
|
||||
# Check column headers
|
||||
headers = table.get_by_role("columnheader").all_inner_texts()
|
||||
assert headers == expected_col_headers
|
||||
|
||||
# Check rows headers
|
||||
rows = table.get_by_role("rowheader").all_inner_texts()
|
||||
assert rows == expected_row_headers
|
||||
|
||||
# Check cells
|
||||
rows = table.get_by_role("cell").all_inner_texts()
|
||||
for i, expected_row in enumerate(expected_cells_data):
|
||||
assert [rows[idx := i * 2], rows[idx + 1]] == expected_row
|
@ -12,7 +12,7 @@ def test_websocket_target_url():
|
||||
var_data = url._get_all_var_data()
|
||||
assert var_data is not None
|
||||
assert sorted(tuple((key for key, _ in var_data.imports))) == sorted(
|
||||
("/utils/state", "/env.json")
|
||||
("$/utils/state", "$/env.json")
|
||||
)
|
||||
|
||||
|
||||
@ -22,10 +22,10 @@ def test_connection_banner():
|
||||
assert sorted(tuple(_imports)) == sorted(
|
||||
(
|
||||
"react",
|
||||
"/utils/context",
|
||||
"/utils/state",
|
||||
"$/utils/context",
|
||||
"$/utils/state",
|
||||
"@radix-ui/themes@^3.0.0",
|
||||
"/env.json",
|
||||
"$/env.json",
|
||||
)
|
||||
)
|
||||
|
||||
@ -40,10 +40,10 @@ def test_connection_modal():
|
||||
assert sorted(tuple(_imports)) == sorted(
|
||||
(
|
||||
"react",
|
||||
"/utils/context",
|
||||
"/utils/state",
|
||||
"$/utils/context",
|
||||
"$/utils/state",
|
||||
"@radix-ui/themes@^3.0.0",
|
||||
"/env.json",
|
||||
"$/env.json",
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -1,5 +1,7 @@
|
||||
import multiprocessing
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
|
||||
@ -42,7 +44,12 @@ def test_set_app_name(base_config_values):
|
||||
("TELEMETRY_ENABLED", True),
|
||||
],
|
||||
)
|
||||
def test_update_from_env(base_config_values, monkeypatch, env_var, value):
|
||||
def test_update_from_env(
|
||||
base_config_values: Dict[str, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
env_var: str,
|
||||
value: Any,
|
||||
):
|
||||
"""Test that environment variables override config values.
|
||||
|
||||
Args:
|
||||
@ -57,6 +64,29 @@ def test_update_from_env(base_config_values, monkeypatch, env_var, value):
|
||||
assert getattr(config, env_var.lower()) == value
|
||||
|
||||
|
||||
def test_update_from_env_path(
|
||||
base_config_values: Dict[str, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
):
|
||||
"""Test that environment variables override config values.
|
||||
|
||||
Args:
|
||||
base_config_values: Config values.
|
||||
monkeypatch: The pytest monkeypatch object.
|
||||
tmp_path: The pytest tmp_path fixture object.
|
||||
"""
|
||||
monkeypatch.setenv("BUN_PATH", "/test")
|
||||
assert os.environ.get("BUN_PATH") == "/test"
|
||||
with pytest.raises(ValueError):
|
||||
rx.Config(**base_config_values)
|
||||
|
||||
monkeypatch.setenv("BUN_PATH", str(tmp_path))
|
||||
assert os.environ.get("BUN_PATH") == str(tmp_path)
|
||||
config = rx.Config(**base_config_values)
|
||||
assert config.bun_path == tmp_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, expected",
|
||||
[
|
||||
|
@ -106,6 +106,7 @@ class TestState(BaseState):
|
||||
fig: Figure = Figure()
|
||||
dt: datetime.datetime = datetime.datetime.fromisoformat("1989-11-09T18:53:00+01:00")
|
||||
_backend: int = 0
|
||||
asynctest: int = 0
|
||||
|
||||
@ComputedVar
|
||||
def sum(self) -> float:
|
||||
@ -129,6 +130,14 @@ class TestState(BaseState):
|
||||
"""Do something."""
|
||||
pass
|
||||
|
||||
async def set_asynctest(self, value: int):
|
||||
"""Set the asynctest value. Intentionally overwrite the default setter with an async one.
|
||||
|
||||
Args:
|
||||
value: The new value.
|
||||
"""
|
||||
self.asynctest = value
|
||||
|
||||
|
||||
class ChildState(TestState):
|
||||
"""A child state fixture."""
|
||||
@ -313,6 +322,7 @@ def test_class_vars(test_state):
|
||||
"upper",
|
||||
"fig",
|
||||
"dt",
|
||||
"asynctest",
|
||||
}
|
||||
|
||||
|
||||
@ -733,6 +743,7 @@ def test_reset(test_state, child_state):
|
||||
"mapping",
|
||||
"dt",
|
||||
"_backend",
|
||||
"asynctest",
|
||||
}
|
||||
|
||||
# The dirty vars should be reset.
|
||||
@ -3179,6 +3190,13 @@ async def test_setvar(mock_app: rx.App, token: str):
|
||||
TestState.setvar(42, 42)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setvar_async_setter():
|
||||
"""Test that overridden async setters raise Exception when used with setvar."""
|
||||
with pytest.raises(NotImplementedError):
|
||||
TestState.setvar("asynctest", 42)
|
||||
|
||||
|
||||
@pytest.mark.skipif("REDIS_URL" not in os.environ, reason="Test requires redis")
|
||||
@pytest.mark.parametrize(
|
||||
"expiration_kwargs, expected_values",
|
||||
@ -3313,3 +3331,68 @@ def test_assignment_to_undeclared_vars():
|
||||
|
||||
state.handle_supported_regular_vars()
|
||||
state.handle_non_var()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deserialize_gc_state_disk(token):
|
||||
"""Test that a state can be deserialized from disk with a grandchild state.
|
||||
|
||||
Args:
|
||||
token: A token.
|
||||
"""
|
||||
|
||||
class Root(BaseState):
|
||||
pass
|
||||
|
||||
class State(Root):
|
||||
num: int = 42
|
||||
|
||||
class Child(State):
|
||||
foo: str = "bar"
|
||||
|
||||
dsm = StateManagerDisk(state=Root)
|
||||
async with dsm.modify_state(token) as root:
|
||||
s = await root.get_state(State)
|
||||
s.num += 1
|
||||
c = await root.get_state(Child)
|
||||
assert s._get_was_touched()
|
||||
assert not c._get_was_touched()
|
||||
|
||||
dsm2 = StateManagerDisk(state=Root)
|
||||
root = await dsm2.get_state(token)
|
||||
s = await root.get_state(State)
|
||||
assert s.num == 43
|
||||
c = await root.get_state(Child)
|
||||
assert c.foo == "bar"
|
||||
|
||||
|
||||
class Obj(Base):
|
||||
"""A object containing a callable for testing fallback pickle."""
|
||||
|
||||
_f: Callable
|
||||
|
||||
|
||||
def test_fallback_pickle():
|
||||
"""Test that state serialization will fall back to dill."""
|
||||
|
||||
class DillState(BaseState):
|
||||
_o: Optional[Obj] = None
|
||||
_f: Optional[Callable] = None
|
||||
_g: Any = None
|
||||
|
||||
state = DillState(_reflex_internal_init=True) # type: ignore
|
||||
state._o = Obj(_f=lambda: 42)
|
||||
state._f = lambda: 420
|
||||
|
||||
pk = state._serialize()
|
||||
|
||||
unpickled_state = BaseState._deserialize(pk)
|
||||
assert unpickled_state._f() == 420
|
||||
assert unpickled_state._o._f() == 42
|
||||
|
||||
# Some object, like generator, are still unpicklable with dill.
|
||||
state._g = (i for i in range(10))
|
||||
pk = state._serialize()
|
||||
assert len(pk) == 0
|
||||
with pytest.raises(EOFError):
|
||||
BaseState._deserialize(pk)
|
||||
|
@ -601,6 +601,7 @@ formatted_router = {
|
||||
"sum": 3.14,
|
||||
"upper": "",
|
||||
"router": formatted_router,
|
||||
"asynctest": 0,
|
||||
},
|
||||
ChildState.get_full_name(): {
|
||||
"count": 23,
|
||||
|
Loading…
Reference in New Issue
Block a user