diff --git a/.github/workflows/integration_app_harness.yml b/.github/workflows/integration_app_harness.yml index 9644e0a19..c86893556 100644 --- a/.github/workflows/integration_app_harness.yml +++ b/.github/workflows/integration_app_harness.yml @@ -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 diff --git a/poetry.lock b/poetry.lock index 7161e6af7..71be30d76 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" @@ -1333,8 +1348,8 @@ files = [ [package.dependencies] numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.22.4", markers = "python_version < \"3.11\""}, ] python-dateutil = ">=2.8.2" @@ -1652,8 +1667,8 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -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" @@ -3047,4 +3048,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "c5da15520cef58124f6699007c81158036840469d4f9972592d72bd456c45e7e" +content-hash = "e03374b85bf10f0a7bb857969b2d6714f25affa63e14a48a88be9fa154b24326" diff --git a/pyproject.toml b/pyproject.toml index 3fe6041f0..2635e1156 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,6 +65,7 @@ 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" diff --git a/reflex/.templates/jinja/web/pages/_app.js.jinja2 b/reflex/.templates/jinja/web/pages/_app.js.jinja2 index c893e19e2..21cfd921a 100644 --- a/reflex/.templates/jinja/web/pages/_app.js.jinja2 +++ b/reflex/.templates/jinja/web/pages/_app.js.jinja2 @@ -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}}"; diff --git a/reflex/.templates/jinja/web/utils/context.js.jinja2 b/reflex/.templates/jinja/web/utils/context.js.jinja2 index 8faecd9d2..2428cfa9d 100644 --- a/reflex/.templates/jinja/web/utils/context.js.jinja2 +++ b/reflex/.templates/jinja/web/utils/context.js.jinja2 @@ -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 = () => [] diff --git a/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js b/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js index 04df06059..dd7886c89 100644 --- a/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js +++ b/reflex/.templates/web/components/reflex/radix_themes_color_mode_provider.js @@ -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; } diff --git a/reflex/.templates/web/jsconfig.json b/reflex/.templates/web/jsconfig.json index 3c8a3257f..3fcb35ba6 100644 --- a/reflex/.templates/web/jsconfig.json +++ b/reflex/.templates/web/jsconfig.json @@ -2,7 +2,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { + "$/*": ["*"], "@/*": ["public/*"] } } -} \ No newline at end of file +} diff --git a/reflex/.templates/web/utils/state.js b/reflex/.templates/web/utils/state.js index 24092f235..e577df67d 100644 --- a/reflex/.templates/web/utils/state.js +++ b/reflex/.templates/web/utils/state.js @@ -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. diff --git a/reflex/app.py b/reflex/app.py index 037659fd6..a50890aa6 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -679,7 +679,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) } diff --git a/reflex/compiler/compiler.py b/reflex/compiler/compiler.py index 909299635..4db4679d5 100644 --- a/reflex/compiler/compiler.py +++ b/reflex/compiler/compiler.py @@ -67,8 +67,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( @@ -228,7 +228,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 +315,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( diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 40640faa6..29398da87 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -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]}" ) diff --git a/reflex/components/component.py b/reflex/components/component.py index d5649ba8f..f03a68e47 100644 --- a/reflex/components/component.py +++ b/reflex/components/component.py @@ -38,6 +38,7 @@ from reflex.constants import ( ) from reflex.constants.compiler import SpecialAttributes from reflex.event import ( + EventCallback, EventChain, EventChainVar, EventHandler, @@ -1126,6 +1127,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 @@ -1305,7 +1308,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`. @@ -1662,7 +1667,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 @@ -2230,7 +2235,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) ] } diff --git a/reflex/components/core/banner.py b/reflex/components/core/banner.py index 29897cffa..8a37b0bf7 100644 --- a/reflex/components/core/banner.py +++ b/reflex/components/core/banner.py @@ -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, diff --git a/reflex/components/core/client_side_routing.py b/reflex/components/core/client_side_routing.py index efa622f81..342c69632 100644 --- a/reflex/components/core/client_side_routing.py +++ b/reflex/components/core/client_side_routing.py @@ -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]: diff --git a/reflex/components/core/clipboard.py b/reflex/components/core/clipboard.py index 88aa2d145..cce0af4a7 100644 --- a/reflex/components/core/clipboard.py +++ b/reflex/components/core/clipboard.py @@ -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 ), } diff --git a/reflex/components/core/cond.py b/reflex/components/core/cond.py index 1590875d3..e0c47f0fe 100644 --- a/reflex/components/core/cond.py +++ b/reflex/components/core/cond.py @@ -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")], } diff --git a/reflex/components/core/debounce.py b/reflex/components/core/debounce.py index 07ad1d69e..86efb7dcd 100644 --- a/reflex/components/core/debounce.py +++ b/reflex/components/core/debounce.py @@ -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): diff --git a/reflex/components/core/upload.py b/reflex/components/core/upload.py index 89665bb31..c454cba7d 100644 --- a/reflex/components/core/upload.py +++ b/reflex/components/core/upload.py @@ -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" diff --git a/reflex/components/core/upload.pyi b/reflex/components/core/upload.pyi index 5bbae892f..43ce3b63d 100644 --- a/reflex/components/core/upload.pyi +++ b/reflex/components/core/upload.pyi @@ -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) diff --git a/reflex/components/datadisplay/dataeditor.py b/reflex/components/datadisplay/dataeditor.py index b9bd4cebf..1caf053b5 100644 --- a/reflex/components/datadisplay/dataeditor.py +++ b/reflex/components/datadisplay/dataeditor.py @@ -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 ), } diff --git a/reflex/components/dynamic.py b/reflex/components/dynamic.py index 9c498fb61..e391c9fd0 100644 --- a/reflex/components/dynamic.py +++ b/reflex/components/dynamic.py @@ -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": [ diff --git a/reflex/components/el/elements/forms.py b/reflex/components/el/elements/forms.py index a343991d5..7cb776ee9 100644 --- a/reflex/components/el/elements/forms.py +++ b/reflex/components/el/elements/forms.py @@ -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 diff --git a/reflex/components/el/elements/forms.pyi b/reflex/components/el/elements/forms.pyi index 135291213..2f889bc38 100644 --- a/reflex/components/el/elements/forms.pyi +++ b/reflex/components/el/elements/forms.pyi @@ -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`. """ ... diff --git a/reflex/components/radix/themes/base.py b/reflex/components/radix/themes/base.py index e41c5e7b0..acca1dce8 100644 --- a/reflex/components/radix/themes/base.py +++ b/reflex/components/radix/themes/base.py @@ -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 diff --git a/reflex/components/sonner/toast.py b/reflex/components/sonner/toast.py index 02c320ac6..65f1157d3 100644 --- a/reflex/components/sonner/toast.py +++ b/reflex/components/sonner/toast.py @@ -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)], } ), diff --git a/reflex/config.py b/reflex/config.py index eb319c5dd..ba86d911d 100644 --- a/reflex/config.py +++ b/reflex/config.py @@ -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 diff --git a/reflex/constants/compiler.py b/reflex/constants/compiler.py index e7806eb04..3afbe3c7a 100644 --- a/reflex/constants/compiler.py +++ b/reflex/constants/compiler.py @@ -118,8 +118,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)], } diff --git a/reflex/event.py b/reflex/event.py index cfe40ef9a..8b75fc01f 100644 --- a/reflex/event.py +++ b/reflex/event.py @@ -16,6 +16,7 @@ from typing import ( Generic, List, Optional, + Sequence, Tuple, Type, TypeVar, @@ -389,7 +390,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[Callable] = dataclasses.field(default=None) @@ -1445,13 +1448,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") @@ -1473,55 +1471,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: @@ -1548,6 +1564,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. @@ -1560,6 +1579,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.""" diff --git a/reflex/experimental/client_state.py b/reflex/experimental/client_state.py index c7b2260a1..698829d33 100644 --- a/reflex/experimental/client_state.py +++ b/reflex/experimental/client_state.py @@ -21,7 +21,7 @@ NoValue = object() _refs_import = { - f"/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], + f"$/{constants.Dirs.STATE_PATH}": [ImportVar(tag="refs")], } diff --git a/reflex/model.py b/reflex/model.py index b269044c5..5f5e8647d 100644 --- a/reflex/model.py +++ b/reflex/model.py @@ -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." ) diff --git a/reflex/state.py b/reflex/state.py index 9c264c5d6..8e6a3985d 100644 --- a/reflex/state.py +++ b/reflex/state.py @@ -218,6 +218,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 +227,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) @@ -2053,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( @@ -2725,9 +2747,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 diff --git a/reflex/style.py b/reflex/style.py index 8e24e9b6b..f0ee8c6a7 100644 --- a/reflex/style.py +++ b/reflex/style.py @@ -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")], } diff --git a/reflex/testing.py b/reflex/testing.py index 414b49c53..60f3b95ee 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -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) diff --git a/reflex/utils/imports.py b/reflex/utils/imports.py index 8f53ed07a..bd422ecc0 100644 --- a/reflex/utils/imports.py +++ b/reflex/utils/imports.py @@ -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( ( diff --git a/reflex/vars/base.py b/reflex/vars/base.py index 6df8eec6f..2007bc091 100644 --- a/reflex/vars/base.py +++ b/reflex/vars/base.py @@ -217,7 +217,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 +956,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 +2530,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}, diff --git a/reflex/vars/number.py b/reflex/vars/number.py index 77c728d13..e403e63e4 100644 --- a/reflex/vars/number.py +++ b/reflex/vars/number.py @@ -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")], } diff --git a/tests/integration/tests_playwright/test_stateless_app.py b/tests/integration/tests_playwright/test_stateless_app.py new file mode 100644 index 000000000..129b69dc7 --- /dev/null +++ b/tests/integration/tests_playwright/test_stateless_app.py @@ -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") diff --git a/tests/units/components/core/test_banner.py b/tests/units/components/core/test_banner.py index 02b030902..7add913ea 100644 --- a/tests/units/components/core/test_banner.py +++ b/tests/units/components/core/test_banner.py @@ -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", ) ) diff --git a/tests/units/test_config.py b/tests/units/test_config.py index c4a143bc5..1027042c9 100644 --- a/tests/units/test_config.py +++ b/tests/units/test_config.py @@ -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", [ diff --git a/tests/units/test_state.py b/tests/units/test_state.py index 161f01f94..c309621e4 100644 --- a/tests/units/test_state.py +++ b/tests/units/test_state.py @@ -105,6 +105,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: @@ -128,6 +129,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.""" @@ -312,6 +321,7 @@ def test_class_vars(test_state): "upper", "fig", "dt", + "asynctest", } @@ -732,6 +742,7 @@ def test_reset(test_state, child_state): "mapping", "dt", "_backend", + "asynctest", } # The dirty vars should be reset. @@ -3180,6 +3191,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", @@ -3315,3 +3333,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) diff --git a/tests/units/utils/test_format.py b/tests/units/utils/test_format.py index 17485d52e..f8b605541 100644 --- a/tests/units/utils/test_format.py +++ b/tests/units/utils/test_format.py @@ -601,6 +601,7 @@ formatted_router = { "sum": 3.14, "upper": "", "router": formatted_router, + "asynctest": 0, }, ChildState.get_full_name(): { "count": 23,