diff --git a/docs/DEBUGGING.md b/docs/DEBUGGING.md new file mode 100644 index 0000000000..8eef6c64c6 --- /dev/null +++ b/docs/DEBUGGING.md @@ -0,0 +1,28 @@ +# Debugging + +It is possible to run Reflex apps in dev mode under a debugger. + +1. Run Reflex as a module: `python -m reflex run --env dev` +2. Set current working directory to the dir containing `rxconfig.py` + +## VSCode + +The following launch configuration can be used to interactively debug a Reflex +app with breakpoints. + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Reflex App", + "type": "python", + "request": "launch", + "module": "reflex", + "args": "run --env dev", + "justMyCode": true, + "cwd": "${fileDirname}/.." + } + ] +} +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e58b983e24..2929b33f9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "reflex" -version = "0.2.5" +version = "0.2.6" description = "Web apps in pure Python." license = "Apache-2.0" authors = [ diff --git a/reflex/components/component.py b/reflex/components/component.py index 0f4756fd0e..2b8f89bf38 100644 --- a/reflex/components/component.py +++ b/reflex/components/component.py @@ -66,6 +66,10 @@ class Component(Base, ABC): # components that cannot be children invalid_children: List[str] = [] + + # components that are only allowed as children + valid_children: List[str] = [] + # custom attribute custom_attrs: Dict[str, str] = {} @@ -103,9 +107,10 @@ def __init__(self, *args, **kwargs): TypeError: If an invalid prop is passed. """ # Set the id and children initially. + children = kwargs.get("children", []) initial_kwargs = { "id": kwargs.get("id"), - "children": kwargs.get("children", []), + "children": children, **{ prop: Var.create(kwargs[prop]) for prop in self.get_initial_props() @@ -114,6 +119,8 @@ def __init__(self, *args, **kwargs): } super().__init__(**initial_kwargs) + self._validate_component_children(children) + # Get the component fields, triggers, and props. fields = self.get_fields() triggers = self.get_triggers() @@ -381,6 +388,7 @@ def create(cls, *children, **props) -> Component: else Bare.create(contents=Var.create(child, is_string=True)) for child in children ] + return cls(children=children, **props) def _add_style(self, style): @@ -435,30 +443,44 @@ def render(self) -> Dict: ), autofocus=self.autofocus, ) - self._validate_component_children( - rendered_dict["name"], rendered_dict["children"] - ) return rendered_dict - def _validate_component_children(self, comp_name: str, children: List[Dict]): + def _validate_component_children(self, children: List[Component]): """Validate the children components. Args: - comp_name: name of the component. - children: list of children components. + children: The children of the component. - Raises: - ValueError: when an unsupported component is matched. """ - if not self.invalid_children: + if not self.invalid_children and not self.valid_children: return - for child in children: - name = child["name"] - if name in self.invalid_children: + + comp_name = type(self).__name__ + + def validate_invalid_child(child_name): + if child_name in self.invalid_children: raise ValueError( - f"The component `{comp_name.lower()}` cannot have `{name.lower()}` as a child component" + f"The component `{comp_name}` cannot have `{child_name}` as a child component" ) + def validate_valid_child(child_name): + if child_name not in self.valid_children: + valid_child_list = ", ".join( + [f"`{v_child}`" for v_child in self.valid_children] + ) + raise ValueError( + f"The component `{comp_name}` only allows the components: {valid_child_list} as children. Got `{child_name}` instead." + ) + + for child in children: + name = type(child).__name__ + + if self.invalid_children: + validate_invalid_child(name) + + if self.valid_children: + validate_valid_child(name) + def _get_custom_code(self) -> Optional[str]: """Get custom code for the component. diff --git a/reflex/components/forms/button.py b/reflex/components/forms/button.py index 3ec573bd93..5c336f8e89 100644 --- a/reflex/components/forms/button.py +++ b/reflex/components/forms/button.py @@ -1,4 +1,5 @@ """A button component.""" +from typing import List from reflex.components.libs.chakra import ChakraComponent from reflex.vars import Var @@ -42,6 +43,9 @@ class Button(ChakraComponent): # The type of button. type_: Var[str] + # Components that are not allowed as children. + invalid_children: List[str] = ["Button", "MenuButton"] + class ButtonGroup(ChakraComponent): """A group of buttons.""" diff --git a/reflex/components/forms/editable.py b/reflex/components/forms/editable.py index 1d0e303a4e..cf004d2ffa 100644 --- a/reflex/components/forms/editable.py +++ b/reflex/components/forms/editable.py @@ -2,8 +2,6 @@ from typing import Dict -from reflex.components.component import Component -from reflex.components.forms.debounce import DebounceInput from reflex.components.libs.chakra import ChakraComponent from reflex.event import EVENT_ARG from reflex.vars import Var @@ -38,29 +36,6 @@ class Editable(ChakraComponent): # The initial value of the Editable in both edit and preview mode. default_value: Var[str] - @classmethod - def create(cls, *children, **props) -> Component: - """Create an Editable component. - - Args: - children: The children of the component. - props: The properties of the component. - - Returns: - The component. - """ - if ( - isinstance(props.get("value"), Var) and props.get("on_change") - ) or "debounce_timeout" in props: - # Create a debounced input if the user requests full control to avoid typing jank - # Currently default to 50ms, which appears to be a good balance - debounce_timeout = props.pop("debounce_timeout", 50) - return DebounceInput.create( - super().create(*children, **props), - debounce_timeout=debounce_timeout, - ) - return super().create(*children, **props) - def get_controlled_triggers(self) -> Dict[str, Var]: """Get the event triggers that pass the component's value to the handler. diff --git a/reflex/components/overlay/menu.py b/reflex/components/overlay/menu.py index 2c23407b0e..f477b36cc5 100644 --- a/reflex/components/overlay/menu.py +++ b/reflex/components/overlay/menu.py @@ -1,6 +1,6 @@ """Menu components.""" -from typing import Set +from typing import List, Set from reflex.components.component import Component from reflex.components.libs.chakra import ChakraComponent @@ -100,6 +100,9 @@ class MenuButton(ChakraComponent): # The variant of the menu button. variant: Var[str] + # Components that are not allowed as children. + invalid_children: List[str] = ["Button", "MenuButton"] + # The tag to use for the menu button. as_: Var[str] diff --git a/reflex/reflex.py b/reflex/reflex.py index db29e5c2b8..b76a7527b2 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -1,7 +1,7 @@ """Reflex CLI to create, run, and deploy apps.""" +import atexit import os -import signal from pathlib import Path import httpx @@ -164,16 +164,18 @@ def run( telemetry.send(f"run-{env.value}", config.telemetry_enabled) # Display custom message when there is a keyboard interrupt. - signal.signal(signal.SIGINT, processes.catch_keyboard_interrupt) + atexit.register(processes.atexit_handler) # Run the frontend and backend together. commands = [] if frontend: setup_frontend(Path.cwd()) commands.append((frontend_cmd, Path.cwd(), frontend_port)) - if backend: + if backend and env == constants.Env.PROD: commands.append((backend_cmd, app.__name__, backend_host, backend_port)) - processes.run_concurrently(*commands) + with processes.run_concurrently_context(*commands): + if env == constants.Env.DEV: + backend_cmd(app.__name__, backend_host, int(backend_port)) @cli.command() @@ -262,17 +264,6 @@ def export( # Post a telemetry event. telemetry.send("export", config.telemetry_enabled) - if zipping: - console.log( - """Backend & Frontend compiled. See [green bold]backend.zip[/green bold] - and [green bold]frontend.zip[/green bold].""" - ) - else: - console.log( - """Backend & Frontend compiled. See [green bold]app[/green bold] - and [green bold].web/_static[/green bold] directories.""" - ) - db_cli = typer.Typer() diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 9411230b6f..ee47d3f719 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -6,9 +6,13 @@ import os import random import subprocess +import zipfile +from enum import Enum from pathlib import Path from typing import Optional, Union +from rich.progress import MofNCompleteColumn, Progress, TimeElapsedColumn + from reflex import constants from reflex.config import get_config from reflex.utils import console, path_ops, prerequisites, processes @@ -85,6 +89,65 @@ def generate_sitemap_config(deploy_url: str): f.write(templates.SITEMAP_CONFIG(config=config)) +class _ComponentName(Enum): + BACKEND = "Backend" + FRONTEND = "Frontend" + + +def _zip( + component_name: _ComponentName, + target: str, + root_dir: str, + dirs_to_exclude: set[str] | None = None, + files_to_exclude: set[str] | None = None, +) -> None: + """Zip utility function. + + Args: + component_name: The name of the component: backend or frontend. + target: The target zip file. + root_dir: The root directory to zip. + dirs_to_exclude: The directories to exclude. + files_to_exclude: The files to exclude. + + """ + dirs_to_exclude = dirs_to_exclude or set() + files_to_exclude = files_to_exclude or set() + files_to_zip: list[str] = [] + # Traverse the root directory in a top-down manner. In this traversal order, + # we can modify the dirs list in-place to remove directories we don't want to include. + for root, dirs, files in os.walk(root_dir, topdown=True): + # Modify the dirs in-place so excluded and hidden directories are skipped in next traversal. + dirs[:] = [ + d + for d in dirs + if (basename := os.path.basename(os.path.normpath(d))) + not in dirs_to_exclude + and not basename.startswith(".") + ] + # Modify the files in-place so the hidden files are excluded. + files[:] = [f for f in files if not f.startswith(".")] + files_to_zip += [ + os.path.join(root, file) for file in files if file not in files_to_exclude + ] + + # Create a progress bar for zipping the component. + progress = Progress( + *Progress.get_default_columns()[:-1], + MofNCompleteColumn(), + TimeElapsedColumn(), + ) + task = progress.add_task( + f"Zipping {component_name.value}:", total=len(files_to_zip) + ) + + with progress, zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as zipf: + for file in files_to_zip: + console.debug(f"{target}: {file}") + progress.advance(task) + zipf.write(file, os.path.relpath(file, root_dir)) + + def export( backend: bool = True, frontend: bool = True, @@ -132,42 +195,22 @@ def export( # Zip up the app. if zip: - if os.name == "posix": - posix_export(backend, frontend) - if os.name == "nt": - nt_export(backend, frontend) - - -def nt_export(backend: bool = True, frontend: bool = True): - """Export for nt (Windows) systems. - - Args: - backend: Whether to zip up the backend app. - frontend: Whether to zip up the frontend app. - """ - cmd = r"" - if frontend: - cmd = r'''powershell -Command "Set-Location .web/_static; Compress-Archive -Path .\* -DestinationPath ..\..\frontend.zip -Force"''' - os.system(cmd) - if backend: - cmd = r'''powershell -Command "Get-ChildItem -File | Where-Object { $_.Name -notin @('.web', 'assets', 'frontend.zip', 'backend.zip') } | Compress-Archive -DestinationPath backend.zip -Update"''' - os.system(cmd) - - -def posix_export(backend: bool = True, frontend: bool = True): - """Export for posix (Linux, OSX) systems. - - Args: - backend: Whether to zip up the backend app. - frontend: Whether to zip up the frontend app. - """ - cmd = r"" - if frontend: - cmd = r"cd .web/_static && zip -r ../../frontend.zip ./*" - os.system(cmd) - if backend: - cmd = r"zip -r backend.zip ./* -x .web/\* ./assets\* ./frontend.zip\* ./backend.zip\*" - os.system(cmd) + files_to_exclude = {constants.FRONTEND_ZIP, constants.BACKEND_ZIP} + if frontend: + _zip( + component_name=_ComponentName.FRONTEND, + target=constants.FRONTEND_ZIP, + root_dir=".web/_static", + files_to_exclude=files_to_exclude, + ) + if backend: + _zip( + component_name=_ComponentName.BACKEND, + target=constants.BACKEND_ZIP, + root_dir=".", + dirs_to_exclude={"assets", "__pycache__"}, + files_to_exclude=files_to_exclude, + ) def setup_frontend( diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index d675c47e0a..d2cb424458 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -7,6 +7,8 @@ import sys from pathlib import Path +import uvicorn + from reflex import constants from reflex.config import get_config from reflex.utils import console, path_ops, prerequisites, processes @@ -95,22 +97,13 @@ def run_backend( port: The app port loglevel: The log level. """ - processes.new_process( - [ - "uvicorn", - f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}", - "--host", - host, - "--port", - str(port), - "--log-level", - loglevel.value, - "--reload", - "--reload-dir", - app_name.split(".")[0], - ], - run=True, - show_logs=True, + uvicorn.run( + app=f"{app_name}:{constants.APP_VAR}.{constants.API_VAR}", + host=host, + port=port, + log_level=loglevel.value, + reload=True, + reload_dirs=[app_name.split(".")[0]], ) diff --git a/reflex/utils/processes.py b/reflex/utils/processes.py index eb1e9c2d78..efcd449918 100644 --- a/reflex/utils/processes.py +++ b/reflex/utils/processes.py @@ -8,7 +8,7 @@ import signal import subprocess from concurrent import futures -from typing import Callable, List, Optional, Tuple, Union +from typing import Callable, Generator, List, Optional, Tuple, Union import psutil import typer @@ -145,13 +145,23 @@ def new_process(args, run: bool = False, show_logs: bool = False, **kwargs): return fn(args, **kwargs) -def run_concurrently(*fns: Union[Callable, Tuple]): +@contextlib.contextmanager +def run_concurrently_context( + *fns: Union[Callable, Tuple] +) -> Generator[list[futures.Future], None, None]: """Run functions concurrently in a thread pool. - Args: *fns: The functions to run. + + Yields: + The futures for the functions. """ + # If no functions are provided, yield an empty list and return. + if not fns: + yield [] + return + # Convert the functions to tuples. fns = [fn if isinstance(fn, tuple) else (fn,) for fn in fns] # type: ignore @@ -160,11 +170,24 @@ def run_concurrently(*fns: Union[Callable, Tuple]): # Submit the tasks. tasks = [executor.submit(*fn) for fn in fns] # type: ignore + # Yield control back to the main thread while tasks are running. + yield tasks + # Get the results in the order completed to check any exceptions. for task in futures.as_completed(tasks): task.result() +def run_concurrently(*fns: Union[Callable, Tuple]) -> None: + """Run functions concurrently in a thread pool. + + Args: + *fns: The functions to run. + """ + with run_concurrently_context(*fns): + pass + + def stream_logs( message: str, process: subprocess.Popen, @@ -247,11 +270,6 @@ def show_progress(message: str, process: subprocess.Popen, checkpoints: List[str break -def catch_keyboard_interrupt(signal, frame): - """Display a custom message with the current time when exiting an app. - - Args: - signal: The keyboard interrupt signal. - frame: The current stack frame. - """ +def atexit_handler(): + """Display a custom message with the current time when exiting an app.""" console.log("Reflex app stopped.") diff --git a/reflex/vars.py b/reflex/vars.py index 2db292c1e6..770ab85988 100644 --- a/reflex/vars.py +++ b/reflex/vars.py @@ -1015,6 +1015,16 @@ def append(self, *args, **kwargs): super().append(*args, **kwargs) self._reassign_field() + def insert(self, *args, **kwargs): + """Insert. + + Args: + args: The args passed. + kwargs: The kwargs passed. + """ + super().insert(*args, **kwargs) + self._reassign_field() + def __setitem__(self, *args, **kwargs): """Set item. diff --git a/tests/components/forms/test_editable.py b/tests/components/forms/test_editable.py deleted file mode 100644 index 833239a5e6..0000000000 --- a/tests/components/forms/test_editable.py +++ /dev/null @@ -1,48 +0,0 @@ -import reflex as rx - - -class S(rx.State): - """Example state for debounce tests.""" - - value: str = "" - - def on_change(self, v: str): - """Dummy on_change handler. - - Args: - v: The changed value. - """ - pass - - -def test_full_control_implicit_debounce_editable(): - """DebounceInput is used when value and on_change are used together.""" - tag = rx.editable( - value=S.value, - on_change=S.on_change, - )._render() - assert tag.props["debounceTimeout"].name == "50" - assert len(tag.props["onChange"].events) == 1 - assert tag.props["onChange"].events[0].handler == S.on_change - assert tag.contents == "" - - -def test_full_control_explicit_debounce_editable(): - """DebounceInput is used when user specifies `debounce_timeout`.""" - tag = rx.editable( - on_change=S.on_change, - debounce_timeout=33, - )._render() - assert tag.props["debounceTimeout"].name == "33" - assert len(tag.props["onChange"].events) == 1 - assert tag.props["onChange"].events[0].handler == S.on_change - assert tag.contents == "" - - -def test_editable_no_debounce(): - """DebounceInput is not used for regular editable.""" - tag = rx.editable( - placeholder=S.value, - )._render() - assert "debounceTimeout" not in tag.props - assert tag.contents == "" diff --git a/tests/components/test_component.py b/tests/components/test_component.py index 23274b80dc..c676e543af 100644 --- a/tests/components/test_component.py +++ b/tests/components/test_component.py @@ -121,13 +121,47 @@ def component5() -> Type[Component]: """ class TestComponent5(Component): - tag = "Tag" + tag = "RandomComponent" invalid_children: List[str] = ["Text"] + valid_children: List[str] = ["Text"] + return TestComponent5 +@pytest.fixture +def component6() -> Type[Component]: + """A test component. + + Returns: + A test component. + """ + + class TestComponent6(Component): + tag = "RandomComponent" + + invalid_children: List[str] = ["Text"] + + return TestComponent6 + + +@pytest.fixture +def component7() -> Type[Component]: + """A test component. + + Returns: + A test component. + """ + + class TestComponent7(Component): + tag = "RandomComponent" + + valid_children: List[str] = ["Text"] + + return TestComponent7 + + @pytest.fixture def on_click1() -> EventHandler: """A sample on click function. @@ -461,16 +495,40 @@ def test_get_hooks_nested2(component3, component4): ) -def test_unsupported_child_components(component5): - """Test that a value error is raised when an unsupported component is provided as a child. +@pytest.mark.parametrize("fixture", ["component5", "component6"]) +def test_unsupported_child_components(fixture, request): + """Test that a value error is raised when an unsupported component (a child component found in the + component's invalid children list) is provided as a child. + + Args: + fixture: the test component as a fixture. + request: Pytest request. + """ + component = request.getfixturevalue(fixture) + with pytest.raises(ValueError) as err: + comp = component.create(rx.text("testing component")) + comp.render() + assert ( + err.value.args[0] + == f"The component `{component.__name__}` cannot have `Text` as a child component" + ) + + +@pytest.mark.parametrize("fixture", ["component5", "component7"]) +def test_component_with_only_valid_children(fixture, request): + """Test that a value error is raised when an unsupported component (a child component not found in the + component's valid children list) is provided as a child. Args: - component5: the test component + fixture: the test component as a fixture. + request: Pytest request. """ + component = request.getfixturevalue(fixture) with pytest.raises(ValueError) as err: - comp = component5.create(rx.text("testing component")) + comp = component.create(rx.box("testing component")) comp.render() assert ( err.value.args[0] - == f"The component `tag` cannot have `text` as a child component" + == f"The component `{component.__name__}` only allows the components: `Text` as children. " + f"Got `Box` instead." )