From 3f804cae51cfc4841059e9c23f006147725a5f76 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Fri, 16 Jun 2023 11:47:59 -0600 Subject: [PATCH 01/29] Update pull_request_template.md --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d889c598a..cf95abff3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -*By submitting this pull request you agree that all contributions to this project are made under the MIT license.* +By submitting this pull request you agree that all contributions to this project are made under the MIT license. ## Issues From 754a6198b04f28e126da8c55c3a03169c1cb29ec Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 2 Jul 2023 16:15:14 -0600 Subject: [PATCH 02/29] minor improvements to project setup (#1082) * minor improvements to project setup * install docs + fix ruff errors * fix lint * fixes first --- .gitignore | 1 + .pre-commit-config.yaml | 15 ++++----- docs/pyproject.toml | 2 +- docs/source/_exts/reactpy_example.py | 4 +-- docs/source/_exts/reactpy_view.py | 6 ++-- docs/source/about/contributor-guide.rst | 22 +++++++++++++ src/py/reactpy/reactpy/core/events.py | 2 +- src/py/reactpy/reactpy/core/layout.py | 6 ++-- tasks.py | 41 ++++++++++++++++++++----- 9 files changed, 74 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 20c041e11..946bff43f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ .jupyter # --- Python --- +.hatch .venv venv MANIFEST diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4a66f532..ae748a41d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,15 +3,9 @@ repos: hooks: - id: lint-py-fix name: Fix Python Lint - entry: hatch run lint-py --fix - language: system - pass_filenames: false - - repo: local - hooks: - - id: lint-py-check - name: Check Python Lint entry: hatch run lint-py language: system + args: [--fix] pass_filenames: false - repo: local hooks: @@ -20,6 +14,13 @@ repos: entry: hatch run lint-js --fix language: system pass_filenames: false + - repo: local + hooks: + - id: lint-py-check + name: Check Python Lint + entry: hatch run lint-py + language: system + pass_filenames: false - repo: local hooks: - id: lint-js-check diff --git a/docs/pyproject.toml b/docs/pyproject.toml index d2f47c577..f47b0e944 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "docs" +name = "docs_app" version = "0.0.0" description = "docs" authors = ["rmorshea "] diff --git a/docs/source/_exts/reactpy_example.py b/docs/source/_exts/reactpy_example.py index c6b054c07..1171d32e0 100644 --- a/docs/source/_exts/reactpy_example.py +++ b/docs/source/_exts/reactpy_example.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from typing import Any +from typing import Any, ClassVar from docs_app.examples import ( SOURCE_DIR, @@ -21,7 +21,7 @@ class WidgetExample(SphinxDirective): required_arguments = 1 _next_id = 0 - option_spec = { + option_spec: ClassVar[dict[str, Any]] = { "result-is-default-tab": directives.flag, "activate-button": directives.flag, } diff --git a/docs/source/_exts/reactpy_view.py b/docs/source/_exts/reactpy_view.py index 7a2bf85a4..6a583998f 100644 --- a/docs/source/_exts/reactpy_view.py +++ b/docs/source/_exts/reactpy_view.py @@ -1,7 +1,5 @@ import os -import sys - -print(sys.path) +from typing import Any, ClassVar from docs_app.examples import get_normalized_example_name from docutils.nodes import raw @@ -20,7 +18,7 @@ class IteractiveWidget(SphinxDirective): required_arguments = 1 _next_id = 0 - option_spec = { + option_spec: ClassVar[dict[str, Any]] = { "activate-button": directives.flag, "margin": float, } diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index b44be9b7e..f9fb93154 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -118,6 +118,26 @@ Then, you should be able to activate your development environment with: hatch shell +From within the shell, to install the projects in this repository, you should then run: + +.. code-block:: bash + + invoke env + +Project Structure +----------------- + +This repository is set up to be able to manage many applications and libraries written +in a variety of languages. All projects can be found under the ``src`` directory: + +- ``src/py/{project}`` - Python packages +- ``src/js/app`` - ReactPy's built-in JS client +- ``src/js/packages/{project}`` - JS packages + +At the root of the repository is a ``pyproject.toml`` file that contains scripts and +their respective dependencies for managing all other projects. Most of these global +scripts can be run via ``hatch run ...`` however, for more complex scripting tasks, we +rely on Invoke_. Scripts implements with Invoke can be found in ``tasks.py``. Running The Tests ----------------- @@ -308,6 +328,8 @@ you should refer to their respective documentation in the links below: .. Links .. ===== +.. _Hatch: https://hatch.pypa.io/ +.. _Invoke: https://www.pyinvoke.org/ .. _Google Chrome: https://www.google.com/chrome/ .. _Docker: https://docs.docker.com/get-docker/ .. _Git: https://git-scm.com/book/en/v2/Getting-Started-Installing-Git diff --git a/src/py/reactpy/reactpy/core/events.py b/src/py/reactpy/reactpy/core/events.py index acc2077b2..cd5de3228 100644 --- a/src/py/reactpy/reactpy/core/events.py +++ b/src/py/reactpy/reactpy/core/events.py @@ -21,7 +21,7 @@ def event( @overload def event( - function: Literal[None] = None, + function: Literal[None] = ..., *, stop_propagation: bool = ..., prevent_default: bool = ..., diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 7c24e5ef7..df24a9a0a 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -37,16 +37,16 @@ class Layout: """Responsible for "rendering" components. That is, turning them into VDOM.""" - __slots__ = [ + __slots__: tuple[str, ...] = ( "root", "_event_handlers", "_rendering_queue", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", - ] + ) if not hasattr(abc.ABC, "__weakref__"): # nocov - __slots__.append("__weakref__") + __slots__ += ("__weakref__",) def __init__(self, root: ComponentType) -> None: super().__init__() diff --git a/tasks.py b/tasks.py index 4bbfe52e2..1fcd3c0a3 100644 --- a/tasks.py +++ b/tasks.py @@ -77,14 +77,21 @@ def env(context: Context): @task def env_py(context: Context): """Install Python development environment""" - for py_proj in PY_PROJECTS: - py_proj_toml = toml.load(py_proj / "pyproject.toml") - hatch_default_env = py_proj_toml["tool"]["hatch"]["envs"].get("default", {}) - hatch_default_features = hatch_default_env.get("features", []) - hatch_default_deps = hatch_default_env.get("dependencies", []) + for py_proj in [ + DOCS_DIR, + # Docs installs non-editable versions of packages - ensure + # we overwrite that by installing projects afterwards. + *PY_PROJECTS, + ]: + py_proj_toml_tools = toml.load(py_proj / "pyproject.toml")["tool"] + if "hatch" in py_proj_toml_tools: + install_func = install_hatch_project + elif "poetry" in py_proj_toml_tools: + install_func = install_poetry_project + else: + raise Exit(f"Unknown project type: {py_proj}") with context.cd(py_proj): - context.run(f"pip install '.[{','.join(hatch_default_features)}]'") - context.run(f"pip install {' '.join(map(repr, hatch_default_deps))}") + install_func(context, py_proj) @task @@ -103,6 +110,7 @@ def lint_py(context: Context, fix: bool = False): """Run linters and type checkers""" if fix: context.run("ruff --fix .") + context.run("black .") else: context.run("ruff .") context.run("black --check --diff .") @@ -417,3 +425,22 @@ def publish(dry_run: bool): ) return publish + + +def install_hatch_project(context: Context, path: Path) -> None: + py_proj_toml = toml.load(path / "pyproject.toml") + hatch_default_env = py_proj_toml["tool"]["hatch"]["envs"].get("default", {}) + hatch_default_features = hatch_default_env.get("features", []) + hatch_default_deps = hatch_default_env.get("dependencies", []) + context.run(f"pip install -e '.[{','.join(hatch_default_features)}]'") + context.run(f"pip install {' '.join(map(repr, hatch_default_deps))}") + + +def install_poetry_project(context: Context, path: Path) -> None: + # install dependencies from poetry into the current environment - not in Poetry's venv + poetry_lock = toml.load(path / "poetry.lock") + packages_to_install = [ + f"{package['name']}=={package['version']}" for package in poetry_lock["package"] + ] + context.run("pip install -e .") + context.run(f"pip install {' '.join(packages_to_install)}") From f065655ae1fc8f93a0ca05769be19e304f607dfa Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 2 Jul 2023 16:31:49 -0600 Subject: [PATCH 03/29] Fix publish (#1064) * use env instead of env_dict * check mypy on tasks --- pyproject.toml | 13 ++++++++++++- tasks.py | 18 +++++++++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4899a495..27e3a937d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ "flake8", "flake8-pyproject", "reactpy-flake8 >=0.7", + # types + "mypy", + "types-toml", # publish "semver >=2, <3", "twine", @@ -42,7 +45,15 @@ test-docs = "invoke test-docs" target-version = ["py39"] line-length = 88 -# --- Flake8 ---------------------------------------------------------------------------- +# --- MyPy ----------------------------------------------------------------------------- + +[tool.mypy] +ignore_missing_imports = true +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true + +# --- Flake8 --------------------------------------------------------------------------- [tool.flake8] select = ["RPY"] # only need to check with reactpy-flake8 diff --git a/tasks.py b/tasks.py index 1fcd3c0a3..65f75b208 100644 --- a/tasks.py +++ b/tasks.py @@ -15,6 +15,7 @@ from invoke import task from invoke.context import Context from invoke.exceptions import Exit +from invoke.runners import Result # --- Typing Preamble ------------------------------------------------------------------ @@ -286,7 +287,9 @@ def get_packages(context: Context) -> dict[str, PackageInfo]: def make_py_pkg_info(context: Context, pkg_dir: Path) -> PackageInfo: with context.cd(pkg_dir): - proj_metadata = json.loads(context.run("hatch project metadata").stdout) + proj_metadata = json.loads( + ensure_result(context, "hatch project metadata").stdout + ) return PackageInfo( name=proj_metadata["name"], path=pkg_dir, @@ -329,7 +332,9 @@ def get_current_tags(context: Context) -> set[str]: line for line in map( str.strip, - context.run("git tag --points-at HEAD", hide=True).stdout.splitlines(), + ensure_result( + context, "git tag --points-at HEAD", hide=True + ).stdout.splitlines(), ) if line } @@ -418,7 +423,7 @@ def publish(dry_run: bool): context.run( "twine upload dist/*", - env_dict={ + env={ "TWINE_USERNAME": twine_username, "TWINE_PASSWORD": twine_password, }, @@ -444,3 +449,10 @@ def install_poetry_project(context: Context, path: Path) -> None: ] context.run("pip install -e .") context.run(f"pip install {' '.join(packages_to_install)}") + + +def ensure_result(context: Context, *args: Any, **kwargs: Any) -> Result: + result = context.run(*args, **kwargs) + if result is None: + raise Exit("Command failed") + return result From e82ffdfaa0a9eb3e30ac062dd3e9136e29b53c81 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 3 Jul 2023 21:10:31 -0600 Subject: [PATCH 04/29] Fix issue from #1081 (#1085) * identify issue from #1081 * fix the bug * update doc * make ruff happy * add changelog entry --- docs/source/about/changelog.rst | 1 + src/py/reactpy/pyproject.toml | 1 + src/py/reactpy/reactpy/core/layout.py | 2 +- src/py/reactpy/reactpy/utils.py | 2 +- src/py/reactpy/tests/test_core/test_layout.py | 60 ++++++++++ src/py/reactpy/tests/tooling/layout.py | 44 +++++++ src/py/reactpy/tests/tooling/select.py | 107 ++++++++++++++++++ 7 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 src/py/reactpy/tests/tooling/layout.py create mode 100644 src/py/reactpy/tests/tooling/select.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index a6eff8f73..a927f0fcf 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -31,6 +31,7 @@ Unreleased - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) - :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`) +- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`) v1.0.0 diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 659ddbf94..87fa7e036 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -139,6 +139,7 @@ testpaths = "tests" xfail_strict = true python_files = "*asserts.py test_*.py" asyncio_mode = "auto" +log_cli_level = "INFO" # --- MyPy ----------------------------------------------------------------------------- diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index df24a9a0a..f84cb104e 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -489,7 +489,7 @@ def _update_component_model_state( index=new_index, key=old_model_state.key, model=Ref(), # does not copy the model - patch_path=old_model_state.patch_path, + patch_path=f"{new_parent.patch_path}/children/{new_index}", children_by_key={}, targets_by_event={}, life_cycle_state=( diff --git a/src/py/reactpy/reactpy/utils.py b/src/py/reactpy/reactpy/utils.py index e5e06d98d..5624846a4 100644 --- a/src/py/reactpy/reactpy/utils.py +++ b/src/py/reactpy/reactpy/utils.py @@ -27,7 +27,7 @@ class Ref(Generic[_RefValue]): You can compare the contents for two ``Ref`` objects using the ``==`` operator. """ - __slots__ = "current" + __slots__ = ("current",) def __init__(self, initial_value: _RefValue = _UNDEFINED) -> None: if initial_value is not _UNDEFINED: diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index d2e1a8099..215e89137 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -13,6 +13,7 @@ from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout +from reactpy.core.types import State from reactpy.testing import ( HookCatcher, StaticEventHandler, @@ -20,8 +21,11 @@ capture_reactpy_logs, ) from reactpy.utils import Ref +from tests.tooling import select from tests.tooling.common import event_message, update_message from tests.tooling.hooks import use_force_render, use_toggle +from tests.tooling.layout import layout_runner +from tests.tooling.select import element_exists, find_element @pytest.fixture(autouse=True) @@ -1190,3 +1194,59 @@ def Child(): done, pending = await asyncio.wait([render_task], timeout=0.1) assert not done and pending render_task.cancel() + + +async def test_ensure_model_path_udpates(): + """ + This is regression test for a bug in which we failed to update the path of a bug + that arose when the "path" of a component within the overall model was not updated + when the component changes position amongst its siblings. This meant that when + a component whose position had changed would attempt to update the view at its old + position. + """ + + @component + def Item(item: str, all_items: State[list[str]]): + color = use_state(None) + + def deleteme(event): + all_items.set_value([i for i in all_items.value if (i != item)]) + + def colorize(event): + color.set_value("blue" if not color.value else None) + + return html.div( + {"id": item, "color": color.value}, + html.button({"on_click": colorize}, f"Color {item}"), + html.button({"on_click": deleteme}, f"Delete {item}"), + ) + + @component + def App(): + items = use_state(["A", "B", "C"]) + return html._([Item(item, items, key=item) for item in items.value]) + + async with layout_runner(reactpy.Layout(App())) as runner: + tree = await runner.render() + + # Delete item B + b, b_info = find_element(tree, select.id_equals("B")) + assert b_info.path == (0, 1, 0) + b_delete, _ = find_element(b, select.text_equals("Delete B")) + await runner.trigger(b_delete, "on_click", {}) + + tree = await runner.render() + + # Set color of item C + assert not element_exists(tree, select.id_equals("B")) + c, c_info = find_element(tree, select.id_equals("C")) + assert c_info.path == (0, 1, 0) + c_color, _ = find_element(c, select.text_equals("Color C")) + await runner.trigger(c_color, "on_click", {}) + + tree = await runner.render() + + # Ensure position and color of item C are correct + c, c_info = find_element(tree, select.id_equals("C")) + assert c_info.path == (0, 1, 0) + assert c["attributes"]["color"] == "blue" diff --git a/src/py/reactpy/tests/tooling/layout.py b/src/py/reactpy/tests/tooling/layout.py new file mode 100644 index 000000000..fe78684fe --- /dev/null +++ b/src/py/reactpy/tests/tooling/layout.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +from jsonpointer import set_pointer + +from reactpy.core.layout import Layout +from reactpy.core.types import VdomJson +from tests.tooling.common import event_message + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def layout_runner(layout: Layout) -> AsyncIterator[LayoutRunner]: + async with layout: + yield LayoutRunner(layout) + + +class LayoutRunner: + def __init__(self, layout: Layout) -> None: + self.layout = layout + self.model = {} + + async def render(self) -> VdomJson: + update = await self.layout.render() + logger.info(f"Rendering element at {update['path'] or '/'!r}") + if not update["path"]: + self.model = update["model"] + else: + self.model = set_pointer( + self.model, update["path"], update["model"], inplace=False + ) + return self.model + + async def trigger(self, element: VdomJson, event_name: str, *data: Any) -> None: + event_handler = element.get("eventHandlers", {}).get(event_name, {}) + logger.info(f"Triggering {event_name!r} with target {event_handler['target']}") + if not event_handler: + raise ValueError(f"Element has no event handler for {event_name}") + await self.layout.deliver(event_message(event_handler["target"], *data)) diff --git a/src/py/reactpy/tests/tooling/select.py b/src/py/reactpy/tests/tooling/select.py new file mode 100644 index 000000000..cf7a9c004 --- /dev/null +++ b/src/py/reactpy/tests/tooling/select.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from collections.abc import Iterator, Sequence +from dataclasses import dataclass +from typing import Callable + +from reactpy.core.types import VdomJson + +Selector = Callable[[VdomJson, "ElementInfo"], bool] + + +def id_equals(id: str) -> Selector: + return lambda element, _: element.get("attributes", {}).get("id") == id + + +def class_equals(class_name: str) -> Selector: + return ( + lambda element, _: class_name + in element.get("attributes", {}).get("class", "").split() + ) + + +def text_equals(text: str) -> Selector: + return lambda element, _: _element_text(element) == text + + +def _element_text(element: VdomJson) -> str: + if isinstance(element, str): + return element + return "".join(_element_text(child) for child in element.get("children", [])) + + +def element_exists(element: VdomJson, selector: Selector) -> bool: + return next(find_elements(element, selector), None) is not None + + +def find_element( + element: VdomJson, + selector: Selector, + *, + first: bool = False, +) -> tuple[VdomJson, ElementInfo]: + """Find an element by a selector. + + Parameters: + element: + The tree to search. + selector: + A function that returns True if the element matches. + first: + If True, return the first element found. If False, raise an error if + multiple elements are found. + + Returns: + Element info, or None if not found. + """ + find_iter = find_elements(element, selector) + found = next(find_iter, None) + if found is None: + raise ValueError("Element not found") + if not first: + try: + next(find_iter) + raise ValueError("Multiple elements found") + except StopIteration: + pass + return found + + +def find_elements( + element: VdomJson, selector: Selector +) -> Iterator[tuple[VdomJson, ElementInfo]]: + """Find an element by a selector. + + Parameters: + element: + The tree to search. + selector: + A function that returns True if the element matches. + + Returns: + Element info, or None if not found. + """ + return _find_elements(element, selector, (), ()) + + +def _find_elements( + element: VdomJson, + selector: Selector, + parents: Sequence[VdomJson], + path: Sequence[int], +) -> tuple[VdomJson, ElementInfo] | None: + info = ElementInfo(parents, path) + if selector(element, info): + yield element, info + + for index, child in enumerate(element.get("children", [])): + if isinstance(child, dict): + yield from _find_elements( + child, selector, (*parents, element), (*path, index) + ) + + +@dataclass +class ElementInfo: + parents: Sequence[VdomJson] + path: Sequence[int] From 77303a38fe4dfcca1a5fb68261379a21460b6f64 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Mon, 3 Jul 2023 20:27:52 -0700 Subject: [PATCH 05/29] `django-reactpy` -> `reactpy-django` (#1080) Co-authored-by: Ryan Morshead --- docs/source/about/contributor-guide.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index f9fb93154..73ae3f23d 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -322,7 +322,7 @@ you should refer to their respective documentation in the links below: Jupyter - `reactpy-dash `__ - ReactPy integration for Plotly Dash -- `django-reactpy `__ - ReactPy integration for +- `reactpy-django `__ - ReactPy integration for Django .. Links From 5582431ca63f944f561b90d6ca965abf1e9fa424 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 3 Jul 2023 23:28:13 -0600 Subject: [PATCH 06/29] reactpy-v1.0.2 (#1087) --- src/py/reactpy/reactpy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 4fb4e8d09..63a8550cc 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -21,7 +21,7 @@ from reactpy.utils import Ref, html_to_vdom, vdom_to_html __author__ = "The Reactive Python Team" -__version__ = "1.0.1" # DO NOT MODIFY +__version__ = "1.0.2" # DO NOT MODIFY __all__ = [ "backend", From 773570b1ec11eb8325ff75f2a0f548b13e450e52 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Mon, 3 Jul 2023 23:48:37 -0600 Subject: [PATCH 07/29] V1.0.2 changelog (#1088) * fix changelog * narrow pre-commit steps to particular files --- .pre-commit-config.yaml | 4 ++++ docs/source/about/changelog.rst | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae748a41d..0383cbb1d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,6 +7,7 @@ repos: language: system args: [--fix] pass_filenames: false + files: \.py$ - repo: local hooks: - id: lint-js-fix @@ -14,6 +15,7 @@ repos: entry: hatch run lint-js --fix language: system pass_filenames: false + files: \.(js|jsx|ts|tsx)$ - repo: local hooks: - id: lint-py-check @@ -21,6 +23,7 @@ repos: entry: hatch run lint-py language: system pass_filenames: false + files: \.py$ - repo: local hooks: - id: lint-js-check @@ -28,3 +31,4 @@ repos: entry: hatch run lint-py language: system pass_filenames: false + files: \.(js|jsx|ts|tsx)$ diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index a927f0fcf..30d595b94 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,6 +23,20 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- +Nothing yet... + + +v1.0.2 +------ + +**Fixed** + +- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`) + + +v1.0.1 +------ + **Changed** - :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. @@ -31,7 +45,6 @@ Unreleased - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) - :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`) -- :issue:`1086` - fix rendering bug when children change positions (via :pull:`1085`) v1.0.0 From ff60ae704615e8eca3d5fd76e8d76727549a8000 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Tue, 4 Jul 2023 17:20:46 -0600 Subject: [PATCH 08/29] Update pull_request_template.md --- .github/pull_request_template.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index cf95abff3..d762951b3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,9 +2,9 @@ ## Issues - + -## Summary +## Solution From 778057d7ab05e76a140a953b568c9a1c881b2483 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 15 Jul 2023 12:32:24 -0600 Subject: [PATCH 09/29] fix ruff error + pin ruff ver for now (#1107) --- pyproject.toml | 2 +- src/py/reactpy/reactpy/core/types.py | 12 ++++++------ src/py/reactpy/reactpy/testing/common.py | 1 - src/py/reactpy/reactpy/widgets.py | 6 +++--- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27e3a937d..ee120a181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "invoke", # lint "black", - "ruff", + "ruff==0.0.278", # Ruff is moving really fast, so pinning for now. "toml", "flake8", "flake8-pyproject", diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 45f300f4f..194706c6e 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -62,21 +62,21 @@ def render(self) -> VdomDict | ComponentType | str | None: """Render the component's view model.""" -_Render = TypeVar("_Render", covariant=True) -_Event = TypeVar("_Event", contravariant=True) +_Render_co = TypeVar("_Render_co", covariant=True) +_Event_contra = TypeVar("_Event_contra", contravariant=True) @runtime_checkable -class LayoutType(Protocol[_Render, _Event]): +class LayoutType(Protocol[_Render_co, _Event_contra]): """Renders and delivers, updates to views and events to handlers, respectively""" - async def render(self) -> _Render: + async def render(self) -> _Render_co: """Render an update to a view""" - async def deliver(self, event: _Event) -> None: + async def deliver(self, event: _Event_contra) -> None: """Relay an event to its respective handler""" - async def __aenter__(self) -> LayoutType[_Render, _Event]: + async def __aenter__(self) -> LayoutType[_Render_co, _Event_contra]: """Prepare the layout for its first render""" async def __aexit__( diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index 945c1c31d..6d126fd2e 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -25,7 +25,6 @@ def clear_reactpy_web_modules_dir() -> None: _P = ParamSpec("_P") _R = TypeVar("_R") -_RC = TypeVar("_RC", covariant=True) _DEFAULT_POLL_DELAY = 0.1 diff --git a/src/py/reactpy/reactpy/widgets.py b/src/py/reactpy/reactpy/widgets.py index cc19be04d..29f941447 100644 --- a/src/py/reactpy/reactpy/widgets.py +++ b/src/py/reactpy/reactpy/widgets.py @@ -78,11 +78,11 @@ def sync_inputs(event: dict[str, Any]) -> None: return inputs -_CastTo = TypeVar("_CastTo", covariant=True) +_CastTo_co = TypeVar("_CastTo_co", covariant=True) -class _CastFunc(Protocol[_CastTo]): - def __call__(self, value: str) -> _CastTo: +class _CastFunc(Protocol[_CastTo_co]): + def __call__(self, value: str) -> _CastTo_co: ... From fb9c57f073366eb3f26d47fb3d23e61b07fc1ff5 Mon Sep 17 00:00:00 2001 From: Mark Bakhit <16909269+Archmonger@users.noreply.github.com> Date: Tue, 18 Jul 2023 02:15:08 -0700 Subject: [PATCH 10/29] `reactpy.run` and `configure(...)` refactoring (#1051) - Change `reactpy.backends.utils.find_all_implementations()` to first try to import `` before importing `reactpy.backend.` - Allows for missing sub-dependencies to not cause `reactpy.run` to silently fail - Import `uvicorn` directly within `serve_with_uvicorn` in order to defer import. - Allows for `ModuleNotFound: Could not import uvicorn` exception to tell the user what went wrong - Added `CommonOptions.serve_index_route: bool` - Allows us to not clutter the route patterns when it's not needed - There are real circumstances where a user might want the index route to 404 - Fix bug where in-use ports are being assigned on Windows. - Removes `allow_reuse_waiting_ports` parameter on `find_available_port()` - Rename `BackendImplementation` to `BackendProtocol` - Change load order of `SUPPORTED_PACKAGES` so that `FastAPI` has a chance to run before `starlette` - Rename `SUPPORTED_PACKAGES` to `SUPPORTED_BACKENDS` - Refactor `reactpy.backend.*` code to be more human readable - Use f-strings where possible - Merge `if` statements where possible - Use `contextlib.supress` where possible - Remove defunct `requirements.txt` file --- docs/source/about/changelog.rst | 4 ++ requirements.txt | 9 --- src/py/reactpy/reactpy/backend/_common.py | 72 +++++++++---------- src/py/reactpy/reactpy/backend/default.py | 32 +++++---- src/py/reactpy/reactpy/backend/fastapi.py | 22 +++--- src/py/reactpy/reactpy/backend/flask.py | 41 ++++++----- src/py/reactpy/reactpy/backend/sanic.py | 54 +++++++------- src/py/reactpy/reactpy/backend/starlette.py | 36 ++++++---- src/py/reactpy/reactpy/backend/tornado.py | 22 ++++-- src/py/reactpy/reactpy/backend/types.py | 4 +- src/py/reactpy/reactpy/backend/utils.py | 63 +++++++--------- src/py/reactpy/reactpy/testing/backend.py | 19 +++-- src/py/reactpy/reactpy/types.py | 4 +- src/py/reactpy/tests/test_backend/test_all.py | 6 +- 14 files changed, 198 insertions(+), 190 deletions(-) delete mode 100644 requirements.txt diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 30d595b94..b683ab4a4 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -40,11 +40,15 @@ v1.0.1 **Changed** - :pull:`1050` - Warn and attempt to fix missing mime types, which can result in ``reactpy.run`` not working as expected. +- :pull:`1051` - Rename ``reactpy.backend.BackendImplementation`` to ``reactpy.backend.BackendType`` +- :pull:`1051` - Allow ``reactpy.run`` to fail in more predictable ways **Fixed** - :issue:`930` - better traceback for JSON serialization errors (via :pull:`1008`) - :issue:`437` - explain that JS component attributes must be JSON (via :pull:`1008`) +- :pull:`1051` - Fix ``reactpy.run`` port assignment sometimes attaching to in-use ports on Windows +- :pull:`1051` - Fix ``reactpy.run`` not recognizing ``fastapi`` v1.0.0 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index dab76855e..000000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ --r requirements/build-docs.txt --r requirements/build-pkg.txt --r requirements/check-style.txt --r requirements/check-types.txt --r requirements/make-release.txt --r requirements/pkg-deps.txt --r requirements/pkg-extras.txt --r requirements/test-env.txt --r requirements/nox-deps.txt diff --git a/src/py/reactpy/reactpy/backend/_common.py b/src/py/reactpy/reactpy/backend/_common.py index 17983a033..b4d6af19c 100644 --- a/src/py/reactpy/reactpy/backend/_common.py +++ b/src/py/reactpy/reactpy/backend/_common.py @@ -14,53 +14,49 @@ from reactpy.utils import vdom_to_html if TYPE_CHECKING: + import uvicorn from asgiref.typing import ASGIApplication PATH_PREFIX = PurePosixPath("/_reactpy") MODULES_PATH = PATH_PREFIX / "modules" ASSETS_PATH = PATH_PREFIX / "assets" STREAM_PATH = PATH_PREFIX / "stream" - CLIENT_BUILD_DIR = Path(_reactpy_file_path).parent / "_static" / "app" / "dist" -try: + +async def serve_with_uvicorn( + app: ASGIApplication | Any, + host: str, + port: int, + started: asyncio.Event | None, +) -> None: + """Run a development server for an ASGI application""" import uvicorn -except ImportError: # nocov - pass -else: - - async def serve_development_asgi( - app: ASGIApplication | Any, - host: str, - port: int, - started: asyncio.Event | None, - ) -> None: - """Run a development server for an ASGI application""" - server = uvicorn.Server( - uvicorn.Config( - app, - host=host, - port=port, - loop="asyncio", - reload=True, - ) + + server = uvicorn.Server( + uvicorn.Config( + app, + host=host, + port=port, + loop="asyncio", ) - server.config.setup_event_loop() - coros: list[Awaitable[Any]] = [server.serve()] + ) + server.config.setup_event_loop() + coros: list[Awaitable[Any]] = [server.serve()] - # If a started event is provided, then use it signal based on `server.started` - if started: - coros.append(_check_if_started(server, started)) + # If a started event is provided, then use it signal based on `server.started` + if started: + coros.append(_check_if_started(server, started)) - try: - await asyncio.gather(*coros) - finally: - # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's - # order of operations. So we need to make sure `shutdown()` always has an initialized - # list of `self.servers` to use. - if not hasattr(server, "servers"): # nocov - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) + try: + await asyncio.gather(*coros) + finally: + # Since we aren't using the uvicorn's `run()` API, we can't guarantee uvicorn's + # order of operations. So we need to make sure `shutdown()` always has an initialized + # list of `self.servers` to use. + if not hasattr(server, "servers"): # nocov + server.servers = [] + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: @@ -72,8 +68,7 @@ async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> N def safe_client_build_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`CLIENT_BUILD_DIR`""" return traversal_safe_path( - CLIENT_BUILD_DIR, - *("index.html" if path in ("", "/") else path).split("/"), + CLIENT_BUILD_DIR, *("index.html" if path in {"", "/"} else path).split("/") ) @@ -140,6 +135,9 @@ class CommonOptions: url_prefix: str = "" """The URL prefix where ReactPy resources will be served from""" + serve_index_route: bool = True + """Automatically generate and serve the index route (``/``)""" + def __post_init__(self) -> None: if self.url_prefix and not self.url_prefix.startswith("/"): msg = "Expected 'url_prefix' to start with '/'" diff --git a/src/py/reactpy/reactpy/backend/default.py b/src/py/reactpy/reactpy/backend/default.py index 4ca192c1c..37aad31af 100644 --- a/src/py/reactpy/reactpy/backend/default.py +++ b/src/py/reactpy/reactpy/backend/default.py @@ -5,13 +5,26 @@ from sys import exc_info from typing import Any, NoReturn -from reactpy.backend.types import BackendImplementation -from reactpy.backend.utils import SUPPORTED_PACKAGES, all_implementations +from reactpy.backend.types import BackendType +from reactpy.backend.utils import SUPPORTED_BACKENDS, all_implementations from reactpy.types import RootComponentConstructor logger = getLogger(__name__) +_DEFAULT_IMPLEMENTATION: BackendType[Any] | None = None +# BackendType.Options +class Options: # nocov + """Configuration options that can be provided to the backend. + This definition should not be used/instantiated. It exists only for + type hinting purposes.""" + + def __init__(self, *args: Any, **kwds: Any) -> NoReturn: + msg = "Default implementation has no options." + raise ValueError(msg) + + +# BackendType.configure def configure( app: Any, component: RootComponentConstructor, options: None = None ) -> None: @@ -22,17 +35,13 @@ def configure( return _default_implementation().configure(app, component) +# BackendType.create_development_app def create_development_app() -> Any: """Create an application instance for development purposes""" return _default_implementation().create_development_app() -def Options(*args: Any, **kwargs: Any) -> NoReturn: # nocov - """Create configuration options""" - msg = "Default implementation has no options." - raise ValueError(msg) - - +# BackendType.serve_development_app async def serve_development_app( app: Any, host: str, @@ -45,10 +54,7 @@ async def serve_development_app( ) -_DEFAULT_IMPLEMENTATION: BackendImplementation[Any] | None = None - - -def _default_implementation() -> BackendImplementation[Any]: +def _default_implementation() -> BackendType[Any]: """Get the first available server implementation""" global _DEFAULT_IMPLEMENTATION # noqa: PLW0603 @@ -59,7 +65,7 @@ def _default_implementation() -> BackendImplementation[Any]: implementation = next(all_implementations()) except StopIteration: # nocov logger.debug("Backend implementation import failed", exc_info=exc_info()) - supported_backends = ", ".join(SUPPORTED_PACKAGES) + supported_backends = ", ".join(SUPPORTED_BACKENDS) msg = ( "It seems you haven't installed a backend. To resolve this issue, " "you can install a backend by running:\n\n" diff --git a/src/py/reactpy/reactpy/backend/fastapi.py b/src/py/reactpy/reactpy/backend/fastapi.py index 575fce1fe..a0137a3dc 100644 --- a/src/py/reactpy/reactpy/backend/fastapi.py +++ b/src/py/reactpy/reactpy/backend/fastapi.py @@ -4,22 +4,22 @@ from reactpy.backend import starlette -serve_development_app = starlette.serve_development_app -"""Alias for :func:`reactpy.backend.starlette.serve_development_app`""" - -use_connection = starlette.use_connection -"""Alias for :func:`reactpy.backend.starlette.use_location`""" - -use_websocket = starlette.use_websocket -"""Alias for :func:`reactpy.backend.starlette.use_websocket`""" - +# BackendType.Options Options = starlette.Options -"""Alias for :class:`reactpy.backend.starlette.Options`""" +# BackendType.configure configure = starlette.configure -"""Alias for :class:`reactpy.backend.starlette.configure`""" +# BackendType.create_development_app def create_development_app() -> FastAPI: """Create a development ``FastAPI`` application instance.""" return FastAPI(debug=True) + + +# BackendType.serve_development_app +serve_development_app = starlette.serve_development_app + +use_connection = starlette.use_connection + +use_websocket = starlette.use_websocket diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index 46aed3c46..2e00e8f64 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -45,6 +45,19 @@ logger = logging.getLogger(__name__) +# BackendType.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.flask.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``flask_cors.CORS`` + """ + + +# BackendType.configure def configure( app: Flask, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -69,20 +82,21 @@ def configure( app.register_blueprint(spa_bp) +# BackendType.create_development_app def create_development_app() -> Flask: """Create an application instance for development purposes""" os.environ["FLASK_DEBUG"] = "true" - app = Flask(__name__) - return app + return Flask(__name__) +# BackendType.serve_development_app async def serve_development_app( app: Flask, host: str, port: int, started: asyncio.Event | None = None, ) -> None: - """Run an application using a development server""" + """Run a development server for FastAPI""" loop = asyncio.get_running_loop() stopped = asyncio.Event() @@ -135,17 +149,6 @@ def use_connection() -> Connection[_FlaskCarrier]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.flask.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - def _setup_common_routes( api_blueprint: Blueprint, spa_blueprint: Blueprint, @@ -166,10 +169,12 @@ def send_modules_dir(path: str = "") -> Any: index_html = read_client_index_html(options) - @spa_blueprint.route("/") - @spa_blueprint.route("/") - def send_client_dir(_: str = "") -> Any: - return index_html + if options.serve_index_route: + + @spa_blueprint.route("/") + @spa_blueprint.route("/") + def send_client_dir(_: str = "") -> Any: + return index_html def _setup_single_view_dispatcher_route( diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 53dd0ce68..3fd48db85 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -22,7 +22,7 @@ read_client_index_html, safe_client_build_dir_path, safe_web_modules_dir_path, - serve_development_asgi, + serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection @@ -34,6 +34,19 @@ logger = logging.getLogger(__name__) +# BackendType.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.sanic.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``sanic_cors.CORS`` + """ + + +# BackendType.configure def configure( app: Sanic, component: RootComponentConstructor, options: Options | None = None ) -> None: @@ -49,14 +62,15 @@ def configure( app.blueprint([spa_bp, api_bp]) +# BackendType.create_development_app def create_development_app() -> Sanic: """Return a :class:`Sanic` app instance in test mode""" Sanic.test_mode = True logger.warning("Sanic.test_mode is now active") - app = Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) - return app + return Sanic(f"reactpy_development_app_{uuid4().hex}", Config()) +# BackendType.serve_development_app async def serve_development_app( app: Sanic, host: str, @@ -64,7 +78,7 @@ async def serve_development_app( started: asyncio.Event | None = None, ) -> None: """Run a development server for :mod:`sanic`""" - await serve_development_asgi(app, host, port, started) + await serve_with_uvicorn(app, host, port, started) def use_request() -> request.Request: @@ -86,17 +100,6 @@ def use_connection() -> Connection[_SanicCarrier]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.sanic.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``sanic_cors.CORS`` - """ - - def _setup_common_routes( api_blueprint: Blueprint, spa_blueprint: Blueprint, @@ -115,16 +118,17 @@ async def single_page_app_files( ) -> response.HTTPResponse: return response.html(index_html) - spa_blueprint.add_route( - single_page_app_files, - "/", - name="single_page_app_files_root", - ) - spa_blueprint.add_route( - single_page_app_files, - "/<_:path>", - name="single_page_app_files_path", - ) + if options.serve_index_route: + spa_blueprint.add_route( + single_page_app_files, + "/", + name="single_page_app_files_root", + ) + spa_blueprint.add_route( + single_page_app_files, + "/<_:path>", + name="single_page_app_files_path", + ) async def asset_files( request: request.Request, diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 3a9695b33..2953b97b3 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -21,7 +21,7 @@ STREAM_PATH, CommonOptions, read_client_index_html, - serve_development_asgi, + serve_with_uvicorn, ) from reactpy.backend.hooks import ConnectionContext from reactpy.backend.hooks import use_connection as _use_connection @@ -34,6 +34,19 @@ logger = logging.getLogger(__name__) +# BackendType.Options +@dataclass +class Options(CommonOptions): + """Render server config for :func:`reactpy.backend.starlette.configure`""" + + cors: bool | dict[str, Any] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` + """ + + +# BackendType.configure def configure( app: Starlette, component: RootComponentConstructor, @@ -54,11 +67,13 @@ def configure( _setup_common_routes(options, app) +# BackendType.create_development_app def create_development_app() -> Starlette: """Return a :class:`Starlette` app instance in debug mode""" return Starlette(debug=True) +# BackendType.serve_development_app async def serve_development_app( app: Starlette, host: str, @@ -66,7 +81,7 @@ async def serve_development_app( started: asyncio.Event | None = None, ) -> None: """Run a development server for starlette""" - await serve_development_asgi(app, host, port, started) + await serve_with_uvicorn(app, host, port, started) def use_websocket() -> WebSocket: @@ -82,17 +97,6 @@ def use_connection() -> Connection[WebSocket]: return conn -@dataclass -class Options(CommonOptions): - """Render server config for :func:`reactpy.backend.starlette.configure`""" - - cors: bool | dict[str, Any] = False - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` - """ - - def _setup_common_routes(options: Options, app: Starlette) -> None: cors_options = options.cors if cors_options: # nocov @@ -115,8 +119,10 @@ def _setup_common_routes(options: Options, app: Starlette) -> None: ) # register this last so it takes least priority index_route = _make_index_route(options) - app.add_route(url_prefix + "/", index_route) - app.add_route(url_prefix + "/{path:path}", index_route) + + if options.serve_index_route: + app.add_route(f"{url_prefix}/", index_route) + app.add_route(url_prefix + "/{path:path}", index_route) def _make_index_route(options: Options) -> Callable[[Request], Awaitable[HTMLResponse]]: diff --git a/src/py/reactpy/reactpy/backend/tornado.py b/src/py/reactpy/reactpy/backend/tornado.py index 5ec877532..8f540ddb4 100644 --- a/src/py/reactpy/reactpy/backend/tornado.py +++ b/src/py/reactpy/reactpy/backend/tornado.py @@ -32,10 +32,11 @@ from reactpy.core.serve import serve_layout from reactpy.core.types import ComponentConstructor +# BackendType.Options Options = CommonOptions -"""Render server config for :func:`reactpy.backend.tornado.configure`""" +# BackendType.configure def configure( app: Application, component: ComponentConstructor, @@ -60,10 +61,12 @@ def configure( ) +# BackendType.create_development_app def create_development_app() -> Application: return Application(debug=True) +# BackendType.serve_development_app async def serve_development_app( app: Application, host: str, @@ -119,12 +122,17 @@ def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: StaticFileHandler, {"path": str(CLIENT_BUILD_DIR / "assets")}, ), - ( - r"/(.*)", - IndexHandler, - {"index_html": read_client_index_html(options)}, - ), - ] + ] + ( + [ + ( + r"/(.*)", + IndexHandler, + {"index_html": read_client_index_html(options)}, + ), + ] + if options.serve_index_route + else [] + ) def _add_handler( diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index fbc4addc0..51e7bef04 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -11,11 +11,11 @@ @runtime_checkable -class BackendImplementation(Protocol[_App]): +class BackendType(Protocol[_App]): """Common interface for built-in web server/framework integrations""" Options: Callable[..., Any] - """A constructor for options passed to :meth:`BackendImplementation.configure`""" + """A constructor for options passed to :meth:`BackendType.configure`""" def configure( self, diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 3d9be13a4..183e801f5 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -3,22 +3,23 @@ import asyncio import logging import socket +import sys from collections.abc import Iterator from contextlib import closing from importlib import import_module from typing import Any -from reactpy.backend.types import BackendImplementation +from reactpy.backend.types import BackendType from reactpy.types import RootComponentConstructor logger = logging.getLogger(__name__) -SUPPORTED_PACKAGES = ( - "starlette", +SUPPORTED_BACKENDS = ( "fastapi", "sanic", "tornado", "flask", + "starlette", ) @@ -26,43 +27,37 @@ def run( component: RootComponentConstructor, host: str = "127.0.0.1", port: int | None = None, - implementation: BackendImplementation[Any] | None = None, + implementation: BackendType[Any] | None = None, ) -> None: """Run a component with a development server""" logger.warning(_DEVELOPMENT_RUN_FUNC_WARNING) implementation = implementation or import_module("reactpy.backend.default") - app = implementation.create_development_app() implementation.configure(app, component) - - host = host port = port or find_available_port(host) - app_cls = type(app) + logger.info( - f"Running with {app_cls.__module__}.{app_cls.__name__} at http://{host}:{port}" + "ReactPy is running with '%s.%s' at http://%s:%s", + app_cls.__module__, + app_cls.__name__, + host, + port, ) - asyncio.run(implementation.serve_development_app(app, host, port)) -def find_available_port( - host: str, - port_min: int = 8000, - port_max: int = 9000, - allow_reuse_waiting_ports: bool = True, -) -> int: +def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) -> int: """Get a port that's available for the given host and port range""" for port in range(port_min, port_max): with closing(socket.socket()) as sock: try: - if allow_reuse_waiting_ports: - # As per this answer: https://stackoverflow.com/a/19247688/3159288 - # setting can be somewhat unreliable because we allow the use of - # ports that are stuck in TIME_WAIT. However, not setting the option - # means we're overly cautious and almost always use a different addr - # even if it could have actually been used. + if sys.platform == "linux": + # Fixes bug where every time you restart the server you'll + # get a different port on Linux. This cannot be set on Windows + # otherwise address will always be reused. + # Ref: https://stackoverflow.com/a/19247688/3159288 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) except OSError: @@ -73,26 +68,20 @@ def find_available_port( raise RuntimeError(msg) -def all_implementations() -> Iterator[BackendImplementation[Any]]: +def all_implementations() -> Iterator[BackendType[Any]]: """Yield all available server implementations""" - for name in SUPPORTED_PACKAGES: + for name in SUPPORTED_BACKENDS: try: - relative_import_name = f"{__name__.rsplit('.', 1)[0]}.{name}" - module = import_module(relative_import_name) + import_module(name) except ImportError: # nocov - logger.debug(f"Failed to import {name!r}", exc_info=True) + logger.debug("Failed to import %s", name, exc_info=True) continue - if not isinstance(module, BackendImplementation): # nocov - msg = f"{module.__name__!r} is an invalid implementation" - raise TypeError(msg) - - yield module + reactpy_backend_name = f"{__name__.rsplit('.', 1)[0]}.{name}" + yield import_module(reactpy_backend_name) -_DEVELOPMENT_RUN_FUNC_WARNING = f"""\ -The `run()` function is only intended for testing during development! To run in \ -production, consider selecting a supported backend and importing its associated \ -`configure()` function from `reactpy.backend.` where `` is one of \ -{list(SUPPORTED_PACKAGES)}. For details refer to the docs on how to run each package.\ +_DEVELOPMENT_RUN_FUNC_WARNING = """\ +The `run()` function is only intended for testing during development! To run \ +in production, refer to the docs on how to use reactpy.backend.*.configure.\ """ diff --git a/src/py/reactpy/reactpy/testing/backend.py b/src/py/reactpy/reactpy/testing/backend.py index 549e16056..b699f3071 100644 --- a/src/py/reactpy/reactpy/testing/backend.py +++ b/src/py/reactpy/reactpy/testing/backend.py @@ -2,13 +2,13 @@ import asyncio import logging -from contextlib import AsyncExitStack +from contextlib import AsyncExitStack, suppress from types import TracebackType from typing import Any, Callable from urllib.parse import urlencode, urlunparse from reactpy.backend import default as default_server -from reactpy.backend.types import BackendImplementation +from reactpy.backend.types import BackendType from reactpy.backend.utils import find_available_port from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT from reactpy.core.component import component @@ -43,21 +43,20 @@ def __init__( host: str = "127.0.0.1", port: int | None = None, app: Any | None = None, - implementation: BackendImplementation[Any] | None = None, + implementation: BackendType[Any] | None = None, options: Any | None = None, timeout: float | None = None, ) -> None: self.host = host - self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) + self.port = port or find_available_port(host) self.mount, self._root_component = _hotswap() self.timeout = ( REACTPY_TESTING_DEFAULT_TIMEOUT.current if timeout is None else timeout ) - if app is not None: - if implementation is None: - msg = "If an application instance its corresponding server implementation must be provided too." - raise ValueError(msg) + if app is not None and implementation is None: + msg = "If an application instance its corresponding server implementation must be provided too." + raise ValueError(msg) self._app = app self.implementation = implementation or default_server @@ -124,10 +123,8 @@ async def __aenter__(self) -> BackendFixture: async def stop_server() -> None: server_future.cancel() - try: + with suppress(asyncio.CancelledError): await asyncio.wait_for(server_future, timeout=self.timeout) - except asyncio.CancelledError: - pass self._exit_stack.push_async_callback(stop_server) diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index 715b66fff..4766fe801 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -4,7 +4,7 @@ - :mod:`reactpy.backend.types` """ -from reactpy.backend.types import BackendImplementation, Connection, Location +from reactpy.backend.types import BackendType, Connection, Location from reactpy.core.component import Component from reactpy.core.hooks import Context from reactpy.core.types import ( @@ -27,7 +27,7 @@ ) __all__ = [ - "BackendImplementation", + "BackendType", "Component", "ComponentConstructor", "ComponentType", diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py index 11b9693a2..d697e5d3f 100644 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ b/src/py/reactpy/tests/test_backend/test_all.py @@ -6,7 +6,7 @@ from reactpy import html from reactpy.backend import default as default_implementation from reactpy.backend._common import PATH_PREFIX -from reactpy.backend.types import BackendImplementation, Connection, Location +from reactpy.backend.types import BackendType, Connection, Location from reactpy.backend.utils import all_implementations from reactpy.testing import BackendFixture, DisplayFixture, poll @@ -17,7 +17,7 @@ scope="module", ) async def display(page, request): - imp: BackendImplementation = request.param + imp: BackendType = request.param # we do this to check that route priorities for each backend are correct if imp is default_implementation: @@ -158,7 +158,7 @@ def ShowRoute(): @pytest.mark.parametrize("imp", all_implementations()) -async def test_customized_head(imp: BackendImplementation, page): +async def test_customized_head(imp: BackendType, page): custom_title = f"Custom Title for {imp.__name__}" @reactpy.component From c42d85c292230d8a85384e626513c2894190dd45 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 23 Jul 2023 22:52:28 -0600 Subject: [PATCH 11/29] setsockopt on mac too --- src/py/reactpy/.temp.py | 28 +++++++++++++++++++++++++ src/py/reactpy/reactpy/backend/utils.py | 8 +++---- 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/py/reactpy/.temp.py diff --git a/src/py/reactpy/.temp.py b/src/py/reactpy/.temp.py new file mode 100644 index 000000000..d8881ad1e --- /dev/null +++ b/src/py/reactpy/.temp.py @@ -0,0 +1,28 @@ +from reactpy import component, html, run, use_state +from reactpy.core.types import State + + +@component +def Item(item: str, all_items: State[list[str]]): + color = use_state(None) + + def deleteme(event): + all_items.set_value([i for i in all_items.value if (i != item)]) + + def colorize(event): + color.set_value("blue" if not color.value else None) + + return html.div( + {"id": item, "style": {"background_color": color.value}}, + html.button({"on_click": colorize}, f"Color {item}"), + html.button({"on_click": deleteme}, f"Delete {item}"), + ) + + +@component +def App(): + items = use_state(["A", "B", "C"]) + return html._([Item(item, items, key=item) for item in items.value]) + + +run(App) diff --git a/src/py/reactpy/reactpy/backend/utils.py b/src/py/reactpy/reactpy/backend/utils.py index 183e801f5..74e87bb7b 100644 --- a/src/py/reactpy/reactpy/backend/utils.py +++ b/src/py/reactpy/reactpy/backend/utils.py @@ -53,10 +53,10 @@ def find_available_port(host: str, port_min: int = 8000, port_max: int = 9000) - for port in range(port_min, port_max): with closing(socket.socket()) as sock: try: - if sys.platform == "linux": - # Fixes bug where every time you restart the server you'll - # get a different port on Linux. This cannot be set on Windows - # otherwise address will always be reused. + if sys.platform in ("linux", "darwin"): + # Fixes bug on Unix-like systems where every time you restart the + # server you'll get a different port on Linux. This cannot be set + # on Windows otherwise address will always be reused. # Ref: https://stackoverflow.com/a/19247688/3159288 sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind((host, port)) From 99cd7b1a01c7a21eba732af1cf162cf4118dfa07 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 23 Jul 2023 22:54:28 -0600 Subject: [PATCH 12/29] need to copy scheme from base url (#1118) * need to copy scheme from base url * add changelog entry --- docs/source/about/changelog.rst | 4 +++- src/py/reactpy/reactpy/web/utils.py | 8 ++++++-- temp.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 temp.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index b683ab4a4..9535d0b67 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,7 +23,9 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -Nothing yet... +**Fixed** + +- :pull:`1118` - `module_from_template` is broken with a recent release of `requests` v1.0.2 diff --git a/src/py/reactpy/reactpy/web/utils.py b/src/py/reactpy/reactpy/web/utils.py index cf8b8638b..295559496 100644 --- a/src/py/reactpy/reactpy/web/utils.py +++ b/src/py/reactpy/reactpy/web/utils.py @@ -1,7 +1,7 @@ import logging import re from pathlib import Path, PurePosixPath -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse import requests @@ -130,7 +130,11 @@ def resolve_module_exports_from_source( def _resolve_relative_url(base_url: str, rel_url: str) -> str: if not rel_url.startswith("."): - return rel_url + if rel_url.startswith("/"): + # copy scheme and hostname from base_url + return urlunparse(urlparse(base_url)[:2] + urlparse(rel_url)[2:]) + else: + return rel_url base_url = base_url.rsplit("/", 1)[0] diff --git a/temp.py b/temp.py new file mode 100644 index 000000000..5104013b6 --- /dev/null +++ b/temp.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI + +from reactpy import html, web +from reactpy.backend.fastapi import configure + +mui = web.module_from_template( + "react", + "@mui/x-date-pickers", + fallback="please wait loading...", +) + + +# Create calendar with material ui +DatePicker = web.export(mui, "DatePicker") + + +def Mycalender(): + return html.div( + DatePicker( + { + "label": "Basic date picker", + }, + "my calender", + ), + ) + + +app = FastAPI() +configure(app, Mycalender) From f053551f891c5047d3e843c0ebadb51691757c13 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 23 Jul 2023 22:55:31 -0600 Subject: [PATCH 13/29] delete accidentally committed file --- temp.py | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 temp.py diff --git a/temp.py b/temp.py deleted file mode 100644 index 5104013b6..000000000 --- a/temp.py +++ /dev/null @@ -1,29 +0,0 @@ -from fastapi import FastAPI - -from reactpy import html, web -from reactpy.backend.fastapi import configure - -mui = web.module_from_template( - "react", - "@mui/x-date-pickers", - fallback="please wait loading...", -) - - -# Create calendar with material ui -DatePicker = web.export(mui, "DatePicker") - - -def Mycalender(): - return html.div( - DatePicker( - { - "label": "Basic date picker", - }, - "my calender", - ), - ) - - -app = FastAPI() -configure(app, Mycalender) From 3faa10fbbeca2a1769fa0c8351a42a6ee35816f9 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 22 Oct 2023 12:26:08 -0600 Subject: [PATCH 14/29] Try to fix lint (#1157) * misc * fix lint --- pyproject.toml | 26 ++++++++++++++++++------- src/py/reactpy/reactpy/backend/sanic.py | 22 ++++++++++++--------- src/py/reactpy/reactpy/core/layout.py | 2 +- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee120a181..3cf94e23f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "invoke", # lint "black", - "ruff==0.0.278", # Ruff is moving really fast, so pinning for now. + "ruff==0.0.278", # Ruff is moving really fast, so pinning for now. "toml", "flake8", "flake8-pyproject", @@ -32,9 +32,11 @@ publish = "invoke publish {args}" docs = "invoke docs {args}" check = ["lint-py", "lint-js", "test-py", "test-js", "test-docs"] +lint = ["lint-py", "lint-js"] lint-py = "invoke lint-py {args}" lint-js = "invoke lint-js {args}" +test = ["test-py", "test-js", "test-docs"] test-py = "invoke test-py {args}" test-js = "invoke test-js" test-docs = "invoke test-docs" @@ -56,7 +58,7 @@ warn_unused_ignores = true # --- Flake8 --------------------------------------------------------------------------- [tool.flake8] -select = ["RPY"] # only need to check with reactpy-flake8 +select = ["RPY"] # only need to check with reactpy-flake8 exclude = ["**/node_modules/*", ".eggs/*", ".tox/*", "**/venv/*"] # --- Ruff ----------------------------------------------------------------------------- @@ -95,7 +97,8 @@ select = [ ] ignore = [ # TODO: turn this on later - "N802", "N806", # allow TitleCase functions/variables + "N802", + "N806", # allow TitleCase functions/variables # We're not any cryptography "S311", # For loop variable re-assignment seems like an uncommon mistake @@ -103,9 +106,12 @@ ignore = [ # Let Black deal with line-length "E501", # Allow args/attrs to shadow built-ins - "A002", "A003", + "A002", + "A003", # Allow unused args (useful for documenting what the parameter is for later) - "ARG001", "ARG002", "ARG005", + "ARG001", + "ARG002", + "ARG005", # Allow non-abstract empty methods in abstract base classes "B027", # Allow boolean positional values in function calls, like `dict.get(... True)` @@ -113,9 +119,15 @@ ignore = [ # If we're making an explicit comparison to a falsy value it was probably intentional "PLC1901", # Ignore checks for possible passwords - "S105", "S106", "S107", + "S105", + "S106", + "S107", # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", + "C901", + "PLR0911", + "PLR0912", + "PLR0913", + "PLR0915", ] unfixable = [ # Don't touch unused imports diff --git a/src/py/reactpy/reactpy/backend/sanic.py b/src/py/reactpy/reactpy/backend/sanic.py index 3fd48db85..76eb0423e 100644 --- a/src/py/reactpy/reactpy/backend/sanic.py +++ b/src/py/reactpy/reactpy/backend/sanic.py @@ -48,7 +48,9 @@ class Options(CommonOptions): # BackendType.configure def configure( - app: Sanic, component: RootComponentConstructor, options: Options | None = None + app: Sanic[Any, Any], + component: RootComponentConstructor, + options: Options | None = None, ) -> None: """Configure an application instance to display the given component""" options = options or Options() @@ -63,7 +65,7 @@ def configure( # BackendType.create_development_app -def create_development_app() -> Sanic: +def create_development_app() -> Sanic[Any, Any]: """Return a :class:`Sanic` app instance in test mode""" Sanic.test_mode = True logger.warning("Sanic.test_mode is now active") @@ -72,7 +74,7 @@ def create_development_app() -> Sanic: # BackendType.serve_development_app async def serve_development_app( - app: Sanic, + app: Sanic[Any, Any], host: str, port: int, started: asyncio.Event | None = None, @@ -81,7 +83,7 @@ async def serve_development_app( await serve_with_uvicorn(app, host, port, started) -def use_request() -> request.Request: +def use_request() -> request.Request[Any, Any]: """Get the current ``Request``""" return use_connection().carrier.request @@ -113,7 +115,7 @@ def _setup_common_routes( index_html = read_client_index_html(options) async def single_page_app_files( - request: request.Request, + request: request.Request[Any, Any], _: str = "", ) -> response.HTTPResponse: return response.html(index_html) @@ -131,7 +133,7 @@ async def single_page_app_files( ) async def asset_files( - request: request.Request, + request: request.Request[Any, Any], path: str = "", ) -> response.HTTPResponse: path = urllib_parse.unquote(path) @@ -140,7 +142,7 @@ async def asset_files( api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") async def web_module_files( - request: request.Request, + request: request.Request[Any, Any], path: str, _: str = "", # this is not used ) -> response.HTTPResponse: @@ -159,7 +161,9 @@ def _setup_single_view_dispatcher_route( options: Options, ) -> None: async def model_stream( - request: request.Request, socket: WebSocketConnection, path: str = "" + request: request.Request[Any, Any], + socket: WebSocketConnection, + path: str = "", ) -> None: asgi_app = getattr(request.app, "_asgi_app", None) scope = asgi_app.transport.scope if asgi_app else {} @@ -220,7 +224,7 @@ async def sock_recv() -> Any: class _SanicCarrier: """A simple wrapper for holding connection information""" - request: request.Request + request: request.Request[Sanic[Any, Any], Any] """The current request object""" websocket: WebSocketConnection diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index f84cb104e..3252ba75c 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -180,7 +180,7 @@ def _render_component( old_parent_model = parent.model.current old_parent_children = old_parent_model["children"] parent.model.current = { - **old_parent_model, # type: ignore[misc] + **old_parent_model, "children": [ *old_parent_children[:index], new_state.model.current, From d3959e4e39622e3316308391f58a2b90b898f550 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 22 Oct 2023 13:05:03 -0600 Subject: [PATCH 15/29] fix flaky test (#1158) --- src/py/reactpy/tests/test_html.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/py/reactpy/tests/test_html.py b/src/py/reactpy/tests/test_html.py index f16d1beed..334fcab03 100644 --- a/src/py/reactpy/tests/test_html.py +++ b/src/py/reactpy/tests/test_html.py @@ -122,6 +122,7 @@ def HasScript(): """ ) + await poll(lambda: hasattr(incr_src_id, "current")).until_is(True) incr_src_id.current() run_count = await display.page.wait_for_selector("#run-count", state="attached") From 701e462f6127dcfeac85c7440f19c8ef633b3299 Mon Sep 17 00:00:00 2001 From: Dekkorate Date: Sun, 22 Oct 2023 22:14:47 +0300 Subject: [PATCH 16/29] Fix flask backend mimetype for modules (#1131) * Fix flask backend mimetype for modules * Update changelog --------- Co-authored-by: Ryan Morshead --- docs/source/about/changelog.rst | 1 + src/py/reactpy/reactpy/backend/flask.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 9535d0b67..32a3df2dc 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -26,6 +26,7 @@ Unreleased **Fixed** - :pull:`1118` - `module_from_template` is broken with a recent release of `requests` +- :pull:`1131` - `module_from_template` did not work when using Flask backend v1.0.2 diff --git a/src/py/reactpy/reactpy/backend/flask.py b/src/py/reactpy/reactpy/backend/flask.py index 2e00e8f64..faa979aa9 100644 --- a/src/py/reactpy/reactpy/backend/flask.py +++ b/src/py/reactpy/reactpy/backend/flask.py @@ -165,7 +165,7 @@ def send_assets_dir(path: str = "") -> Any: @api_blueprint.route(f"/{MODULES_PATH.name}/") def send_modules_dir(path: str = "") -> Any: - return send_file(safe_web_modules_dir_path(path)) + return send_file(safe_web_modules_dir_path(path), mimetype="text/javascript") index_html = read_client_index_html(options) From 341a4925fd7ed55735e2b5142ebecefb2ce0aac5 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 9 Dec 2023 09:11:46 -0700 Subject: [PATCH 17/29] Concurrent Renders (#1165) * initial work on concurrent renders * concurrent renders * limit to 3.11 * fix docs * update changelog * simpler add_effect interface * improve docstring * better changelog description * effect function accepts stop event * simplify concurrent render process * test serial renders too * remove ready event * fix doc example * add docstrings * use function scope async fixtures * fix flaky test * rename config option * move effect kick-off into component did render * move effect start to back to layout render * try 3.x again * require tracerite 1.1.1 * fix docs build --- .github/workflows/.hatch-run.yml | 108 +++---- .github/workflows/check.yml | 83 ++--- docs/source/about/changelog.rst | 6 + src/py/reactpy/pyproject.toml | 4 +- src/py/reactpy/reactpy/_option.py | 7 +- src/py/reactpy/reactpy/backend/hooks.py | 3 +- src/py/reactpy/reactpy/config.py | 8 + .../reactpy/reactpy/core/_life_cycle_hook.py | 245 +++++++++++++++ src/py/reactpy/reactpy/core/hooks.py | 283 ++---------------- src/py/reactpy/reactpy/core/layout.py | 164 ++++++---- src/py/reactpy/reactpy/core/types.py | 23 ++ src/py/reactpy/reactpy/testing/common.py | 4 +- src/py/reactpy/reactpy/types.py | 2 +- src/py/reactpy/tests/conftest.py | 22 +- src/py/reactpy/tests/test_backend/test_all.py | 1 - src/py/reactpy/tests/test_client.py | 22 +- src/py/reactpy/tests/test_core/test_hooks.py | 60 ++-- src/py/reactpy/tests/test_core/test_layout.py | 72 ++++- src/py/reactpy/tests/test_core/test_serve.py | 30 +- src/py/reactpy/tests/tooling/aio.py | 16 + src/py/reactpy/tests/tooling/loop.py | 91 ------ 21 files changed, 685 insertions(+), 569 deletions(-) create mode 100644 src/py/reactpy/reactpy/core/_life_cycle_hook.py create mode 100644 src/py/reactpy/tests/tooling/aio.py delete mode 100644 src/py/reactpy/tests/tooling/loop.py diff --git a/.github/workflows/.hatch-run.yml b/.github/workflows/.hatch-run.yml index b312869e4..1b21e4202 100644 --- a/.github/workflows/.hatch-run.yml +++ b/.github/workflows/.hatch-run.yml @@ -1,59 +1,59 @@ name: hatch-run on: - workflow_call: - inputs: - job-name: - required: true - type: string - hatch-run: - required: true - type: string - runs-on-array: - required: false - type: string - default: '["ubuntu-latest"]' - python-version-array: - required: false - type: string - default: '["3.x"]' - node-registry-url: - required: false - type: string - default: "" - secrets: - node-auth-token: - required: false - pypi-username: - required: false - pypi-password: - required: false + workflow_call: + inputs: + job-name: + required: true + type: string + hatch-run: + required: true + type: string + runs-on-array: + required: false + type: string + default: '["ubuntu-latest"]' + python-version-array: + required: false + type: string + default: '["3.x"]' + node-registry-url: + required: false + type: string + default: "" + secrets: + node-auth-token: + required: false + pypi-username: + required: false + pypi-password: + required: false jobs: - hatch: - name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} - strategy: - matrix: - python-version: ${{ fromJson(inputs.python-version-array) }} - runs-on: ${{ fromJson(inputs.runs-on-array) }} - runs-on: ${{ matrix.runs-on }} - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: - node-version: "14.x" - registry-url: ${{ inputs.node-registry-url }} - - name: Pin NPM Version - run: npm install -g npm@8.19.3 - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install hatch poetry - - name: Run Scripts - env: - NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} - PYPI_USERNAME: ${{ secrets.pypi-username }} - PYPI_PASSWORD: ${{ secrets.pypi-password }} - run: hatch run ${{ inputs.hatch-run }} + hatch: + name: ${{ format(inputs.job-name, matrix.python-version, matrix.runs-on) }} + strategy: + matrix: + python-version: ${{ fromJson(inputs.python-version-array) }} + runs-on: ${{ fromJson(inputs.runs-on-array) }} + runs-on: ${{ matrix.runs-on }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: "14.x" + registry-url: ${{ inputs.node-registry-url }} + - name: Pin NPM Version + run: npm install -g npm@8.19.3 + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install hatch poetry + - name: Run Scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.node-auth-token }} + PYPI_USERNAME: ${{ secrets.pypi-username }} + PYPI_PASSWORD: ${{ secrets.pypi-password }} + run: hatch run ${{ inputs.hatch-run }} diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index af768579c..d370ea129 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -1,45 +1,48 @@ name: check on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * 0" + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * 0" jobs: - test-py-cov: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-py" - lint-py: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "lint-py" - test-py-matrix: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0} {1}" - hatch-run: "test-py --no-cov" - runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' - python-version-array: '["3.9", "3.10", "3.11"]' - test-docs: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "python-{0}" - hatch-run: "test-docs" - test-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "test-js" - lint-js: - uses: ./.github/workflows/.hatch-run.yml - with: - job-name: "{1}" - hatch-run: "lint-js" + test-py-cov: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "test-py" + lint-py: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "lint-py" + test-py-matrix: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0} {1}" + hatch-run: "test-py --no-cov" + runs-on-array: '["ubuntu-latest", "macos-latest", "windows-latest"]' + python-version-array: '["3.9", "3.10", "3.11"]' + test-docs: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "python-{0}" + hatch-run: "test-docs" + # as of Dec 2023 lxml does have wheels for 3.12 + # https://bugs.launchpad.net/lxml/+bug/2040440 + python-version-array: '["3.11"]' + test-js: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "{1}" + hatch-run: "test-js" + lint-js: + uses: ./.github/workflows/.hatch-run.yml + with: + job-name: "{1}" + hatch-run: "lint-js" diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 32a3df2dc..d874a470f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -28,6 +28,12 @@ Unreleased - :pull:`1118` - `module_from_template` is broken with a recent release of `requests` - :pull:`1131` - `module_from_template` did not work when using Flask backend +**Added** + +- :pull:`1165` - Allow concurrent renders of discrete component tree - enable this + experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This should improve + the overall responsiveness of your app, particularly when handling larger renders + that would otherwise block faster renders from being processed. v1.0.2 ------ diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 87fa7e036..67189808b 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -45,6 +45,8 @@ starlette = [ sanic = [ "sanic >=21", "sanic-cors", + "tracerite>=1.1.1", + "setuptools", "uvicorn[standard] >=0.19.0", ] fastapi = [ @@ -80,7 +82,7 @@ pre-install-command = "hatch build --hooks-only" dependencies = [ "coverage[toml]>=6.5", "pytest", - "pytest-asyncio>=0.17", + "pytest-asyncio>=0.23", "pytest-mock", "pytest-rerunfailures", "pytest-timeout", diff --git a/src/py/reactpy/reactpy/_option.py b/src/py/reactpy/reactpy/_option.py index 09d0304a9..1db0857e3 100644 --- a/src/py/reactpy/reactpy/_option.py +++ b/src/py/reactpy/reactpy/_option.py @@ -68,6 +68,10 @@ def current(self) -> _O: def current(self, new: _O) -> None: self.set_current(new) + @current.deleter + def current(self) -> None: + self.unset() + def subscribe(self, handler: Callable[[_O], None]) -> Callable[[_O], None]: """Register a callback that will be triggered when this option changes""" if not self.mutable: @@ -123,7 +127,8 @@ def unset(self) -> None: msg = f"{self} cannot be modified after initial load" raise TypeError(msg) old = self.current - delattr(self, "_current") + if hasattr(self, "_current"): + delattr(self, "_current") if self.current != old: for sub_func in self._subscribers: sub_func(self.current) diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index 19ad114ed..ee4ce1b5c 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -4,7 +4,8 @@ from typing import Any from reactpy.backend.types import Connection, Location -from reactpy.core.hooks import Context, create_context, use_context +from reactpy.core.hooks import create_context, use_context +from reactpy.core.types import Context # backend implementations should establish this context at the root of an app ConnectionContext: Context[Connection[Any] | None] = create_context(None) diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 8371e6d08..8ea6aed03 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -80,3 +80,11 @@ def boolean(value: str | bool | int) -> bool: validator=float, ) """A default timeout for testing utilities in ReactPy""" + +REACTPY_ASYNC_RENDERING = Option( + "REACTPY_CONCURRENT_RENDERING", + default=False, + mutable=True, + validator=boolean, +) +"""Whether to render components concurrently. This is currently an experimental feature.""" diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py new file mode 100644 index 000000000..ea5e6d634 --- /dev/null +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import logging +from asyncio import Event, Task, create_task, gather +from typing import Any, Callable, Protocol, TypeVar + +from anyio import Semaphore + +from reactpy.core._thread_local import ThreadLocal +from reactpy.core.types import ComponentType, Context, ContextProviderType + +T = TypeVar("T") + + +class EffectFunc(Protocol): + async def __call__(self, stop: Event) -> None: + ... + + +logger = logging.getLogger(__name__) + +_HOOK_STATE: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) + + +def current_hook() -> LifeCycleHook: + """Get the current :class:`LifeCycleHook`""" + hook_stack = _HOOK_STATE.get() + if not hook_stack: + msg = "No life cycle hook is active. Are you rendering in a layout?" + raise RuntimeError(msg) + return hook_stack[-1] + + +class LifeCycleHook: + """An object which manages the "life cycle" of a layout component. + + The "life cycle" of a component is the set of events which occur from the time + a component is first rendered until it is removed from the layout. The life cycle + is ultimately driven by the layout itself, but components can "hook" into those + events to perform actions. Components gain access to their own life cycle hook + by calling :func:`current_hook`. They can then perform actions such as: + + 1. Adding state via :meth:`use_state` + 2. Adding effects via :meth:`add_effect` + 3. Setting or getting context providers via + :meth:`LifeCycleHook.set_context_provider` and + :meth:`get_context_provider` respectively. + + Components can request access to their own life cycle events and state through hooks + while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle + forward by triggering events and rendering view changes. + + Example: + + If removed from the complexities of a layout, a very simplified full life cycle + for a single component with no child components would look a bit like this: + + .. testcode:: + + from reactpy.core._life_cycle_hook import LifeCycleHook + from reactpy.core.hooks import current_hook + + # this function will come from a layout implementation + schedule_render = lambda: ... + + # --- start life cycle --- + + hook = LifeCycleHook(schedule_render) + + # --- start render cycle --- + + component = ... + await hook.affect_component_will_render(component) + try: + # render the component + ... + + # the component may access the current hook + assert current_hook() is hook + + # and save state or add effects + current_hook().use_state(lambda: ...) + + async def my_effect(stop_event): + ... + + current_hook().add_effect(my_effect) + finally: + await hook.affect_component_did_render() + + # This should only be called after the full set of changes associated with a + # given render have been completed. + await hook.affect_layout_did_render() + + # Typically an event occurs and a new render is scheduled, thus beginning + # the render cycle anew. + hook.schedule_render() + + + # --- end render cycle --- + + hook.affect_component_will_unmount() + del hook + + # --- end render cycle --- + """ + + __slots__ = ( + "__weakref__", + "_context_providers", + "_current_state_index", + "_effect_funcs", + "_effect_stops", + "_effect_tasks", + "_render_access", + "_rendered_atleast_once", + "_schedule_render_callback", + "_scheduled_render", + "_state", + "component", + ) + + component: ComponentType + + def __init__( + self, + schedule_render: Callable[[], None], + ) -> None: + self._context_providers: dict[Context[Any], ContextProviderType[Any]] = {} + self._schedule_render_callback = schedule_render + self._scheduled_render = False + self._rendered_atleast_once = False + self._current_state_index = 0 + self._state: tuple[Any, ...] = () + self._effect_funcs: list[EffectFunc] = [] + self._effect_tasks: list[Task[None]] = [] + self._effect_stops: list[Event] = [] + self._render_access = Semaphore(1) # ensure only one render at a time + + def schedule_render(self) -> None: + if self._scheduled_render: + return None + try: + self._schedule_render_callback() + except Exception: + msg = f"Failed to schedule render via {self._schedule_render_callback}" + logger.exception(msg) + else: + self._scheduled_render = True + + def use_state(self, function: Callable[[], T]) -> T: + """Add state to this hook + + If this hook has not yet rendered, the state is appended to the state tuple. + Otherwise, the state is retrieved from the tuple. This allows state to be + preserved across renders. + """ + if not self._rendered_atleast_once: + # since we're not initialized yet we're just appending state + result = function() + self._state += (result,) + else: + # once finalized we iterate over each succesively used piece of state + result = self._state[self._current_state_index] + self._current_state_index += 1 + return result + + def add_effect(self, effect_func: EffectFunc) -> None: + """Add an effect to this hook + + A task to run the effect is created when the component is done rendering. + When the component will be unmounted, the event passed to the effect is + triggered and the task is awaited. The effect should eventually halt after + the event is triggered. + """ + self._effect_funcs.append(effect_func) + + def set_context_provider(self, provider: ContextProviderType[Any]) -> None: + """Set a context provider for this hook + + The context provider will be used to provide state to any child components + of this hook's component which request a context provider of the same type. + """ + self._context_providers[provider.type] = provider + + def get_context_provider( + self, context: Context[T] + ) -> ContextProviderType[T] | None: + """Get a context provider for this hook of the given type + + The context provider will have been set by a parent component. If no provider + is found, ``None`` is returned. + """ + return self._context_providers.get(context) + + async def affect_component_will_render(self, component: ComponentType) -> None: + """The component is about to render""" + await self._render_access.acquire() + self._scheduled_render = False + self.component = component + self.set_current() + + async def affect_component_did_render(self) -> None: + """The component completed a render""" + self.unset_current() + self._rendered_atleast_once = True + self._current_state_index = 0 + self._render_access.release() + del self.component + + async def affect_layout_did_render(self) -> None: + """The layout completed a render""" + stop = Event() + self._effect_stops.append(stop) + self._effect_tasks.extend(create_task(e(stop)) for e in self._effect_funcs) + self._effect_funcs.clear() + + async def affect_component_will_unmount(self) -> None: + """The component is about to be removed from the layout""" + for stop in self._effect_stops: + stop.set() + self._effect_stops.clear() + try: + await gather(*self._effect_tasks) + except Exception: + logger.exception("Error in effect") + finally: + self._effect_tasks.clear() + + def set_current(self) -> None: + """Set this hook as the active hook in this thread + + This method is called by a layout before entering the render method + of this hook's associated component. + """ + hook_stack = _HOOK_STATE.get() + if hook_stack: + parent = hook_stack[-1] + self._context_providers.update(parent._context_providers) + hook_stack.append(self) + + def unset_current(self) -> None: + """Unset this hook as the active hook in this thread""" + if _HOOK_STATE.get().pop() is not self: + raise RuntimeError("Hook stack is in an invalid state") # nocov diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index a8334458b..4513dadef 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -1,7 +1,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Sequence +from collections.abc import Coroutine, Sequence from logging import getLogger from types import FunctionType from typing import ( @@ -9,7 +9,6 @@ Any, Callable, Generic, - NewType, Protocol, TypeVar, cast, @@ -19,8 +18,8 @@ from typing_extensions import TypeAlias from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core._thread_local import ThreadLocal -from reactpy.core.types import ComponentType, Key, State, VdomDict +from reactpy.core._life_cycle_hook import current_hook +from reactpy.core.types import Context, Key, State, VdomDict from reactpy.utils import Ref if not TYPE_CHECKING: @@ -96,7 +95,9 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: _EffectCleanFunc: TypeAlias = "Callable[[], None]" _SyncEffectFunc: TypeAlias = "Callable[[], _EffectCleanFunc | None]" -_AsyncEffectFunc: TypeAlias = "Callable[[], Awaitable[_EffectCleanFunc | None]]" +_AsyncEffectFunc: TypeAlias = ( + "Callable[[], Coroutine[None, None, _EffectCleanFunc | None]]" +) _EffectApplyFunc: TypeAlias = "_SyncEffectFunc | _AsyncEffectFunc" @@ -147,25 +148,30 @@ def add_effect(function: _EffectApplyFunc) -> None: async_function = cast(_AsyncEffectFunc, function) def sync_function() -> _EffectCleanFunc | None: - future = asyncio.ensure_future(async_function()) + task = asyncio.create_task(async_function()) def clean_future() -> None: - if not future.cancel(): - clean = future.result() - if clean is not None: - clean() + if not task.cancel(): + try: + clean = task.result() + except asyncio.CancelledError: + pass + else: + if clean is not None: + clean() return clean_future - def effect() -> None: + async def effect(stop: asyncio.Event) -> None: if last_clean_callback.current is not None: last_clean_callback.current() - + last_clean_callback.current = None clean = last_clean_callback.current = sync_function() + await stop.wait() if clean is not None: - hook.add_effect(COMPONENT_WILL_UNMOUNT_EFFECT, clean) + clean() - return memoize(lambda: hook.add_effect(LAYOUT_DID_RENDER_EFFECT, effect)) + return memoize(lambda: hook.add_effect(effect)) if function is not None: add_effect(function) @@ -212,8 +218,8 @@ def context( *children: Any, value: _Type = default_value, key: Key | None = None, - ) -> ContextProvider[_Type]: - return ContextProvider( + ) -> _ContextProvider[_Type]: + return _ContextProvider( *children, value=value, key=key, @@ -225,18 +231,6 @@ def context( return context -class Context(Protocol[_Type]): - """Returns a :class:`ContextProvider` component""" - - def __call__( - self, - *children: Any, - value: _Type = ..., - key: Key | None = ..., - ) -> ContextProvider[_Type]: - ... - - def use_context(context: Context[_Type]) -> _Type: """Get the current value for the given context type. @@ -255,10 +249,10 @@ def use_context(context: Context[_Type]) -> _Type: raise TypeError(f"{context} has no 'value' kwarg") # nocov return cast(_Type, context.__kwdefaults__["value"]) - return provider._value + return provider.value -class ContextProvider(Generic[_Type]): +class _ContextProvider(Generic[_Type]): def __init__( self, *children: Any, @@ -269,14 +263,14 @@ def __init__( self.children = children self.key = key self.type = type - self._value = value + self.value = value def render(self) -> VdomDict: current_hook().set_context_provider(self) return {"tagName": "", "children": self.children} def __repr__(self) -> str: - return f"{type(self).__name__}({self.type})" + return f"ContextProvider({self.type})" _ActionType = TypeVar("_ActionType") @@ -495,231 +489,6 @@ def _try_to_infer_closure_values( return values -def current_hook() -> LifeCycleHook: - """Get the current :class:`LifeCycleHook`""" - hook_stack = _hook_stack.get() - if not hook_stack: - msg = "No life cycle hook is active. Are you rendering in a layout?" - raise RuntimeError(msg) - return hook_stack[-1] - - -_hook_stack: ThreadLocal[list[LifeCycleHook]] = ThreadLocal(list) - - -EffectType = NewType("EffectType", str) -"""Used in :meth:`LifeCycleHook.add_effect` to indicate what effect should be saved""" - -COMPONENT_DID_RENDER_EFFECT = EffectType("COMPONENT_DID_RENDER") -"""An effect that will be triggered each time a component renders""" - -LAYOUT_DID_RENDER_EFFECT = EffectType("LAYOUT_DID_RENDER") -"""An effect that will be triggered each time a layout renders""" - -COMPONENT_WILL_UNMOUNT_EFFECT = EffectType("COMPONENT_WILL_UNMOUNT") -"""An effect that will be triggered just before the component is unmounted""" - - -class LifeCycleHook: - """Defines the life cycle of a layout component. - - Components can request access to their own life cycle events and state through hooks - while :class:`~reactpy.core.proto.LayoutType` objects drive drive the life cycle - forward by triggering events and rendering view changes. - - Example: - - If removed from the complexities of a layout, a very simplified full life cycle - for a single component with no child components would look a bit like this: - - .. testcode:: - - from reactpy.core.hooks import ( - current_hook, - LifeCycleHook, - COMPONENT_DID_RENDER_EFFECT, - ) - - - # this function will come from a layout implementation - schedule_render = lambda: ... - - # --- start life cycle --- - - hook = LifeCycleHook(schedule_render) - - # --- start render cycle --- - - hook.affect_component_will_render(...) - - hook.set_current() - - try: - # render the component - ... - - # the component may access the current hook - assert current_hook() is hook - - # and save state or add effects - current_hook().use_state(lambda: ...) - current_hook().add_effect(COMPONENT_DID_RENDER_EFFECT, lambda: ...) - finally: - hook.unset_current() - - hook.affect_component_did_render() - - # This should only be called after the full set of changes associated with a - # given render have been completed. - hook.affect_layout_did_render() - - # Typically an event occurs and a new render is scheduled, thus beginning - # the render cycle anew. - hook.schedule_render() - - - # --- end render cycle --- - - hook.affect_component_will_unmount() - del hook - - # --- end render cycle --- - """ - - __slots__ = ( - "__weakref__", - "_context_providers", - "_current_state_index", - "_event_effects", - "_is_rendering", - "_rendered_atleast_once", - "_schedule_render_callback", - "_schedule_render_later", - "_state", - "component", - ) - - component: ComponentType - - def __init__( - self, - schedule_render: Callable[[], None], - ) -> None: - self._context_providers: dict[Context[Any], ContextProvider[Any]] = {} - self._schedule_render_callback = schedule_render - self._schedule_render_later = False - self._is_rendering = False - self._rendered_atleast_once = False - self._current_state_index = 0 - self._state: tuple[Any, ...] = () - self._event_effects: dict[EffectType, list[Callable[[], None]]] = { - COMPONENT_DID_RENDER_EFFECT: [], - LAYOUT_DID_RENDER_EFFECT: [], - COMPONENT_WILL_UNMOUNT_EFFECT: [], - } - - def schedule_render(self) -> None: - if self._is_rendering: - self._schedule_render_later = True - else: - self._schedule_render() - - def use_state(self, function: Callable[[], _Type]) -> _Type: - if not self._rendered_atleast_once: - # since we're not initialized yet we're just appending state - result = function() - self._state += (result,) - else: - # once finalized we iterate over each succesively used piece of state - result = self._state[self._current_state_index] - self._current_state_index += 1 - return result - - def add_effect(self, effect_type: EffectType, function: Callable[[], None]) -> None: - """Trigger a function on the occurrence of the given effect type""" - self._event_effects[effect_type].append(function) - - def set_context_provider(self, provider: ContextProvider[Any]) -> None: - self._context_providers[provider.type] = provider - - def get_context_provider( - self, context: Context[_Type] - ) -> ContextProvider[_Type] | None: - return self._context_providers.get(context) - - def affect_component_will_render(self, component: ComponentType) -> None: - """The component is about to render""" - self.component = component - - self._is_rendering = True - self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT].clear() - - def affect_component_did_render(self) -> None: - """The component completed a render""" - del self.component - - component_did_render_effects = self._event_effects[COMPONENT_DID_RENDER_EFFECT] - for effect in component_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Component post-render effect {effect} failed") - component_did_render_effects.clear() - - self._is_rendering = False - self._rendered_atleast_once = True - self._current_state_index = 0 - - def affect_layout_did_render(self) -> None: - """The layout completed a render""" - layout_did_render_effects = self._event_effects[LAYOUT_DID_RENDER_EFFECT] - for effect in layout_did_render_effects: - try: - effect() - except Exception: - logger.exception(f"Layout post-render effect {effect} failed") - layout_did_render_effects.clear() - - if self._schedule_render_later: - self._schedule_render() - self._schedule_render_later = False - - def affect_component_will_unmount(self) -> None: - """The component is about to be removed from the layout""" - will_unmount_effects = self._event_effects[COMPONENT_WILL_UNMOUNT_EFFECT] - for effect in will_unmount_effects: - try: - effect() - except Exception: - logger.exception(f"Pre-unmount effect {effect} failed") - will_unmount_effects.clear() - - def set_current(self) -> None: - """Set this hook as the active hook in this thread - - This method is called by a layout before entering the render method - of this hook's associated component. - """ - hook_stack = _hook_stack.get() - if hook_stack: - parent = hook_stack[-1] - self._context_providers.update(parent._context_providers) - hook_stack.append(self) - - def unset_current(self) -> None: - """Unset this hook as the active hook in this thread""" - if _hook_stack.get().pop() is not self: - raise RuntimeError("Hook stack is in an invalid state") # nocov - - def _schedule_render(self) -> None: - try: - self._schedule_render_callback() - except Exception: - logger.exception( - f"Failed to schedule render via {self._schedule_render_callback}" - ) - - def strictly_equal(x: Any, y: Any) -> bool: """Check if two values are identical or, for a limited set or types, equal. diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 3252ba75c..d59ab31eb 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -1,10 +1,18 @@ from __future__ import annotations import abc -import asyncio +from asyncio import ( + FIRST_COMPLETED, + CancelledError, + Queue, + Task, + create_task, + get_running_loop, + wait, +) from collections import Counter from collections.abc import Iterator -from contextlib import ExitStack +from contextlib import AsyncExitStack from logging import getLogger from typing import ( Any, @@ -18,8 +26,14 @@ from uuid import uuid4 from weakref import ref as weakref -from reactpy.config import REACTPY_CHECK_VDOM_SPEC, REACTPY_DEBUG_MODE -from reactpy.core.hooks import LifeCycleHook +from anyio import Semaphore + +from reactpy.config import ( + REACTPY_ASYNC_RENDERING, + REACTPY_CHECK_VDOM_SPEC, + REACTPY_DEBUG_MODE, +) +from reactpy.core._life_cycle_hook import LifeCycleHook from reactpy.core.types import ( ComponentType, EventHandlerDict, @@ -41,6 +55,8 @@ class Layout: "root", "_event_handlers", "_rendering_queue", + "_render_tasks", + "_render_tasks_ready", "_root_life_cycle_state_id", "_model_states_by_life_cycle_state_id", ) @@ -58,21 +74,30 @@ def __init__(self, root: ComponentType) -> None: async def __aenter__(self) -> Layout: # create attributes here to avoid access before entering context manager self._event_handlers: EventHandlerDict = {} + self._render_tasks: set[Task[LayoutUpdateMessage]] = set() + self._render_tasks_ready: Semaphore = Semaphore(0) self._rendering_queue: _ThreadSafeQueue[_LifeCycleStateId] = _ThreadSafeQueue() - root_model_state = _new_root_model_state(self.root, self._rendering_queue.put) + root_model_state = _new_root_model_state(self.root, self._schedule_render_task) self._root_life_cycle_state_id = root_id = root_model_state.life_cycle_state.id - self._rendering_queue.put(root_id) - self._model_states_by_life_cycle_state_id = {root_id: root_model_state} + self._schedule_render_task(root_id) return self async def __aexit__(self, *exc: Any) -> None: root_csid = self._root_life_cycle_state_id root_model_state = self._model_states_by_life_cycle_state_id[root_csid] - self._unmount_model_states([root_model_state]) + + for t in self._render_tasks: + t.cancel() + try: + await t + except CancelledError: + pass + + await self._unmount_model_states([root_model_state]) # delete attributes here to avoid access after exiting context manager del self._event_handlers @@ -100,6 +125,12 @@ async def deliver(self, event: LayoutEventMessage) -> None: ) async def render(self) -> LayoutUpdateMessage: + if REACTPY_ASYNC_RENDERING.current: + return await self._concurrent_render() + else: # nocov + return await self._serial_render() + + async def _serial_render(self) -> LayoutUpdateMessage: # nocov """Await the next available render. This will block until a component is updated""" while True: model_state_id = await self._rendering_queue.get() @@ -111,19 +142,27 @@ async def render(self) -> LayoutUpdateMessage: f"{model_state_id!r} - component already unmounted" ) else: - update = self._create_layout_update(model_state) - if REACTPY_CHECK_VDOM_SPEC.current: - root_id = self._root_life_cycle_state_id - root_model = self._model_states_by_life_cycle_state_id[root_id] - validate_vdom_json(root_model.model.current) - return update - - def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: + return await self._create_layout_update(model_state) + + async def _concurrent_render(self) -> LayoutUpdateMessage: + """Await the next available render. This will block until a component is updated""" + await self._render_tasks_ready.acquire() + done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED) + update_task: Task[LayoutUpdateMessage] = done.pop() + self._render_tasks.remove(update_task) + return update_task.result() + + async def _create_layout_update( + self, old_state: _ModelState + ) -> LayoutUpdateMessage: new_state = _copy_component_model_state(old_state) component = new_state.life_cycle_state.component - with ExitStack() as exit_stack: - self._render_component(exit_stack, old_state, new_state, component) + async with AsyncExitStack() as exit_stack: + await self._render_component(exit_stack, old_state, new_state, component) + + if REACTPY_CHECK_VDOM_SPEC.current: + validate_vdom_json(new_state.model.current) return { "type": "layout-update", @@ -131,9 +170,9 @@ def _create_layout_update(self, old_state: _ModelState) -> LayoutUpdateMessage: "model": new_state.model.current, } - def _render_component( + async def _render_component( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, component: ComponentType, @@ -143,9 +182,8 @@ def _render_component( self._model_states_by_life_cycle_state_id[life_cycle_state.id] = new_state - life_cycle_hook.affect_component_will_render(component) - exit_stack.callback(life_cycle_hook.affect_layout_did_render) - life_cycle_hook.set_current() + await life_cycle_hook.affect_component_will_render(component) + exit_stack.push_async_callback(life_cycle_hook.affect_layout_did_render) try: raw_model = component.render() # wrap the model in a fragment (i.e. tagName="") to ensure components have @@ -154,7 +192,7 @@ def _render_component( wrapper_model: VdomDict = {"tagName": ""} if raw_model is not None: wrapper_model["children"] = [raw_model] - self._render_model(exit_stack, old_state, new_state, wrapper_model) + await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") new_state.model.current = { @@ -166,8 +204,7 @@ def _render_component( ), } finally: - life_cycle_hook.unset_current() - life_cycle_hook.affect_component_did_render() + await life_cycle_hook.affect_component_did_render() try: parent = new_state.parent @@ -188,9 +225,9 @@ def _render_component( ], } - def _render_model( + async def _render_model( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_model: Any, @@ -205,7 +242,7 @@ def _render_model( if "importSource" in raw_model: new_state.model.current["importSource"] = raw_model["importSource"] self._render_model_attributes(old_state, new_state, raw_model) - self._render_model_children( + await self._render_model_children( exit_stack, old_state, new_state, raw_model.get("children", []) ) @@ -272,9 +309,9 @@ def _render_model_event_handlers_without_old_state( return None - def _render_model_children( + async def _render_model_children( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, old_state: _ModelState | None, new_state: _ModelState, raw_children: Any, @@ -284,12 +321,12 @@ def _render_model_children( if old_state is None: if raw_children: - self._render_model_children_without_old_state( + await self._render_model_children_without_old_state( exit_stack, new_state, raw_children ) return None elif not raw_children: - self._unmount_model_states(list(old_state.children_by_key.values())) + await self._unmount_model_states(list(old_state.children_by_key.values())) return None child_type_key_tuples = list(_process_child_type_and_key(raw_children)) @@ -303,7 +340,7 @@ def _render_model_children( old_keys = set(old_state.children_by_key).difference(new_keys) if old_keys: - self._unmount_model_states( + await self._unmount_model_states( [old_state.children_by_key[key] for key in old_keys] ) @@ -319,7 +356,7 @@ def _render_model_children( key, ) elif old_child_state.is_component_state: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_child_state = _make_element_model_state( new_state, index, @@ -332,7 +369,9 @@ def _render_model_children( new_state, index, ) - self._render_model(exit_stack, old_child_state, new_child_state, child) + await self._render_model( + exit_stack, old_child_state, new_child_state, child + ) new_state.append_child(new_child_state.model.current) new_state.children_by_key[key] = new_child_state elif child_type is _COMPONENT_TYPE: @@ -344,19 +383,19 @@ def _render_model_children( index, key, child, - self._rendering_queue.put, + self._schedule_render_task, ) elif old_child_state.is_component_state and ( old_child_state.life_cycle_state.component.type != child.type ): - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) old_child_state = None new_child_state = _make_component_model_state( new_state, index, key, child, - self._rendering_queue.put, + self._schedule_render_task, ) else: new_child_state = _update_component_model_state( @@ -364,20 +403,20 @@ def _render_model_children( new_state, index, child, - self._rendering_queue.put, + self._schedule_render_task, ) - self._render_component( + await self._render_component( exit_stack, old_child_state, new_child_state, child ) else: old_child_state = old_state.children_by_key.get(key) if old_child_state is not None: - self._unmount_model_states([old_child_state]) + await self._unmount_model_states([old_child_state]) new_state.append_child(child) - def _render_model_children_without_old_state( + async def _render_model_children_without_old_state( self, - exit_stack: ExitStack, + exit_stack: AsyncExitStack, new_state: _ModelState, raw_children: list[Any], ) -> None: @@ -394,18 +433,18 @@ def _render_model_children_without_old_state( for index, (child, child_type, key) in enumerate(child_type_key_tuples): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) - self._render_model(exit_stack, None, child_state, child) + await self._render_model(exit_stack, None, child_state, child) new_state.append_child(child_state.model.current) new_state.children_by_key[key] = child_state elif child_type is _COMPONENT_TYPE: child_state = _make_component_model_state( - new_state, index, key, child, self._rendering_queue.put + new_state, index, key, child, self._schedule_render_task ) - self._render_component(exit_stack, None, child_state, child) + await self._render_component(exit_stack, None, child_state, child) else: new_state.append_child(child) - def _unmount_model_states(self, old_states: list[_ModelState]) -> None: + async def _unmount_model_states(self, old_states: list[_ModelState]) -> None: to_unmount = old_states[::-1] # unmount in reversed order of rendering while to_unmount: model_state = to_unmount.pop() @@ -416,10 +455,25 @@ def _unmount_model_states(self, old_states: list[_ModelState]) -> None: if model_state.is_component_state: life_cycle_state = model_state.life_cycle_state del self._model_states_by_life_cycle_state_id[life_cycle_state.id] - life_cycle_state.hook.affect_component_will_unmount() + await life_cycle_state.hook.affect_component_will_unmount() to_unmount.extend(model_state.children_by_key.values()) + def _schedule_render_task(self, lcs_id: _LifeCycleStateId) -> None: + if not REACTPY_ASYNC_RENDERING.current: + self._rendering_queue.put(lcs_id) + return None + try: + model_state = self._model_states_by_life_cycle_state_id[lcs_id] + except KeyError: + logger.debug( + "Did not render component with model state ID " + f"{lcs_id!r} - component already unmounted" + ) + else: + self._render_tasks.add(create_task(self._create_layout_update(model_state))) + self._render_tasks_ready.release() + def __repr__(self) -> str: return f"{type(self).__name__}({self.root})" @@ -538,6 +592,7 @@ class _ModelState: __slots__ = ( "__weakref__", "_parent_ref", + "_render_semaphore", "children_by_key", "index", "key", @@ -649,11 +704,9 @@ class _LifeCycleState(NamedTuple): class _ThreadSafeQueue(Generic[_Type]): - __slots__ = "_loop", "_queue", "_pending" - def __init__(self) -> None: - self._loop = asyncio.get_running_loop() - self._queue: asyncio.Queue[_Type] = asyncio.Queue() + self._loop = get_running_loop() + self._queue: Queue[_Type] = Queue() self._pending: set[_Type] = set() def put(self, value: _Type) -> None: @@ -662,10 +715,7 @@ def put(self, value: _Type) -> None: self._loop.call_soon_threadsafe(self._queue.put_nowait, value) async def get(self) -> _Type: - while True: - value = await self._queue.get() - if value in self._pending: - break + value = await self._queue.get() self._pending.remove(value) return value diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 194706c6e..e5a81814f 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -233,3 +233,26 @@ class LayoutEventMessage(TypedDict): """The ID of the event handler.""" data: Sequence[Any] """A list of event data passed to the event handler.""" + + +class Context(Protocol[_Type]): + """Returns a :class:`ContextProvider` component""" + + def __call__( + self, + *children: Any, + value: _Type = ..., + key: Key | None = ..., + ) -> ContextProviderType[_Type]: + ... + + +class ContextProviderType(ComponentType, Protocol[_Type]): + """A component which provides a context value to its children""" + + type: Context[_Type] + """The context type""" + + @property + def value(self) -> _Type: + "Current context value" diff --git a/src/py/reactpy/reactpy/testing/common.py b/src/py/reactpy/reactpy/testing/common.py index 6d126fd2e..c1eb18ba5 100644 --- a/src/py/reactpy/reactpy/testing/common.py +++ b/src/py/reactpy/reactpy/testing/common.py @@ -13,8 +13,8 @@ from typing_extensions import ParamSpec from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT, REACTPY_WEB_MODULES_DIR +from reactpy.core._life_cycle_hook import LifeCycleHook, current_hook from reactpy.core.events import EventHandler, to_event_handler_function -from reactpy.core.hooks import LifeCycleHook, current_hook def clear_reactpy_web_modules_dir() -> None: @@ -67,7 +67,7 @@ async def until( break elif (time.time() - started_at) > timeout: # nocov msg = f"Expected {description} after {timeout} seconds - last value was {result!r}" - raise TimeoutError(msg) + raise asyncio.TimeoutError(msg) async def until_is( self, diff --git a/src/py/reactpy/reactpy/types.py b/src/py/reactpy/reactpy/types.py index 4766fe801..1ac04395a 100644 --- a/src/py/reactpy/reactpy/types.py +++ b/src/py/reactpy/reactpy/types.py @@ -6,10 +6,10 @@ from reactpy.backend.types import BackendType, Connection, Location from reactpy.core.component import Component -from reactpy.core.hooks import Context from reactpy.core.types import ( ComponentConstructor, ComponentType, + Context, EventHandlerDict, EventHandlerFunc, EventHandlerMapping, diff --git a/src/py/reactpy/tests/conftest.py b/src/py/reactpy/tests/conftest.py index 21b23c12e..743d67f02 100644 --- a/src/py/reactpy/tests/conftest.py +++ b/src/py/reactpy/tests/conftest.py @@ -8,14 +8,18 @@ from _pytest.config.argparsing import Parser from playwright.async_api import async_playwright -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT +from reactpy.config import ( + REACTPY_ASYNC_RENDERING, + REACTPY_TESTING_DEFAULT_TIMEOUT, +) from reactpy.testing import ( BackendFixture, DisplayFixture, capture_reactpy_logs, clear_reactpy_web_modules_dir, ) -from tests.tooling.loop import open_event_loop + +REACTPY_ASYNC_RENDERING.current = True def pytest_addoption(parser: Parser) -> None: @@ -33,13 +37,13 @@ async def display(server, page): yield display -@pytest.fixture(scope="session") +@pytest.fixture async def server(): async with BackendFixture() as server: yield server -@pytest.fixture(scope="session") +@pytest.fixture async def page(browser): pg = await browser.new_page() pg.set_default_timeout(REACTPY_TESTING_DEFAULT_TIMEOUT.current * 1000) @@ -49,18 +53,18 @@ async def page(browser): await pg.close() -@pytest.fixture(scope="session") +@pytest.fixture async def browser(pytestconfig: Config): async with async_playwright() as pw: yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) @pytest.fixture(scope="session") -def event_loop(): +def event_loop_policy(): if os.name == "nt": # nocov - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - with open_event_loop() as loop: - yield loop + return asyncio.WindowsProactorEventLoopPolicy() + else: + return asyncio.DefaultEventLoopPolicy() @pytest.fixture(autouse=True) diff --git a/src/py/reactpy/tests/test_backend/test_all.py b/src/py/reactpy/tests/test_backend/test_all.py index d697e5d3f..dc8ec1284 100644 --- a/src/py/reactpy/tests/test_backend/test_all.py +++ b/src/py/reactpy/tests/test_backend/test_all.py @@ -14,7 +14,6 @@ @pytest.fixture( params=[*list(all_implementations()), default_implementation], ids=lambda imp: imp.__name__, - scope="module", ) async def display(page, request): imp: BackendType = request.param diff --git a/src/py/reactpy/tests/test_client.py b/src/py/reactpy/tests/test_client.py index 3c7250e48..a9ff10a89 100644 --- a/src/py/reactpy/tests/test_client.py +++ b/src/py/reactpy/tests/test_client.py @@ -30,6 +30,11 @@ def SomeComponent(): ), ) + async def get_count(): + # need to refetch element because may unmount on reconnect + count = await page.wait_for_selector("#count") + return await count.get_attribute("data-count") + async with AsyncExitStack() as exit_stack: server = await exit_stack.enter_async_context(BackendFixture(port=port)) display = await exit_stack.enter_async_context( @@ -38,11 +43,10 @@ def SomeComponent(): await display.show(SomeComponent) - count = await page.wait_for_selector("#count") incr = await page.wait_for_selector("#incr") for i in range(3): - assert (await count.get_attribute("data-count")) == str(i) + await poll(get_count).until_equals(str(i)) await incr.click() # the server is disconnected but the last view state is still shown @@ -57,13 +61,7 @@ def SomeComponent(): # use mount instead of show to avoid a page refresh display.backend.mount(SomeComponent) - async def get_count(): - # need to refetch element because may unmount on reconnect - count = await page.wait_for_selector("#count") - return await count.get_attribute("data-count") - for i in range(3): - # it may take a moment for the websocket to reconnect so need to poll await poll(get_count).until_equals(str(i)) # need to refetch element because may unmount on reconnect @@ -98,11 +96,15 @@ def ButtonWithChangingColor(): button = await display.page.wait_for_selector("#my-button") - assert (await _get_style(button))["background-color"] == "red" + await poll(_get_style, button).until( + lambda style: style["background-color"] == "red" + ) for color in ["blue", "red"] * 2: await button.click() - assert (await _get_style(button))["background-color"] == color + await poll(_get_style, button).until( + lambda style, c=color: style["background-color"] == c + ) async def _get_style(element): diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index 453d07c99..fa6acafd1 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -5,12 +5,8 @@ import reactpy from reactpy import html from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.hooks import ( - COMPONENT_DID_RENDER_EFFECT, - LifeCycleHook, - current_hook, - strictly_equal, -) +from reactpy.core._life_cycle_hook import LifeCycleHook +from reactpy.core.hooks import strictly_equal, use_effect from reactpy.core.layout import Layout from reactpy.testing import DisplayFixture, HookCatcher, assert_reactpy_did_log, poll from reactpy.testing.logs import assert_reactpy_did_not_log @@ -32,10 +28,15 @@ def SimpleComponentWithHook(): async def test_simple_stateful_component(): + index = 0 + + def set_index(x): + return None + @reactpy.component def SimpleStatefulComponent(): + nonlocal index, set_index index, set_index = reactpy.hooks.use_state(0) - set_index(index + 1) return reactpy.html.div(index) sse = SimpleStatefulComponent() @@ -49,6 +50,7 @@ def SimpleStatefulComponent(): "children": [{"tagName": "div", "children": ["0"]}], }, ) + set_index(index + 1) update_2 = await layout.render() assert update_2 == update_message( @@ -58,6 +60,7 @@ def SimpleStatefulComponent(): "children": [{"tagName": "div", "children": ["1"]}], }, ) + set_index(index + 1) update_3 = await layout.render() assert update_3 == update_message( @@ -278,18 +281,18 @@ def double_set_state(event): first = await display.page.wait_for_selector("#first") second = await display.page.wait_for_selector("#second") - assert (await first.get_attribute("data-value")) == "0" - assert (await second.get_attribute("data-value")) == "0" + await poll(first.get_attribute, "data-value").until_equals("0") + await poll(second.get_attribute, "data-value").until_equals("0") await button.click() - assert (await first.get_attribute("data-value")) == "1" - assert (await second.get_attribute("data-value")) == "1" + await poll(first.get_attribute, "data-value").until_equals("1") + await poll(second.get_attribute, "data-value").until_equals("1") await button.click() - assert (await first.get_attribute("data-value")) == "2" - assert (await second.get_attribute("data-value")) == "2" + await poll(first.get_attribute, "data-value").until_equals("2") + await poll(second.get_attribute, "data-value").until_equals("2") async def test_use_effect_callback_occurs_after_full_render_is_complete(): @@ -562,7 +565,7 @@ def bad_effect(): return reactpy.html.div() - with assert_reactpy_did_log(match_message=r"Layout post-render effect .* failed"): + with assert_reactpy_did_log(match_message=r"Error in effect"): async with reactpy.Layout(ComponentWithEffect()) as layout: await layout.render() # no error @@ -588,7 +591,7 @@ def bad_cleanup(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"Pre-unmount effect .*? failed", + match_message=r"Error in effect", error_type=ValueError, ): async with reactpy.Layout(OuterComponent()) as layout: @@ -1007,7 +1010,7 @@ def bad_effect(): return reactpy.html.div() with assert_reactpy_did_log( - match_message=r"post-render effect .*? failed", + match_message=r"Error in effect", error_type=ValueError, match_error="The error message", ): @@ -1030,13 +1033,15 @@ def SetStateDuringRender(): async with Layout(SetStateDuringRender()) as layout: await layout.render() - assert render_count.current == 1 - await layout.render() - assert render_count.current == 2 - # there should be no more renders to perform - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(layout.render(), timeout=0.1) + # we expect a second render to be triggered in the background + await poll(lambda: render_count.current).until_equals(2) + + # give an opportunity for a render to happen if it were to. + await asyncio.sleep(0.1) + + # however, we don't expect any more renders + assert render_count.current == 2 @pytest.mark.skipif(not REACTPY_DEBUG_MODE.current, reason="only logs in debug mode") @@ -1240,16 +1245,17 @@ async def test_error_in_component_effect_cleanup_is_gracefully_handled(): @reactpy.component @component_hook.capture def ComponentWithEffect(): - hook = current_hook() + @use_effect + def effect(): + def bad_cleanup(): + raise ValueError("The error message") - def bad_effect(): - raise ValueError("The error message") + return bad_cleanup - hook.add_effect(COMPONENT_DID_RENDER_EFFECT, bad_effect) return reactpy.html.div() with assert_reactpy_did_log( - match_message="Component post-render effect .*? failed", + match_message="Error in effect", error_type=ValueError, match_error="The error message", ): diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 215e89137..9f27727df 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -2,6 +2,7 @@ import gc import random import re +from unittest.mock import patch from weakref import finalize from weakref import ref as weakref @@ -9,7 +10,7 @@ import reactpy from reactpy import html -from reactpy.config import REACTPY_DEBUG_MODE +from reactpy.config import REACTPY_ASYNC_RENDERING, REACTPY_DEBUG_MODE from reactpy.core.component import component from reactpy.core.hooks import use_effect, use_state from reactpy.core.layout import Layout @@ -20,14 +21,22 @@ assert_reactpy_did_log, capture_reactpy_logs, ) +from reactpy.testing.common import poll from reactpy.utils import Ref from tests.tooling import select +from tests.tooling.aio import Event from tests.tooling.common import event_message, update_message from tests.tooling.hooks import use_force_render, use_toggle from tests.tooling.layout import layout_runner from tests.tooling.select import element_exists, find_element +@pytest.fixture(autouse=True, params=[True, False]) +def concurrent_rendering(request): + with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param): + yield request.param + + @pytest.fixture(autouse=True) def no_logged_errors(): with capture_reactpy_logs() as logs: @@ -164,7 +173,7 @@ def make_child_model(state): async def test_layout_render_error_has_partial_update_with_error_message(): @reactpy.component def Main(): - return reactpy.html.div([OkChild(), BadChild(), OkChild()]) + return reactpy.html.div(OkChild(), BadChild(), OkChild()) @reactpy.component def OkChild(): @@ -622,7 +631,7 @@ async def test_hooks_for_keyed_components_get_garbage_collected(): def Outer(): items, set_items = reactpy.hooks.use_state([1, 2, 3]) pop_item.current = lambda: set_items(items[:-1]) - return reactpy.html.div(Inner(key=k, finalizer_id=k) for k in items) + return reactpy.html.div([Inner(key=k, finalizer_id=k) for k in items]) @reactpy.component def Inner(finalizer_id): @@ -831,17 +840,19 @@ def some_effect(): async with reactpy.Layout(Root()) as layout: await layout.render() - assert effects == ["mount x"] + await poll(lambda: effects).until_equals(["mount x"]) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y"] + await poll(lambda: effects).until_equals(["mount x", "unmount x", "mount y"]) set_toggle.current() await layout.render() - assert effects == ["mount x", "unmount x", "mount y", "unmount y", "mount x"] + await poll(lambda: effects).until_equals( + ["mount x", "unmount x", "mount y", "unmount y", "mount x"] + ) async def test_layout_does_not_copy_element_children_by_key(): @@ -1250,3 +1261,52 @@ def App(): c, c_info = find_element(tree, select.id_equals("C")) assert c_info.path == (0, 1, 0) assert c["attributes"]["color"] == "blue" + + +async def test_concurrent_renders(concurrent_rendering): + if not concurrent_rendering: + raise pytest.skip("Concurrent rendering not enabled") + + child_1_hook = HookCatcher() + child_2_hook = HookCatcher() + child_1_rendered = Event() + child_2_rendered = Event() + child_1_render_count = Ref(0) + child_2_render_count = Ref(0) + + @component + def outer(): + return html._(child_1(), child_2()) + + @component + @child_1_hook.capture + def child_1(): + child_1_rendered.set() + child_1_render_count.current += 1 + + @component + @child_2_hook.capture + def child_2(): + child_2_rendered.set() + child_2_render_count.current += 1 + + async with Layout(outer()) as layout: + await layout.render() + + # clear render events and counts + child_1_rendered.clear() + child_2_rendered.clear() + child_1_render_count.current = 0 + child_2_render_count.current = 0 + + # we schedule two renders but expect only one + child_1_hook.latest.schedule_render() + child_1_hook.latest.schedule_render() + child_2_hook.latest.schedule_render() + child_2_hook.latest.schedule_render() + + await child_1_rendered.wait() + await child_2_rendered.wait() + + assert child_1_render_count.current == 1 + assert child_2_render_count.current == 1 diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 64be0ec8b..9b22ee866 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -5,10 +5,12 @@ from jsonpointer import set_pointer import reactpy +from reactpy.core.hooks import use_effect from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout from reactpy.core.types import LayoutUpdateMessage from reactpy.testing import StaticEventHandler +from tests.tooling.aio import Event from tests.tooling.common import event_message EVENT_NAME = "on_event" @@ -96,9 +98,10 @@ async def test_dispatch(): async def test_dispatcher_handles_more_than_one_event_at_a_time(): - block_and_never_set = asyncio.Event() - will_block = asyncio.Event() - second_event_did_execute = asyncio.Event() + did_render = Event() + block_and_never_set = Event() + will_block = Event() + second_event_did_execute = Event() blocked_handler = StaticEventHandler() non_blocked_handler = StaticEventHandler() @@ -114,6 +117,10 @@ async def block_forever(): async def handle_event(): second_event_did_execute.set() + @use_effect + def set_did_render(): + did_render.set() + return reactpy.html.div( reactpy.html.button({"on_click": block_forever}), reactpy.html.button({"on_click": handle_event}), @@ -129,11 +136,12 @@ async def handle_event(): recv_queue.get, ) ) - - await recv_queue.put(event_message(blocked_handler.target)) - await will_block.wait() - - await recv_queue.put(event_message(non_blocked_handler.target)) - await second_event_did_execute.wait() - - task.cancel() + try: + await did_render.wait() + await recv_queue.put(event_message(blocked_handler.target)) + await will_block.wait() + + await recv_queue.put(event_message(non_blocked_handler.target)) + await second_event_did_execute.wait() + finally: + task.cancel() diff --git a/src/py/reactpy/tests/tooling/aio.py b/src/py/reactpy/tests/tooling/aio.py new file mode 100644 index 000000000..b0f719400 --- /dev/null +++ b/src/py/reactpy/tests/tooling/aio.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from asyncio import Event as _Event +from asyncio import wait_for + +from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT + + +class Event(_Event): + """An event with a ``wait_for`` method.""" + + async def wait(self, timeout: float | None = None): + return await wait_for( + super().wait(), + timeout=timeout or REACTPY_TESTING_DEFAULT_TIMEOUT.current, + ) diff --git a/src/py/reactpy/tests/tooling/loop.py b/src/py/reactpy/tests/tooling/loop.py deleted file mode 100644 index f9e100981..000000000 --- a/src/py/reactpy/tests/tooling/loop.py +++ /dev/null @@ -1,91 +0,0 @@ -import asyncio -import threading -import time -from asyncio import wait_for -from collections.abc import Iterator -from contextlib import contextmanager - -from reactpy.config import REACTPY_TESTING_DEFAULT_TIMEOUT - - -@contextmanager -def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: - """Open a new event loop and cleanly stop it - - Args: - as_current: whether to make this loop the current loop in this thread - """ - loop = asyncio.new_event_loop() - try: - if as_current: - asyncio.set_event_loop(loop) - loop.set_debug(True) - yield loop - finally: - try: - _cancel_all_tasks(loop, as_current) - if as_current: - loop.run_until_complete( - wait_for( - loop.shutdown_asyncgens(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - loop.run_until_complete( - wait_for( - loop.shutdown_default_executor(), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - finally: - if as_current: - asyncio.set_event_loop(None) - start = time.time() - while loop.is_running(): - if (time.time() - start) > REACTPY_TESTING_DEFAULT_TIMEOUT.current: - msg = f"Failed to stop loop after {REACTPY_TESTING_DEFAULT_TIMEOUT.current} seconds" - raise TimeoutError(msg) - time.sleep(0.1) - loop.close() - - -def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: - to_cancel = asyncio.all_tasks(loop) - if not to_cancel: - return - - done = threading.Event() - count = len(to_cancel) - - def one_task_finished(future): - nonlocal count - count -= 1 - if count == 0: - done.set() - - for task in to_cancel: - loop.call_soon_threadsafe(task.cancel) - task.add_done_callback(one_task_finished) - - if is_current: - loop.run_until_complete( - wait_for( - asyncio.gather(*to_cancel, return_exceptions=True), - REACTPY_TESTING_DEFAULT_TIMEOUT.current, - ) - ) - elif not done.wait(timeout=3): # user was responsible for cancelling all tasks - msg = "Could not stop event loop in time" - raise TimeoutError(msg) - - for task in to_cancel: - if task.cancelled(): - continue - if task.exception() is not None: - loop.call_exception_handler( - { - "message": "unhandled exception during event loop shutdown", - "exception": task.exception(), - "task": task, - } - ) From d6f9bfe23c6ca16eeae8649632163542d44ff11e Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 9 Dec 2023 09:28:33 -0700 Subject: [PATCH 18/29] update nodejs install method in docker (#1168) * fix docker nodejs install * use install script --- docs/Dockerfile | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/Dockerfile b/docs/Dockerfile index 39b9c51be..7a5d49b7b 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,12 +1,14 @@ FROM python:3.9 - WORKDIR /app/ +RUN apt-get update + # Install NodeJS # -------------- -RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - -RUN apt-get install -y build-essential nodejs npm -RUN npm install -g npm@8.5.0 +RUN curl -SLO https://deb.nodesource.com/nsolid_setup_deb.sh +RUN chmod 500 nsolid_setup_deb.sh +RUN ./nsolid_setup_deb.sh 20 +RUN apt-get install nodejs -y # Install Poetry # -------------- From 43009e42fe8088d249f65e6632344397bd46220b Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 9 Dec 2023 19:07:31 -0700 Subject: [PATCH 19/29] fix strict eq effect test (#1170) --- src/py/reactpy/tests/test_core/test_hooks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/py/reactpy/tests/test_core/test_hooks.py b/src/py/reactpy/tests/test_core/test_hooks.py index fa6acafd1..5b8f71c62 100644 --- a/src/py/reactpy/tests/test_core/test_hooks.py +++ b/src/py/reactpy/tests/test_core/test_hooks.py @@ -1204,7 +1204,7 @@ def SomeComponent(): @pytest.mark.parametrize("get_value", STRICT_EQUALITY_VALUE_CONSTRUCTORS) async def test_use_effect_compares_with_strict_equality(get_value): effect_count = reactpy.Ref(0) - value = reactpy.Ref("string") + value = reactpy.Ref(get_value()) hook = HookCatcher() @reactpy.component @@ -1217,7 +1217,7 @@ def incr_effect_count(): async with reactpy.Layout(SomeComponent()) as layout: await layout.render() assert effect_count.current == 1 - value.current = "string" # new string instance but same value + value.current = get_value() hook.latest.schedule_render() await layout.render() # effect does not trigger From 3a3ad3f706477642133190c08bbe1e43a991bfa3 Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Wed, 27 Dec 2023 20:02:16 -0700 Subject: [PATCH 20/29] Skip rendering None in all situations (#1171) * skip rendering none * add changelog * conditional render none should not reset state for sibling components * minor renaming + better changelog * misc fixes * raises exceptiongroup * skipif * handle egroup in starlette * final nocov --- docs/source/about/changelog.rst | 17 +++++++ src/py/reactpy/pyproject.toml | 1 + src/py/reactpy/reactpy/__init__.py | 1 - src/py/reactpy/reactpy/backend/starlette.py | 15 ++++-- src/py/reactpy/reactpy/core/layout.py | 48 +++++++++-------- src/py/reactpy/reactpy/core/serve.py | 12 ++++- src/py/reactpy/reactpy/core/types.py | 11 +--- src/py/reactpy/tests/test_core/test_layout.py | 51 +++++++++++++++---- src/py/reactpy/tests/test_core/test_serve.py | 8 ++- 9 files changed, 116 insertions(+), 48 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index d874a470f..feecbd1f0 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -35,6 +35,23 @@ Unreleased the overall responsiveness of your app, particularly when handling larger renders that would otherwise block faster renders from being processed. +**Changed** + +- :pull:`1171` - Previously ``None``, when present in an HTML element, would render as + the string ``"None"``. Now ``None`` will not render at all. This is consistent with + how ``None`` is handled when returned from components. It also makes it easier to + conditionally render elements. For example, previously you would have needed to use a + fragment to conditionally render an element by writing + ``something if condition else html._()``. Now you can simply write + ``something if condition else None``. + +**Deprecated** + +- :pull:`1171` - The ``Stop`` exception. Recent releases of ``anyio`` have made this + exception difficult to use since it now raises an ``ExceptionGroup``. This exception + was primarily used for internal testing purposes and so is now deprecated. + + v1.0.2 ------ diff --git a/src/py/reactpy/pyproject.toml b/src/py/reactpy/pyproject.toml index 67189808b..309248507 100644 --- a/src/py/reactpy/pyproject.toml +++ b/src/py/reactpy/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: Implementation :: PyPy", ] dependencies = [ + "exceptiongroup >=1.0", "typing-extensions >=3.10", "mypy-extensions >=0.4.3", "anyio >=3", diff --git a/src/py/reactpy/reactpy/__init__.py b/src/py/reactpy/reactpy/__init__.py index 63a8550cc..49e357441 100644 --- a/src/py/reactpy/reactpy/__init__.py +++ b/src/py/reactpy/reactpy/__init__.py @@ -16,7 +16,6 @@ use_state, ) from reactpy.core.layout import Layout -from reactpy.core.serve import Stop from reactpy.core.vdom import vdom from reactpy.utils import Ref, html_to_vdom, vdom_to_html diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 2953b97b3..9bc68db47 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Callable +from exceptiongroup import BaseExceptionGroup from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request @@ -137,8 +138,6 @@ async def serve_index(request: Request) -> HTMLResponse: def _setup_single_view_dispatcher_route( options: Options, app: Starlette, component: RootComponentConstructor ) -> None: - @app.websocket_route(str(STREAM_PATH)) - @app.websocket_route(f"{STREAM_PATH}/{{path:path}}") async def model_stream(socket: WebSocket) -> None: await socket.accept() send, recv = _make_send_recv_callbacks(socket) @@ -162,8 +161,16 @@ async def model_stream(socket: WebSocket) -> None: send, recv, ) - except WebSocketDisconnect as error: - logger.info(f"WebSocket disconnect: {error.code}") + except BaseExceptionGroup as egroup: + for e in egroup.exceptions: + if isinstance(e, WebSocketDisconnect): + logger.info(f"WebSocket disconnect: {e.code}") + break + else: # nocov + raise + + app.add_websocket_route(str(STREAM_PATH), model_stream) + app.add_websocket_route(f"{STREAM_PATH}/{{path:path}}", model_stream) def _make_send_recv_callbacks( diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index d59ab31eb..70bdbbbff 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -11,7 +11,7 @@ wait, ) from collections import Counter -from collections.abc import Iterator +from collections.abc import Sequence from contextlib import AsyncExitStack from logging import getLogger from typing import ( @@ -27,6 +27,7 @@ from weakref import ref as weakref from anyio import Semaphore +from typing_extensions import TypeAlias from reactpy.config import ( REACTPY_ASYNC_RENDERING, @@ -37,8 +38,10 @@ from reactpy.core.types import ( ComponentType, EventHandlerDict, + Key, LayoutEventMessage, LayoutUpdateMessage, + VdomChild, VdomDict, VdomJson, ) @@ -189,9 +192,7 @@ async def _render_component( # wrap the model in a fragment (i.e. tagName="") to ensure components have # a separate node in the model state tree. This could be removed if this # components are given a node in the tree some other way - wrapper_model: VdomDict = {"tagName": ""} - if raw_model is not None: - wrapper_model["children"] = [raw_model] + wrapper_model: VdomDict = {"tagName": "", "children": [raw_model]} await self._render_model(exit_stack, old_state, new_state, wrapper_model) except Exception as error: logger.exception(f"Failed to render {component}") @@ -329,11 +330,11 @@ async def _render_model_children( await self._unmount_model_states(list(old_state.children_by_key.values())) return None - child_type_key_tuples = list(_process_child_type_and_key(raw_children)) + children_info = _get_children_info(raw_children) - new_keys = {item[2] for item in child_type_key_tuples} - if len(new_keys) != len(raw_children): - key_counter = Counter(item[2] for item in child_type_key_tuples) + new_keys = {k for _, _, k in children_info} + if len(new_keys) != len(children_info): + key_counter = Counter(item[2] for item in children_info) duplicate_keys = [key for key, count in key_counter.items() if count > 1] msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" raise ValueError(msg) @@ -345,7 +346,7 @@ async def _render_model_children( ) new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(child_type_key_tuples): + for index, (child, child_type, key) in enumerate(children_info): old_child_state = old_state.children_by_key.get(key) if child_type is _DICT_TYPE: old_child_state = old_state.children_by_key.get(key) @@ -420,17 +421,17 @@ async def _render_model_children_without_old_state( new_state: _ModelState, raw_children: list[Any], ) -> None: - child_type_key_tuples = list(_process_child_type_and_key(raw_children)) + children_info = _get_children_info(raw_children) - new_keys = {item[2] for item in child_type_key_tuples} - if len(new_keys) != len(raw_children): - key_counter = Counter(item[2] for item in child_type_key_tuples) + new_keys = {k for _, _, k in children_info} + if len(new_keys) != len(children_info): + key_counter = Counter(k for _, _, k in children_info) duplicate_keys = [key for key, count in key_counter.items() if count > 1] msg = f"Duplicate keys {duplicate_keys} at {new_state.patch_path or '/'!r}" raise ValueError(msg) new_state.model.current["children"] = [] - for index, (child, child_type, key) in enumerate(child_type_key_tuples): + for index, (child, child_type, key) in enumerate(children_info): if child_type is _DICT_TYPE: child_state = _make_element_model_state(new_state, index, key) await self._render_model(exit_stack, None, child_state, child) @@ -609,7 +610,7 @@ def __init__( key: Any, model: Ref[VdomJson], patch_path: str, - children_by_key: dict[str, _ModelState], + children_by_key: dict[Key, _ModelState], targets_by_event: dict[str, str], life_cycle_state: _LifeCycleState | None = None, ): @@ -720,16 +721,17 @@ async def get(self) -> _Type: return value -def _process_child_type_and_key( - children: list[Any], -) -> Iterator[tuple[Any, _ElementType, Any]]: +def _get_children_info(children: list[VdomChild]) -> Sequence[_ChildInfo]: + infos: list[_ChildInfo] = [] for index, child in enumerate(children): - if isinstance(child, dict): + if child is None: + continue + elif isinstance(child, dict): child_type = _DICT_TYPE key = child.get("key") elif isinstance(child, ComponentType): child_type = _COMPONENT_TYPE - key = getattr(child, "key", None) + key = child.key else: child = f"{child}" child_type = _STRING_TYPE @@ -738,8 +740,12 @@ def _process_child_type_and_key( if key is None: key = index - yield (child, child_type, key) + infos.append((child, child_type, key)) + return infos + + +_ChildInfo: TypeAlias = tuple[Any, "_ElementType", Key] # used in _process_child_type_and_key _ElementType = NewType("_ElementType", int) diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 3a530e854..3a540af59 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -3,6 +3,7 @@ from collections.abc import Awaitable from logging import getLogger from typing import Callable +from warnings import warn from anyio import create_task_group from anyio.abc import TaskGroup @@ -24,7 +25,9 @@ class Stop(BaseException): - """Stop serving changes and events + """Deprecated + + Stop serving changes and events Raising this error will tell dispatchers to gracefully exit. Typically this is called by code running inside a layout to tell it to stop rendering. @@ -42,7 +45,12 @@ async def serve_layout( async with create_task_group() as task_group: task_group.start_soon(_single_outgoing_loop, layout, send) task_group.start_soon(_single_incoming_loop, task_group, layout, recv) - except Stop: + except Stop: # nocov + warn( + "The Stop exception is deprecated and will be removed in a future version", + UserWarning, + stacklevel=1, + ) logger.info(f"Stopped serving {layout}") diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index e5a81814f..39a9b3534 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -91,7 +91,7 @@ async def __aexit__( VdomAttributes = Mapping[str, Any] """Describes the attributes of a :class:`VdomDict`""" -VdomChild: TypeAlias = "ComponentType | VdomDict | str" +VdomChild: TypeAlias = "ComponentType | VdomDict | str | None | Any" """A single child element of a :class:`VdomDict`""" VdomChildren: TypeAlias = "Sequence[VdomChild] | VdomChild" @@ -100,14 +100,7 @@ async def __aexit__( class _VdomDictOptional(TypedDict, total=False): key: Key | None - children: Sequence[ - # recursive types are not allowed yet: - # https://github.com/python/mypy/issues/731 - ComponentType - | dict[str, Any] - | str - | Any - ] + children: Sequence[ComponentType | VdomChild] attributes: VdomAttributes eventHandlers: EventHandlerDict importSource: ImportSourceDict diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 9f27727df..6eec7a8d2 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -102,15 +102,6 @@ def SimpleComponent(): ) -async def test_component_can_return_none(): - @reactpy.component - def SomeComponent(): - return None - - async with reactpy.Layout(SomeComponent()) as layout: - assert (await layout.render())["model"] == {"tagName": ""} - - async def test_nested_component_layout(): parent_set_state = reactpy.Ref(None) child_set_state = reactpy.Ref(None) @@ -1310,3 +1301,45 @@ def child_2(): assert child_1_render_count.current == 1 assert child_2_render_count.current == 1 + + +async def test_none_does_not_render(): + @component + def Root(): + return html.div(None, Child()) + + @component + def Child(): + return None + + async with layout_runner(Layout(Root())) as runner: + tree = await runner.render() + assert tree == { + "tagName": "", + "children": [ + {"tagName": "div", "children": [{"tagName": "", "children": []}]} + ], + } + + +async def test_conditionally_render_none_does_not_trigger_state_change_in_siblings(): + toggle_condition = Ref() + effect_run_count = Ref(0) + + @component + def Root(): + condition, toggle_condition.current = use_toggle(True) + return html.div("text" if condition else None, Child()) + + @component + def Child(): + @reactpy.use_effect + def effect(): + effect_run_count.current += 1 + + async with layout_runner(Layout(Root())) as runner: + await runner.render() + poll(lambda: effect_run_count.current).until_equals(1) + toggle_condition.current() + await runner.render() + assert effect_run_count.current == 1 diff --git a/src/py/reactpy/tests/test_core/test_serve.py b/src/py/reactpy/tests/test_core/test_serve.py index 9b22ee866..bae3c1e01 100644 --- a/src/py/reactpy/tests/test_core/test_serve.py +++ b/src/py/reactpy/tests/test_core/test_serve.py @@ -1,7 +1,9 @@ import asyncio +import sys from collections.abc import Sequence from typing import Any +import pytest from jsonpointer import set_pointer import reactpy @@ -31,7 +33,7 @@ async def send(patch): changes.append(patch) sem.release() if not events_to_inject: - raise reactpy.Stop() + raise Exception("Stop running") async def recv(): await sem.acquire() @@ -90,10 +92,12 @@ def Counter(): return reactpy.html.div({EVENT_NAME: handler, "count": count}) +@pytest.mark.skipif(sys.version_info < (3, 11), reason="ExceptionGroup not available") async def test_dispatch(): events, expected_model = make_events_and_expected_model() changes, send, recv = make_send_recv_callbacks(events) - await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1) + with pytest.raises(ExceptionGroup): + await asyncio.wait_for(serve_layout(Layout(Counter()), send, recv), 1) assert_changes_produce_expected_model(changes, expected_model) From 21011917ac799a0bcd1f887eefa83198d01ddcbc Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sat, 10 Feb 2024 10:59:16 -0800 Subject: [PATCH 21/29] fix black lint (#1193) --- .../_examples/set_remove.py | 6 ++--- .../_examples/set_update.py | 6 ++--- pyproject.toml | 6 ++--- .../reactpy/reactpy/core/_life_cycle_hook.py | 3 +-- src/py/reactpy/reactpy/core/events.py | 6 ++--- src/py/reactpy/reactpy/core/hooks.py | 27 +++++++------------ src/py/reactpy/reactpy/core/types.py | 17 +++++------- src/py/reactpy/reactpy/core/vdom.py | 9 +++---- src/py/reactpy/reactpy/web/module.py | 6 ++--- src/py/reactpy/reactpy/widgets.py | 3 +-- .../test_rewrite_camel_case_props.py | 6 ++--- .../tests/test__console/test_rewrite_keys.py | 6 ++--- src/py/reactpy/tests/test_core/test_layout.py | 6 ++--- tasks.py | 3 +-- 14 files changed, 43 insertions(+), 67 deletions(-) diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py index be5366cb2..abe55a918 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_remove.py @@ -24,9 +24,9 @@ def handle_click(event): "style": { "height": "30px", "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", + "background_color": ( + "black" if index in selected_indices else "white" + ), "outline": "1px solid grey", "cursor": "pointer", }, diff --git a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py index 8ff2e1ca4..27f170a42 100644 --- a/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py +++ b/docs/source/guides/adding-interactivity/dangers-of-mutability/_examples/set_update.py @@ -21,9 +21,9 @@ def handle_click(event): "style": { "height": "30px", "width": "30px", - "background_color": "black" - if index in selected_indices - else "white", + "background_color": ( + "black" if index in selected_indices else "white" + ), "outline": "1px solid grey", "cursor": "pointer", }, diff --git a/pyproject.toml b/pyproject.toml index 3cf94e23f..775ab01a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,10 +12,10 @@ detached = true dependencies = [ "invoke", # lint - "black", - "ruff==0.0.278", # Ruff is moving really fast, so pinning for now. + "black==24.1.1", # Pin lint tools we don't control to avoid breaking changes + "ruff==0.0.278", # Pin lint tools we don't control to avoid breaking changes "toml", - "flake8", + "flake8==7.0.0", # Pin lint tools we don't control to avoid breaking changes "flake8-pyproject", "reactpy-flake8 >=0.7", # types diff --git a/src/py/reactpy/reactpy/core/_life_cycle_hook.py b/src/py/reactpy/reactpy/core/_life_cycle_hook.py index ea5e6d634..88d3386a8 100644 --- a/src/py/reactpy/reactpy/core/_life_cycle_hook.py +++ b/src/py/reactpy/reactpy/core/_life_cycle_hook.py @@ -13,8 +13,7 @@ class EffectFunc(Protocol): - async def __call__(self, stop: Event) -> None: - ... + async def __call__(self, stop: Event) -> None: ... logger = logging.getLogger(__name__) diff --git a/src/py/reactpy/reactpy/core/events.py b/src/py/reactpy/reactpy/core/events.py index cd5de3228..f715b7e9d 100644 --- a/src/py/reactpy/reactpy/core/events.py +++ b/src/py/reactpy/reactpy/core/events.py @@ -15,8 +15,7 @@ def event( *, stop_propagation: bool = ..., prevent_default: bool = ..., -) -> EventHandler: - ... +) -> EventHandler: ... @overload @@ -25,8 +24,7 @@ def event( *, stop_propagation: bool = ..., prevent_default: bool = ..., -) -> Callable[[Callable[..., Any]], EventHandler]: - ... +) -> Callable[[Callable[..., Any]], EventHandler]: ... def event( diff --git a/src/py/reactpy/reactpy/core/hooks.py b/src/py/reactpy/reactpy/core/hooks.py index 4513dadef..640cbf14c 100644 --- a/src/py/reactpy/reactpy/core/hooks.py +++ b/src/py/reactpy/reactpy/core/hooks.py @@ -42,13 +42,11 @@ @overload -def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: - ... +def use_state(initial_value: Callable[[], _Type]) -> State[_Type]: ... @overload -def use_state(initial_value: _Type) -> State[_Type]: - ... +def use_state(initial_value: _Type) -> State[_Type]: ... def use_state(initial_value: _Type | Callable[[], _Type]) -> State[_Type]: @@ -105,16 +103,14 @@ def dispatch(new: _Type | Callable[[_Type], _Type]) -> None: def use_effect( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_EffectApplyFunc], None]: - ... +) -> Callable[[_EffectApplyFunc], None]: ... @overload def use_effect( function: _EffectApplyFunc, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> None: - ... +) -> None: ... def use_effect( @@ -313,16 +309,14 @@ def dispatch(action: _ActionType) -> None: def use_callback( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> Callable[[_CallbackFunc], _CallbackFunc]: - ... +) -> Callable[[_CallbackFunc], _CallbackFunc]: ... @overload def use_callback( function: _CallbackFunc, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _CallbackFunc: - ... +) -> _CallbackFunc: ... def use_callback( @@ -358,24 +352,21 @@ def setup(function: _CallbackFunc) -> _CallbackFunc: class _LambdaCaller(Protocol): """MyPy doesn't know how to deal with TypeVars only used in function return""" - def __call__(self, func: Callable[[], _Type]) -> _Type: - ... + def __call__(self, func: Callable[[], _Type]) -> _Type: ... @overload def use_memo( function: None = None, dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _LambdaCaller: - ... +) -> _LambdaCaller: ... @overload def use_memo( function: Callable[[], _Type], dependencies: Sequence[Any] | ellipsis | None = ..., -) -> _Type: - ... +) -> _Type: ... def use_memo( diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 39a9b3534..b451be30a 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -159,8 +159,7 @@ class _JsonImportSource(TypedDict): class EventHandlerFunc(Protocol): """A coroutine which can handle event data""" - async def __call__(self, data: Sequence[Any]) -> None: - ... + async def __call__(self, data: Sequence[Any]) -> None: ... @runtime_checkable @@ -192,18 +191,17 @@ class VdomDictConstructor(Protocol): """Standard function for constructing a :class:`VdomDict`""" @overload - def __call__(self, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: - ... + def __call__( + self, attributes: VdomAttributes, *children: VdomChildren + ) -> VdomDict: ... @overload - def __call__(self, *children: VdomChildren) -> VdomDict: - ... + def __call__(self, *children: VdomChildren) -> VdomDict: ... @overload def __call__( self, *attributes_and_children: VdomAttributes | VdomChildren - ) -> VdomDict: - ... + ) -> VdomDict: ... class LayoutUpdateMessage(TypedDict): @@ -236,8 +234,7 @@ def __call__( *children: Any, value: _Type = ..., key: Key | None = ..., - ) -> ContextProviderType[_Type]: - ... + ) -> ContextProviderType[_Type]: ... class ContextProviderType(ComponentType, Protocol[_Type]): diff --git a/src/py/reactpy/reactpy/core/vdom.py b/src/py/reactpy/reactpy/core/vdom.py index 840a09c7c..e494b5269 100644 --- a/src/py/reactpy/reactpy/core/vdom.py +++ b/src/py/reactpy/reactpy/core/vdom.py @@ -125,13 +125,11 @@ def is_vdom(value: Any) -> bool: @overload -def vdom(tag: str, *children: VdomChildren) -> VdomDict: - ... +def vdom(tag: str, *children: VdomChildren) -> VdomDict: ... @overload -def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: - ... +def vdom(tag: str, attributes: VdomAttributes, *children: VdomChildren) -> VdomDict: ... def vdom( @@ -345,8 +343,7 @@ def __call__( children: Sequence[VdomChild], key: Key | None, event_handlers: EventHandlerDict, - ) -> VdomDict: - ... + ) -> VdomDict: ... class _EllipsisRepr: diff --git a/src/py/reactpy/reactpy/web/module.py b/src/py/reactpy/reactpy/web/module.py index 48322fe24..c3192da4e 100644 --- a/src/py/reactpy/reactpy/web/module.py +++ b/src/py/reactpy/reactpy/web/module.py @@ -314,8 +314,7 @@ def export( export_names: str, fallback: Any | None = ..., allow_children: bool = ..., -) -> VdomDictConstructor: - ... +) -> VdomDictConstructor: ... @overload @@ -324,8 +323,7 @@ def export( export_names: list[str] | tuple[str, ...], fallback: Any | None = ..., allow_children: bool = ..., -) -> list[VdomDictConstructor]: - ... +) -> list[VdomDictConstructor]: ... def export( diff --git a/src/py/reactpy/reactpy/widgets.py b/src/py/reactpy/reactpy/widgets.py index 29f941447..63b45a7e0 100644 --- a/src/py/reactpy/reactpy/widgets.py +++ b/src/py/reactpy/reactpy/widgets.py @@ -82,8 +82,7 @@ def sync_inputs(event: dict[str, Any]) -> None: class _CastFunc(Protocol[_CastTo_co]): - def __call__(self, value: str) -> _CastTo_co: - ... + def __call__(self, value: str) -> _CastTo_co: ... if TYPE_CHECKING: diff --git a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py b/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py index 47b8baabc..ca928cf3b 100644 --- a/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py +++ b/src/py/reactpy/tests/test__console/test_rewrite_camel_case_props.py @@ -106,9 +106,9 @@ def test_rewrite_camel_case_props_declarations_no_files(): None, ), ], - ids=lambda item: " ".join(map(str.strip, item.split())) - if isinstance(item, str) - else item, + ids=lambda item: ( + " ".join(map(str.strip, item.split())) if isinstance(item, str) else item + ), ) def test_generate_rewrite(source, expected): actual = generate_rewrite(Path("test.py"), dedent(source).strip()) diff --git a/src/py/reactpy/tests/test__console/test_rewrite_keys.py b/src/py/reactpy/tests/test__console/test_rewrite_keys.py index da0b26c4f..95c49a019 100644 --- a/src/py/reactpy/tests/test__console/test_rewrite_keys.py +++ b/src/py/reactpy/tests/test__console/test_rewrite_keys.py @@ -225,9 +225,9 @@ def func(): None, ), ], - ids=lambda item: " ".join(map(str.strip, item.split())) - if isinstance(item, str) - else item, + ids=lambda item: ( + " ".join(map(str.strip, item.split())) if isinstance(item, str) else item + ), ) def test_generate_rewrite(source, expected): actual = generate_rewrite(Path("test.py"), dedent(source).strip()) diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index 6eec7a8d2..cfb544758 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -48,8 +48,7 @@ def no_logged_errors(): def test_layout_repr(): @reactpy.component - def MyComponent(): - ... + def MyComponent(): ... my_component = MyComponent() layout = reactpy.Layout(my_component) @@ -65,8 +64,7 @@ def test_layout_expects_abstract_component(): async def test_layout_cannot_be_used_outside_context_manager(caplog): @reactpy.component - def Component(): - ... + def Component(): ... component = Component() layout = reactpy.Layout(component) diff --git a/tasks.py b/tasks.py index 65f75b208..e11d291e3 100644 --- a/tasks.py +++ b/tasks.py @@ -28,8 +28,7 @@ class ReleasePrepFunc(Protocol): def __call__( self, context: Context, package: PackageInfo - ) -> Callable[[bool], None]: - ... + ) -> Callable[[bool], None]: ... LanguageName: TypeAlias = "Literal['py', 'js']" From 618e5792ef7f6288647efed3dcc7dda61df8d957 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 5 Mar 2024 18:50:00 -0800 Subject: [PATCH 22/29] Update PR template and VSCode workspace (#1053) --- .github/pull_request_template.md | 18 +++++++++--------- .vscode/extensions.json | 12 ++++++++++++ docs/source/about/changelog.rst | 13 ++++--------- 3 files changed, 25 insertions(+), 18 deletions(-) create mode 100644 .vscode/extensions.json diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d762951b3..a55532008 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,14 +1,14 @@ -By submitting this pull request you agree that all contributions to this project are made under the MIT license. +## Description -## Issues + - - -## Solution +## Checklist - +Please update this checklist as you complete each item: -## Checklist +- [ ] Tests have been developed for bug fixes or new functionality. +- [ ] The changelog has been updated, if necessary. +- [ ] Documentation has been updated, if necessary. +- [ ] GitHub Issues closed by this PR have been linked. -- [ ] Tests have been included for all bug fixes or added functionality. -- [ ] The `changelog.rst` has been updated with any significant changes. +By submitting this pull request I agree that all contributions comply with this project's open source license(s). diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..7471953dc --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "wholroyd.jinja", + "esbenp.prettier-vscode", + "ms-python.vscode-pylance", + "ms-python.python", + "charliermarsh.ruff", + "dbaeumer.vscode-eslint", + "ms-python.black-formatter", + "ms-python.mypy-type-checker" + ] +} diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index feecbd1f0..bc8e164b4 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -3,15 +3,10 @@ Changelog .. note:: - The ReactPy team manages their short and long term plans with `GitHub Projects - `__. If you have questions about what - the team are working on, or have feedback on how issues should be prioritized, feel - free to :discussion-type:`open up a discussion `. - -All notable changes to this project will be recorded in this document. The style of -which is based on `Keep a Changelog `__. The versioning -scheme for the project adheres to `Semantic Versioning `__. For -more info, see the :ref:`Contributor Guide `. + All notable changes to this project will be recorded in this document. The style of + which is based on `Keep a Changelog `__. The versioning + scheme for the project adheres to `Semantic Versioning `__. For + more info, see the :ref:`Contributor Guide `. .. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS From 3f05f81e53f28afa06c6c3f3c80c3ef0b9fe1aa6 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Tue, 5 Mar 2024 19:13:39 -0800 Subject: [PATCH 23/29] Use `utf-8` for reading files (#1200) --- docs/source/about/changelog.rst | 1 + src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py | 2 +- src/py/reactpy/reactpy/_console/rewrite_keys.py | 2 +- src/py/reactpy/reactpy/web/module.py | 4 ++-- src/py/reactpy/reactpy/web/utils.py | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index bc8e164b4..b2297c20c 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -22,6 +22,7 @@ Unreleased - :pull:`1118` - `module_from_template` is broken with a recent release of `requests` - :pull:`1131` - `module_from_template` did not work when using Flask backend +- :pull:`1200` - Fixed `UnicodeDecodeError` when using `reactpy.web.export` **Added** diff --git a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py b/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py index e5d1860c2..d706adecf 100644 --- a/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py +++ b/src/py/reactpy/reactpy/_console/rewrite_camel_case_props.py @@ -29,7 +29,7 @@ def rewrite_camel_case_props(paths: list[str]) -> None: for p in map(Path, paths): for f in [p] if p.is_file() else p.rglob("*.py"): - result = generate_rewrite(file=f, source=f.read_text()) + result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8")) if result is not None: f.write_text(result) diff --git a/src/py/reactpy/reactpy/_console/rewrite_keys.py b/src/py/reactpy/reactpy/_console/rewrite_keys.py index 64ed42f33..08db9e227 100644 --- a/src/py/reactpy/reactpy/_console/rewrite_keys.py +++ b/src/py/reactpy/reactpy/_console/rewrite_keys.py @@ -51,7 +51,7 @@ def rewrite_keys(paths: list[str]) -> None: for p in map(Path, paths): for f in [p] if p.is_file() else p.rglob("*.py"): - result = generate_rewrite(file=f, source=f.read_text()) + result = generate_rewrite(file=f, source=f.read_text(encoding="utf-8")) if result is not None: f.write_text(result) diff --git a/src/py/reactpy/reactpy/web/module.py b/src/py/reactpy/reactpy/web/module.py index c3192da4e..e1a5db82f 100644 --- a/src/py/reactpy/reactpy/web/module.py +++ b/src/py/reactpy/reactpy/web/module.py @@ -145,7 +145,7 @@ def module_from_template( raise ValueError(msg) variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} - content = Template(template_file.read_text()).substitute(variables) + content = Template(template_file.read_text(encoding="utf-8")).substitute(variables) return module_from_string( _FROM_TEMPLATE_DIR + "/" + package_name, @@ -270,7 +270,7 @@ def module_from_string( target_file = _web_module_path(name) - if target_file.exists() and target_file.read_text() != content: + if target_file.exists() and target_file.read_text(encoding="utf-8") != content: logger.info( f"Existing web module {name!r} will " f"be replaced with {target_file.resolve()}" diff --git a/src/py/reactpy/reactpy/web/utils.py b/src/py/reactpy/reactpy/web/utils.py index 295559496..338fa504a 100644 --- a/src/py/reactpy/reactpy/web/utils.py +++ b/src/py/reactpy/reactpy/web/utils.py @@ -29,7 +29,7 @@ def resolve_module_exports_from_file( return set() export_names, references = resolve_module_exports_from_source( - file.read_text(), exclude_default=is_re_export + file.read_text(encoding="utf-8"), exclude_default=is_re_export ) for ref in references: From 2c6a1f78674399f12d3071924969f6e56cecfc0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 19:27:03 -0800 Subject: [PATCH 24/29] Bump postcss from 8.4.24 to 8.4.35 in /src/js (#1205) Bumps [postcss](https://github.com/postcss/postcss) from 8.4.24 to 8.4.35. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.4.24...8.4.35) --- updated-dependencies: - dependency-name: postcss dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- src/js/package-lock.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 2edfdd260..d836d2798 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -28,7 +28,7 @@ "@types/react": "^17.0", "@types/react-dom": "^17.0", "typescript": "^4.9.5", - "vite": "^3.1.8" + "vite": "^3.2.7" } }, "app/node_modules/@reactpy/client": { @@ -2429,9 +2429,9 @@ "dev": true }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { @@ -2712,9 +2712,9 @@ } }, "node_modules/postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "funding": [ { @@ -2731,7 +2731,7 @@ } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -3955,7 +3955,7 @@ "@types/react-dom": "^17.0", "preact": "^10.7.0", "typescript": "^4.9.5", - "vite": "^3.1.8" + "vite": "^3.2.7" }, "dependencies": { "@reactpy/client": { @@ -5285,9 +5285,9 @@ "dev": true }, "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true }, "natural-compare": { @@ -5476,12 +5476,12 @@ "dev": true }, "postcss": { - "version": "8.4.24", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz", - "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "requires": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } From 34a2d24dab0501f553c43a8d6c9737d58599f899 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 03:41:15 +0000 Subject: [PATCH 25/29] Bump postcss from 8.4.21 to 8.4.35 in /src/js/app (#1208) --- src/js/app/package-lock.json | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/js/app/package-lock.json b/src/js/app/package-lock.json index 9794c53d6..1a254ec74 100644 --- a/src/js/app/package-lock.json +++ b/src/js/app/package-lock.json @@ -540,9 +540,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { @@ -579,9 +579,9 @@ "dev": true }, "node_modules/postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "funding": [ { @@ -591,10 +591,14 @@ { "type": "tidelift", "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "dependencies": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" }, @@ -1064,9 +1068,9 @@ } }, "nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true }, "object-assign": { @@ -1088,12 +1092,12 @@ "dev": true }, "postcss": { - "version": "8.4.21", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.21.tgz", - "integrity": "sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==", + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", "dev": true, "requires": { - "nanoid": "^3.3.4", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } From 97c3b19a6ebeacd9b3b0d58cfbd294fc1b66a119 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:01:03 -0800 Subject: [PATCH 26/29] Bump vite from 3.2.7 to 3.2.8 in /src/js (#1207) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 3.2.7 to 3.2.8. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v3.2.8/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v3.2.8/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mark Bakhit --- src/js/app/package.json | 2 +- src/js/package-lock.json | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/js/app/package.json b/src/js/app/package.json index 40ce94739..55a42fd66 100644 --- a/src/js/app/package.json +++ b/src/js/app/package.json @@ -12,7 +12,7 @@ "@types/react": "^17.0", "@types/react-dom": "^17.0", "typescript": "^4.9.5", - "vite": "^3.2.7" + "vite": "^3.2.8" }, "repository": { "type": "git", diff --git a/src/js/package-lock.json b/src/js/package-lock.json index d836d2798..9e5f9678e 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -28,7 +28,7 @@ "@types/react": "^17.0", "@types/react-dom": "^17.0", "typescript": "^4.9.5", - "vite": "^3.2.7" + "vite": "^3.2.8" } }, "app/node_modules/@reactpy/client": { @@ -3328,9 +3328,9 @@ } }, "node_modules/vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz", + "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==", "dev": true, "dependencies": { "esbuild": "^0.15.9", @@ -3955,7 +3955,7 @@ "@types/react-dom": "^17.0", "preact": "^10.7.0", "typescript": "^4.9.5", - "vite": "^3.2.7" + "vite": "^3.2.8" }, "dependencies": { "@reactpy/client": { @@ -5888,9 +5888,9 @@ } }, "vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz", + "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==", "dev": true, "requires": { "esbuild": "^0.15.9", From 044eb17c7200b31086e4357a4cf04c133cd5f37c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:11:10 -0800 Subject: [PATCH 27/29] Bump vite from 3.2.7 to 3.2.8 in /src/js/app (#1206) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 3.2.7 to 3.2.8. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v3.2.8/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v3.2.8/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Mark Bakhit --- src/js/app/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/js/app/package-lock.json b/src/js/app/package-lock.json index 1a254ec74..adc398279 100644 --- a/src/js/app/package-lock.json +++ b/src/js/app/package-lock.json @@ -13,7 +13,7 @@ "@types/react": "^17.0", "@types/react-dom": "^17.0", "typescript": "^4.9.5", - "vite": "^3.2.7" + "vite": "^3.2.8" } }, "node_modules/@esbuild/android-arm": { @@ -719,9 +719,9 @@ } }, "node_modules/vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz", + "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==", "dev": true, "dependencies": { "esbuild": "^0.15.9", @@ -1177,9 +1177,9 @@ "dev": true }, "vite": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.7.tgz", - "integrity": "sha512-29pdXjk49xAP0QBr0xXqu2s5jiQIXNvE/xwd0vUizYT2Hzqe4BksNNoWllFVXJf4eLZ+UlVQmXfB4lWrc+t18g==", + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.8.tgz", + "integrity": "sha512-EtQU16PLIJpAZol2cTLttNP1mX6L0SyI0pgQB1VOoWeQnMSvtiwovV3D6NcjN8CZQWWyESD2v5NGnpz5RvgOZA==", "dev": true, "requires": { "esbuild": "^0.15.9", From 4307a09dfa75e6c1b10a285e5ae4bdf0323cd018 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 Mar 2024 07:21:50 +0000 Subject: [PATCH 28/29] Bump word-wrap from 1.2.3 to 1.2.5 in /src/js (#1209) --- src/js/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 9e5f9678e..91b7f302c 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -3474,9 +3474,9 @@ } }, "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -5976,9 +5976,9 @@ } }, "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true }, "wrappy": { From f6f13f0e0f483217111481e2c1d4e7697116f9a9 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Thu, 7 Mar 2024 19:26:20 -0800 Subject: [PATCH 29/29] Concurrent rendering naming fixes (#1211) --- docs/source/about/changelog.rst | 8 ++++---- src/py/reactpy/reactpy/config.py | 4 ++-- src/py/reactpy/reactpy/core/layout.py | 8 +++++--- src/py/reactpy/tests/test_core/test_layout.py | 8 ++++---- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index b2297c20c..9fc13e015 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -26,10 +26,10 @@ Unreleased **Added** -- :pull:`1165` - Allow concurrent renders of discrete component tree - enable this - experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This should improve - the overall responsiveness of your app, particularly when handling larger renders - that would otherwise block faster renders from being processed. +- :pull:`1165` - Allow concurrently rendering discrete component trees - enable this + experimental feature by setting `REACTPY_ASYNC_RENDERING=true`. This improves + the overall responsiveness of your app in situations where larger renders would + otherwise block smaller renders from executing. **Changed** diff --git a/src/py/reactpy/reactpy/config.py b/src/py/reactpy/reactpy/config.py index 8ea6aed03..d08cdc218 100644 --- a/src/py/reactpy/reactpy/config.py +++ b/src/py/reactpy/reactpy/config.py @@ -82,9 +82,9 @@ def boolean(value: str | bool | int) -> bool: """A default timeout for testing utilities in ReactPy""" REACTPY_ASYNC_RENDERING = Option( - "REACTPY_CONCURRENT_RENDERING", + "REACTPY_ASYNC_RENDERING", default=False, mutable=True, validator=boolean, ) -"""Whether to render components concurrently. This is currently an experimental feature.""" +"""Whether to render components asynchronously. This is currently an experimental feature.""" diff --git a/src/py/reactpy/reactpy/core/layout.py b/src/py/reactpy/reactpy/core/layout.py index 70bdbbbff..f45becf7a 100644 --- a/src/py/reactpy/reactpy/core/layout.py +++ b/src/py/reactpy/reactpy/core/layout.py @@ -129,7 +129,7 @@ async def deliver(self, event: LayoutEventMessage) -> None: async def render(self) -> LayoutUpdateMessage: if REACTPY_ASYNC_RENDERING.current: - return await self._concurrent_render() + return await self._parallel_render() else: # nocov return await self._serial_render() @@ -147,8 +147,10 @@ async def _serial_render(self) -> LayoutUpdateMessage: # nocov else: return await self._create_layout_update(model_state) - async def _concurrent_render(self) -> LayoutUpdateMessage: - """Await the next available render. This will block until a component is updated""" + async def _parallel_render(self) -> LayoutUpdateMessage: + """Await to fetch the first completed render within our asyncio task group. + We use the `asyncio.tasks.wait` API in order to return the first completed task. + """ await self._render_tasks_ready.acquire() done, _ = await wait(self._render_tasks, return_when=FIRST_COMPLETED) update_task: Task[LayoutUpdateMessage] = done.pop() diff --git a/src/py/reactpy/tests/test_core/test_layout.py b/src/py/reactpy/tests/test_core/test_layout.py index cfb544758..f93ffeb3d 100644 --- a/src/py/reactpy/tests/test_core/test_layout.py +++ b/src/py/reactpy/tests/test_core/test_layout.py @@ -32,7 +32,7 @@ @pytest.fixture(autouse=True, params=[True, False]) -def concurrent_rendering(request): +def async_rendering(request): with patch.object(REACTPY_ASYNC_RENDERING, "current", request.param): yield request.param @@ -1252,9 +1252,9 @@ def App(): assert c["attributes"]["color"] == "blue" -async def test_concurrent_renders(concurrent_rendering): - if not concurrent_rendering: - raise pytest.skip("Concurrent rendering not enabled") +async def test_async_renders(async_rendering): + if not async_rendering: + raise pytest.skip("Async rendering not enabled") child_1_hook = HookCatcher() child_2_hook = HookCatcher()