From 754a6198b04f28e126da8c55c3a03169c1cb29ec Mon Sep 17 00:00:00 2001 From: Ryan Morshead Date: Sun, 2 Jul 2023 16:15:14 -0600 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] `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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] `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 10/12] 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 11/12] 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 12/12] 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)