From 83557a44ccd83cb95a98d694a46d782ce7ec5a97 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sun, 26 Feb 2023 16:57:00 -0800 Subject: [PATCH 01/24] Fix `idom.run` uvicorn self.servers exception --- src/reactpy/backend/_common.py | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 555e2c7f6..c44f06efb 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -7,8 +7,7 @@ from typing import Any, Awaitable, Sequence, cast from asgiref.typing import ASGIApplication -from uvicorn.config import Config as UvicornConfig -from uvicorn.server import Server as UvicornServer +import uvicorn from reactpy import __file__ as _reactpy_file_path from reactpy import html @@ -31,9 +30,9 @@ async def serve_development_asgi( port: int, started: asyncio.Event | None, ) -> None: - """Run a development server for starlette""" - server = UvicornServer( - UvicornConfig( + """Run a development server for an ASGI application""" + server = uvicorn.Server( + uvicorn.Config( app, host=host, port=port, @@ -41,22 +40,8 @@ async def serve_development_asgi( reload=True, ) ) - - coros: list[Awaitable[Any]] = [server.serve()] - - if started: - coros.append(_check_if_started(server, started)) - - try: - await asyncio.gather(*coros) - finally: - await asyncio.wait_for(server.shutdown(), timeout=3) - - -async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None: - while not server.started: - await asyncio.sleep(0.2) - started.set() + server.config.setup_event_loop() + await server.serve() def safe_client_build_dir_path(path: str) -> Path: From ba0c95be4b23c6dfa6071e4dfe74d4bc582716ca Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 1 Mar 2023 14:50:12 -0800 Subject: [PATCH 02/24] `started` compatibility --- src/reactpy/backend/_common.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index c44f06efb..3ab14ce7e 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -41,7 +41,19 @@ async def serve_development_asgi( ) ) server.config.setup_event_loop() - await server.serve() + 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)) + + await asyncio.gather(*coros) + + +async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: + while not server.started: + await asyncio.sleep(0.2) + started.set() def safe_client_build_dir_path(path: str) -> Path: From 0b47b0f9a6d03faa54ff848130fa2db3e8e3b87b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 01:54:37 -0800 Subject: [PATCH 03/24] try fixing tests --- src/reactpy/backend/_common.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 3ab14ce7e..a026bd7ba 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -40,7 +40,6 @@ async def serve_development_asgi( reload=True, ) ) - 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` From 0c3a91a825ccc576112ddefb8f7ec408d6280d38 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 02:04:18 -0800 Subject: [PATCH 04/24] use exception suppression --- src/reactpy/backend/_common.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index a026bd7ba..247118b6b 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import contextlib import os from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -40,13 +41,15 @@ async def serve_development_asgi( reload=True, ) ) + 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)) - await asyncio.gather(*coros) + with contextlib.suppress(Exception): + await asyncio.gather(*coros) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: From 8f4ac8c586bd3eba8c37c20769fb5d5ab738005e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 02:09:24 -0800 Subject: [PATCH 05/24] try again --- src/reactpy/backend/_common.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 247118b6b..10491b63e 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import contextlib import os from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -48,8 +47,11 @@ async def serve_development_asgi( if started: coros.append(_check_if_started(server, started)) - with contextlib.suppress(Exception): + try: await asyncio.gather(*coros) + except Exception: + if server.started: + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: From 1657b89dea194de0a980024675dd6a76564a5be7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 02:16:29 -0800 Subject: [PATCH 06/24] try again --- src/reactpy/backend/_common.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 10491b63e..cbdbf6cfb 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -49,9 +49,10 @@ async def serve_development_asgi( try: await asyncio.gather(*coros) - except Exception: - if server.started: - await asyncio.wait_for(server.shutdown(), timeout=3) + finally: + if not server.servers: + server.servers = [] + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: From 74f5298394659343be931b622eb954c305a6e720 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 02:23:45 -0800 Subject: [PATCH 07/24] try alternative shutdown --- src/reactpy/backend/_common.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index cbdbf6cfb..870fedba5 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -50,9 +50,7 @@ async def serve_development_asgi( try: await asyncio.gather(*coros) finally: - if not server.servers: - server.servers = [] - await asyncio.wait_for(server.shutdown(), timeout=3) + pass async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: From 15a0eae0b026fa36a15616d3610128563ca1fc72 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 02:29:30 -0800 Subject: [PATCH 08/24] Revert "try alternative shutdown" This reverts commit 74f5298394659343be931b622eb954c305a6e720. --- src/reactpy/backend/_common.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 870fedba5..cbdbf6cfb 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -50,7 +50,9 @@ async def serve_development_asgi( try: await asyncio.gather(*coros) finally: - pass + if not server.servers: + server.servers = [] + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: uvicorn.Server, started: asyncio.Event) -> None: From 55ecd27bd5a06205868d9cfe230668893d445ebf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 18:47:58 -0800 Subject: [PATCH 09/24] use hasattr --- src/reactpy/backend/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index cbdbf6cfb..6c847bb65 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -50,7 +50,7 @@ async def serve_development_asgi( try: await asyncio.gather(*coros) finally: - if not server.servers: + if not hasattr(server, "servers"): server.servers = [] await asyncio.wait_for(server.shutdown(), timeout=3) From 8ffcf8bdfa200f79848d8851e1fb2b7a2a08339b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 18:49:55 -0800 Subject: [PATCH 10/24] remove victorybar tests --- tests/test_backend/test_all.py | 7 ------- tests/test_web/test_module.py | 15 --------------- 2 files changed, 22 deletions(-) diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index fcf6b7286..4eac6aab2 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -72,13 +72,6 @@ def Counter(): await counter.click() -async def test_module_from_template(display: DisplayFixture): - victory = reactpy.web.module_from_template("react", "victory-bar@35.4.0") - VictoryBar = reactpy.web.export(victory, "VictoryBar") - await display.show(VictoryBar) - await display.page.wait_for_selector(".VictoryContainer") - - async def test_use_connection(display: DisplayFixture): conn = reactpy.Ref() diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 4b0de2af1..e4f342c3f 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -77,21 +77,6 @@ def ShowSimpleButton(): await display.page.wait_for_selector("#my-button") -def test_module_from_template_where_template_does_not_exist(): - with pytest.raises(ValueError, match="No template for 'does-not-exist.js'"): - reactpy.web.module_from_template("does-not-exist", "something.js") - - -async def test_module_from_template(display: DisplayFixture): - victory = reactpy.web.module_from_template("react@18.2.0", "victory-bar@35.4.0") - - assert "react@18.2.0" in victory.file.read_text() - VictoryBar = reactpy.web.export(victory, "VictoryBar") - await display.show(VictoryBar) - - await display.page.wait_for_selector(".VictoryContainer") - - async def test_module_from_file(display: DisplayFixture): SimpleButton = reactpy.web.export( reactpy.web.module_from_file( From f70d99d86a5361374cf435632da1ebe56f2f4233 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 3 Mar 2023 22:16:42 -0800 Subject: [PATCH 11/24] add no cov --- src/reactpy/backend/_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 6c847bb65..36af72804 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -50,7 +50,7 @@ async def serve_development_asgi( try: await asyncio.gather(*coros) finally: - if not hasattr(server, "servers"): + if not hasattr(server, "servers"): # pragma: no cover server.servers = [] await asyncio.wait_for(server.shutdown(), timeout=3) From 4ed3caed558ef426ec25ea28d5e6b35caad9e7de Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 28 Mar 2023 22:53:07 -0700 Subject: [PATCH 12/24] change pragmas --- src/reactpy/backend/_common.py | 7 +++++-- src/reactpy/backend/flask.py | 4 ++-- src/reactpy/backend/sanic.py | 4 ++-- src/reactpy/backend/starlette.py | 4 ++-- src/reactpy/backend/tornado.py | 4 ++-- src/reactpy/backend/utils.py | 6 ++---- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 36af72804..4c49ced53 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -6,8 +6,8 @@ from pathlib import Path, PurePosixPath from typing import Any, Awaitable, Sequence, cast -from asgiref.typing import ASGIApplication import uvicorn +from asgiref.typing import ASGIApplication from reactpy import __file__ as _reactpy_file_path from reactpy import html @@ -50,6 +50,9 @@ async def serve_development_asgi( 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"): # pragma: no cover server.servers = [] await asyncio.wait_for(server.shutdown(), timeout=3) @@ -69,7 +72,7 @@ def safe_client_build_dir_path(path: str) -> Path: ) -def safe_web_modules_dir_path(path: str) -> Path: +def safe_web_modules_dir_path(path: str) -> Path: # pragma: no cover """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py index 1e07159c9..19dfbe1c3 100644 --- a/src/reactpy/backend/flask.py +++ b/src/reactpy/backend/flask.py @@ -129,8 +129,8 @@ def use_request() -> Request: def use_connection() -> Connection[_FlaskCarrier]: """Get the current :class:`Connection`""" conn = _use_connection() - if not isinstance(conn.carrier, _FlaskCarrier): - raise TypeError( # pragma: no cover + if not isinstance(conn.carrier, _FlaskCarrier): # pragma: no cover + raise TypeError( f"Connection has unexpected carrier {conn.carrier}. " "Are you running with a Flask server?" ) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index 41c59f7db..cead587b1 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -82,8 +82,8 @@ def use_websocket() -> WebSocketConnection: def use_connection() -> Connection[_SanicCarrier]: """Get the current :class:`Connection`""" conn = _use_connection() - if not isinstance(conn.carrier, _SanicCarrier): - raise TypeError( # pragma: no cover + if not isinstance(conn.carrier, _SanicCarrier): # pragma: no cover + raise TypeError( f"Connection has unexpected carrier {conn.carrier}. " "Are you running with a Sanic server?" ) diff --git a/src/reactpy/backend/starlette.py b/src/reactpy/backend/starlette.py index 234737cf1..4ab16e91b 100644 --- a/src/reactpy/backend/starlette.py +++ b/src/reactpy/backend/starlette.py @@ -78,8 +78,8 @@ def use_websocket() -> WebSocket: def use_connection() -> Connection[WebSocket]: conn = _use_connection() - if not isinstance(conn.carrier, WebSocket): - raise TypeError( # pragma: no cover + if not isinstance(conn.carrier, WebSocket): # pragma: no cover + raise TypeError( f"Connection has unexpected carrier {conn.carrier}. " "Are you running with a Flask server?" ) diff --git a/src/reactpy/backend/tornado.py b/src/reactpy/backend/tornado.py index 30fd174f7..c2a034ab2 100644 --- a/src/reactpy/backend/tornado.py +++ b/src/reactpy/backend/tornado.py @@ -100,8 +100,8 @@ def use_request() -> HTTPServerRequest: def use_connection() -> Connection[HTTPServerRequest]: conn = _use_connection() - if not isinstance(conn.carrier, HTTPServerRequest): - raise TypeError( # pragma: no cover + if not isinstance(conn.carrier, HTTPServerRequest): # pragma: no cover + raise TypeError( f"Connection has unexpected carrier {conn.carrier}. " "Are you running with a Flask server?" ) diff --git a/src/reactpy/backend/utils.py b/src/reactpy/backend/utils.py index 5105cf9c0..b7576cfb0 100644 --- a/src/reactpy/backend/utils.py +++ b/src/reactpy/backend/utils.py @@ -85,10 +85,8 @@ def all_implementations() -> Iterator[BackendImplementation[Any]]: logger.debug(f"Failed to import {name!r}", exc_info=True) continue - if not isinstance(module, BackendImplementation): - raise TypeError( # pragma: no cover - f"{module.__name__!r} is an invalid implementation" - ) + if not isinstance(module, BackendImplementation): # pragma: no cover + raise TypeError(f"{module.__name__!r} is an invalid implementation") yield module From bb2732cf638605ec9de703a4593dc3a3a8149393 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 28 Mar 2023 22:53:15 -0700 Subject: [PATCH 13/24] remove module_from_template --- src/reactpy/web/module.py | 80 --------------------------------------- 1 file changed, 80 deletions(-) diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index efdd2395f..27d7f0252 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -78,86 +78,6 @@ def module_from_url( _FROM_TEMPLATE_DIR = "__from_template__" -def module_from_template( - template: str, - package: str, - cdn: str = "https://esm.sh", - fallback: Any | None = None, - resolve_exports: bool | None = None, - resolve_exports_depth: int = 5, - unmount_before_update: bool = False, -) -> WebModule: - """Create a :class:`WebModule` from a framework template - - This is useful for experimenting with component libraries that do not already - support ReactPy's :ref:`Custom Javascript Component` interface. - - .. warning:: - - This approach is not recommended for use in a production setting because the - framework templates may use unpinned dependencies that could change without - warning. It's best to author a module adhering to the - :ref:`Custom Javascript Component` interface instead. - - **Templates** - - - ``react``: for modules exporting React components - - Parameters: - template: - The name of the framework template to use with the given ``package``. - package: - The name of a package to load. May include a file extension (defaults to - ``.js`` if not given) - cdn: - Where the package should be loaded from. The CDN must distribute ESM modules - fallback: - What to temporarilly display while the module is being loaded. - resolve_imports: - Whether to try and find all the named exports of this module. - resolve_exports_depth: - How deeply to search for those exports. - unmount_before_update: - Cause the component to be unmounted before each update. This option should - only be used if the imported package failes to re-render when props change. - Using this option has negative performance consequences since all DOM - elements must be changed on each render. See :issue:`461` for more info. - """ - warn( - "module_from_template() is deprecated due to instability - use the Javascript " - "Components API instead. This function will be removed in a future release.", - DeprecationWarning, - ) - template_name, _, template_version = template.partition("@") - template_version = "@" + template_version if template_version else "" - - # We do this since the package may be any valid URL path. Thus we may need to strip - # object parameters or query information so we save the resulting template under the - # correct file name. - package_name = urlparse(package).path - - # downstream code assumes no trailing slash - cdn = cdn.rstrip("/") - - template_file_name = template_name + module_name_suffix(package_name) - - template_file = Path(__file__).parent / "templates" / template_file_name - if not template_file.exists(): - raise ValueError(f"No template for {template_file_name!r} exists") - - variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} - content = Template(template_file.read_text()).substitute(variables) - - return module_from_string( - _FROM_TEMPLATE_DIR + "/" + package_name, - content, - fallback, - resolve_exports, - resolve_exports_depth, - unmount_before_update=unmount_before_update, - ) - - def module_from_file( name: str, file: str | Path, From 96d28c198f307f4aa4606cbfe2e1f8234b8917a8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 28 Mar 2023 23:01:39 -0700 Subject: [PATCH 14/24] try to fix tests --- docs/source/about/contributor-guide.rst | 10 +-- .../_examples/material_ui_button_no_action.py | 17 ---- .../_examples/material_ui_button_on_click.py | 31 ------- .../_examples/super_simple_chart/main.py | 33 -------- .../super_simple_chart/super-simple-chart.js | 82 ------------------- .../reference/_examples/material_ui_switch.py | 23 ------ .../reference/_examples/network_graph.py | 41 ---------- .../source/reference/_examples/pigeon_maps.py | 49 ----------- .../reference/_examples/simple_dashboard.py | 68 --------------- .../reference/_examples/victory_chart.py | 8 -- src/reactpy/web/__init__.py | 2 - 11 files changed, 2 insertions(+), 362 deletions(-) delete mode 100644 docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py delete mode 100644 docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py delete mode 100644 docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py delete mode 100644 docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js delete mode 100644 docs/source/reference/_examples/material_ui_switch.py delete mode 100644 docs/source/reference/_examples/network_graph.py delete mode 100644 docs/source/reference/_examples/pigeon_maps.py delete mode 100644 docs/source/reference/_examples/victory_chart.py diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index 11ff79a43..79cd2fb4c 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -140,23 +140,17 @@ followed the `earlier instructions `_. The suite covers 3. Client-side Javascript code with UVU_ -Before running the test suite you'll need to install the required browsers by running: - -.. code-block:: bash - - playwright install - Once you've installed them you'll be able to run: .. code-block:: bash - nox -s test + nox -s check-python-tests You can observe the browser as the tests are running by passing an extra flag: .. code-block:: bash - nox -s test -- --headed + nox -s check-python-tests -- --headed To see a full list of available commands (e.g. ``nox -s ``) run: diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py deleted file mode 100644 index f109326e8..000000000 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py +++ /dev/null @@ -1,17 +0,0 @@ -from reactpy import component, run, web - - -mui = web.module_from_template( - "react@^17.0.0", - "@material-ui/core@4.12.4", - fallback="⌛", -) -Button = web.export(mui, "Button") - - -@component -def HelloWorld(): - return Button({"color": "primary", "variant": "contained"}, "Hello World!") - - -run(HelloWorld) diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py deleted file mode 100644 index ad3b13cb2..000000000 --- a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py +++ /dev/null @@ -1,31 +0,0 @@ -import json - -import reactpy - - -mui = reactpy.web.module_from_template( - "react@^17.0.0", - "@material-ui/core@4.12.4", - fallback="⌛", -) -Button = reactpy.web.export(mui, "Button") - - -@reactpy.component -def ViewButtonEvents(): - event, set_event = reactpy.hooks.use_state(None) - - return reactpy.html.div( - Button( - { - "color": "primary", - "variant": "contained", - "onClick": lambda event: set_event(event), - }, - "Click Me!", - ), - reactpy.html.pre(json.dumps(event, indent=2)), - ) - - -reactpy.run(ViewButtonEvents) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py deleted file mode 100644 index b9954b0f2..000000000 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py +++ /dev/null @@ -1,33 +0,0 @@ -from pathlib import Path - -from reactpy import component, run, web - - -file = Path(__file__).parent / "super-simple-chart.js" -ssc = web.module_from_file("super-simple-chart", file, fallback="⌛") -SuperSimpleChart = web.export(ssc, "SuperSimpleChart") - - -@component -def App(): - return SuperSimpleChart( - { - "data": [ - {"x": 1, "y": 2}, - {"x": 2, "y": 4}, - {"x": 3, "y": 7}, - {"x": 4, "y": 3}, - {"x": 5, "y": 5}, - {"x": 6, "y": 9}, - {"x": 7, "y": 6}, - ], - "height": 300, - "width": 500, - "color": "royalblue", - "lineWidth": 4, - "axisColor": "silver", - } - ) - - -run(App) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js deleted file mode 100644 index 486e5c363..000000000 --- a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js +++ /dev/null @@ -1,82 +0,0 @@ -import { h, render } from "https://unpkg.com/preact?module"; -import htm from "https://unpkg.com/htm?module"; - -const html = htm.bind(h); - -export function bind(node, config) { - return { - create: (component, props, children) => h(component, props, ...children), - render: (element) => render(element, node), - unmount: () => render(null, node), - }; -} - -export function SuperSimpleChart(props) { - const data = props.data; - const lastDataIndex = data.length - 1; - - const options = { - height: props.height || 100, - width: props.width || 100, - color: props.color || "blue", - lineWidth: props.lineWidth || 2, - axisColor: props.axisColor || "black", - }; - - const xData = data.map((point) => point.x); - const yData = data.map((point) => point.y); - - const domain = { - xMin: Math.min(...xData), - xMax: Math.max(...xData), - yMin: Math.min(...yData), - yMax: Math.max(...yData), - }; - - return html` - ${makePath(props, domain, data, options)} ${makeAxis(props, options)} - `; -} - -function makePath(props, domain, data, options) { - const { xMin, xMax, yMin, yMax } = domain; - const { width, height } = options; - const getSvgX = (x) => ((x - xMin) / (xMax - xMin)) * width; - const getSvgY = (y) => height - ((y - yMin) / (yMax - yMin)) * height; - - let pathD = - `M ${getSvgX(data[0].x)} ${getSvgY(data[0].y)} ` + - data.map(({ x, y }, i) => `L ${getSvgX(x)} ${getSvgY(y)}`).join(" "); - - return html``; -} - -function makeAxis(props, options) { - return html` - - - `; -} diff --git a/docs/source/reference/_examples/material_ui_switch.py b/docs/source/reference/_examples/material_ui_switch.py deleted file mode 100644 index ed66d83de..000000000 --- a/docs/source/reference/_examples/material_ui_switch.py +++ /dev/null @@ -1,23 +0,0 @@ -import reactpy - - -mui = reactpy.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛") -Switch = reactpy.web.export(mui, "Switch") - - -@reactpy.component -def DayNightSwitch(): - checked, set_checked = reactpy.hooks.use_state(False) - - return reactpy.html.div( - Switch( - { - "checked": checked, - "onChange": lambda event, checked: set_checked(checked), - } - ), - "🌞" if checked else "🌚", - ) - - -reactpy.run(DayNightSwitch) diff --git a/docs/source/reference/_examples/network_graph.py b/docs/source/reference/_examples/network_graph.py deleted file mode 100644 index f31bd96b0..000000000 --- a/docs/source/reference/_examples/network_graph.py +++ /dev/null @@ -1,41 +0,0 @@ -import random - -import reactpy - - -react_cytoscapejs = reactpy.web.module_from_template( - "react", - "react-cytoscapejs", - fallback="⌛", -) -Cytoscape = reactpy.web.export(react_cytoscapejs, "default") - - -@reactpy.component -def RandomNetworkGraph(): - return Cytoscape( - { - "style": {"width": "100%", "height": "200px"}, - "elements": random_network(20), - "layout": {"name": "cose"}, - } - ) - - -def random_network(number_of_nodes): - conns = [] - nodes = [{"data": {"id": 0, "label": 0}}] - - for src_node_id in range(1, number_of_nodes + 1): - tgt_node = random.choice(nodes) - src_node = {"data": {"id": src_node_id, "label": src_node_id}} - - new_conn = {"data": {"source": src_node_id, "target": tgt_node["data"]["id"]}} - - nodes.append(src_node) - conns.append(new_conn) - - return nodes + conns - - -reactpy.run(RandomNetworkGraph) diff --git a/docs/source/reference/_examples/pigeon_maps.py b/docs/source/reference/_examples/pigeon_maps.py deleted file mode 100644 index 5241a0259..000000000 --- a/docs/source/reference/_examples/pigeon_maps.py +++ /dev/null @@ -1,49 +0,0 @@ -import reactpy - - -pigeon_maps = reactpy.web.module_from_template("react", "pigeon-maps", fallback="⌛") -Map, Marker = reactpy.web.export(pigeon_maps, ["Map", "Marker"]) - - -@reactpy.component -def MapWithMarkers(): - marker_anchor, add_marker_anchor, remove_marker_anchor = use_set() - - markers = list( - map( - lambda anchor: Marker( - { - "anchor": anchor, - "onClick": lambda: remove_marker_anchor(anchor), - }, - key=str(anchor), - ), - marker_anchor, - ) - ) - - return Map( - { - "defaultCenter": (37.774, -122.419), - "defaultZoom": 12, - "height": "300px", - "metaWheelZoom": True, - "onClick": lambda event: add_marker_anchor(tuple(event["latLng"])), - }, - markers, - ) - - -def use_set(initial_value=None): - values, set_values = reactpy.hooks.use_state(initial_value or set()) - - def add_value(lat_lon): - set_values(values.union({lat_lon})) - - def remove_value(lat_lon): - set_values(values.difference({lat_lon})) - - return values, add_value, remove_value - - -reactpy.run(MapWithMarkers) diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py index 3d592f775..bb9fcd15c 100644 --- a/docs/source/reference/_examples/simple_dashboard.py +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -6,71 +6,6 @@ from reactpy.widgets import Input -victory = reactpy.web.module_from_template( - "react", - "victory-line", - fallback="⌛", - # not usually required (see issue #461 for more info) - unmount_before_update=True, -) -VictoryLine = reactpy.web.export(victory, "VictoryLine") - - -@reactpy.component -def RandomWalk(): - mu = reactpy.hooks.use_ref(0) - sigma = reactpy.hooks.use_ref(1) - - return reactpy.html.div( - RandomWalkGraph(mu, sigma), - reactpy.html.style( - """ - .number-input-container {margin-bottom: 20px} - .number-input-container input {width: 48%;float: left} - .number-input-container input + input {margin-left: 4%} - """ - ), - NumberInput( - "Mean", - mu.current, - mu.set_current, - (-1, 1, 0.01), - ), - NumberInput( - "Standard Deviation", - sigma.current, - sigma.set_current, - (0, 1, 0.01), - ), - ) - - -@reactpy.component -def RandomWalkGraph(mu, sigma): - interval = use_interval(0.5) - data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) - - @reactpy.hooks.use_effect - async def animate(): - await interval - last_data_point = data[-1] - next_data_point = { - "x": last_data_point["x"] + 1, - "y": last_data_point["y"] + random.gauss(mu.current, sigma.current), - } - set_data(data[1:] + [next_data_point]) - - return VictoryLine( - { - "data": data, - "style": { - "parent": {"width": "100%"}, - "data": {"stroke": "royalblue"}, - }, - } - ) - - @reactpy.component def NumberInput(label, value, set_value_callback, domain): minimum, maximum, step = domain @@ -98,6 +33,3 @@ async def interval() -> None: usage_time.current = time.time() return asyncio.ensure_future(interval()) - - -reactpy.run(RandomWalk) diff --git a/docs/source/reference/_examples/victory_chart.py b/docs/source/reference/_examples/victory_chart.py deleted file mode 100644 index 43bf2dbde..000000000 --- a/docs/source/reference/_examples/victory_chart.py +++ /dev/null @@ -1,8 +0,0 @@ -import reactpy - - -victory = reactpy.web.module_from_template("react", "victory-bar", fallback="⌛") -VictoryBar = reactpy.web.export(victory, "VictoryBar") - -bar_style = {"parent": {"width": "500px"}, "data": {"fill": "royalblue"}} -reactpy.run(reactpy.component(lambda: VictoryBar({"style": bar_style}))) diff --git a/src/reactpy/web/__init__.py b/src/reactpy/web/__init__.py index 6fe239ed9..3ba8974e6 100644 --- a/src/reactpy/web/__init__.py +++ b/src/reactpy/web/__init__.py @@ -2,7 +2,6 @@ export, module_from_file, module_from_string, - module_from_template, module_from_url, ) @@ -10,7 +9,6 @@ __all__ = [ "module_from_file", "module_from_string", - "module_from_template", "module_from_url", "export", ] From 22126dfa7ef4a27843ccf754fb9442539439f982 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Mar 2023 00:04:15 -0700 Subject: [PATCH 15/24] remove dead file --- .../reference/_examples/simple_dashboard.py | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 docs/source/reference/_examples/simple_dashboard.py diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py deleted file mode 100644 index bb9fcd15c..000000000 --- a/docs/source/reference/_examples/simple_dashboard.py +++ /dev/null @@ -1,35 +0,0 @@ -import asyncio -import random -import time - -import reactpy -from reactpy.widgets import Input - - -@reactpy.component -def NumberInput(label, value, set_value_callback, domain): - minimum, maximum, step = domain - attrs = {"min": minimum, "max": maximum, "step": step} - - value, set_value = reactpy.hooks.use_state(value) - - def update_value(value): - set_value(value) - set_value_callback(value) - - return reactpy.html.fieldset( - {"class_name": "number-input-container"}, - reactpy.html.legend({"style": {"font-size": "medium"}}, label), - Input(update_value, "number", value, attributes=attrs, cast=float), - Input(update_value, "range", value, attributes=attrs, cast=float), - ) - - -def use_interval(rate): - usage_time = reactpy.hooks.use_ref(time.time()) - - async def interval() -> None: - await asyncio.sleep(rate - (time.time() - usage_time.current)) - usage_time.current = time.time() - - return asyncio.ensure_future(interval()) From 2175254e84d3dc8dec7c8b8323268288b1233ad8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 29 Mar 2023 00:49:21 -0700 Subject: [PATCH 16/24] add changelog entry --- docs/source/about/changelog.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 3268f3739..e75e52cc8 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 ---------- -No changes. +**Removed** + +- :pull:`943` - remove `module_from_template` v1.0.0 From bdf96f6b5a3454e6c3af4b4e00bda4befc3f5c4b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Mar 2023 15:45:46 -0700 Subject: [PATCH 17/24] add no covers --- src/reactpy/backend/flask.py | 4 ++-- src/reactpy/backend/sanic.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py index 19dfbe1c3..e455518d1 100644 --- a/src/reactpy/backend/flask.py +++ b/src/reactpy/backend/flask.py @@ -148,13 +148,13 @@ class Options(CommonOptions): """ -def _setup_common_routes( +def _setup_common_routes( # pragma: no cover api_blueprint: Blueprint, spa_blueprint: Blueprint, options: Options, ) -> None: cors_options = options.cors - if cors_options: # pragma: no cover + if cors_options: cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index cead587b1..d5919b347 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -139,7 +139,7 @@ async def asset_files( api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - async def web_module_files( + async def web_module_files( # pragma: no cover request: request.Request, path: str, _: str = "", # this is not used From b18ccaa910ea4c0dbcb8ca7bb32d058dd448d768 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Mar 2023 16:33:43 -0700 Subject: [PATCH 18/24] move pragmas around --- src/reactpy/backend/flask.py | 6 +++--- src/reactpy/backend/sanic.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py index e455518d1..1947e872b 100644 --- a/src/reactpy/backend/flask.py +++ b/src/reactpy/backend/flask.py @@ -148,13 +148,13 @@ class Options(CommonOptions): """ -def _setup_common_routes( # pragma: no cover +def _setup_common_routes( api_blueprint: Blueprint, spa_blueprint: Blueprint, options: Options, ) -> None: cors_options = options.cors - if cors_options: + if cors_options: # pragma: no cover cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(api_blueprint, **cors_params) @@ -163,7 +163,7 @@ def send_assets_dir(path: str = "") -> Any: return send_file(safe_client_build_dir_path(f"assets/{path}")) @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: + def send_modules_dir(path: str = "") -> Any: # pragma: no cover return send_file(safe_web_modules_dir_path(path)) index_html = read_client_index_html(options) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index d5919b347..4d68f61b3 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -139,11 +139,11 @@ async def asset_files( api_blueprint.add_route(asset_files, f"/{ASSETS_PATH.name}/") - async def web_module_files( # pragma: no cover + async def web_module_files( request: request.Request, path: str, _: str = "", # this is not used - ) -> response.HTTPResponse: + ) -> response.HTTPResponse: # pragma: no cover path = urllib_parse.unquote(path) return await response.file( safe_web_modules_dir_path(path), From 09392000815480f39c4c0d2a901699e06c6c6054 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Mar 2023 17:08:25 -0700 Subject: [PATCH 19/24] fix mypy warning --- src/reactpy/backend/sanic.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index 4d68f61b3..b672b9d94 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -164,11 +164,10 @@ async def model_stream( app = request.app try: asgi_app = app._asgi_app + scope = asgi_app.transport.scope except AttributeError: # pragma: no cover logger.warning("No scope. Sanic may not be running with an ASGI server") scope: MutableMapping[str, Any] = {} - else: - scope = asgi_app.transport.scope send, recv = _make_send_recv_callbacks(socket) await serve_layout( From daa90c71b488cbc5a5ec21bdd404897658e6e586 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 31 Mar 2023 17:36:27 -0700 Subject: [PATCH 20/24] more mypy fixes --- src/reactpy/backend/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index b672b9d94..ac66e04f8 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -162,12 +162,12 @@ async def model_stream( request: request.Request, socket: WebSocketConnection, path: str = "" ) -> None: app = request.app + scope: MutableMapping[str, Any] = {} try: asgi_app = app._asgi_app scope = asgi_app.transport.scope except AttributeError: # pragma: no cover logger.warning("No scope. Sanic may not be running with an ASGI server") - scope: MutableMapping[str, Any] = {} send, recv = _make_send_recv_callbacks(socket) await serve_layout( From 89f434f752125cd0773fc705856afbe9c7e66de2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Apr 2023 14:54:46 -0700 Subject: [PATCH 21/24] undo `module_from_template` removal --- docs/source/about/changelog.rst | 4 +- .../_examples/material_ui_button_no_action.py | 17 +++ .../_examples/material_ui_button_on_click.py | 31 ++++++ .../_examples/super_simple_chart/main.py | 33 ++++++ .../super_simple_chart/super-simple-chart.js | 82 ++++++++++++++ .../reference/_examples/material_ui_switch.py | 23 ++++ .../reference/_examples/network_graph.py | 41 +++++++ .../source/reference/_examples/pigeon_maps.py | 49 +++++++++ .../reference/_examples/simple_dashboard.py | 103 ++++++++++++++++++ .../reference/_examples/victory_chart.py | 8 ++ src/reactpy/web/__init__.py | 2 + src/reactpy/web/module.py | 80 ++++++++++++++ tests/test_backend/test_all.py | 7 ++ tests/test_web/test_module.py | 15 +++ 14 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py create mode 100644 docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py create mode 100644 docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py create mode 100644 docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js create mode 100644 docs/source/reference/_examples/material_ui_switch.py create mode 100644 docs/source/reference/_examples/network_graph.py create mode 100644 docs/source/reference/_examples/pigeon_maps.py create mode 100644 docs/source/reference/_examples/simple_dashboard.py create mode 100644 docs/source/reference/_examples/victory_chart.py diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index e75e52cc8..3268f3739 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,9 +23,7 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -**Removed** - -- :pull:`943` - remove `module_from_template` +No changes. v1.0.0 diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py new file mode 100644 index 000000000..f109326e8 --- /dev/null +++ b/docs/source/guides/escape-hatches/_examples/material_ui_button_no_action.py @@ -0,0 +1,17 @@ +from reactpy import component, run, web + + +mui = web.module_from_template( + "react@^17.0.0", + "@material-ui/core@4.12.4", + fallback="⌛", +) +Button = web.export(mui, "Button") + + +@component +def HelloWorld(): + return Button({"color": "primary", "variant": "contained"}, "Hello World!") + + +run(HelloWorld) diff --git a/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py new file mode 100644 index 000000000..ad3b13cb2 --- /dev/null +++ b/docs/source/guides/escape-hatches/_examples/material_ui_button_on_click.py @@ -0,0 +1,31 @@ +import json + +import reactpy + + +mui = reactpy.web.module_from_template( + "react@^17.0.0", + "@material-ui/core@4.12.4", + fallback="⌛", +) +Button = reactpy.web.export(mui, "Button") + + +@reactpy.component +def ViewButtonEvents(): + event, set_event = reactpy.hooks.use_state(None) + + return reactpy.html.div( + Button( + { + "color": "primary", + "variant": "contained", + "onClick": lambda event: set_event(event), + }, + "Click Me!", + ), + reactpy.html.pre(json.dumps(event, indent=2)), + ) + + +reactpy.run(ViewButtonEvents) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py new file mode 100644 index 000000000..b9954b0f2 --- /dev/null +++ b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from reactpy import component, run, web + + +file = Path(__file__).parent / "super-simple-chart.js" +ssc = web.module_from_file("super-simple-chart", file, fallback="⌛") +SuperSimpleChart = web.export(ssc, "SuperSimpleChart") + + +@component +def App(): + return SuperSimpleChart( + { + "data": [ + {"x": 1, "y": 2}, + {"x": 2, "y": 4}, + {"x": 3, "y": 7}, + {"x": 4, "y": 3}, + {"x": 5, "y": 5}, + {"x": 6, "y": 9}, + {"x": 7, "y": 6}, + ], + "height": 300, + "width": 500, + "color": "royalblue", + "lineWidth": 4, + "axisColor": "silver", + } + ) + + +run(App) diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js new file mode 100644 index 000000000..486e5c363 --- /dev/null +++ b/docs/source/guides/escape-hatches/_examples/super_simple_chart/super-simple-chart.js @@ -0,0 +1,82 @@ +import { h, render } from "https://unpkg.com/preact?module"; +import htm from "https://unpkg.com/htm?module"; + +const html = htm.bind(h); + +export function bind(node, config) { + return { + create: (component, props, children) => h(component, props, ...children), + render: (element) => render(element, node), + unmount: () => render(null, node), + }; +} + +export function SuperSimpleChart(props) { + const data = props.data; + const lastDataIndex = data.length - 1; + + const options = { + height: props.height || 100, + width: props.width || 100, + color: props.color || "blue", + lineWidth: props.lineWidth || 2, + axisColor: props.axisColor || "black", + }; + + const xData = data.map((point) => point.x); + const yData = data.map((point) => point.y); + + const domain = { + xMin: Math.min(...xData), + xMax: Math.max(...xData), + yMin: Math.min(...yData), + yMax: Math.max(...yData), + }; + + return html` + ${makePath(props, domain, data, options)} ${makeAxis(props, options)} + `; +} + +function makePath(props, domain, data, options) { + const { xMin, xMax, yMin, yMax } = domain; + const { width, height } = options; + const getSvgX = (x) => ((x - xMin) / (xMax - xMin)) * width; + const getSvgY = (y) => height - ((y - yMin) / (yMax - yMin)) * height; + + let pathD = + `M ${getSvgX(data[0].x)} ${getSvgY(data[0].y)} ` + + data.map(({ x, y }, i) => `L ${getSvgX(x)} ${getSvgY(y)}`).join(" "); + + return html``; +} + +function makeAxis(props, options) { + return html` + + + `; +} diff --git a/docs/source/reference/_examples/material_ui_switch.py b/docs/source/reference/_examples/material_ui_switch.py new file mode 100644 index 000000000..ed66d83de --- /dev/null +++ b/docs/source/reference/_examples/material_ui_switch.py @@ -0,0 +1,23 @@ +import reactpy + + +mui = reactpy.web.module_from_template("react", "@material-ui/core@^5.0", fallback="⌛") +Switch = reactpy.web.export(mui, "Switch") + + +@reactpy.component +def DayNightSwitch(): + checked, set_checked = reactpy.hooks.use_state(False) + + return reactpy.html.div( + Switch( + { + "checked": checked, + "onChange": lambda event, checked: set_checked(checked), + } + ), + "🌞" if checked else "🌚", + ) + + +reactpy.run(DayNightSwitch) diff --git a/docs/source/reference/_examples/network_graph.py b/docs/source/reference/_examples/network_graph.py new file mode 100644 index 000000000..f31bd96b0 --- /dev/null +++ b/docs/source/reference/_examples/network_graph.py @@ -0,0 +1,41 @@ +import random + +import reactpy + + +react_cytoscapejs = reactpy.web.module_from_template( + "react", + "react-cytoscapejs", + fallback="⌛", +) +Cytoscape = reactpy.web.export(react_cytoscapejs, "default") + + +@reactpy.component +def RandomNetworkGraph(): + return Cytoscape( + { + "style": {"width": "100%", "height": "200px"}, + "elements": random_network(20), + "layout": {"name": "cose"}, + } + ) + + +def random_network(number_of_nodes): + conns = [] + nodes = [{"data": {"id": 0, "label": 0}}] + + for src_node_id in range(1, number_of_nodes + 1): + tgt_node = random.choice(nodes) + src_node = {"data": {"id": src_node_id, "label": src_node_id}} + + new_conn = {"data": {"source": src_node_id, "target": tgt_node["data"]["id"]}} + + nodes.append(src_node) + conns.append(new_conn) + + return nodes + conns + + +reactpy.run(RandomNetworkGraph) diff --git a/docs/source/reference/_examples/pigeon_maps.py b/docs/source/reference/_examples/pigeon_maps.py new file mode 100644 index 000000000..5241a0259 --- /dev/null +++ b/docs/source/reference/_examples/pigeon_maps.py @@ -0,0 +1,49 @@ +import reactpy + + +pigeon_maps = reactpy.web.module_from_template("react", "pigeon-maps", fallback="⌛") +Map, Marker = reactpy.web.export(pigeon_maps, ["Map", "Marker"]) + + +@reactpy.component +def MapWithMarkers(): + marker_anchor, add_marker_anchor, remove_marker_anchor = use_set() + + markers = list( + map( + lambda anchor: Marker( + { + "anchor": anchor, + "onClick": lambda: remove_marker_anchor(anchor), + }, + key=str(anchor), + ), + marker_anchor, + ) + ) + + return Map( + { + "defaultCenter": (37.774, -122.419), + "defaultZoom": 12, + "height": "300px", + "metaWheelZoom": True, + "onClick": lambda event: add_marker_anchor(tuple(event["latLng"])), + }, + markers, + ) + + +def use_set(initial_value=None): + values, set_values = reactpy.hooks.use_state(initial_value or set()) + + def add_value(lat_lon): + set_values(values.union({lat_lon})) + + def remove_value(lat_lon): + set_values(values.difference({lat_lon})) + + return values, add_value, remove_value + + +reactpy.run(MapWithMarkers) diff --git a/docs/source/reference/_examples/simple_dashboard.py b/docs/source/reference/_examples/simple_dashboard.py new file mode 100644 index 000000000..3d592f775 --- /dev/null +++ b/docs/source/reference/_examples/simple_dashboard.py @@ -0,0 +1,103 @@ +import asyncio +import random +import time + +import reactpy +from reactpy.widgets import Input + + +victory = reactpy.web.module_from_template( + "react", + "victory-line", + fallback="⌛", + # not usually required (see issue #461 for more info) + unmount_before_update=True, +) +VictoryLine = reactpy.web.export(victory, "VictoryLine") + + +@reactpy.component +def RandomWalk(): + mu = reactpy.hooks.use_ref(0) + sigma = reactpy.hooks.use_ref(1) + + return reactpy.html.div( + RandomWalkGraph(mu, sigma), + reactpy.html.style( + """ + .number-input-container {margin-bottom: 20px} + .number-input-container input {width: 48%;float: left} + .number-input-container input + input {margin-left: 4%} + """ + ), + NumberInput( + "Mean", + mu.current, + mu.set_current, + (-1, 1, 0.01), + ), + NumberInput( + "Standard Deviation", + sigma.current, + sigma.set_current, + (0, 1, 0.01), + ), + ) + + +@reactpy.component +def RandomWalkGraph(mu, sigma): + interval = use_interval(0.5) + data, set_data = reactpy.hooks.use_state([{"x": 0, "y": 0}] * 50) + + @reactpy.hooks.use_effect + async def animate(): + await interval + last_data_point = data[-1] + next_data_point = { + "x": last_data_point["x"] + 1, + "y": last_data_point["y"] + random.gauss(mu.current, sigma.current), + } + set_data(data[1:] + [next_data_point]) + + return VictoryLine( + { + "data": data, + "style": { + "parent": {"width": "100%"}, + "data": {"stroke": "royalblue"}, + }, + } + ) + + +@reactpy.component +def NumberInput(label, value, set_value_callback, domain): + minimum, maximum, step = domain + attrs = {"min": minimum, "max": maximum, "step": step} + + value, set_value = reactpy.hooks.use_state(value) + + def update_value(value): + set_value(value) + set_value_callback(value) + + return reactpy.html.fieldset( + {"class_name": "number-input-container"}, + reactpy.html.legend({"style": {"font-size": "medium"}}, label), + Input(update_value, "number", value, attributes=attrs, cast=float), + Input(update_value, "range", value, attributes=attrs, cast=float), + ) + + +def use_interval(rate): + usage_time = reactpy.hooks.use_ref(time.time()) + + async def interval() -> None: + await asyncio.sleep(rate - (time.time() - usage_time.current)) + usage_time.current = time.time() + + return asyncio.ensure_future(interval()) + + +reactpy.run(RandomWalk) diff --git a/docs/source/reference/_examples/victory_chart.py b/docs/source/reference/_examples/victory_chart.py new file mode 100644 index 000000000..43bf2dbde --- /dev/null +++ b/docs/source/reference/_examples/victory_chart.py @@ -0,0 +1,8 @@ +import reactpy + + +victory = reactpy.web.module_from_template("react", "victory-bar", fallback="⌛") +VictoryBar = reactpy.web.export(victory, "VictoryBar") + +bar_style = {"parent": {"width": "500px"}, "data": {"fill": "royalblue"}} +reactpy.run(reactpy.component(lambda: VictoryBar({"style": bar_style}))) diff --git a/src/reactpy/web/__init__.py b/src/reactpy/web/__init__.py index 3ba8974e6..6fe239ed9 100644 --- a/src/reactpy/web/__init__.py +++ b/src/reactpy/web/__init__.py @@ -2,6 +2,7 @@ export, module_from_file, module_from_string, + module_from_template, module_from_url, ) @@ -9,6 +10,7 @@ __all__ = [ "module_from_file", "module_from_string", + "module_from_template", "module_from_url", "export", ] diff --git a/src/reactpy/web/module.py b/src/reactpy/web/module.py index 27d7f0252..efdd2395f 100644 --- a/src/reactpy/web/module.py +++ b/src/reactpy/web/module.py @@ -78,6 +78,86 @@ def module_from_url( _FROM_TEMPLATE_DIR = "__from_template__" +def module_from_template( + template: str, + package: str, + cdn: str = "https://esm.sh", + fallback: Any | None = None, + resolve_exports: bool | None = None, + resolve_exports_depth: int = 5, + unmount_before_update: bool = False, +) -> WebModule: + """Create a :class:`WebModule` from a framework template + + This is useful for experimenting with component libraries that do not already + support ReactPy's :ref:`Custom Javascript Component` interface. + + .. warning:: + + This approach is not recommended for use in a production setting because the + framework templates may use unpinned dependencies that could change without + warning. It's best to author a module adhering to the + :ref:`Custom Javascript Component` interface instead. + + **Templates** + + - ``react``: for modules exporting React components + + Parameters: + template: + The name of the framework template to use with the given ``package``. + package: + The name of a package to load. May include a file extension (defaults to + ``.js`` if not given) + cdn: + Where the package should be loaded from. The CDN must distribute ESM modules + fallback: + What to temporarilly display while the module is being loaded. + resolve_imports: + Whether to try and find all the named exports of this module. + resolve_exports_depth: + How deeply to search for those exports. + unmount_before_update: + Cause the component to be unmounted before each update. This option should + only be used if the imported package failes to re-render when props change. + Using this option has negative performance consequences since all DOM + elements must be changed on each render. See :issue:`461` for more info. + """ + warn( + "module_from_template() is deprecated due to instability - use the Javascript " + "Components API instead. This function will be removed in a future release.", + DeprecationWarning, + ) + template_name, _, template_version = template.partition("@") + template_version = "@" + template_version if template_version else "" + + # We do this since the package may be any valid URL path. Thus we may need to strip + # object parameters or query information so we save the resulting template under the + # correct file name. + package_name = urlparse(package).path + + # downstream code assumes no trailing slash + cdn = cdn.rstrip("/") + + template_file_name = template_name + module_name_suffix(package_name) + + template_file = Path(__file__).parent / "templates" / template_file_name + if not template_file.exists(): + raise ValueError(f"No template for {template_file_name!r} exists") + + variables = {"PACKAGE": package, "CDN": cdn, "VERSION": template_version} + content = Template(template_file.read_text()).substitute(variables) + + return module_from_string( + _FROM_TEMPLATE_DIR + "/" + package_name, + content, + fallback, + resolve_exports, + resolve_exports_depth, + unmount_before_update=unmount_before_update, + ) + + def module_from_file( name: str, file: str | Path, diff --git a/tests/test_backend/test_all.py b/tests/test_backend/test_all.py index 4eac6aab2..fcf6b7286 100644 --- a/tests/test_backend/test_all.py +++ b/tests/test_backend/test_all.py @@ -72,6 +72,13 @@ def Counter(): await counter.click() +async def test_module_from_template(display: DisplayFixture): + victory = reactpy.web.module_from_template("react", "victory-bar@35.4.0") + VictoryBar = reactpy.web.export(victory, "VictoryBar") + await display.show(VictoryBar) + await display.page.wait_for_selector(".VictoryContainer") + + async def test_use_connection(display: DisplayFixture): conn = reactpy.Ref() diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index e4f342c3f..4b0de2af1 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -77,6 +77,21 @@ def ShowSimpleButton(): await display.page.wait_for_selector("#my-button") +def test_module_from_template_where_template_does_not_exist(): + with pytest.raises(ValueError, match="No template for 'does-not-exist.js'"): + reactpy.web.module_from_template("does-not-exist", "something.js") + + +async def test_module_from_template(display: DisplayFixture): + victory = reactpy.web.module_from_template("react@18.2.0", "victory-bar@35.4.0") + + assert "react@18.2.0" in victory.file.read_text() + VictoryBar = reactpy.web.export(victory, "VictoryBar") + await display.show(VictoryBar) + + await display.page.wait_for_selector(".VictoryContainer") + + async def test_module_from_file(display: DisplayFixture): SimpleButton = reactpy.web.export( reactpy.web.module_from_file( From 14c4da56f46548796bb0b49cbdf627c1a38acebf Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Apr 2023 15:09:45 -0700 Subject: [PATCH 22/24] fix style --- src/reactpy/backend/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index acbc05cd8..d254da16a 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -4,7 +4,7 @@ import json import logging from dataclasses import dataclass -from typing import Any, MutableMapping, Tuple +from typing import Any, Tuple from urllib import parse as urllib_parse from uuid import uuid4 From f1b9e37675d026ac26ea202556273b96fa0685b2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Apr 2023 15:18:10 -0700 Subject: [PATCH 23/24] try to remove some pragmas --- src/reactpy/backend/_common.py | 2 +- src/reactpy/backend/flask.py | 2 +- src/reactpy/backend/sanic.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy/backend/_common.py b/src/reactpy/backend/_common.py index 4c49ced53..41bff8512 100644 --- a/src/reactpy/backend/_common.py +++ b/src/reactpy/backend/_common.py @@ -72,7 +72,7 @@ def safe_client_build_dir_path(path: str) -> Path: ) -def safe_web_modules_dir_path(path: str) -> Path: # pragma: no cover +def safe_web_modules_dir_path(path: str) -> Path: """Prevent path traversal out of :data:`reactpy.config.REACTPY_WEB_MODULES_DIR`""" return traversal_safe_path(REACTPY_WEB_MODULES_DIR.current, *path.split("/")) diff --git a/src/reactpy/backend/flask.py b/src/reactpy/backend/flask.py index 1947e872b..19dfbe1c3 100644 --- a/src/reactpy/backend/flask.py +++ b/src/reactpy/backend/flask.py @@ -163,7 +163,7 @@ def send_assets_dir(path: str = "") -> Any: return send_file(safe_client_build_dir_path(f"assets/{path}")) @api_blueprint.route(f"/{MODULES_PATH.name}/") - def send_modules_dir(path: str = "") -> Any: # pragma: no cover + def send_modules_dir(path: str = "") -> Any: return send_file(safe_web_modules_dir_path(path)) index_html = read_client_index_html(options) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index d254da16a..6ed6e5d60 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -143,7 +143,7 @@ async def web_module_files( request: request.Request, path: str, _: str = "", # this is not used - ) -> response.HTTPResponse: # pragma: no cover + ) -> response.HTTPResponse: path = urllib_parse.unquote(path) return await response.file( safe_web_modules_dir_path(path), From ea33932983cc1177fd5652843d3f28354c600bc7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 4 Apr 2023 15:26:06 -0700 Subject: [PATCH 24/24] more logical if statement --- src/reactpy/backend/sanic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy/backend/sanic.py b/src/reactpy/backend/sanic.py index 6ed6e5d60..91d20b838 100644 --- a/src/reactpy/backend/sanic.py +++ b/src/reactpy/backend/sanic.py @@ -163,7 +163,7 @@ async def model_stream( ) -> None: asgi_app = getattr(request.app, "_asgi_app", None) scope = asgi_app.transport.scope if asgi_app else {} - if asgi_app is None: # pragma: no cover + if not scope: # pragma: no cover logger.warning("No scope. Sanic may not be running with an ASGI server") send, recv = _make_send_recv_callbacks(socket)