From 241b10b43a85ccf927c34ae8d7f6bf9a7235a7f4 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 16:30:08 -0800 Subject: [PATCH 1/7] use fetch request to upload form data --- .../idom-client-react/src/components.js | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/src/client/packages/idom-client-react/src/components.js b/src/client/packages/idom-client-react/src/components.js index 7ca471c6e..9412ede1f 100644 --- a/src/client/packages/idom-client-react/src/components.js +++ b/src/client/packages/idom-client-react/src/components.js @@ -29,6 +29,14 @@ export function Layout({ saveUpdateHook, sendEvent, loadImportSource }) { `; } +const ELEMENT_WRAPPER_COMPONENTS = { + form: FormElement, + input: UserInputElement, + script: ScriptElement, + select: UserInputElement, + textarea: UserInputElement, +}; + export function Element({ model }) { if (model.error !== undefined) { if (model.error) { @@ -36,15 +44,16 @@ export function Element({ model }) { } else { return null; } - } else if (model.tagName == "script") { - return html`<${ScriptElement} model=${model} />`; - } else if (["input", "select", "textarea"].includes(model.tagName)) { - return html`<${UserInputElement} model=${model} />`; - } else if (model.importSource) { - return html`<${ImportedElement} model=${model} />`; + } + + let Component; + if (model.importSource) { + Component = ImportedElement; } else { - return html`<${StandardElement} model=${model} />`; + Component = ELEMENT_WRAPPER_COMPONENTS[model.tagName] || StandardElement; } + + return html`<${Component} model=${model} />`; } function StandardElement({ model }) { @@ -155,6 +164,41 @@ function ScriptElement({ model }) { return html`
`; } +function FormElement({ model }) { + const layoutContext = React.useContext(LayoutContext); + const props = createElementAttributes(model, layoutContext.sendEvent); + + if (props.target && props.target !== "_self") { + return html`<${StandardElement} model=${model} />`; + } + + const oldOnSubmit = props.onSubmit || (() => {}); + async function onSubmit(event) { + event.preventDefault(); + if (!model?.eventHandlers?.onSubmit?.preventDefault) { + await fetch(props.action || window.location.href, { + method: props.method || "POST", + body: new FormData(event.target), + }); + } + if (oldOnSubmit) { + oldOnSubmit(event); + } + } + + // Use createElement here to avoid warning about variable numbers of children not + // having keys. Warning about this must now be the responsibility of the server + // providing the models instead of the client rendering them. + return React.createElement( + model.tagName, + { ...props, onSubmit }, + ...createElementChildren( + model, + (model) => html`<${Element} key=${model.key} model=${model} />` + ) + ); +} + function ImportedElement({ model }) { const layoutContext = React.useContext(LayoutContext); From 3cd3104a2c73082a4548710ffeb6c8478eb76583 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 17:08:42 -0800 Subject: [PATCH 2/7] add test for file upload via form --- requirements/test-env.txt | 3 +++ tests/test_client.py | 52 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/requirements/test-env.txt b/requirements/test-env.txt index 7e5ab1955..6522ebd17 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -9,3 +9,6 @@ playwright # I'm not quite sure why this needs to be installed for tests with Sanic to pass sanic-testing + +# required to test uploading form data with starlette +python-multipart diff --git a/tests/test_client.py b/tests/test_client.py index 0e48e3390..60966683c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,10 +1,17 @@ import asyncio from contextlib import AsyncExitStack from pathlib import Path +from tempfile import NamedTemporaryFile from playwright.async_api import Browser +from starlette.applications import Starlette +from starlette.datastructures import UploadFile +from starlette.requests import Request +from starlette.responses import Response import idom +from idom import html +from idom.backend import starlette as starlette_backend from idom.backend.utils import find_available_port from idom.testing import BackendFixture, DisplayFixture from tests.tooling.common import DEFAULT_TYPE_DELAY @@ -125,3 +132,48 @@ async def handle_change(event): await inp.type("hello", delay=DEFAULT_TYPE_DELAY) assert (await inp.evaluate("node => node.value")) == "hello" + + +async def test_form_upload_file(page): + file_content = asyncio.Future() + + async def handle_file_upload(request: Request) -> Response: + form = await request.form() + file: UploadFile = form.get("file") + file_content.set_result((await file.read()).decode("utf-8")) + return Response() + + app = Starlette() + app.add_route("/file-upload", handle_file_upload, methods=["POST"]) + + @idom.component + def CheckUploadFile(): + return html.form( + { + "enctype": "multipart/form-data", + "action": "/file-upload", + "method": "POST", + }, + html.input({"type": "file", "name": "file", "id": "file-input"}), + html.input({"type": "submit", "id": "form-submit"}), + ) + + async with AsyncExitStack() as es: + file = Path(es.enter_context(NamedTemporaryFile()).name) + + expected_file_content = "Hello, World!" + file.write_text(expected_file_content) + + server = await es.enter_async_context( + BackendFixture(app=app, implementation=starlette_backend) + ) + display = await es.enter_async_context(DisplayFixture(server, driver=page)) + + await display.show(CheckUploadFile) + + file_input = await display.page.wait_for_selector("#file-input") + await file_input.set_input_files(file) + + await (await display.page.wait_for_selector("#form-submit")).click() + + assert (await asyncio.wait_for(file_content, 5)) == expected_file_content From 0d705943fa1b0716a15cdcf05a97335335b48f54 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 17:39:02 -0800 Subject: [PATCH 3/7] no re-runs --- noxfile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index a50ba1912..547b9f79e 100644 --- a/noxfile.py +++ b/noxfile.py @@ -181,7 +181,6 @@ def test_python_suite(session: Session) -> None: install_requirements_file(session, "test-env") session.run("playwright", "install", "chromium") posargs = session.posargs - posargs += ["--reruns", "3", "--reruns-delay", "1"] if "--no-cov" in session.posargs: session.log("Coverage won't be checked") From 54d3640e81b069056d71fca2fc1293b4d90a5571 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 18:00:46 -0800 Subject: [PATCH 4/7] only shutdown if started --- src/idom/backend/_common.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/idom/backend/_common.py b/src/idom/backend/_common.py index 90e2dea5b..85f3d29af 100644 --- a/src/idom/backend/_common.py +++ b/src/idom/backend/_common.py @@ -32,6 +32,8 @@ async def serve_development_asgi( started: asyncio.Event | None, ) -> None: """Run a development server for starlette""" + started = started or asyncio.Event() + server = UvicornServer( UvicornConfig( app, @@ -42,15 +44,11 @@ async def serve_development_asgi( ) ) - coros: list[Awaitable[Any]] = [server.serve()] - - if started: - coros.append(_check_if_started(server, started)) - try: - await asyncio.gather(*coros) + await asyncio.gather(server.serve(), _check_if_started(server, started)) finally: - await asyncio.wait_for(server.shutdown(), timeout=3) + if started.is_set(): + await asyncio.wait_for(server.shutdown(), timeout=3) async def _check_if_started(server: UvicornServer, started: asyncio.Event) -> None: From 0bcadb88df83dfd8c3aad4a9fcdc3922db20b829 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 18:10:18 -0800 Subject: [PATCH 5/7] rework module_from_url to use starlette --- tests/test_web/test_module.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 497b89787..56b2e7850 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -1,10 +1,11 @@ from pathlib import Path import pytest -from sanic import Sanic +from starlette.applications import Starlette +from starlette.responses import FileResponse import idom -from idom.backend import sanic as sanic_implementation +from idom.backend import starlette as starlette_implementation from idom.testing import ( BackendFixture, DisplayFixture, @@ -52,14 +53,12 @@ def ShowCurrentComponent(): async def test_module_from_url(browser): - app = Sanic("test_module_from_url") + app = Starlette() # instead of directing the URL to a CDN, we just point it to this static file - app.static( - "/simple-button.js", - str(JS_FIXTURES_DIR / "simple-button.js"), - content_type="text/javascript", - ) + @app.route("/simple-button.js") + async def simple_button_js(request): + return FileResponse(str(JS_FIXTURES_DIR / "simple-button.js")) SimpleButton = idom.web.export( idom.web.module_from_url("/simple-button.js", resolve_exports=False), @@ -70,7 +69,9 @@ async def test_module_from_url(browser): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - async with BackendFixture(app=app, implementation=sanic_implementation) as server: + async with BackendFixture( + app=app, implementation=starlette_implementation + ) as server: async with DisplayFixture(server, browser) as display: await display.show(ShowSimpleButton) From ff61beb4ed9f1d2f5fbeff690b32380ab2a23a0c Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 18:11:01 -0800 Subject: [PATCH 6/7] add back re-runs --- noxfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/noxfile.py b/noxfile.py index 547b9f79e..a50ba1912 100644 --- a/noxfile.py +++ b/noxfile.py @@ -181,6 +181,7 @@ def test_python_suite(session: Session) -> None: install_requirements_file(session, "test-env") session.run("playwright", "install", "chromium") posargs = session.posargs + posargs += ["--reruns", "3", "--reruns-delay", "1"] if "--no-cov" in session.posargs: session.log("Coverage won't be checked") From 3f0c998e6e2b81fb3c4d8f2244808de81a365c16 Mon Sep 17 00:00:00 2001 From: rmorshea Date: Wed, 28 Dec 2022 20:19:41 -0800 Subject: [PATCH 7/7] set max sanic version See https://github.com/sanic-org/sanic/issues/2643 --- requirements/pkg-extras.txt | 3 ++- src/idom/backend/sanic.py | 24 ++++++++++++++++++++---- src/idom/backend/utils.py | 3 +-- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 7d7a59bdb..54aab2c12 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -3,8 +3,9 @@ starlette >=0.13.6 uvicorn[standard] >=0.19.0 # extra=sanic -sanic >=21 +sanic >=21,<22.12 sanic-cors +uvicorn[standard] >=0.19.0 # extra=fastapi fastapi >=0.63.0 diff --git a/src/idom/backend/sanic.py b/src/idom/backend/sanic.py index fda9d214f..d6f79e2bc 100644 --- a/src/idom/backend/sanic.py +++ b/src/idom/backend/sanic.py @@ -122,8 +122,16 @@ async def single_page_app_files( ) -> response.HTTPResponse: return response.html(index_html) - spa_blueprint.add_route(single_page_app_files, "/") - spa_blueprint.add_route(single_page_app_files, "/<_:path>") + 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, @@ -188,8 +196,16 @@ async def model_stream( recv, ) - api_blueprint.add_websocket_route(model_stream, f"/{STREAM_PATH.name}") - api_blueprint.add_websocket_route(model_stream, f"/{STREAM_PATH.name}//") + api_blueprint.add_websocket_route( + model_stream, + f"/{STREAM_PATH.name}", + name="model_stream_root", + ) + api_blueprint.add_websocket_route( + model_stream, + f"/{STREAM_PATH.name}//", + name="model_stream_path", + ) def _make_send_recv_callbacks( diff --git a/src/idom/backend/utils.py b/src/idom/backend/utils.py index 6398d6b0d..ccfc6830e 100644 --- a/src/idom/backend/utils.py +++ b/src/idom/backend/utils.py @@ -7,10 +7,9 @@ from importlib import import_module from typing import Any, Iterator +from idom.backend.types import BackendImplementation from idom.types import RootComponentConstructor -from .types import BackendImplementation - logger = logging.getLogger(__name__)