diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 239281f79..8be799f5a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - uses: nanasess/setup-chromedriver@master - uses: actions/setup-node@v2 with: node-version: "14.x" @@ -29,7 +28,7 @@ jobs: run: pip install -r requirements/nox-deps.txt - name: Run Tests env: { "CI": "true" } - run: nox -s test_python_suite -- --headless --maxfail=3 + run: nox -s test_python_suite -- --maxfail=3 test-python-environments: runs-on: ${{ matrix.os }} strategy: @@ -38,7 +37,6 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@v2 - - uses: nanasess/setup-chromedriver@master - uses: actions/setup-node@v2 with: node-version: "14.x" @@ -52,7 +50,7 @@ jobs: run: pip install -r requirements/nox-deps.txt - name: Run Tests env: { "CI": "true" } - run: nox -s test_python --stop-on-first-error -- --headless --maxfail=3 --no-cov + run: nox -s test_python --stop-on-first-error -- --maxfail=3 --no-cov test-docs: runs-on: ubuntu-latest steps: diff --git a/docs/app.py b/docs/app.py index b065dd09e..c51095ee2 100644 --- a/docs/app.py +++ b/docs/app.py @@ -4,8 +4,9 @@ from sanic import Sanic, response -from idom.server.sanic import PerClientStateServer -from idom.widgets import multiview +from idom import component +from idom.core.types import ComponentConstructor +from idom.server.sanic import Options, configure, use_request from .examples import load_examples @@ -22,13 +23,13 @@ def run(): app = make_app() - PerClientStateServer( - make_examples_component(), - { - "redirect_root_to_index": False, - "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX, - }, + configure( app, + Example(), + Options( + redirect_root=False, + url_prefix=IDOM_MODEL_SERVER_URL_PREFIX, + ), ) app.run( @@ -39,8 +40,28 @@ def run(): ) +@component +def Example(): + view_id = use_request().get_args().get("view_id") + return _get_examples()[view_id]() + + +def _get_examples(): + if not _EXAMPLES: + _EXAMPLES.update(load_examples()) + return _EXAMPLES + + +def reload_examples(): + _EXAMPLES.clear() + _EXAMPLES.update(load_examples()) + + +_EXAMPLES: dict[str, ComponentConstructor] = {} + + def make_app(): - app = Sanic(__name__) + app = Sanic("docs_app") app.static("/docs", str(HERE / "build")) @@ -49,12 +70,3 @@ async def forward_to_index(request): return response.redirect("/docs/index.html") return app - - -def make_examples_component(): - mount, component = multiview() - - for example_name, example_component in load_examples(): - mount.add(example_name, example_component) - - return component diff --git a/docs/examples.py b/docs/examples.py index e5f8eacc6..b61e7295e 100644 --- a/docs/examples.py +++ b/docs/examples.py @@ -23,7 +23,7 @@ def load_examples() -> Iterator[tuple[str, Callable[[], ComponentType]]]: def all_example_names() -> set[str]: names = set() for file in _iter_example_files(SOURCE_DIR): - path = file.parent if file.name == "app.py" else file + path = file.parent if file.name == "main.py" else file names.add("/".join(path.relative_to(SOURCE_DIR).with_suffix("").parts)) return names @@ -48,7 +48,7 @@ def get_main_example_file_by_name( ) -> Path: path = _get_root_example_path_by_name(name, relative_to) if path.is_dir(): - return path / "app.py" + return path / "main.py" else: return path.with_suffix(".py") diff --git a/docs/source/_exts/idom_example.py b/docs/source/_exts/idom_example.py index 07c0a74a2..686734392 100644 --- a/docs/source/_exts/idom_example.py +++ b/docs/source/_exts/idom_example.py @@ -51,7 +51,7 @@ def run(self): if len(ex_files) == 1: labeled_tab_items.append( ( - "app.py", + "main.py", _literal_include( path=ex_files[0], linenos=show_linenos, diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index cf9a35696..f4bbab94a 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -11,13 +11,41 @@ Changelog All notable changes to this project will be recorded in this document. The style of which is based on `Keep a Changelog `__. The versioning scheme for the project adheres to `Semantic Versioning `__. For -more info, see the :ref:`Contributor Guide `. +more info, see the :ref:`Contributor Guide `. +.. INSTRUCTIONS FOR CHANGELOG CONTRIBUTORS +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +.. If you're adding a changelog entry, be sure to read the "Creating a Changelog Entry" +.. section of the documentation before doing so for instructions on how to adhere to the +.. "Keep a Changelog" style guide (https://keepachangelog.com). + Unreleased ---------- -Nothing yet... +Changed: + +- How IDOM integrates with servers - :pull:`703` + + - ``idom.run`` no longer accepts an app instance to discourage use outside of testing + - IDOM's server implementations now provide ``configure()`` functions instead + - ``idom.testing`` has been completely reworked in order to support async web drivers + +Added: + +- Access to underlying server requests via contexts - :issue:`669` + +Removed: + +- ``idom.widgets.multiview`` since basic routing view ``use_scope`` is now possible +- All ``SharedClientStateServer`` implementations. +- All ``PerClientStateServer`` implementations have been replaced with ``configure()`` + +Fixed: + +- IDOM's test suite no longer uses sync web drivers - :issue:`591` +- Updated Sanic requirement to ``>=21`` - :issue:`678` +- How we advertise ``idom.run`` - :issue:`657` 0.37.2 @@ -30,7 +58,8 @@ Changed: Fixed: -- A typo caused IDOM to use the insecure `ws` web-socket protocol on pages loaded with `https` instead of the secure `wss` protocol - :pull:`716` +- A typo caused IDOM to use the insecure ``ws`` web-socket protocol on pages loaded with + ``https`` instead of the secure ``wss`` protocol - :pull:`716` 0.37.1 diff --git a/docs/source/about/contributor-guide.rst b/docs/source/about/contributor-guide.rst index 38c362edf..b70b7cd32 100644 --- a/docs/source/about/contributor-guide.rst +++ b/docs/source/about/contributor-guide.rst @@ -66,8 +66,8 @@ about how to get started. To make a change to IDOM you'll do the following: :ref:`equality checks ` and, with any luck, accept your request. At that point your contribution will be merged into the main codebase! -Create a Changelog Entry -........................ +Creating a Changelog Entry +.......................... As part of your pull request, you'll want to edit the `Changelog `__ by diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/adding_state_variable/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/isolated_state/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/multiple_state_variables/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/app.py b/docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py similarity index 100% rename from docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/app.py rename to docs/source/guides/adding-interactivity/components-with-state/_examples/when_variables_are_not_enough/main.py diff --git a/docs/source/guides/adding-interactivity/components-with-state/index.rst b/docs/source/guides/adding-interactivity/components-with-state/index.rst index 5c9161de2..3925b3cf2 100644 --- a/docs/source/guides/adding-interactivity/components-with-state/index.rst +++ b/docs/source/guides/adding-interactivity/components-with-state/index.rst @@ -151,7 +151,7 @@ below highlights a line of code where something of interest occurs:

Initial render

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 2 @@ -165,7 +165,7 @@ below highlights a line of code where something of interest occurs:

Initial state declaration

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 3 @@ -181,7 +181,7 @@ below highlights a line of code where something of interest occurs:

Define event handler

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 5 @@ -196,7 +196,7 @@ below highlights a line of code where something of interest occurs:

Return the view

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 16 @@ -212,7 +212,7 @@ below highlights a line of code where something of interest occurs:

User interaction

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 5 @@ -226,7 +226,7 @@ below highlights a line of code where something of interest occurs:

New state is set

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 6 @@ -242,7 +242,7 @@ below highlights a line of code where something of interest occurs:

Next render begins

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 2 @@ -256,7 +256,7 @@ below highlights a line of code where something of interest occurs:

Next state is acquired

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 :emphasize-lines: 3 @@ -272,7 +272,7 @@ below highlights a line of code where something of interest occurs:

Repeat...

- .. literalinclude:: _examples/adding_state_variable/app.py + .. literalinclude:: _examples/adding_state_variable/main.py :lines: 12-33 From this point on, the steps remain the same. The only difference being the diff --git a/docs/source/guides/escape-hatches/_examples/super_simple_chart/app.py b/docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py similarity index 100% rename from docs/source/guides/escape-hatches/_examples/super_simple_chart/app.py rename to docs/source/guides/escape-hatches/_examples/super_simple_chart/main.py diff --git a/docs/source/guides/getting-started/_examples/hello_world.py b/docs/source/guides/getting-started/_examples/hello_world.py index a5621718e..1ad68582e 100644 --- a/docs/source/guides/getting-started/_examples/hello_world.py +++ b/docs/source/guides/getting-started/_examples/hello_world.py @@ -3,7 +3,7 @@ @component def App(): - return html.h1("Hello, World!") + return html.h1("Hello, world!") run(App) diff --git a/docs/source/guides/getting-started/_examples/run_fastapi.py b/docs/source/guides/getting-started/_examples/run_fastapi.py new file mode 100644 index 000000000..f114333bb --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_fastapi.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import fastapi as fastapi_server + + +# the run() function is the entry point for examples +fastapi_server.configure = lambda _, cmpt: run(cmpt) + + +from fastapi import FastAPI + +from idom import component, html +from idom.server.fastapi import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = FastAPI() +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_flask.py b/docs/source/guides/getting-started/_examples/run_flask.py new file mode 100644 index 000000000..9f64d0e15 --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_flask.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import flask as flask_server + + +# the run() function is the entry point for examples +flask_server.configure = lambda _, cmpt: run(cmpt) + + +from flask import Flask + +from idom import component, html +from idom.server.flask import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Flask(__name__) +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_sanic.py b/docs/source/guides/getting-started/_examples/run_sanic.py new file mode 100644 index 000000000..449e2b2e1 --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_sanic.py @@ -0,0 +1,27 @@ +# :lines: 11- + +from idom import run +from idom.server import sanic as sanic_server + + +# the run() function is the entry point for examples +sanic_server.configure = lambda _, cmpt: run(cmpt) + + +from sanic import Sanic + +from idom import component, html +from idom.server.sanic import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Sanic("MyApp") +configure(app, HelloWorld) + + +if __name__ == "__main__": + app.run(port=8000) diff --git a/docs/source/guides/getting-started/_examples/run_starlette.py b/docs/source/guides/getting-started/_examples/run_starlette.py new file mode 100644 index 000000000..f287b831b --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_starlette.py @@ -0,0 +1,23 @@ +# :lines: 11- + +from idom import run +from idom.server import starlette as starlette_server + + +# the run() function is the entry point for examples +starlette_server.configure = lambda _, cmpt: run(cmpt) + + +from starlette.applications import Starlette + +from idom import component, html +from idom.server.starlette import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +app = Starlette() +configure(app, HelloWorld) diff --git a/docs/source/guides/getting-started/_examples/run_tornado.py b/docs/source/guides/getting-started/_examples/run_tornado.py new file mode 100644 index 000000000..313fdf4fe --- /dev/null +++ b/docs/source/guides/getting-started/_examples/run_tornado.py @@ -0,0 +1,32 @@ +# :lines: 11- + +from idom import run +from idom.server import tornado as tornado_server + + +# the run() function is the entry point for examples +tornado_server.configure = lambda _, cmpt: run(cmpt) + + +import tornado.ioloop +import tornado.web + +from idom import component, html +from idom.server.tornado import configure + + +@component +def HelloWorld(): + return html.h1("Hello, world!") + + +def make_app(): + app = tornado.web.Application() + configure(app, HelloWorld) + return app + + +if __name__ == "__main__": + app = make_app() + app.listen(8000) + tornado.ioloop.IOLoop.current().start() diff --git a/docs/source/guides/getting-started/_examples/sample_app.py b/docs/source/guides/getting-started/_examples/sample_app.py index 610f8988a..332035e87 100644 --- a/docs/source/guides/getting-started/_examples/sample_app.py +++ b/docs/source/guides/getting-started/_examples/sample_app.py @@ -1,4 +1,4 @@ import idom -idom.run(idom.sample.App) +idom.run(idom.sample.SampleApp) diff --git a/docs/source/guides/getting-started/_static/embed-idom-view/main.py b/docs/source/guides/getting-started/_static/embed-idom-view/main.py index 0c0cb5ac0..e33173173 100644 --- a/docs/source/guides/getting-started/_static/embed-idom-view/main.py +++ b/docs/source/guides/getting-started/_static/embed-idom-view/main.py @@ -2,10 +2,10 @@ from sanic.response import file from idom import component, html -from idom.server.sanic import Config, PerClientStateServer +from idom.server.sanic import Options, configure -app = Sanic(__name__) +app = Sanic("MyApp") @app.route("/") @@ -18,6 +18,6 @@ def IdomView(): return html.code("This text came from an IDOM App") -PerClientStateServer(IdomView, app=app, config=Config(url_prefix="/_idom")) +configure(app, IdomView, Options(url_prefix="/_idom")) app.run(host="127.0.0.1", port=5000) diff --git a/docs/source/guides/getting-started/index.rst b/docs/source/guides/getting-started/index.rst index 0a5ed4daf..1aed237d9 100644 --- a/docs/source/guides/getting-started/index.rst +++ b/docs/source/guides/getting-started/index.rst @@ -18,15 +18,15 @@ Getting Started :link: installing-idom :link-type: doc - Learn how IDOM can be installed in a variety of different ways - with different web - servers and even in different frameworks. + Learn how IDOM can be installed in a variety of different ways - with + different web servers and even in different frameworks. .. grid-item-card:: :octicon:`play` Running IDOM :link: running-idom :link-type: doc - See the ways that IDOM can be run with servers or be embedded in existing - applications. + See how IDOM can be run with a variety of different production servers or be + added to existing applications. The fastest way to get started with IDOM is to try it out in a `Juptyer Notebook `__. @@ -46,19 +46,35 @@ notebook linked below will demonstrate how to do this: Section 1: Installing IDOM -------------------------- -The next fastest option is to install IDOM with ``pip``: +The next fastest option is to install IDOM along with a supported server (like +``starlette``) with ``pip``: .. code-block:: bash - pip install "idom[stable]" + pip install "idom[starlette]" To check that everything is working you can run the sample application: .. code-block:: bash - python -c "import idom; idom.run_sample_app(open_browser=True)" + python -c "import idom; idom.run(idom.sample.SampleApp)" -This should automatically open up a browser window to a page that looks like this: +.. note:: + + This launches a simple development server which is good enough for testing, but + probably not what you want to use in production. When deploying in production, + there's a number of different ways of :ref:`running IDOM
`. + +You should then see a few log messages: + +.. code-block:: text + + 2022-03-27T11:58:59-0700 | WARNING | You are running a development server. Change this before deploying in production! + 2022-03-27T11:58:59-0700 | INFO | Running with 'Starlette' at http://127.0.0.1:8000 + +The second log message includes a URL indicating where you should go to view the app. +That will usually be http://127.0.0.1:8000. Once you go to that URL you should see +something like this: .. card:: @@ -70,9 +86,8 @@ If you get a ``RuntimeError`` similar to the following: Found none of the following builtin server implementations... -Then be sure you installed ``"idom[stable]"`` and not just ``idom``. - -For anything else, report your issue in IDOM's :discussion-type:`discussion forum +Then be sure you run ``pip install "idom[starlette]"`` instead of just ``idom``. For +anything else, report your issue in IDOM's :discussion-type:`discussion forum `. .. card:: @@ -89,11 +104,11 @@ For anything else, report your issue in IDOM's :discussion-type:`discussion foru Section 2: Running IDOM ----------------------- -Once you've :ref:`installed IDOM `. The simplest way to run IDOM is -with the :func:`~idom.server.prefab.run` function. By default this will execute your -application using one of the builtin server implementations whose dependencies have all -been installed. Running a tiny "hello world" application just requires the following -code: +Once you've :ref:`installed IDOM `, you'll want to learn how to run an +application. Throughout most of the examples in this documentation, you'll see the +:func:`~idom.server.utils.run` function used. While it's convenient tool for development +it shouldn't be used in production settings - it's slow, and could leak secrets through +debug log messages. .. idom:: _examples/hello_world @@ -104,5 +119,5 @@ code: :octicon:`book` Read More ^^^^^^^^^^^^^^^^^^^^^^^^^ - See the ways that IDOM can be run with servers or be embedded in existing - applications. + See how IDOM can be run with a variety of different production servers or be + added to existing applications. diff --git a/docs/source/guides/getting-started/installing-idom.rst b/docs/source/guides/getting-started/installing-idom.rst index 1187704e4..06dffe0a7 100644 --- a/docs/source/guides/getting-started/installing-idom.rst +++ b/docs/source/guides/getting-started/installing-idom.rst @@ -1,37 +1,45 @@ Installing IDOM =============== -The easiest way to ``pip`` install idom is to do so using the ``stable`` option: +Installing IDOM with ``pip`` will generally require doing so alongside a supported +server implementation. This can be done by specifying an installation extra using square +brackets. For example, if we want to run IDOM using `Starlette +`__ we would run: .. code-block:: bash - pip install "idom[stable]" + pip install "idom[starlette]" -This includes a set of default dependencies for one of the builtin web server -implementation. If you want to install IDOM without these dependencies you may simply -``pip install idom``. +If you want to install a "pure" version of IDOM without a server implementation you can +do so without any installation extras. You might do this if you wanted to user a server +which is not officially supported or if you wanted to manually pin your dependencies: + +.. code-block:: bash + + pip install idom -Installing Other Servers ------------------------- +Officially Supported Servers +---------------------------- IDOM includes built-in support for a variety web server implementations. To install the -required dependencies for each you should substitute ``stable`` from the ``pip install`` -command above with one of the options below: +required dependencies for each you should substitute ``starlette`` from the ``pip +install`` command above with one of the options below: - ``fastapi`` - https://fastapi.tiangolo.com - ``flask`` - https://palletsprojects.com/p/flask/ - ``sanic`` - https://sanicframework.org +- ``starlette`` - https://www.starlette.io/ - ``tornado`` - https://www.tornadoweb.org/en/stable/ If you need to, you can install more than one option by separating them with commas: .. code-block:: bash - pip install idom[fastapi,flask,sanic,tornado] + pip install "idom[fastapi,flask,sanic,starlette,tornado]" Once this is complete you should be able to :ref:`run IDOM ` with your -:ref:`chosen server implementation `. +chosen server implementation. Installing In Other Frameworks @@ -40,7 +48,7 @@ Installing In Other Frameworks While IDOM can run in a variety of contexts, sometimes web frameworks require extra work in order to integrate with them. In these cases, the IDOM team distributes bindings for various frameworks as separate Python packages. For documentation on how to install and -run IDOM in the supported frameworks, follow the links below: +run IDOM in these supported frameworks, follow the links below: .. raw:: html diff --git a/docs/source/guides/getting-started/running-idom.rst b/docs/source/guides/getting-started/running-idom.rst index 1a471979b..0b913070b 100644 --- a/docs/source/guides/getting-started/running-idom.rst +++ b/docs/source/guides/getting-started/running-idom.rst @@ -1,236 +1,171 @@ Running IDOM ============ -The simplest way to run IDOM is with the :func:`~idom.server.prefab.run` function. By -default this will execute your application using one of the builtin server -implementations whose dependencies have all been installed. Running a tiny "hello world" -application just requires the following code: +The simplest way to run IDOM is with the :func:`~idom.server.utils.run` function. This +is the method you'll see used throughout this documentation. However, this executes your +application using a development server which is great for testing, but probably not what +if you're :ref:`deploying in production `. Below are some +more robust and performant ways of running IDOM with various supported servers. -.. idom:: _examples/hello_world -.. note:: - - Try clicking the **▶️ Result** tab to see what this displays! - - -Running IDOM in Debug Mode +Running IDOM in Production -------------------------- -IDOM provides a debug mode that is turned off by default. This can be enabled when you -run your application by setting the ``IDOM_DEBUG_MODE`` environment variable. - -.. tab-set:: - - .. tab-item:: Unix Shell - - .. code-block:: - - export IDOM_DEBUG_MODE=1 - python my_idom_app.py - - .. tab-item:: Command Prompt - - .. code-block:: text - - set IDOM_DEBUG_MODE=1 - python my_idom_app.py - - .. tab-item:: PowerShell - - .. code-block:: powershell +The first thing you'll need to do if you want to run IDOM in production is choose a +server implementation and follow its documentation on how to create and run an +application. This is the server :ref:`you probably chose ` +when installing IDOM. Then you'll need to configure that application with an IDOM view. +We should the basics how how to run each supported server below, but all implementations +will follow a pattern similar to the following: - $env:IDOM_DEBUG_MODE = "1" - python my_idom_app.py +.. code-block:: -.. danger:: + from my_chosen_server import Application - Leave debug mode off in production! - -Among other things, running in this mode: + from idom import component, html + from idom.server.my_chosen_server import configure -- Turns on debug log messages -- Adds checks to ensure the :ref:`VDOM` spec is adhered to -- Displays error messages that occur within your app -Errors will be displayed where the uppermost component is located in the view: + @component + def HelloWorld(): + return html.h1("Hello, world!") -.. idom:: _examples/debug_error_example + app = Application() + configure(app, HelloWorld) -Choosing a Server Implementation --------------------------------- +You'll then run this ``app`` using a `ASGI `__ or +`WSGI `__ server from the command line. -Without extra care, running an IDOM app with the ``run()`` function can be somewhat -inpredictable since the kind of server being used by default depends on what gets -discovered first. To be more explicit about which server implementation you want to run -with you can import your chosen server class and pass it to the ``server_type`` -parameter of ``run()``: -.. code-block:: +Running with `FastAPI `__ +....................................................... - from idom import component, html, run - from idom.server.sanic import PerClientStateServer +.. idom:: _examples/run_fastapi +Then assuming you put this in ``main.py``, you can run the ``app`` using `Uvicorn +`__: - @component - def App(): - return html.h1(f"Hello, World!") +.. code-block:: bash + uvicorn main:app - run(App, server_type=PerClientStateServer) -Presently IDOM's core library supports the following server implementations: +Running with `Flask `__ +............................................................. -- :mod:`idom.server.fastapi` -- :mod:`idom.server.sanic` -- :mod:`idom.server.flask` -- :mod:`idom.server.tornado` +.. idom:: _examples/run_flask -.. hint:: +Then assuming you put this in ``main.py``, you can run the ``app`` using `Gunicorn +`__: - To install them, see the :ref:`Installing Other Servers` section. +.. code-block:: bash + gunicorn main:app -Available Server Types ----------------------- -Some of server implementations have more than one server type available. The server type -which exists for all implementations is the ``PerClientStateServer``. This server type -displays a unique view to each user who visits the site. For those that support it, -there may also be a ``SharedClientStateServer`` available. This server type presents the -same view to all users who visit the site. For example, if you were to run the following -code: +Running with `Sanic `__ +................................................... -.. code-block:: +.. idom:: _examples/run_sanic - from idom import component, hooks, html, run - from idom.server.sanic import SharedClientStateServer +Then assuming you put this in ``main.py``, you can run the ``app`` using Sanic's builtin +server: +.. code-block:: bash - @component - def Slider(): - value, set_value = hooks.use_state(50) - return html.input({"type": "range", "min": 1, "max": 100, "value": value}) + sanic main.app - run(Slider, server_type=SharedClientStateServer) +Running with `Starlette `__ +...................................................... -Two clients could see the slider and see a synchronized view of it. That is, when one -client moved the slider, the other would see the slider update without their action. -This might look similar to the video below: +.. idom:: _examples/run_starlette -.. image:: _static/shared-client-state-server-slider.gif +Then assuming you put this in ``main.py``, you can run the application using `Uvicorn +`__: -Presently the following server implementations support the ``SharedClientStateServer``: +.. code-block:: bash -- :func:`idom.server.fastapi.SharedClientStateServer` -- :func:`idom.server.sanic.SharedClientStateServer` + uvicorn main:app -.. note:: - If you need to, your can :ref:`write your own server implementation `. +Running with `Tornado `__ +................................................................ -Common Server Settings ----------------------- +.. idom:: _examples/run_tornado -Each server implementation has its own high-level settings that are defined by its -respective ``Config`` (a typed dictionary). As a general rule, these ``Config`` types -expose the same options across implementations. These configuration dictionaries can -then be passed to the ``run()`` function via the ``config`` parameter: +Tornado is run using it's own builtin server rather than an external WSGI or ASGI +server. -.. code-block:: - from idom import run, component, html - from idom.server.sanic import PerClientStateServer, Config +Running IDOM in Debug Mode +-------------------------- +IDOM provides a debug mode that is turned off by default. This can be enabled when you +run your application by setting the ``IDOM_DEBUG_MODE`` environment variable. - @component - def App(): - return html.h1(f"Hello, World!") +.. tab-set:: + .. tab-item:: Unix Shell - server_config = Config( - cors=False, - url_prefix="", - serve_static_files=True, - redirect_root_to_index=True, - ) + .. code-block:: - run(App, server_type=PerClientStateServer, server_config=server_config) + export IDOM_DEBUG_MODE=1 + python my_idom_app.py -Here's the list of available configuration types: + .. tab-item:: Command Prompt -- :class:`idom.server.fastapi.Config` -- :class:`idom.server.sanic.Config` -- :class:`idom.server.flask.Config` -- :class:`idom.server.tornado.Config` + .. code-block:: text + set IDOM_DEBUG_MODE=1 + python my_idom_app.py -Specific Server Settings ------------------------- + .. tab-item:: PowerShell -The ``Config`` :ref:`described above ` is meant to be an -implementation agnostic - all ``Config`` objects support a similar set of options. -However, there are inevitably cases where you need to set up your chosen server using -implementation specific details. For instance, you might want to add an extra route to -the server your using in order to provide extra resources to your application. + .. code-block:: powershell -Doing this kind of set up can be achieved by passing in an instance of your chosen -server implementation into the ``app`` parameter of the ``run()`` function. To -illustrate, if I'm making my application with ``sanic`` and I want to add an extra route -I would do the following: + $env:IDOM_DEBUG_MODE = "1" + python my_idom_app.py -.. code-block:: +.. danger:: - from sanic import Sanic - from idom import component, html, run - from idom.server.sanic import PerClientStateServer + Leave debug mode off in production! - app = Sanic(__name__) +Among other things, running in this mode: - # these are implementation specific settings only known to `sanic` servers - app.config.REQUEST_TIMEOUT = 60 - app.config.RESPONSE_TIMEOUT = 60 +- Turns on debug log messages +- Adds checks to ensure the :ref:`VDOM` spec is adhered to +- Displays error messages that occur within your app +Errors will be displayed where the uppermost component is located in the view: - @component - def SomeView(): - return html.form({"action": }) +.. idom:: _examples/debug_error_example - run(SomeView, server_type=PerClientStateServer, app=app) +Server Configuration Options +---------------------------- +IDOM's various server implementations come with ``Options`` that can be passed to their +respective ``configure()`` functions. Those which are common amongst the options are: -Add to an Existing Web Server ------------------------------ +- ``url_prefix`` - prefix all routes configured by IDOM +- ``redirect_root`` - whether to redirect the root of the application to the IDOM view +- ``serve_static_files`` - whether to server IDOM's static files from it's default route -If you're already serving an application with one of the supported web servers listed -above, you can add an IDOM to them as a server extension. Instead of using the ``run()`` -function, you'll instantiate one of IDOM's server implementations by passing it an -instance of your existing application: +You'd then pass these options to ``configure()`` in the following way: .. code-block:: - from sanic import Sanic - - from idom import component, html - from idom.server.sanic import PerClientStateServer, Config - - existing_app = Sanic(__name__) - - - @component - def IdomView(): - return html.h1("This is an IDOM App") - - - PerClientStateServer(IdomView, app=existing_app, config=Config(url_prefix="app")) + configure(app, MyComponent, Options(...)) - existing_app.run(host="127.0.0.1", port=8000) +To learn more read the description for your chosen server implementation: -To test that everything is working, you should be able to navigate to -``https://127.0.0.1:8000/app`` where you should see the results from ``IdomView``. +- :class:`idom.server.fastapi.Options` +- :class:`idom.server.flask.Options` +- :class:`idom.server.sanic.Options` +- :class:`idom.server.starlette.Options` +- :class:`idom.server.tornado.Options` Embed in an Existing Webpage @@ -261,8 +196,8 @@ embedding one the examples from this documentation into your own webpage: As mentioned though, this is connecting to the server that is hosting this documentation. If you want to connect to a view from your own server, you'll need to -change the URL above to one you provide. One way to do this might be to :ref:`add to an -existing web server`. Another would be to run IDOM in an adjacent web server instance +change the URL above to one you provide. One way to do this might be to add to an +existing application. Another would be to run IDOM in an adjacent web server instance that you coordinate with something like `NGINX `__. For the sake of simplicity, we'll assume you do something similar to the following in an existing Python app: diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/app.py b/docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py similarity index 100% rename from docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/app.py rename to docs/source/guides/managing-state/sharing-component-state/_examples/filterable_list/main.py diff --git a/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/app.py b/docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py similarity index 100% rename from docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/app.py rename to docs/source/guides/managing-state/sharing-component-state/_examples/synced_inputs/main.py diff --git a/docs/source/reference/_examples/character_movement/app.py b/docs/source/reference/_examples/character_movement/main.py similarity index 100% rename from docs/source/reference/_examples/character_movement/app.py rename to docs/source/reference/_examples/character_movement/main.py diff --git a/noxfile.py b/noxfile.py index bd9ad2599..b954df00d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -179,7 +179,7 @@ def test_python_suite(session: Session) -> None: """Run the Python-based test suite""" session.env["IDOM_DEBUG_MODE"] = "1" install_requirements_file(session, "test-env") - + session.run("playwright", "install", "chromium") posargs = session.posargs posargs += ["--reruns", "3", "--reruns-delay", "1"] @@ -353,7 +353,7 @@ def tag(session: Session) -> None: # stage, commit, tag, and push version bump session.run("git", "add", "--all", external=True) - session.run("git", "commit", "-m", repr(f"version {new_version}"), external=True) + session.run("git", "commit", "-m", f"version {new_version}", external=True) session.run("git", "tag", version, external=True) session.run("git", "push", "origin", "main", "--tags", external=True) diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index f4d440e32..706e6fdcd 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -4,3 +4,4 @@ anyio >=3.0 jsonpatch >=1.32 fastjsonschema >=2.14.5 requests >=2.0 +colorlog >=6 diff --git a/requirements/pkg-extras.txt b/requirements/pkg-extras.txt index 3e7d3aab1..834885aa4 100644 --- a/requirements/pkg-extras.txt +++ b/requirements/pkg-extras.txt @@ -1,26 +1,23 @@ -# extra=stable,sanic -sanic <19.12.0 +# extra=starlette +starlette >=0.13.6 +uvicorn[standard] >=0.13.4 + +# extra=sanic +sanic >=21 sanic-cors # extra=fastapi fastapi >=0.63.0 uvicorn[standard] >=0.13.4 -# extra=starlette -fastapi >=0.16.0 -uvicorn[standard] >=0.13.4 - # extra=flask flask<2.0 markupsafe<2.1 flask-cors flask-sockets -# tornado +# extra=tornado tornado # extra=testing -selenium - -# extra=matplotlib -matplotlib +playwright diff --git a/requirements/test-env.txt b/requirements/test-env.txt index c277b335c..7e5ab1955 100644 --- a/requirements/test-env.txt +++ b/requirements/test-env.txt @@ -1,9 +1,11 @@ -ipython pytest -pytest-asyncio +pytest-asyncio>=0.17 pytest-cov pytest-mock pytest-rerunfailures pytest-timeout responses -selenium +playwright + +# I'm not quite sure why this needs to be installed for tests with Sanic to pass +sanic-testing diff --git a/scripts/live_docs.py b/scripts/live_docs.py index f0173d436..e21339326 100644 --- a/scripts/live_docs.py +++ b/scripts/live_docs.py @@ -1,4 +1,8 @@ +import asyncio import os +import threading +import time +import webbrowser from sphinx_autobuild.cli import ( Server, @@ -9,8 +13,8 @@ get_parser, ) -from docs.app import IDOM_MODEL_SERVER_URL_PREFIX, make_app, make_examples_component -from idom.server.sanic import PerClientStateServer +from docs.app import IDOM_MODEL_SERVER_URL_PREFIX, Example, make_app, reload_examples +from idom.server.sanic import Options, configure, serve_development_app from idom.testing import clear_idom_web_modules_dir @@ -23,19 +27,41 @@ def wrap_builder(old_builder): # This is the bit that we're injecting to get the example components to reload too - def new_builder(): - [s.stop() for s in _running_idom_servers] - clear_idom_web_modules_dir() - server = PerClientStateServer( - make_examples_component(), - {"cors": True, "url_prefix": IDOM_MODEL_SERVER_URL_PREFIX}, - make_app(), + app = make_app() + + configure( + app, + Example, + Options(cors=True, url_prefix=IDOM_MODEL_SERVER_URL_PREFIX), + ) + + thread_started = threading.Event() + + def run_in_thread(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + server_started = asyncio.Event() + + async def set_thread_event_when_started(): + await server_started.wait() + thread_started.set() + + loop.run_until_complete( + asyncio.gather( + serve_development_app(app, "127.0.0.1", 5555, server_started), + set_thread_event_when_started(), + ) ) - server.run_in_thread("127.0.0.1", 5555, debug=True) - _running_idom_servers.append(server) - server.wait_until_started() + threading.Thread(target=run_in_thread, daemon=True).start() + + thread_started.wait() + + def new_builder(): + clear_idom_web_modules_dir() + reload_examples() old_builder() return new_builder @@ -71,9 +97,14 @@ def main(): # Find the free port portn = args.port or find_free_port() if args.openbrowser is True: - server.serve(port=portn, host=args.host, root=outdir, open_url_delay=args.delay) - else: - server.serve(port=portn, host=args.host, root=outdir) + + def opener(): + time.sleep(args.delay) + webbrowser.open("http://%s:%s/index.html" % (args.host, 8000)) + + threading.Thread(target=opener, daemon=True).start() + + server.serve(port=portn, host=args.host, root=outdir) if __name__ == "__main__": diff --git a/setup.cfg b/setup.cfg index 7e05d27bd..293907987 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,9 @@ warn_unused_ignores = True [flake8] ignore = E203, E266, E501, W503, F811, N802, N806 +per-file-ignores = + # sometimes this is required in order to hide setup for an example + docs/*/_examples/*.py:E402 max-line-length = 88 max-complexity = 18 select = B,C,E,F,W,T4,B9,N,ROH @@ -24,7 +27,8 @@ testpaths = tests xfail_strict = True markers = slow: marks tests as slow (deselect with '-m "not slow"') -python_files = assert_*.py test_*.py +python_files = *asserts.py test_*.py +asyncio_mode = auto [coverage:report] fail_under = 100 diff --git a/src/idom/__init__.py b/src/idom/__init__.py index 8d250090e..336f3332f 100644 --- a/src/idom/__init__.py +++ b/src/idom/__init__.py @@ -1,7 +1,6 @@ -from . import config, html, log, types, web +from . import config, html, logging, sample, server, types, web from .core import hooks from .core.component import component -from .core.dispatcher import Stop from .core.events import event from .core.hooks import ( create_context, @@ -14,11 +13,11 @@ use_state, ) from .core.layout import Layout +from .core.serve import Stop from .core.vdom import vdom -from .sample import run_sample_app -from .server.prefab import run +from .server.utils import run from .utils import Ref, html_to_vdom -from .widgets import hotswap, multiview +from .widgets import hotswap __author__ = "idom-team" @@ -34,11 +33,11 @@ "html_to_vdom", "html", "Layout", - "log", - "multiview", + "logging", "Ref", - "run_sample_app", "run", + "sample", + "server", "Stop", "types", "use_callback", diff --git a/src/idom/core/dispatcher.py b/src/idom/core/dispatcher.py deleted file mode 100644 index 540c2757b..000000000 --- a/src/idom/core/dispatcher.py +++ /dev/null @@ -1,235 +0,0 @@ -from __future__ import annotations - -from asyncio import Future, Queue, ensure_future -from asyncio.tasks import FIRST_COMPLETED, ensure_future, gather, wait -from contextlib import asynccontextmanager -from logging import getLogger -from typing import ( - Any, - AsyncIterator, - Awaitable, - Callable, - Dict, - List, - NamedTuple, - Sequence, - Tuple, - cast, -) -from weakref import WeakSet - -from anyio import create_task_group - -from idom.utils import Ref - -from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore -from .layout import LayoutEvent, LayoutUpdate -from .types import LayoutType, VdomJson - - -logger = getLogger(__name__) - - -SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] -"""Send model patches given by a dispatcher""" - -RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] -"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEvent` - -The event will then trigger an :class:`idom.core.proto.EventHandlerType` in a layout. -""" - - -class Stop(BaseException): - """Stop dispatching changes and events - - Raising this error will tell dispatchers to gracefully exit. Typically this is - called by code running inside a layout to tell it to stop rendering. - """ - - -async def dispatch_single_view( - layout: LayoutType[LayoutUpdate, LayoutEvent], - send: SendCoroutine, - recv: RecvCoroutine, -) -> None: - """Run a dispatch loop for a single view instance""" - with layout: - try: - async with create_task_group() as task_group: - task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, layout, recv) - except Stop: - logger.info("Stopped dispatch task") - - -SharedViewDispatcher = Callable[[SendCoroutine, RecvCoroutine], Awaitable[None]] -_SharedViewDispatcherFuture = Callable[[SendCoroutine, RecvCoroutine], "Future[None]"] - - -@asynccontextmanager -async def create_shared_view_dispatcher( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> AsyncIterator[_SharedViewDispatcherFuture]: - """Enter a dispatch context where all subsequent view instances share the same state""" - with layout: - ( - dispatch_shared_view, - send_patch, - ) = await _create_shared_view_dispatcher(layout) - - dispatch_tasks: List[Future[None]] = [] - - def dispatch_shared_view_soon( - send: SendCoroutine, recv: RecvCoroutine - ) -> Future[None]: - future = ensure_future(dispatch_shared_view(send, recv)) - dispatch_tasks.append(future) - return future - - yield dispatch_shared_view_soon - - gathered_dispatch_tasks = gather(*dispatch_tasks, return_exceptions=True) - - while True: - ( - update_future, - dispatchers_completed_future, - ) = await _wait_until_first_complete( - layout.render(), - gathered_dispatch_tasks, - ) - - if dispatchers_completed_future.done(): - update_future.cancel() - break - else: - patch = VdomJsonPatch.create_from(update_future.result()) - - send_patch(patch) - - -def ensure_shared_view_dispatcher_future( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> Tuple[Future[None], SharedViewDispatcher]: - """Ensure the future of a dispatcher made by :func:`create_shared_view_dispatcher` - - This returns a future that can be awaited to block until all dispatch tasks have - completed as well as the dispatcher coroutine itself which is used to start dispatch - tasks. - - This is required in situations where usage of the async context manager from - :func:`create_shared_view_dispatcher` is not possible. Typically this happens when - integrating IDOM with other frameworks, servers, or applications. - """ - dispatcher_future: Future[SharedViewDispatcher] = Future() - - async def dispatch_shared_view_forever() -> None: - with layout: - ( - dispatch_shared_view, - send_patch, - ) = await _create_shared_view_dispatcher(layout) - - dispatcher_future.set_result(dispatch_shared_view) - - while True: - send_patch(await render_json_patch(layout)) - - async def dispatch(send: SendCoroutine, recv: RecvCoroutine) -> None: - await (await dispatcher_future)(send, recv) - - return ensure_future(dispatch_shared_view_forever()), dispatch - - -async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: - """Render a class:`VdomJsonPatch` from a layout""" - return VdomJsonPatch.create_from(await layout.render()) - - -class VdomJsonPatch(NamedTuple): - """An object describing an update to a :class:`Layout` in the form of a JSON patch""" - - path: str - """The path where changes should be applied""" - - changes: List[Dict[str, Any]] - """A list of JSON patches to apply at the given path""" - - def apply_to(self, model: VdomJson) -> VdomJson: - """Return the model resulting from the changes in this update""" - return cast( - VdomJson, - apply_patch( - model, [{**c, "path": self.path + c["path"]} for c in self.changes] - ), - ) - - @classmethod - def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: - """Return a patch given an layout update""" - return cls(update.path, make_patch(update.old or {}, update.new).patch) - - -async def _create_shared_view_dispatcher( - layout: LayoutType[LayoutUpdate, LayoutEvent], -) -> Tuple[SharedViewDispatcher, Callable[[VdomJsonPatch], None]]: - update = await layout.render() - model_state = Ref(update.new) - - # We push updates to queues instead of pushing directly to send() callbacks in - # order to isolate send_patch() from any errors send() callbacks might raise. - all_patch_queues: WeakSet[Queue[VdomJsonPatch]] = WeakSet() - - async def dispatch_shared_view(send: SendCoroutine, recv: RecvCoroutine) -> None: - patch_queue: Queue[VdomJsonPatch] = Queue() - try: - async with create_task_group() as inner_task_group: - all_patch_queues.add(patch_queue) - effective_update = LayoutUpdate("", None, model_state.current) - await send(VdomJsonPatch.create_from(effective_update)) - inner_task_group.start_soon(_single_incoming_loop, layout, recv) - inner_task_group.start_soon(_shared_outgoing_loop, send, patch_queue) - except Stop: - logger.info("Stopped dispatch task") - finally: - all_patch_queues.remove(patch_queue) - return None - - def send_patch(patch: VdomJsonPatch) -> None: - model_state.current = patch.apply_to(model_state.current) - for queue in all_patch_queues: - queue.put_nowait(patch) - - return dispatch_shared_view, send_patch - - -async def _single_outgoing_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine -) -> None: - while True: - await send(await render_json_patch(layout)) - - -async def _single_incoming_loop( - layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine -) -> None: - while True: - # We need to fire and forget here so that we avoid waiting on the completion - # of this event handler before receiving and running the next one. - ensure_future(layout.deliver(await recv())) - - -async def _shared_outgoing_loop( - send: SendCoroutine, queue: Queue[VdomJsonPatch] -) -> None: - while True: - await send(await queue.get()) - - -async def _wait_until_first_complete( - *tasks: Awaitable[Any], -) -> Sequence[Future[Any]]: - futures = [ensure_future(t) for t in tasks] - await wait(futures, return_when=FIRST_COMPLETED) - return futures diff --git a/src/idom/core/hooks.py b/src/idom/core/hooks.py index d8ff3ab54..d6e8983ec 100644 --- a/src/idom/core/hooks.py +++ b/src/idom/core/hooks.py @@ -206,19 +206,18 @@ def effect() -> None: def create_context( default_value: _StateType, name: str | None = None -) -> type[_Context[_StateType]]: +) -> type[Context[_StateType]]: """Return a new context type for use in :func:`use_context`""" - class Context(_Context[_StateType]): + class _Context(Context[_StateType]): _default_value = default_value - if name is not None: - Context.__name__ = name + _Context.__name__ = name or "Context" - return Context + return _Context -def use_context(context_type: type[_Context[_StateType]]) -> _StateType: +def use_context(context_type: type[Context[_StateType]]) -> _StateType: """Get the current value for the given context type. See the full :ref:`Use Context` docs for more information. @@ -228,7 +227,7 @@ def use_context(context_type: type[_Context[_StateType]]) -> _StateType: # that newly present current context. When we update it though, we don't need to # schedule a new render since we're already rending right now. Thus we can't do this # with use_state() since we'd incur an extra render when calling set_state. - context_ref: Ref[_Context[_StateType] | None] = use_ref(None) + context_ref: Ref[Context[_StateType] | None] = use_ref(None) if context_ref.current is None: provided_context = context_type._current.get() @@ -244,7 +243,7 @@ def use_context(context_type: type[_Context[_StateType]]) -> _StateType: @use_effect def subscribe_to_context_change() -> Callable[[], None]: - def set_context(new: _Context[_StateType]) -> None: + def set_context(new: Context[_StateType]) -> None: # We don't need to check if `new is not context_ref.current` because we only # trigger this callback when the value of a context, and thus the context # itself changes. Therefore we can always schedule a render. @@ -260,13 +259,13 @@ def set_context(new: _Context[_StateType]) -> None: _UNDEFINED: Any = object() -class _Context(Generic[_StateType]): +class Context(Generic[_StateType]): # This should be _StateType instead of Any, but it can't due to this limitation: # https://github.com/python/mypy/issues/5144 _default_value: ClassVar[Any] - _current: ClassVar[ThreadLocal[_Context[Any] | None]] + _current: ClassVar[ThreadLocal[Context[Any] | None]] def __init_subclass__(cls) -> None: # every context type tracks which of its instances are currently in use @@ -281,7 +280,7 @@ def __init__( self.children = children self.value: _StateType = self._default_value if value is _UNDEFINED else value self.key = key - self.subscribers: set[Callable[[_Context[_StateType]], None]] = set() + self.subscribers: set[Callable[[Context[_StateType]], None]] = set() self.type = self.__class__ def render(self) -> VdomDict: @@ -297,7 +296,7 @@ def reset_ctx() -> None: return vdom("", *self.children) - def should_render(self, new: _Context[_StateType]) -> bool: + def should_render(self, new: Context[_StateType]) -> bool: if self.value is not new.value: new.subscribers.update(self.subscribers) for set_context in self.subscribers: diff --git a/src/idom/core/layout.py b/src/idom/core/layout.py index fe3817a4d..1f67bd586 100644 --- a/src/idom/core/layout.py +++ b/src/idom/core/layout.py @@ -193,6 +193,7 @@ def _render_component( if ( old_state is not None + and hasattr(old_state.model, "current") and old_state.is_component_state and not _check_should_render( old_state.life_cycle_state.component, component diff --git a/src/idom/core/serve.py b/src/idom/core/serve.py new file mode 100644 index 000000000..af21f40f7 --- /dev/null +++ b/src/idom/core/serve.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from asyncio import ensure_future +from asyncio.tasks import ensure_future +from logging import getLogger +from typing import Any, Awaitable, Callable, Dict, List, NamedTuple, cast + +from anyio import create_task_group + +from ._fixed_jsonpatch import apply_patch, make_patch # type: ignore +from .layout import LayoutEvent, LayoutUpdate +from .types import LayoutType, VdomJson + + +logger = getLogger(__name__) + + +SendCoroutine = Callable[["VdomJsonPatch"], Awaitable[None]] +"""Send model patches given by a dispatcher""" + +RecvCoroutine = Callable[[], Awaitable[LayoutEvent]] +"""Called by a dispatcher to return a :class:`idom.core.layout.LayoutEvent` + +The event will then trigger an :class:`idom.core.proto.EventHandlerType` in a layout. +""" + + +class Stop(BaseException): + """Stop serving changes and events + + Raising this error will tell dispatchers to gracefully exit. Typically this is + called by code running inside a layout to tell it to stop rendering. + """ + + +async def serve_json_patch( + layout: LayoutType[LayoutUpdate, LayoutEvent], + send: SendCoroutine, + recv: RecvCoroutine, +) -> None: + """Run a dispatch loop for a single view instance""" + with layout: + try: + async with create_task_group() as task_group: + task_group.start_soon(_single_outgoing_loop, layout, send) + task_group.start_soon(_single_incoming_loop, layout, recv) + except Stop: + logger.info("Stopped dispatch task") + + +async def render_json_patch(layout: LayoutType[LayoutUpdate, Any]) -> VdomJsonPatch: + """Render a class:`VdomJsonPatch` from a layout""" + return VdomJsonPatch.create_from(await layout.render()) + + +class VdomJsonPatch(NamedTuple): + """An object describing an update to a :class:`Layout` in the form of a JSON patch""" + + path: str + """The path where changes should be applied""" + + changes: List[Dict[str, Any]] + """A list of JSON patches to apply at the given path""" + + def apply_to(self, model: VdomJson) -> VdomJson: + """Return the model resulting from the changes in this update""" + return cast( + VdomJson, + apply_patch( + model, [{**c, "path": self.path + c["path"]} for c in self.changes] + ), + ) + + @classmethod + def create_from(cls, update: LayoutUpdate) -> VdomJsonPatch: + """Return a patch given an layout update""" + return cls(update.path, make_patch(update.old or {}, update.new).patch) + + +async def _single_outgoing_loop( + layout: LayoutType[LayoutUpdate, LayoutEvent], send: SendCoroutine +) -> None: + while True: + await send(await render_json_patch(layout)) + + +async def _single_incoming_loop( + layout: LayoutType[LayoutUpdate, LayoutEvent], recv: RecvCoroutine +) -> None: + while True: + # We need to fire and forget here so that we avoid waiting on the completion + # of this event handler before receiving and running the next one. + ensure_future(layout.deliver(await recv())) diff --git a/src/idom/core/types.py b/src/idom/core/types.py index ffa9ba99a..cdac08b50 100644 --- a/src/idom/core/types.py +++ b/src/idom/core/types.py @@ -21,6 +21,9 @@ ComponentConstructor = Callable[..., "ComponentType"] """Simple function returning a new component""" +RootComponentConstructor = Callable[[], "ComponentType"] +"""The root component should be constructed by a function accepting no arguments.""" + Key = Union[str, int] @@ -35,7 +38,7 @@ class ComponentType(Protocol): key: Key | None """An identifier which is unique amongst a component's immediate siblings""" - type: type[Any] | Callable[..., Any] + type: Any """The function or class defining the behavior of this component This is used to see if two component instances share the same definition. @@ -84,6 +87,7 @@ def __exit__( VdomAttributesAndChildren = Union[ Mapping[str, Any], # this describes both VdomDict and VdomAttributes Iterable[VdomChild], + VdomChild, ] """Useful for the ``*attributes_and_children`` parameter in :func:`idom.core.vdom.vdom`""" diff --git a/src/idom/log.py b/src/idom/logging.py similarity index 85% rename from src/idom/log.py rename to src/idom/logging.py index 856ee9ab2..4f77e72c2 100644 --- a/src/idom/log.py +++ b/src/idom/logging.py @@ -24,9 +24,9 @@ }, "formatters": { "generic": { - "format": "%(asctime)s | %(levelname)s | %(message)s", + "format": "%(asctime)s | %(log_color)s%(levelname)s%(reset)s | %(message)s", "datefmt": r"%Y-%m-%dT%H:%M:%S%z", - "class": "logging.Formatter", + "class": "colorlog.ColoredFormatter", } }, } diff --git a/src/idom/sample.py b/src/idom/sample.py index b0844ada9..908de34b7 100644 --- a/src/idom/sample.py +++ b/src/idom/sample.py @@ -1,20 +1,14 @@ from __future__ import annotations -import webbrowser -from typing import Any - -from idom.server.types import ServerType - from . import html from .core.component import component from .core.types import VdomDict -from .server.utils import find_available_port, find_builtin_server_type @component -def App() -> VdomDict: +def SampleApp() -> VdomDict: return html.div( - {"style": {"padding": "15px"}}, + {"id": "sample", "style": {"padding": "15px"}}, html.h1("Sample Application"), html.p( "This is a basic application made with IDOM. Click ", @@ -25,36 +19,3 @@ def App() -> VdomDict: " to learn more.", ), ) - - -def run_sample_app( - host: str = "127.0.0.1", - port: int | None = None, - open_browser: bool = False, - run_in_thread: bool | None = None, -) -> ServerType[Any]: - """Run a sample application. - - Args: - host: host where the server should run - port: the port on the host to serve from - open_browser: whether to open a browser window after starting the server - """ - port = port or find_available_port(host) - server_type = find_builtin_server_type("PerClientStateServer") - server = server_type(App) - - run_in_thread = open_browser or run_in_thread - - if not run_in_thread: # pragma: no cover - server.run(host=host, port=port) - return server - - thread = server.run_in_thread(host=host, port=port) - server.wait_until_started(5) - - if open_browser: # pragma: no cover - webbrowser.open(f"http://{host}:{port}") - thread.join() - - return server diff --git a/src/idom/server/__init__.py b/src/idom/server/__init__.py index 0dfd40ace..e69de29bb 100644 --- a/src/idom/server/__init__.py +++ b/src/idom/server/__init__.py @@ -1,8 +0,0 @@ -from .prefab import hotswap_server, multiview_server, run - - -__all__ = [ - "hotswap_server", - "multiview_server", - "run", -] diff --git a/src/idom/server/_asgi.py b/src/idom/server/_asgi.py new file mode 100644 index 000000000..9e01a21e7 --- /dev/null +++ b/src/idom/server/_asgi.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Awaitable + +from asgiref.typing import ASGIApplication +from uvicorn.config import Config as UvicornConfig +from uvicorn.server import Server as UvicornServer + + +async def serve_development_asgi( + app: ASGIApplication | Any, + host: str, + port: int, + started: asyncio.Event | None, +) -> None: + """Run a development server for starlette""" + server = UvicornServer( + UvicornConfig( + app, + host=host, + port=port, + loop="asyncio", + debug=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() diff --git a/src/idom/server/default.py b/src/idom/server/default.py new file mode 100644 index 000000000..68adaf41b --- /dev/null +++ b/src/idom/server/default.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from idom.types import RootComponentConstructor + +from .types import ServerImplementation +from .utils import all_implementations + + +def configure( + app: Any, component: RootComponentConstructor, options: None = None +) -> None: + """Configure the given app instance to display the given component""" + if options is not None: # pragma: no cover + raise ValueError("Default implementation cannot be configured with options") + return _default_implementation().configure(app, component) + + +def create_development_app() -> Any: + """Create an application instance for development purposes""" + return _default_implementation().create_development_app() + + +async def serve_development_app( + app: Any, + host: str, + port: int, + started: asyncio.Event | None = None, +) -> None: + """Run an application using a development server""" + return await _default_implementation().serve_development_app( + app, host, port, started + ) + + +def use_scope() -> Any: + return _default_implementation().use_scope() + + +_DEFAULT_IMPLEMENTATION: ServerImplementation[Any] | None = None + + +def _default_implementation() -> ServerImplementation[Any]: + """Get the first available server implementation""" + global _DEFAULT_IMPLEMENTATION + + if _DEFAULT_IMPLEMENTATION is not None: + return _DEFAULT_IMPLEMENTATION + + try: + implementation = next(all_implementations()) + except StopIteration: # pragma: no cover + raise RuntimeError("No built-in server implementation installed.") + else: + _DEFAULT_IMPLEMENTATION = implementation + return implementation diff --git a/src/idom/server/fastapi.py b/src/idom/server/fastapi.py index 4dbb0e281..2cf66918d 100644 --- a/src/idom/server/fastapi.py +++ b/src/idom/server/fastapi.py @@ -1,54 +1,28 @@ -from typing import Optional +from __future__ import annotations from fastapi import FastAPI -from idom.core.types import ComponentConstructor - -from .starlette import ( - Config, - StarletteServer, - _setup_common_routes, - _setup_config_and_app, - _setup_shared_view_dispatcher_route, - _setup_single_view_dispatcher_route, -) - - -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[FastAPI] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol - - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, FastAPI) - _setup_common_routes(config, app) - _setup_single_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) - - -def SharedClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[FastAPI] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client shares state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol - - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, FastAPI) - _setup_common_routes(config, app) - _setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) +from . import starlette + + +serve_development_app = starlette.serve_development_app +"""Alias for :func:`idom.server.starlette.serve_development_app`""" + +# see: https://github.com/idom-team/flake8-idom-hooks/issues/12 +use_scope = starlette.use_scope # noqa: ROH101 +"""Alias for :func:`idom.server.starlette.use_scope`""" + +# see: https://github.com/idom-team/flake8-idom-hooks/issues/12 +use_websocket = starlette.use_websocket # noqa: ROH101 +"""Alias for :func:`idom.server.starlette.use_websocket`""" + +Options = starlette.Options +"""Alias for :class:`idom.server.starlette.Options`""" + +configure = starlette.configure +"""Alias for :class:`idom.server.starlette.configure`""" + + +def create_development_app() -> FastAPI: + """Create a development ``FastAPI`` application instance.""" + return FastAPI(debug=True) diff --git a/src/idom/server/flask.py b/src/idom/server/flask.py index 667071808..1cf2ddb66 100644 --- a/src/idom/server/flask.py +++ b/src/idom/server/flask.py @@ -4,147 +4,155 @@ import json import logging from asyncio import Queue as AsyncQueue +from dataclasses import dataclass from queue import Queue as ThreadQueue from threading import Event as ThreadEvent from threading import Thread -from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple, Union, cast -from urllib.parse import parse_qs as parse_query_string - -from flask import Blueprint, Flask, redirect, request, send_from_directory, url_for +from typing import Any, Callable, Dict, NamedTuple, Optional, Union, cast + +from flask import ( + Blueprint, + Flask, + Request, + copy_current_request_context, + redirect, + request, + send_from_directory, + url_for, +) from flask_cors import CORS from flask_sockets import Sockets from gevent import pywsgi from geventwebsocket.handler import WebSocketHandler from geventwebsocket.websocket import WebSocket -from typing_extensions import TypedDict import idom -from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import dispatch_single_view +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import LayoutEvent, LayoutUpdate -from idom.core.types import ComponentConstructor, ComponentType +from idom.core.serve import serve_json_patch +from idom.core.types import ComponentType, RootComponentConstructor -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) - -class Config(TypedDict, total=False): - """Render server config for :class:`FlaskRenderServer`""" - - cors: Union[bool, Dict[str, Any]] - """Enable or configure Cross Origin Resource Sharing (CORS) - - For more information see docs for ``flask_cors.CORS`` - """ - - import_name: str - """The module where the application instance was created - - For more info see :class:`flask.Flask`. - """ - - redirect_root_to_index: bool - """Whether to redirect the root URL (with prefix) to ``index.html``""" - - serve_static_files: bool - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str - """The URL prefix where IDOM resources will be served from""" +RequestContext: type[Context[Request | None]] = create_context(None, "RequestContext") -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Flask] = None, -) -> FlaskServer: +def configure( + app: Flask, component: RootComponentConstructor, options: Options | None = None +) -> None: """Return a :class:`FlaskServer` where each client has its own state. Implements the :class:`~idom.server.proto.ServerFactory` protocol Parameters: constructor: A component constructor - config: Options for configuring server behavior + options: Options for configuring server behavior app: An application instance (otherwise a default instance is created) """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint("idom", __name__, url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_single_view_dispatcher_route(app, config, constructor) + options = options or Options() + blueprint = Blueprint("idom", __name__, url_prefix=options.url_prefix) + _setup_common_routes(blueprint, options) + _setup_single_view_dispatcher_route(app, options, component) app.register_blueprint(blueprint) - return FlaskServer(app) - -class FlaskServer: - """A thin wrapper for running a Flask application - See :class:`idom.server.proto.Server` for more info - """ +def create_development_app() -> Flask: + """Create an application instance for development purposes""" + return Flask(__name__) - _wsgi_server: pywsgi.WSGIServer - def __init__(self, app: Flask) -> None: - self.app = app - self._did_start = ThreadEvent() +async def serve_development_app( + app: Flask, + host: str, + port: int, + started: asyncio.Event | None = None, +) -> None: + """Run an application using a development server""" + loop = asyncio.get_event_loop() + stopped = asyncio.Event() - @app.before_first_request - def server_did_start() -> None: - self._did_start.set() + server: pywsgi.WSGIServer - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - if IDOM_DEBUG_MODE.current: - logging.basicConfig(level=logging.DEBUG) # pragma: no cover - logger.info(f"Running at http://{host}:{port}") - self._wsgi_server = _StartCallbackWSGIServer( - self._did_start.set, + def run_server() -> None: # pragma: no cover + # we don't cover this function because coverage doesn't work right in threads + nonlocal server + server = pywsgi.WSGIServer( (host, port), - self.app, - *args, + app, handler_class=WebSocketHandler, - **kwargs, ) - self._wsgi_server.serve_forever() + server.start() + if started: + loop.call_soon_threadsafe(started.set) + try: + server.serve_forever() + finally: + loop.call_soon_threadsafe(stopped.set) - run_in_thread = threaded(run) + thread = Thread(target=run_server, daemon=True) + thread.start() - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - wait_on_event(f"start {self.app}", self._did_start, timeout) + if started: + await started.wait() - def stop(self, timeout: Optional[float] = 3.0) -> None: - try: - server = self._wsgi_server - except AttributeError: # pragma: no cover - raise RuntimeError( - f"Application is not running or was not started by {self}" - ) - else: - server.stop(timeout) - - -def _setup_config_and_app( - config: Optional[Config], app: Optional[Flask] -) -> Tuple[Config, Flask]: - return ( - { - "url_prefix": "", - "cors": False, - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or Flask(__name__), - ) - - -def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover - cors_params = cors_config if isinstance(cors_config, dict) else {} + try: + await stopped.wait() + finally: + # we may have exitted because this task was cancelled + server.stop(3) + # the thread should eventually join + thread.join(timeout=3) + # just double check it happened + if thread.is_alive(): # pragma: no cover + raise RuntimeError("Failed to shutdown server.") + + +def use_request() -> Request: + """Get the current ``Request``""" + request = use_context(RequestContext) + if request is None: + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Flask server?" + ) + return request + + +def use_scope() -> dict[str, Any]: + """Get the current WSGI environment""" + return use_request().environ + + +@dataclass +class Options: + """Render server config for :class:`FlaskRenderServer`""" + + cors: Union[bool, Dict[str, Any]] = False + """Enable or configure Cross Origin Resource Sharing (CORS) + + For more information see docs for ``flask_cors.CORS`` + """ + + redirect_root: bool = True + """Whether to redirect the root URL (with prefix) to ``index.html``""" + + serve_static_files: bool = True + """Whether or not to serve static files (i.e. web modules)""" + + url_prefix: str = "" + """The URL prefix where IDOM resources will be served from""" + + +def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: + cors_options = options.cors + if cors_options: # pragma: no cover + cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if config["serve_static_files"]: + if options.serve_static_files: @blueprint.route("/client/") def send_client_dir(path: str) -> Any: @@ -154,7 +162,7 @@ def send_client_dir(path: str) -> Any: def send_modules_dir(path: str) -> Any: return send_from_directory(str(IDOM_WEB_MODULES_DIR.current), path) - if config["redirect_root_to_index"]: + if options.redirect_root: @blueprint.route("/") def redirect_to_index() -> Any: @@ -168,11 +176,11 @@ def redirect_to_index() -> Any: def _setup_single_view_dispatcher_route( - app: Flask, config: Config, constructor: ComponentConstructor + app: Flask, options: Options, constructor: RootComponentConstructor ) -> None: sockets = Sockets(app) - @sockets.route(_join_url_paths(config["url_prefix"], "/stream")) # type: ignore + @sockets.route(_join_url_paths(options.url_prefix, "/stream")) # type: ignore def model_stream(ws: WebSocket) -> None: def send(value: Any) -> None: ws.send(json.dumps(value)) @@ -184,17 +192,10 @@ def recv() -> Optional[LayoutEvent]: else: return None - dispatch_single_view_in_thread(constructor(**_get_query_params(ws)), send, recv) - + dispatch_in_thread(constructor(), send, recv) -def _get_query_params(ws: WebSocket) -> Dict[str, Any]: - return { - k: v if len(v) > 1 else v[0] - for k, v in parse_query_string(ws.environ["QUERY_STRING"]).items() - } - -def dispatch_single_view_in_thread( +def dispatch_in_thread( component: ComponentType, send: Callable[[Any], None], recv: Callable[[], Optional[LayoutEvent]], @@ -202,6 +203,7 @@ def dispatch_single_view_in_thread( dispatch_thread_info_created = ThreadEvent() dispatch_thread_info_ref: idom.Ref[Optional[_DispatcherThreadInfo]] = idom.Ref(None) + @copy_current_request_context def run_dispatcher() -> None: loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -216,7 +218,11 @@ async def recv_coro() -> Any: return await async_recv_queue.get() async def main() -> None: - await dispatch_single_view(idom.Layout(component), send_coro, recv_coro) + await serve_json_patch( + idom.Layout(RequestContext(component, value=request)), + send_coro, + recv_coro, + ) main_future = asyncio.ensure_future(main()) @@ -270,25 +276,6 @@ class _DispatcherThreadInfo(NamedTuple): async_recv_queue: "AsyncQueue[LayoutEvent]" -class _StartCallbackWSGIServer(pywsgi.WSGIServer): # type: ignore - def __init__( - self, before_first_request: Callable[[], None], *args: Any, **kwargs: Any - ) -> None: - self._before_first_request_callback = before_first_request - super().__init__(*args, **kwargs) - - def update_environ(self) -> None: - """ - Called before the first request is handled to fill in WSGI environment values. - - This includes getting the correct server name and port. - """ - super().update_environ() - # BUG: https://github.com/nedbat/coveragepy/issues/1012 - # Coverage isn't able to support concurrency coverage for both threading and gevent - self._before_first_request_callback() # pragma: no cover - - def _join_url_paths(*args: str) -> str: # urllib.parse.urljoin performs more logic than is needed. Thus we need a util func # to join paths as if they were POSIX paths. diff --git a/src/idom/server/prefab.py b/src/idom/server/prefab.py deleted file mode 100644 index f264ce9ca..000000000 --- a/src/idom/server/prefab.py +++ /dev/null @@ -1,151 +0,0 @@ -import logging -from typing import Any, Dict, Optional, Tuple, TypeVar - -from idom.core.types import ComponentConstructor -from idom.widgets import MountFunc, MultiViewMount, hotswap, multiview - -from .types import ServerFactory, ServerType -from .utils import find_available_port, find_builtin_server_type - - -logger = logging.getLogger(__name__) - -_App = TypeVar("_App") -_Config = TypeVar("_Config") - - -def run( - component: ComponentConstructor, - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[Any] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, - daemon: bool = False, -) -> ServerType[_App]: - """A utility for quickly running a render server with minimal boilerplate - - Parameters: - component: - The root of the view. - server_type: - What server to run. Defaults to a builtin implementation if available. - host: - The host string. - port: - The port number. Defaults to a dynamically discovered available port. - server_config: - Options passed to configure the server. - run_kwargs: - Keyword arguments passed to the :meth:`~idom.server.proto.Server.run` - or :meth:`~idom.server.proto.Server.run_in_thread` methods of the server - depending on whether ``daemon`` is set or not. - app: - Register the server to an existing application and run that. - daemon: - Whether the server should be run in a daemon thread. - - Returns: - The server instance. This isn't really useful unless the server is spawned - as a daemon. Otherwise this function blocks until the server has stopped. - """ - if server_type is None: - server_type = find_builtin_server_type("PerClientStateServer") - if port is None: # pragma: no cover - port = find_available_port(host) - - server = server_type(component, server_config, app) - logger.info(f"Using {type(server).__name__}") - - run_server = server.run if not daemon else server.run_in_thread - run_server(host, port, **(run_kwargs or {})) - server.wait_until_started() - - return server - - -def multiview_server( - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, -) -> Tuple[MultiViewMount, ServerType[_App]]: - """Set up a server where views can be dynamically added. - - In other words this allows the user to work with IDOM in an imperative manner. Under - the hood this uses the :func:`idom.widgets.multiview` function to add the views on - the fly. - - Parameters: - server: The server type to start up as a daemon - host: The server hostname - port: The server port number - server_config: Value passed to :meth:`~idom.server.proto.ServerFactory` - run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread` - app: Optionally provide a prexisting application to register to - - Returns: - The server instance and a function for adding views. See - :func:`idom.widgets.multiview` for details. - """ - mount, component = multiview() - - server = run( - component, - server_type, - host, - port, - server_config=server_config, - run_kwargs=run_kwargs, - daemon=True, - app=app, - ) - - return mount, server - - -def hotswap_server( - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - app: Optional[Any] = None, - sync_views: bool = False, -) -> Tuple[MountFunc, ServerType[_App]]: - """Set up a server where views can be dynamically swapped out. - - In other words this allows the user to work with IDOM in an imperative manner. Under - the hood this uses the :func:`idom.widgets.hotswap` function to swap the views on - the fly. - - Parameters: - server: The server type to start up as a daemon - host: The server hostname - port: The server port number - server_config: Value passed to :meth:`~idom.server.proto.ServerFactory` - run_kwargs: Keyword args passed to :meth:`~idom.server.proto.Server.run_in_thread` - app: Optionally provide a prexisting application to register to - sync_views: Whether to update all displays with newly mounted components - - Returns: - The server instance and a function for swapping views. See - :func:`idom.widgets.hotswap` for details. - """ - mount, component = hotswap(update_on_change=sync_views) - - server = run( - component, - server_type, - host, - port, - server_config=server_config, - run_kwargs=run_kwargs, - daemon=True, - app=app, - ) - - return mount, server diff --git a/src/idom/server/sanic.py b/src/idom/server/sanic.py index 4845f1f6a..3c47250a6 100644 --- a/src/idom/server/sanic.py +++ b/src/idom/server/sanic.py @@ -3,192 +3,115 @@ import asyncio import json import logging -from asyncio import Future -from asyncio.events import AbstractEventLoop -from threading import Event -from typing import Any, Dict, Optional, Tuple, Union +from dataclasses import dataclass +from typing import Any, Dict, Tuple, Union +from uuid import uuid4 -from mypy_extensions import TypedDict from sanic import Blueprint, Sanic, request, response +from sanic.config import Config +from sanic.models.asgi import ASGIScope from sanic_cors import CORS -from websockets import WebSocketCommonProtocol +from websockets.legacy.protocol import WebSocketCommonProtocol from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import ( +from idom.core.hooks import Context, create_context, use_context +from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import ( RecvCoroutine, SendCoroutine, - SharedViewDispatcher, VdomJsonPatch, - dispatch_single_view, - ensure_shared_view_dispatcher_future, + serve_json_patch, ) -from idom.core.layout import Layout, LayoutEvent -from idom.core.types import ComponentConstructor +from idom.core.types import RootComponentConstructor -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from ._asgi import serve_development_asgi +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) -_SERVER_COUNT = 0 - - -class Config(TypedDict, total=False): - """Config for :class:`SanicRenderServer`""" - - cors: Union[bool, Dict[str, Any]] - """Enable or configure Cross Origin Resource Sharing (CORS) +RequestContext: type[Context[request.Request | None]] = create_context( + None, "RequestContext" +) - For more information see docs for ``sanic_cors.CORS`` - """ - redirect_root_to_index: bool - """Whether to redirect the root URL (with prefix) to ``index.html``""" +def configure( + app: Sanic, component: RootComponentConstructor, options: Options | None = None +) -> None: + """Configure an application instance to display the given component""" + options = options or Options() + blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=options.url_prefix) + _setup_common_routes(blueprint, options) + _setup_single_view_dispatcher_route(blueprint, component) + app.blueprint(blueprint) - serve_static_files: bool - """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str - """The URL prefix where IDOM resources will be served from""" +def create_development_app() -> Sanic: + """Return a :class:`Sanic` app instance in debug mode""" + return Sanic(f"idom_development_app_{uuid4().hex}", Config()) -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Sanic] = None, -) -> SanicServer: - """Return a :class:`SanicServer` where each client has its own state. +async def serve_development_app( + app: Sanic, + host: str, + port: int, + started: asyncio.Event | None = None, +) -> None: + """Run a development server for :mod:`sanic`""" + await serve_development_asgi(app, host, port, started) - Implements the :class:`~idom.server.proto.ServerFactory` protocol - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_single_view_dispatcher_route(blueprint, constructor) - app.blueprint(blueprint) - return SanicServer(app) +def use_request() -> request.Request: + """Get the current ``Request``""" + request = use_context(RequestContext) + if request is None: + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Sanic server?" + ) + return request -def SharedClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Sanic] = None, -) -> SanicServer: - """Return a :class:`SanicServer` where each client shares state. +def use_scope() -> ASGIScope: + """Get the current ASGI scope""" + app = use_request().app + try: + asgi_app = app._asgi_app + except AttributeError: # pragma: no cover + raise RuntimeError("No scope. Sanic may not be running with an ASGI server") + return asgi_app.transport.scope - Implements the :class:`~idom.server.proto.ServerFactory` protocol - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app) - blueprint = Blueprint(f"idom_dispatcher_{id(app)}", url_prefix=config["url_prefix"]) - _setup_common_routes(blueprint, config) - _setup_shared_view_dispatcher_route(app, blueprint, constructor) - app.blueprint(blueprint) - return SanicServer(app) +@dataclass +class Options: + """Options for :class:`SanicRenderServer`""" + cors: Union[bool, Dict[str, Any]] = False + """Enable or configure Cross Origin Resource Sharing (CORS) -class SanicServer: - """A thin wrapper for running a Sanic application - - See :class:`idom.server.proto.Server` for more info + For more information see docs for ``sanic_cors.CORS`` """ - _loop: AbstractEventLoop - - def __init__(self, app: Sanic) -> None: - self.app = app - self._did_start = Event() - self._did_stop = Event() - app.register_listener(self._server_did_start, "after_server_start") - app.register_listener(self._server_did_stop, "after_server_stop") + redirect_root: bool = True + """Whether to redirect the root URL (with prefix) to ``index.html``""" - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self.app.run(host, port, *args, **kwargs) # pragma: no cover + serve_static_files: bool = True + """Whether or not to serve static files (i.e. web modules)""" - @threaded - def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - loop = asyncio.get_event_loop() + url_prefix: str = "" + """The URL prefix where IDOM resources will be served from""" - # what follows was copied from: - # https://github.com/sanic-org/sanic/blob/7028eae083b0da72d09111b9892ddcc00bce7df4/examples/run_async_advanced.py - serv_coro = self.app.create_server( - host, port, *args, **kwargs, return_asyncio_server=True - ) - serv_task = asyncio.ensure_future(serv_coro, loop=loop) - server = loop.run_until_complete(serv_task) - server.after_start() - try: - loop.run_forever() - except KeyboardInterrupt: # pragma: no cover - loop.stop() - finally: - server.before_stop() - - # Wait for server to close - close_task = server.close() - loop.run_until_complete(close_task) - - # Complete all tasks on the loop - for connection in server.connections: - connection.close_if_idle() - server.after_stop() - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - wait_on_event(f"start {self.app}", self._did_start, timeout) - - def stop(self, timeout: Optional[float] = 3.0) -> None: - self._loop.call_soon_threadsafe(self.app.stop) - wait_on_event(f"stop {self.app}", self._did_stop, timeout) - - async def _server_did_start(self, app: Sanic, loop: AbstractEventLoop) -> None: - self._loop = loop - self._did_start.set() - - async def _server_did_stop(self, app: Sanic, loop: AbstractEventLoop) -> None: - self._did_stop.set() - - -def _setup_config_and_app( - config: Optional[Config], - app: Optional[Sanic], -) -> Tuple[Config, Sanic]: - if app is None: - global _SERVER_COUNT - _SERVER_COUNT += 1 - app = Sanic(f"{__name__}[{_SERVER_COUNT}]") - return ( - { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app, - ) - - -def _setup_common_routes(blueprint: Blueprint, config: Config) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover - cors_params = cors_config if isinstance(cors_config, dict) else {} +def _setup_common_routes(blueprint: Blueprint, options: Options) -> None: + cors_options = options.cors + if cors_options: # pragma: no cover + cors_params = cors_options if isinstance(cors_options, dict) else {} CORS(blueprint, **cors_params) - if config["serve_static_files"]: + if options.serve_static_files: blueprint.static("/client", str(CLIENT_BUILD_DIR)) blueprint.static("/modules", str(IDOM_WEB_MODULES_DIR.current)) - if config["redirect_root_to_index"]: + if options.redirect_root: @blueprint.route("/") # type: ignore def redirect_to_index( @@ -200,49 +123,19 @@ def redirect_to_index( def _setup_single_view_dispatcher_route( - blueprint: Blueprint, constructor: ComponentConstructor + blueprint: Blueprint, constructor: RootComponentConstructor ) -> None: @blueprint.websocket("/stream") # type: ignore async def model_stream( request: request.Request, socket: WebSocketCommonProtocol ) -> None: send, recv = _make_send_recv_callbacks(socket) - component_params = {k: request.args.get(k) for k in request.args} - await dispatch_single_view(Layout(constructor(**component_params)), send, recv) - - -def _setup_shared_view_dispatcher_route( - app: Sanic, blueprint: Blueprint, constructor: ComponentConstructor -) -> None: - dispatcher_future: Future[None] - dispatch_coroutine: SharedViewDispatcher - - async def activate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: - nonlocal dispatcher_future - nonlocal dispatch_coroutine - dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future( - Layout(constructor()) + await serve_json_patch( + Layout(RequestContext(constructor(), value=request)), + send, + recv, ) - async def deactivate_dispatcher(app: Sanic, loop: AbstractEventLoop) -> None: - logger.debug("Stopping dispatcher - server is shutting down") - dispatcher_future.cancel() - await asyncio.wait([dispatcher_future]) - - app.register_listener(activate_dispatcher, "before_server_start") - app.register_listener(deactivate_dispatcher, "before_server_stop") - - @blueprint.websocket("/stream") # type: ignore - async def model_stream( - request: request.Request, socket: WebSocketCommonProtocol - ) -> None: - if request.args: - raise ValueError( - "SharedClientState server does not support per-client view parameters" - ) - send, recv = _make_send_recv_callbacks(socket) - await dispatch_coroutine(send, recv) - def _make_send_recv_callbacks( socket: WebSocketCommonProtocol, diff --git a/src/idom/server/starlette.py b/src/idom/server/starlette.py index fe6c27f87..6e483241f 100644 --- a/src/idom/server/starlette.py +++ b/src/idom/server/starlette.py @@ -3,202 +3,120 @@ import asyncio import json import logging -import sys -from asyncio import Future -from threading import Event, Thread, current_thread -from typing import Any, Dict, Optional, Tuple, TypeVar, Union +from dataclasses import dataclass +from typing import Any, Dict, Tuple, Union -from mypy_extensions import TypedDict from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import RedirectResponse from starlette.staticfiles import StaticFiles +from starlette.types import Scope from starlette.websockets import WebSocket, WebSocketDisconnect -from uvicorn.config import Config as UvicornConfig -from uvicorn.server import Server as UvicornServer -from uvicorn.supervisors.multiprocess import Multiprocess -from uvicorn.supervisors.statreload import StatReload as ChangeReload -from idom.config import IDOM_DEBUG_MODE, IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import ( +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.hooks import Context, create_context, use_context +from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import ( RecvCoroutine, SendCoroutine, - SharedViewDispatcher, VdomJsonPatch, - dispatch_single_view, - ensure_shared_view_dispatcher_future, + serve_json_patch, ) -from idom.core.layout import Layout, LayoutEvent -from idom.core.types import ComponentConstructor +from idom.core.types import RootComponentConstructor -from .utils import CLIENT_BUILD_DIR, poll, threaded +from ._asgi import serve_development_asgi +from .utils import CLIENT_BUILD_DIR logger = logging.getLogger(__name__) -_StarletteType = TypeVar("_StarletteType", bound=Starlette) +WebSocketContext: type[Context[WebSocket | None]] = create_context( + None, "WebSocketContext" +) -class Config(TypedDict, total=False): - """Config for :class:`StarletteRenderServer`""" +def configure( + app: Starlette, + constructor: RootComponentConstructor, + options: Options | None = None, +) -> None: + """Return a :class:`StarletteServer` where each client has its own state. - cors: Union[bool, Dict[str, Any]] - """Enable or configure Cross Origin Resource Sharing (CORS) + Implements the :class:`~idom.server.proto.ServerFactory` protocol - For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` + Parameters: + app: An application instance + constructor: A component constructor + options: Options for configuring server behavior """ + options = options or Options() + _setup_common_routes(options, app) + _setup_single_view_dispatcher_route(options.url_prefix, app, constructor) - redirect_root_to_index: bool - """Whether to redirect the root URL (with prefix) to ``index.html``""" - serve_static_files: bool - """Whether or not to serve static files (i.e. web modules)""" - - url_prefix: str - """The URL prefix where IDOM resources will be served from""" +def create_development_app() -> Starlette: + """Return a :class:`Starlette` app instance in debug mode""" + return Starlette(debug=True) -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Starlette] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client has its own state. - - Implements the :class:`~idom.server.proto.ServerFactory` protocol +async def serve_development_app( + app: Starlette, + host: str, + port: int, + started: asyncio.Event | None = None, +) -> None: + """Run a development server for starlette""" + await serve_development_asgi(app, host, port, started) - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, Starlette) - _setup_common_routes(config, app) - _setup_single_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) +def use_websocket() -> WebSocket: + """Get the current WebSocket object""" + websocket = use_context(WebSocketContext) + if websocket is None: + raise RuntimeError( # pragma: no cover + "No websocket. Are you running with a Starllette server?" + ) + return websocket -def SharedClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Starlette] = None, -) -> StarletteServer: - """Return a :class:`StarletteServer` where each client shares state. - Implements the :class:`~idom.server.proto.ServerFactory` protocol +def use_scope() -> Scope: + """Get the current ASGI scope dictionary""" + return use_websocket().scope - Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) - """ - config, app = _setup_config_and_app(config, app, Starlette) - _setup_common_routes(config, app) - _setup_shared_view_dispatcher_route(config["url_prefix"], app, constructor) - return StarletteServer(app) +@dataclass +class Options: + """Optionsuration options for :class:`StarletteRenderServer`""" -class StarletteServer: - """A thin wrapper for running a Starlette application + cors: Union[bool, Dict[str, Any]] = False + """Enable or configure Cross Origin Resource Sharing (CORS) - See :class:`idom.server.proto.Server` for more info + For more information see docs for ``starlette.middleware.cors.CORSMiddleware`` """ - _server: UvicornServer - _current_thread: Thread + redirect_root: bool = True + """Whether to redirect the root URL (with prefix) to ``index.html``""" - def __init__(self, app: Starlette) -> None: - self.app = app - self._did_stop = Event() - app.on_event("shutdown")(self._server_did_stop) + serve_static_files: bool = True + """Whether or not to serve static files (i.e. web modules)""" - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self._current_thread = current_thread() + url_prefix: str = "" + """The URL prefix where IDOM resources will be served from""" - self._server = server = UvicornServer( - UvicornConfig( - self.app, host=host, port=port, loop="asyncio", *args, **kwargs - ) - ) - # The following was copied from the uvicorn source with minimal modification. We - # shouldn't need to do this, but unfortunately there's no easy way to gain access to - # the server instance so you can stop it. - # BUG: https://github.com/encode/uvicorn/issues/742 - config = server.config - - if (config.reload or config.workers > 1) and not isinstance( - server.config.app, str - ): # pragma: no cover - logger = logging.getLogger("uvicorn.error") - logger.warning( - "You must pass the application as an import string to enable 'reload' or " - "'workers'." - ) - sys.exit(1) - - if config.should_reload: # pragma: no cover - sock = config.bind_socket() - supervisor = ChangeReload(config, target=server.run, sockets=[sock]) - supervisor.run() - elif config.workers > 1: # pragma: no cover - sock = config.bind_socket() - supervisor = Multiprocess(config, target=server.run, sockets=[sock]) - supervisor.run() - else: - import asyncio - - asyncio.set_event_loop(asyncio.new_event_loop()) - server.run() - - run_in_thread = threaded(run) - - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - poll( - f"start {self.app}", - 0.01, - timeout, - lambda: hasattr(self, "_server") and self._server.started, - ) - - def stop(self, timeout: Optional[float] = 3.0) -> None: - self._server.should_exit = True - self._did_stop.wait(timeout) - - async def _server_did_stop(self) -> None: - self._did_stop.set() - - -def _setup_config_and_app( - config: Optional[Config], - app: Optional[_StarletteType], - app_type: type[_StarletteType], -) -> Tuple[Config, _StarletteType]: - return ( - { - "cors": False, - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or app_type(debug=IDOM_DEBUG_MODE.current), - ) - - -def _setup_common_routes(config: Config, app: Starlette) -> None: - cors_config = config["cors"] - if cors_config: # pragma: no cover +def _setup_common_routes(options: Options, app: Starlette) -> None: + cors_options = options.cors + if cors_options: # pragma: no cover cors_params = ( - cors_config if isinstance(cors_config, dict) else {"allow_origins": ["*"]} + cors_options if isinstance(cors_options, dict) else {"allow_origins": ["*"]} ) app.add_middleware(CORSMiddleware, **cors_params) # This really should be added to the APIRouter, but there's a bug in Starlette # BUG: https://github.com/tiangolo/fastapi/issues/1469 - url_prefix = config["url_prefix"] - if config["serve_static_files"]: + url_prefix = options.url_prefix + if options.serve_static_files: app.mount( f"{url_prefix}/client", StaticFiles( @@ -218,7 +136,7 @@ def _setup_common_routes(config: Config, app: Starlette) -> None: name="idom_web_module_files", ) - if config["redirect_root_to_index"]: + if options.redirect_root: @app.route(f"{url_prefix}/") def redirect_to_index(request: Request) -> RedirectResponse: @@ -228,53 +146,18 @@ def redirect_to_index(request: Request) -> RedirectResponse: def _setup_single_view_dispatcher_route( - url_prefix: str, app: Starlette, constructor: ComponentConstructor + url_prefix: str, app: Starlette, constructor: RootComponentConstructor ) -> None: @app.websocket_route(f"{url_prefix}/stream") async def model_stream(socket: WebSocket) -> None: await socket.accept() send, recv = _make_send_recv_callbacks(socket) try: - await dispatch_single_view( - Layout(constructor(**dict(socket.query_params))), send, recv - ) - except WebSocketDisconnect as error: - logger.info(f"WebSocket disconnect: {error.code}") - - -def _setup_shared_view_dispatcher_route( - url_prefix: str, app: Starlette, constructor: ComponentConstructor -) -> None: - dispatcher_future: Future[None] - dispatch_coroutine: SharedViewDispatcher - - @app.on_event("startup") - async def activate_dispatcher() -> None: - nonlocal dispatcher_future - nonlocal dispatch_coroutine - dispatcher_future, dispatch_coroutine = ensure_shared_view_dispatcher_future( - Layout(constructor()) - ) - - @app.on_event("shutdown") - async def deactivate_dispatcher() -> None: - logger.debug("Stopping dispatcher - server is shutting down") - dispatcher_future.cancel() - await asyncio.wait([dispatcher_future]) - - @app.websocket_route(f"{url_prefix}/stream") - async def model_stream(socket: WebSocket) -> None: - await socket.accept() - - if socket.query_params: - raise ValueError( - "SharedClientState server does not support per-client view parameters" + await serve_json_patch( + Layout(WebSocketContext(constructor(), value=socket)), + send, + recv, ) - - send, recv = _make_send_recv_callbacks(socket) - - try: - await dispatch_coroutine(send, recv) except WebSocketDisconnect as error: logger.info(f"WebSocket disconnect: {error.code}") diff --git a/src/idom/server/tornado.py b/src/idom/server/tornado.py index c1f8ae569..d1d708c42 100644 --- a/src/idom/server/tornado.py +++ b/src/idom/server/tornado.py @@ -4,124 +4,121 @@ import json from asyncio import Queue as AsyncQueue from asyncio.futures import Future -from threading import Event as ThreadEvent -from typing import Any, List, Optional, Tuple, Type, Union +from dataclasses import dataclass +from typing import Any, List, Tuple, Type, Union from urllib.parse import urljoin +from tornado.httpserver import HTTPServer +from tornado.httputil import HTTPServerRequest +from tornado.log import enable_pretty_logging from tornado.platform.asyncio import AsyncIOMainLoop from tornado.web import Application, RedirectHandler, RequestHandler, StaticFileHandler from tornado.websocket import WebSocketHandler -from typing_extensions import TypedDict +from tornado.wsgi import WSGIContainer from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.dispatcher import VdomJsonPatch, dispatch_single_view +from idom.core.hooks import Context, create_context, use_context from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.core.types import ComponentConstructor -from .utils import CLIENT_BUILD_DIR, threaded, wait_on_event +from .utils import CLIENT_BUILD_DIR -_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] - - -class Config(TypedDict, total=False): - """Render server config for :class:`TornadoRenderServer` subclasses""" +RequestContext: type[Context[HTTPServerRequest | None]] = create_context( + None, "RequestContext" +) - redirect_root_to_index: bool - """Whether to redirect the root URL (with prefix) to ``index.html``""" - - serve_static_files: bool - """Whether or not to serve static files (i.e. web modules)""" - url_prefix: str - """The URL prefix where IDOM resources will be served from""" - - -def PerClientStateServer( - constructor: ComponentConstructor, - config: Optional[Config] = None, - app: Optional[Application] = None, -) -> TornadoServer: +def configure( + app: Application, + component: ComponentConstructor, + options: Options | None = None, +) -> None: """Return a :class:`TornadoServer` where each client has its own state. Implements the :class:`~idom.server.proto.ServerFactory` protocol Parameters: - constructor: A component constructor - config: Options for configuring server behavior - app: An application instance (otherwise a default instance is created) + app: A tornado ``Application`` instance. + component: A root component constructor + options: Options for configuring how the component is mounted to the server. """ - config, app = _setup_config_and_app(config, app) + options = options or Options() _add_handler( app, - config, - _setup_common_routes(config) + _setup_single_view_dispatcher_route(constructor), + options, + _setup_common_routes(options) + _setup_single_view_dispatcher_route(component), ) - return TornadoServer(app) -class TornadoServer: - """A thin wrapper for running a Tornado application +def create_development_app() -> Application: + return Application(debug=True) - See :class:`idom.server.proto.Server` for more info - """ - - _loop: asyncio.AbstractEventLoop - def __init__(self, app: Application) -> None: - self.app = app - self._did_start = ThreadEvent() +async def serve_development_app( + app: Application, + host: str, + port: int, + started: asyncio.Event | None = None, +) -> None: + enable_pretty_logging() + + # setup up tornado to use asyncio + AsyncIOMainLoop().install() + + server = HTTPServer(app) + server.listen(port, host) + + if started: + # at this point the server is accepting connection + started.set() + + try: + # block forever - tornado has already set up its own background tasks + await asyncio.get_event_loop().create_future() + finally: + # stop accepting new connections + server.stop() + # wait for existing connections to complete + await server.close_all_connections() + + +def use_request() -> HTTPServerRequest: + """Get the current ``HTTPServerRequest``""" + request = use_context(RequestContext) + if request is None: + raise RuntimeError( # pragma: no cover + "No request. Are you running with a Tornado server?" + ) + return request - def run(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self._loop = asyncio.get_event_loop() - AsyncIOMainLoop().install() - self.app.listen(port, host, *args, **kwargs) - self._did_start.set() - asyncio.get_event_loop().run_forever() - @threaded - def run_in_thread(self, host: str, port: int, *args: Any, **kwargs: Any) -> None: - self.run(host, port, *args, **kwargs) +def use_scope() -> dict[str, Any]: + """Get the current WSGI environment dictionary""" + return WSGIContainer.environ(use_request()) - def wait_until_started(self, timeout: Optional[float] = 3.0) -> None: - self._did_start.wait(timeout) - def stop(self, timeout: Optional[float] = 3.0) -> None: - try: - loop = self._loop - except AttributeError: # pragma: no cover - raise RuntimeError( - f"Application is not running or was not started by {self}" - ) - else: - did_stop = ThreadEvent() +@dataclass +class Options: + """Render server options for :class:`TornadoRenderServer` subclasses""" - def stop() -> None: - loop.stop() - did_stop.set() + redirect_root: bool = True + """Whether to redirect the root URL (with prefix) to ``index.html``""" - loop.call_soon_threadsafe(stop) + serve_static_files: bool = True + """Whether or not to serve static files (i.e. web modules)""" - wait_on_event(f"stop {self.app}", did_stop, timeout) + url_prefix: str = "" + """The URL prefix where IDOM resources will be served from""" -def _setup_config_and_app( - config: Optional[Config], app: Optional[Application] -) -> Tuple[Config, Application]: - return ( - { - "url_prefix": "", - "serve_static_files": True, - "redirect_root_to_index": True, - **(config or {}), # type: ignore - }, - app or Application(), - ) +_RouteHandlerSpecs = List[Tuple[str, Type[RequestHandler], Any]] -def _setup_common_routes(config: Config) -> _RouteHandlerSpecs: +def _setup_common_routes(options: Options) -> _RouteHandlerSpecs: handlers: _RouteHandlerSpecs = [] - if config["serve_static_files"]: + if options.serve_static_files: handlers.append( ( r"/client/(.*)", @@ -136,16 +133,22 @@ def _setup_common_routes(config: Config) -> _RouteHandlerSpecs: {"path": str(IDOM_WEB_MODULES_DIR.current)}, ) ) - if config["redirect_root_to_index"]: - handlers.append(("/", RedirectHandler, {"url": "./client/index.html"})) + if options.redirect_root: + handlers.append( + ( + urljoin("/", options.url_prefix), + RedirectHandler, + {"url": "./client/index.html"}, + ) + ) return handlers def _add_handler( - app: Application, config: Config, handlers: _RouteHandlerSpecs + app: Application, options: Options, handlers: _RouteHandlerSpecs ) -> None: prefixed_handlers: List[Any] = [ - (urljoin(config["url_prefix"], route_pattern),) + tuple(handler_info) + (urljoin(options.url_prefix, route_pattern),) + tuple(handler_info) for route_pattern, *handler_info in handlers ] app.add_handlers(r".*", prefixed_handlers) @@ -157,13 +160,13 @@ def _setup_single_view_dispatcher_route( return [ ( "/stream", - PerClientStateModelStreamHandler, + ModelStreamHandler, {"component_constructor": constructor}, ) ] -class PerClientStateModelStreamHandler(WebSocketHandler): +class ModelStreamHandler(WebSocketHandler): """A web-socket handler that serves up a new model stream to each new client""" _dispatch_future: Future[None] @@ -174,7 +177,6 @@ def initialize(self, component_constructor: ComponentConstructor) -> None: async def open(self, *args: str, **kwargs: str) -> None: message_queue: "AsyncQueue[str]" = AsyncQueue() - query_params = {k: v[0].decode() for k, v in self.request.arguments.items()} async def send(value: VdomJsonPatch) -> None: await self.write_message(json.dumps(value)) @@ -184,8 +186,10 @@ async def recv() -> LayoutEvent: self._message_queue = message_queue self._dispatch_future = asyncio.ensure_future( - dispatch_single_view( - Layout(self._component_constructor(**query_params)), + serve_json_patch( + Layout( + RequestContext(self._component_constructor(), value=self.request) + ), send, recv, ) diff --git a/src/idom/server/types.py b/src/idom/server/types.py index d17495664..73b34f1b1 100644 --- a/src/idom/server/types.py +++ b/src/idom/server/types.py @@ -1,43 +1,39 @@ from __future__ import annotations -from threading import Thread -from typing import Optional, TypeVar +import asyncio +from typing import Any, MutableMapping, TypeVar -from typing_extensions import Protocol +from typing_extensions import Protocol, runtime_checkable -from idom.core.types import ComponentConstructor +from idom.types import RootComponentConstructor _App = TypeVar("_App") -_Config = TypeVar("_Config", contravariant=True) -class ServerFactory(Protocol[_App, _Config]): - """Setup a :class:`Server`""" +@runtime_checkable +class ServerImplementation(Protocol[_App]): + """Common interface for IDOM's builti-in server implementations""" - def __call__( + def configure( self, - constructor: ComponentConstructor, - config: Optional[_Config] = None, - app: Optional[_App] = None, - ) -> ServerType[_App]: - ... + app: _App, + component: RootComponentConstructor, + options: Any | None = None, + ) -> None: + """Configure the given app instance to display the given component""" + def create_development_app(self) -> _App: + """Create an application instance for development purposes""" -class ServerType(Protocol[_App]): - """A thin wrapper around a web server that provides a common operational interface""" - - app: _App - """The server's underlying application""" - - def run(self, host: str, port: int) -> None: - """Start running the server""" - - def run_in_thread(self, host: str, port: int) -> Thread: - """Run the server in a thread""" - - def wait_until_started(self, timeout: Optional[float] = None) -> None: - """Block until the server is able to receive requests""" - - def stop(self, timeout: Optional[float] = None) -> None: - """Stop the running server""" + async def serve_development_app( + self, + app: _App, + host: str, + port: int, + started: asyncio.Event | None = None, + ) -> None: + """Run an application using a development server""" + + def use_scope(self) -> MutableMapping[str, Any]: + """Get an ASGI scope or WSGI environment dictionary""" diff --git a/src/idom/server/utils.py b/src/idom/server/utils.py index cb2c88a7c..d84ad34e4 100644 --- a/src/idom/server/utils.py +++ b/src/idom/server/utils.py @@ -1,99 +1,56 @@ +from __future__ import annotations + import asyncio +import logging import socket -import time from contextlib import closing -from functools import wraps from importlib import import_module from pathlib import Path -from threading import Event, Thread -from typing import Any, Callable, List, Optional - -from typing_extensions import ParamSpec +from typing import Any, Iterator import idom +from idom.types import RootComponentConstructor -from .types import ServerFactory +from .types import ServerImplementation +logger = logging.getLogger(__name__) CLIENT_BUILD_DIR = Path(idom.__file__).parent / "client" -_SUPPORTED_PACKAGES = [ - "sanic", +SUPPORTED_PACKAGES = ( + "starlette", "fastapi", - "flask", + "sanic", "tornado", - "starlette", -] - - -_FuncParams = ParamSpec("_FuncParams") - - -def threaded(function: Callable[_FuncParams, None]) -> Callable[_FuncParams, Thread]: - @wraps(function) - def wrapper(*args: Any, **kwargs: Any) -> Thread: - def target() -> None: - asyncio.set_event_loop(asyncio.new_event_loop()) - function(*args, **kwargs) + "flask", +) - thread = Thread(target=target, daemon=True) - thread.start() - return thread +def run( + component: RootComponentConstructor, + host: str = "127.0.0.1", + port: int | None = None, + implementation: ServerImplementation[Any] | None = None, +) -> None: + """Run a component with a development server""" + logger.warn( + "You are running a development server. " + "Change this before deploying in production!" + ) - return wrapper + implementation = implementation or import_module("idom.server.default") + app = implementation.create_development_app() + implementation.configure(app, component) -def wait_on_event(description: str, event: Event, timeout: Optional[float]) -> None: - if not event.wait(timeout): - raise TimeoutError(f"Did not {description} within {timeout} seconds") + host = host + port = port or find_available_port(host) + logger.info(f"Running with {type(app).__name__!r} at http://{host}:{port}") -def poll( - description: str, - frequency: float, - timeout: Optional[float], - function: Callable[[], bool], -) -> None: - if timeout is not None: - expiry = time.time() + timeout - while not function(): - if time.time() > expiry: - raise TimeoutError(f"Did not {description} within {timeout} seconds") - time.sleep(frequency) - else: - while not function(): - time.sleep(frequency) - - -def find_builtin_server_type(type_name: str) -> ServerFactory[Any, Any]: - """Find first installed server implementation - - Raises: - :class:`RuntimeError` if one cannot be found - """ - installed_builtins: List[str] = [] - for name in _SUPPORTED_PACKAGES: - try: - import_module(name) - except ImportError: # pragma: no cover - continue - else: - builtin_module = import_module(f"idom.server.{name}") - installed_builtins.append(builtin_module.__name__) - try: - return getattr(builtin_module, type_name) # type: ignore - except AttributeError: # pragma: no cover - pass - else: # pragma: no cover - if not installed_builtins: - raise RuntimeError( - f"Found none of the following builtin server implementations {_SUPPORTED_PACKAGES}" - ) - else: - raise ImportError( - f"No server type {type_name!r} found in installed implementations {installed_builtins}" - ) + asyncio.get_event_loop().run_until_complete( + implementation.serve_development_app(app, host, port) + ) def find_available_port( @@ -121,3 +78,19 @@ def find_available_port( raise RuntimeError( f"Host {host!r} has no available port in range {port_max}-{port_max}" ) + + +def all_implementations() -> Iterator[ServerImplementation[Any]]: + """Yield all available server implementations""" + for name in SUPPORTED_PACKAGES: + try: + module = import_module(f"idom.server.{name}") + except ImportError: # pragma: no cover + continue + + if not isinstance(module, ServerImplementation): + raise TypeError( # pragma: no cover + f"{module.__name__!r} is an invalid implementation" + ) + + yield module diff --git a/src/idom/testing.py b/src/idom/testing.py deleted file mode 100644 index e32086af1..000000000 --- a/src/idom/testing.py +++ /dev/null @@ -1,439 +0,0 @@ -from __future__ import annotations - -import logging -import re -import shutil -from contextlib import contextmanager -from functools import wraps -from traceback import format_exception -from types import TracebackType -from typing import ( - Any, - Callable, - Dict, - Generic, - Iterator, - List, - NoReturn, - Optional, - Tuple, - Type, - TypeVar, - Union, -) -from urllib.parse import urlencode, urlunparse -from uuid import uuid4 -from weakref import ref - -from selenium.webdriver.chrome.options import Options as ChromeOptions -from selenium.webdriver.chrome.webdriver import WebDriver as Chrome -from selenium.webdriver.common.options import BaseOptions -from selenium.webdriver.remote.webdriver import WebDriver - -from idom.config import IDOM_WEB_MODULES_DIR -from idom.core.events import EventHandler, to_event_handler_function -from idom.core.hooks import LifeCycleHook, current_hook -from idom.server.prefab import hotswap_server -from idom.server.types import ServerFactory, ServerType -from idom.server.utils import find_available_port - -from .log import ROOT_LOGGER - - -__all__ = [ - "find_available_port", - "create_simple_selenium_web_driver", - "ServerMountPoint", -] - - -def create_simple_selenium_web_driver( - driver_type: Type[WebDriver] = Chrome, - driver_options: BaseOptions = ChromeOptions(), - implicit_wait_timeout: float = 10.0, - page_load_timeout: float = 5.0, - window_size: Tuple[int, int] = (1080, 800), -) -> WebDriver: - driver = driver_type(options=driver_options) - - driver.set_window_size(*window_size) - driver.set_page_load_timeout(page_load_timeout) - driver.implicitly_wait(implicit_wait_timeout) - - return driver - - -_Self = TypeVar("_Self", bound="ServerMountPoint[Any, Any]") -_Mount = TypeVar("_Mount") -_Server = TypeVar("_Server", bound=ServerType[Any]) -_App = TypeVar("_App") -_Config = TypeVar("_Config") - - -class ServerMountPoint(Generic[_Mount, _Server]): - """A context manager for imperatively mounting views to a render server when testing""" - - mount: _Mount - server: _Server - - _log_handler: "_LogRecordCaptor" - - def __init__( - self, - server_type: Optional[ServerFactory[_App, _Config]] = None, - host: str = "127.0.0.1", - port: Optional[int] = None, - server_config: Optional[_Config] = None, - run_kwargs: Optional[Dict[str, Any]] = None, - mount_and_server_constructor: "Callable[..., Tuple[_Mount, _Server]]" = hotswap_server, # type: ignore - app: Optional[_App] = None, - **other_options: Any, - ) -> None: - self.host = host - self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) - self._mount_and_server_constructor: "Callable[[], Tuple[_Mount, _Server]]" = ( - lambda: mount_and_server_constructor( - server_type, - self.host, - self.port, - server_config, - run_kwargs, - app, - **other_options, - ) - ) - - @property - def log_records(self) -> List[logging.LogRecord]: - """A list of captured log records""" - return self._log_handler.records - - def url(self, path: str = "", query: Optional[Any] = None) -> str: - """Return a URL string pointing to the host and point of the server - - Args: - path: the path to a resource on the server - query: a dictionary or list of query parameters - """ - return urlunparse( - [ - "http", - f"{self.host}:{self.port}", - path, - "", - urlencode(query or ()), - "", - ] - ) - - def list_logged_exceptions( - self, - pattern: str = "", - types: Union[Type[Any], Tuple[Type[Any], ...]] = Exception, - log_level: int = logging.ERROR, - del_log_records: bool = True, - ) -> List[BaseException]: - """Return a list of logged exception matching the given criteria - - Args: - log_level: The level of log to check - exclude_exc_types: Any exception types to ignore - del_log_records: Whether to delete the log records for yielded exceptions - """ - found: List[BaseException] = [] - compiled_pattern = re.compile(pattern) - for index, record in enumerate(self.log_records): - if record.levelno >= log_level and record.exc_info: - error = record.exc_info[1] - if ( - error is not None - and isinstance(error, types) - and compiled_pattern.search(str(error)) - ): - if del_log_records: - del self.log_records[index - len(found)] - found.append(error) - return found - - def __enter__(self: _Self) -> _Self: - self._log_handler = _LogRecordCaptor() - logging.getLogger().addHandler(self._log_handler) - self.mount, self.server = self._mount_and_server_constructor() - return self - - def __exit__( - self, - exc_type: Optional[Type[BaseException]], - exc_value: Optional[BaseException], - traceback: Optional[TracebackType], - ) -> None: - self.server.stop() - logging.getLogger().removeHandler(self._log_handler) - del self.mount, self.server - logged_errors = self.list_logged_exceptions(del_log_records=False) - if logged_errors: # pragma: no cover - raise logged_errors[0] - return None - - -class LogAssertionError(AssertionError): - """An assertion error raised in relation to log messages.""" - - -@contextmanager -def assert_idom_logged( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", - clear_matched_records: bool = False, -) -> Iterator[None]: - """Assert that IDOM produced a log matching the described message or error. - - Args: - match_message: Must match a logged message. - error_type: Checks the type of logged exceptions. - match_error: Must match an error message. - clear_matched_records: Whether to remove logged records that match. - """ - message_pattern = re.compile(match_message) - error_pattern = re.compile(match_error) - - try: - with capture_idom_logs(yield_existing=clear_matched_records) as log_records: - yield None - except Exception: - raise - else: - found = False - for record in list(log_records): - if ( - # record message matches - message_pattern.findall(record.getMessage()) - # error type matches - and ( - error_type is None - or ( - record.exc_info is not None - and record.exc_info[0] is not None - and issubclass(record.exc_info[0], error_type) - ) - ) - # error message pattern matches - and ( - not match_error - or ( - record.exc_info is not None - and error_pattern.findall( - "".join(format_exception(*record.exc_info)) - ) - ) - ) - ): - found = True - if clear_matched_records: - log_records.remove(record) - - if not found: # pragma: no cover - _raise_log_message_error( - "Could not find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -@contextmanager -def assert_idom_did_not_log( - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", - clear_matched_records: bool = False, -) -> Iterator[None]: - """Assert the inverse of :func:`assert_idom_logged`""" - try: - with assert_idom_logged( - match_message, error_type, match_error, clear_matched_records - ): - yield None - except LogAssertionError: - pass - else: - _raise_log_message_error( - "Did find a log record matching the given", - match_message, - error_type, - match_error, - ) - - -def _raise_log_message_error( - prefix: str, - match_message: str = "", - error_type: type[Exception] | None = None, - match_error: str = "", -) -> NoReturn: - conditions = [] - if match_message: - conditions.append(f"log message pattern {match_message!r}") - if error_type: - conditions.append(f"exception type {error_type}") - if match_error: - conditions.append(f"error message pattern {match_error!r}") - raise LogAssertionError(prefix + " " + " and ".join(conditions)) - - -@contextmanager -def capture_idom_logs( - yield_existing: bool = False, -) -> Iterator[list[logging.LogRecord]]: - """Capture logs from IDOM - - Parameters: - yield_existing: - If already inside an existing capture context yield the same list of logs. - This is useful if you need to mutate the list of logs to affect behavior in - the outer context. - """ - if yield_existing: - for handler in reversed(ROOT_LOGGER.handlers): - if isinstance(handler, _LogRecordCaptor): - yield handler.records - return None - - handler = _LogRecordCaptor() - original_level = ROOT_LOGGER.level - - ROOT_LOGGER.setLevel(logging.DEBUG) - ROOT_LOGGER.addHandler(handler) - try: - yield handler.records - finally: - ROOT_LOGGER.removeHandler(handler) - ROOT_LOGGER.setLevel(original_level) - - -class _LogRecordCaptor(logging.NullHandler): - def __init__(self) -> None: - self.records: List[logging.LogRecord] = [] - super().__init__() - - def handle(self, record: logging.LogRecord) -> bool: - self.records.append(record) - return True - - -class HookCatcher: - """Utility for capturing a LifeCycleHook from a component - - Example: - .. code-block:: - - hooks = HookCatcher(index_by_kwarg="thing") - - @idom.component - @hooks.capture - def MyComponent(thing): - ... - - ... # render the component - - # grab the last render of where MyComponent(thing='something') - hooks.index["something"] - # or grab the hook from the component's last render - hooks.latest - - After the first render of ``MyComponent`` the ``HookCatcher`` will have - captured the component's ``LifeCycleHook``. - """ - - latest: LifeCycleHook - - def __init__(self, index_by_kwarg: Optional[str] = None): - self.index_by_kwarg = index_by_kwarg - self.index: Dict[Any, LifeCycleHook] = {} - - def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: - """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" - - # The render function holds a reference to `self` and, via the `LifeCycleHook`, - # the component. Some tests check whether components are garbage collected, thus - # we must use a `ref` here to ensure these checks pass once the catcher itself - # has been collected. - self_ref = ref(self) - - @wraps(render_function) - def wrapper(*args: Any, **kwargs: Any) -> Any: - self = self_ref() - assert self is not None, "Hook catcher has been garbage collected" - - hook = current_hook() - if self.index_by_kwarg is not None: - self.index[kwargs[self.index_by_kwarg]] = hook - self.latest = hook - return render_function(*args, **kwargs) - - return wrapper - - -class StaticEventHandler: - """Utility for capturing the target of one event handler - - Example: - .. code-block:: - - static_handler = StaticEventHandler() - - @idom.component - def MyComponent(): - state, set_state = idom.hooks.use_state(0) - handler = static_handler.use(lambda event: set_state(state + 1)) - return idom.html.button({"onClick": handler}, "Click me!") - - # gives the target ID for onClick where from the last render of MyComponent - static_handlers.target - - If you need to capture event handlers from different instances of a component - the you should create multiple ``StaticEventHandler`` instances. - - .. code-block:: - - static_handlers_by_key = { - "first": StaticEventHandler(), - "second": StaticEventHandler(), - } - - @idom.component - def Parent(): - return idom.html.div(Child(key="first"), Child(key="second")) - - @idom.component - def Child(key): - state, set_state = idom.hooks.use_state(0) - handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) - return idom.html.button({"onClick": handler}, "Click me!") - - # grab the individual targets for each instance above - first_target = static_handlers_by_key["first"].target - second_target = static_handlers_by_key["second"].target - """ - - def __init__(self) -> None: - self.target = uuid4().hex - - def use( - self, - function: Callable[..., Any], - stop_propagation: bool = False, - prevent_default: bool = False, - ) -> EventHandler: - return EventHandler( - to_event_handler_function(function), - stop_propagation, - prevent_default, - self.target, - ) - - -def clear_idom_web_modules_dir() -> None: - for path in IDOM_WEB_MODULES_DIR.current.iterdir(): - shutil.rmtree(path) if path.is_dir() else path.unlink() diff --git a/src/idom/testing/__init__.py b/src/idom/testing/__init__.py new file mode 100644 index 000000000..62c80adcb --- /dev/null +++ b/src/idom/testing/__init__.py @@ -0,0 +1,23 @@ +from .common import HookCatcher, StaticEventHandler, clear_idom_web_modules_dir, poll +from .display import DisplayFixture +from .logs import ( + LogAssertionError, + assert_idom_did_not_log, + assert_idom_logged, + capture_idom_logs, +) +from .server import ServerFixture + + +__all__ = [ + "assert_idom_did_not_log", + "assert_idom_logged", + "capture_idom_logs", + "clear_idom_web_modules_dir", + "DisplayFixture", + "HookCatcher", + "LogAssertionError", + "poll", + "ServerFixture", + "StaticEventHandler", +] diff --git a/src/idom/testing/common.py b/src/idom/testing/common.py new file mode 100644 index 000000000..3e7ccbd0d --- /dev/null +++ b/src/idom/testing/common.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import shutil +import time +from functools import wraps +from inspect import iscoroutinefunction +from typing import Any, Awaitable, Callable, Generic, Optional, TypeVar, cast +from uuid import uuid4 +from weakref import ref + +from typing_extensions import ParamSpec, Protocol + +from idom.config import IDOM_WEB_MODULES_DIR +from idom.core.events import EventHandler, to_event_handler_function +from idom.core.hooks import LifeCycleHook, current_hook + + +def clear_idom_web_modules_dir() -> None: + """Clear the directory where IDOM stores registered web modules""" + for path in IDOM_WEB_MODULES_DIR.current.iterdir(): + shutil.rmtree(path) if path.is_dir() else path.unlink() + + +_P = ParamSpec("_P") +_R = TypeVar("_R") +_RC = TypeVar("_RC", covariant=True) +_DEFAULT_TIMEOUT = 3.0 + + +class _UntilFunc(Protocol[_RC]): + def __call__( + self, condition: Callable[[_RC], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> Any: + ... + + +class poll(Generic[_R]): # noqa: N801 + """Wait until the result of an sync or async function meets some condition""" + + def __init__( + self, + function: Callable[_P, Awaitable[_R] | _R], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> None: + self.until: _UntilFunc[_R] + """Check that the coroutines result meets a condition within the timeout""" + + if iscoroutinefunction(function): + coro_function = cast(Callable[_P, Awaitable[_R]], function) + + async def coro_until( + condition: Callable[[_R], bool], timeout: float = _DEFAULT_TIMEOUT + ) -> None: + started_at = time.time() + while True: + result = await coro_function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: # pragma: no cover + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) + + self.until = coro_until + else: + sync_function = cast(Callable[_P, _R], function) + + def sync_until( + condition: Callable[[_R], bool] | Any, timeout: float = _DEFAULT_TIMEOUT + ) -> None: + started_at = time.time() + while True: + result = sync_function(*args, **kwargs) + if condition(result): + break + elif (time.time() - started_at) > timeout: # pragma: no cover + raise TimeoutError( + f"Condition not met within {timeout} " + f"seconds - last value was {result!r}" + ) + + self.until = sync_until + + def until_is(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is identical to the given value""" + return self.until(lambda left: left is right, timeout) + + def until_equals(self, right: Any, timeout: float = _DEFAULT_TIMEOUT) -> Any: + """Wait until the result is equal to the given value""" + # not really sure why I need a type ignore comment here + return self.until(lambda left: left == right, timeout) # type: ignore + + +class HookCatcher: + """Utility for capturing a LifeCycleHook from a component + + Example: + .. code-block:: + + hooks = HookCatcher(index_by_kwarg="thing") + + @idom.component + @hooks.capture + def MyComponent(thing): + ... + + ... # render the component + + # grab the last render of where MyComponent(thing='something') + hooks.index["something"] + # or grab the hook from the component's last render + hooks.latest + + After the first render of ``MyComponent`` the ``HookCatcher`` will have + captured the component's ``LifeCycleHook``. + """ + + latest: LifeCycleHook + + def __init__(self, index_by_kwarg: Optional[str] = None): + self.index_by_kwarg = index_by_kwarg + self.index: dict[Any, LifeCycleHook] = {} + + def capture(self, render_function: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for capturing a ``LifeCycleHook`` on each render of a component""" + + # The render function holds a reference to `self` and, via the `LifeCycleHook`, + # the component. Some tests check whether components are garbage collected, thus + # we must use a `ref` here to ensure these checks pass once the catcher itself + # has been collected. + self_ref = ref(self) + + @wraps(render_function) + def wrapper(*args: Any, **kwargs: Any) -> Any: + self = self_ref() + assert self is not None, "Hook catcher has been garbage collected" + + hook = current_hook() + if self.index_by_kwarg is not None: + self.index[kwargs[self.index_by_kwarg]] = hook + self.latest = hook + return render_function(*args, **kwargs) + + return wrapper + + +class StaticEventHandler: + """Utility for capturing the target of one event handler + + Example: + .. code-block:: + + static_handler = StaticEventHandler() + + @idom.component + def MyComponent(): + state, set_state = idom.hooks.use_state(0) + handler = static_handler.use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # gives the target ID for onClick where from the last render of MyComponent + static_handlers.target + + If you need to capture event handlers from different instances of a component + the you should create multiple ``StaticEventHandler`` instances. + + .. code-block:: + + static_handlers_by_key = { + "first": StaticEventHandler(), + "second": StaticEventHandler(), + } + + @idom.component + def Parent(): + return idom.html.div(Child(key="first"), Child(key="second")) + + @idom.component + def Child(key): + state, set_state = idom.hooks.use_state(0) + handler = static_handlers_by_key[key].use(lambda event: set_state(state + 1)) + return idom.html.button({"onClick": handler}, "Click me!") + + # grab the individual targets for each instance above + first_target = static_handlers_by_key["first"].target + second_target = static_handlers_by_key["second"].target + """ + + def __init__(self) -> None: + self.target = uuid4().hex + + def use( + self, + function: Callable[..., Any], + stop_propagation: bool = False, + prevent_default: bool = False, + ) -> EventHandler: + return EventHandler( + to_event_handler_function(function), + stop_propagation, + prevent_default, + self.target, + ) diff --git a/src/idom/testing/display.py b/src/idom/testing/display.py new file mode 100644 index 000000000..b89aa92ca --- /dev/null +++ b/src/idom/testing/display.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from contextlib import AsyncExitStack +from types import TracebackType +from typing import Any + +from playwright.async_api import Browser, BrowserContext, Page, async_playwright + +from idom import html +from idom.types import RootComponentConstructor + +from .server import ServerFixture + + +class DisplayFixture: + """A fixture for running web-based tests using ``playwright``""" + + _exit_stack: AsyncExitStack + + def __init__( + self, + server: ServerFixture | None = None, + driver: Browser | BrowserContext | Page | None = None, + ) -> None: + if server is not None: + self.server = server + if driver is not None: + if isinstance(driver, Page): + self.page = driver + else: + self._browser = driver + self._next_view_id = 0 + + async def show( + self, + component: RootComponentConstructor, + query: dict[str, Any] | None = None, + ) -> None: + self._next_view_id += 1 + view_id = f"display-{self._next_view_id}" + self.server.mount(lambda: html.div({"id": view_id}, component())) + + await self.page.goto(self.server.url(query=query)) + await self.page.wait_for_selector(f"#{view_id}", state="attached") + + async def __aenter__(self) -> DisplayFixture: + es = self._exit_stack = AsyncExitStack() + + browser: Browser | BrowserContext + if not hasattr(self, "page"): + if not hasattr(self, "_browser"): + pw = await es.enter_async_context(async_playwright()) + browser = await pw.chromium.launch() + else: + browser = self._browser + self.page = await browser.new_page() + + if not hasattr(self, "server"): + self.server = ServerFixture() + await es.enter_async_context(self.server) + + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + self.server.mount(None) + await self._exit_stack.aclose() diff --git a/src/idom/testing/logs.py b/src/idom/testing/logs.py new file mode 100644 index 000000000..f0639bb40 --- /dev/null +++ b/src/idom/testing/logs.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import logging +import re +from contextlib import contextmanager +from traceback import format_exception +from typing import Any, Iterator, NoReturn + +from idom.logging import ROOT_LOGGER + + +class LogAssertionError(AssertionError): + """An assertion error raised in relation to log messages.""" + + +@contextmanager +def assert_idom_logged( + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> Iterator[None]: + """Assert that IDOM produced a log matching the described message or error. + + Args: + match_message: Must match a logged message. + error_type: Checks the type of logged exceptions. + match_error: Must match an error message. + """ + message_pattern = re.compile(match_message) + error_pattern = re.compile(match_error) + + with capture_idom_logs() as log_records: + try: + yield None + except Exception: + raise + else: + for record in list(log_records): + if ( + # record message matches + message_pattern.findall(record.getMessage()) + # error type matches + and ( + error_type is None + or ( + record.exc_info is not None + and record.exc_info[0] is not None + and issubclass(record.exc_info[0], error_type) + ) + ) + # error message pattern matches + and ( + not match_error + or ( + record.exc_info is not None + and error_pattern.findall( + "".join(format_exception(*record.exc_info)) + ) + ) + ) + ): + break + else: # pragma: no cover + _raise_log_message_error( + "Could not find a log record matching the given", + match_message, + error_type, + match_error, + ) + + +@contextmanager +def assert_idom_did_not_log( + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> Iterator[None]: + """Assert the inverse of :func:`assert_idom_logged`""" + try: + with assert_idom_logged(match_message, error_type, match_error): + yield None + except LogAssertionError: + pass + else: + _raise_log_message_error( + "Did find a log record matching the given", + match_message, + error_type, + match_error, + ) + + +def list_logged_exceptions( + log_records: list[logging.LogRecord], + pattern: str = "", + types: type[Any] | tuple[type[Any], ...] = Exception, + log_level: int = logging.ERROR, + del_log_records: bool = True, +) -> list[BaseException]: + """Return a list of logged exception matching the given criteria + + Args: + log_level: The level of log to check + exclude_exc_types: Any exception types to ignore + del_log_records: Whether to delete the log records for yielded exceptions + """ + found: list[BaseException] = [] + compiled_pattern = re.compile(pattern) + for index, record in enumerate(log_records): + if record.levelno >= log_level and record.exc_info: + error = record.exc_info[1] + if ( + error is not None + and isinstance(error, types) + and compiled_pattern.search(str(error)) + ): + if del_log_records: + del log_records[index - len(found)] + found.append(error) + return found + + +@contextmanager +def capture_idom_logs() -> Iterator[list[logging.LogRecord]]: + """Capture logs from IDOM + + Any logs produced in this context are cleared afterwards + """ + original_level = ROOT_LOGGER.level + ROOT_LOGGER.setLevel(logging.DEBUG) + try: + if _LOG_RECORD_CAPTOR in ROOT_LOGGER.handlers: + start_index = len(_LOG_RECORD_CAPTOR.records) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + end_index = len(_LOG_RECORD_CAPTOR.records) + _LOG_RECORD_CAPTOR.records[start_index:end_index] = [] + return None + + ROOT_LOGGER.addHandler(_LOG_RECORD_CAPTOR) + try: + yield _LOG_RECORD_CAPTOR.records + finally: + ROOT_LOGGER.removeHandler(_LOG_RECORD_CAPTOR) + _LOG_RECORD_CAPTOR.records.clear() + finally: + ROOT_LOGGER.setLevel(original_level) + + +class _LogRecordCaptor(logging.NullHandler): + def __init__(self) -> None: + self.records: list[logging.LogRecord] = [] + super().__init__() + + def handle(self, record: logging.LogRecord) -> bool: + self.records.append(record) + return True + + +_LOG_RECORD_CAPTOR = _LogRecordCaptor() + + +def _raise_log_message_error( + prefix: str, + match_message: str = "", + error_type: type[Exception] | None = None, + match_error: str = "", +) -> NoReturn: + conditions = [] + if match_message: + conditions.append(f"log message pattern {match_message!r}") + if error_type: + conditions.append(f"exception type {error_type}") + if match_error: + conditions.append(f"error message pattern {match_error!r}") + raise LogAssertionError(prefix + " " + " and ".join(conditions)) diff --git a/src/idom/testing/server.py b/src/idom/testing/server.py new file mode 100644 index 000000000..862d50a7d --- /dev/null +++ b/src/idom/testing/server.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import AsyncExitStack +from types import TracebackType +from typing import Any, Optional, Tuple, Type, Union +from urllib.parse import urlencode, urlunparse + +from idom.server import default as default_server +from idom.server.types import ServerImplementation +from idom.server.utils import find_available_port +from idom.widgets import hotswap + +from .logs import LogAssertionError, capture_idom_logs, list_logged_exceptions + + +class ServerFixture: + """A test fixture for running a server and imperatively displaying views + + This fixture is typically used alongside async web drivers like ``playwight``. + + Example: + .. code-block:: + + async with ServerFixture() as server: + server.mount(MyComponent) + """ + + _records: list[logging.LogRecord] + _server_future: asyncio.Task[Any] + _exit_stack = AsyncExitStack() + + def __init__( + self, + host: str = "127.0.0.1", + port: Optional[int] = None, + app: Any | None = None, + implementation: ServerImplementation[Any] | None = None, + options: Any | None = None, + ) -> None: + self.host = host + self.port = port or find_available_port(host, allow_reuse_waiting_ports=False) + self.mount, self._root_component = hotswap() + + if app is not None: + if implementation is None: + raise ValueError( + "If an application instance its corresponding " + "server implementation must be provided too." + ) + + self._app = app + self.implementation = implementation or default_server + self._options = options + + @property + def log_records(self) -> list[logging.LogRecord]: + """A list of captured log records""" + return self._records + + def url(self, path: str = "", query: Optional[Any] = None) -> str: + """Return a URL string pointing to the host and point of the server + + Args: + path: the path to a resource on the server + query: a dictionary or list of query parameters + """ + return urlunparse( + [ + "http", + f"{self.host}:{self.port}", + path, + "", + urlencode(query or ()), + "", + ] + ) + + def list_logged_exceptions( + self, + pattern: str = "", + types: Union[Type[Any], Tuple[Type[Any], ...]] = Exception, + log_level: int = logging.ERROR, + del_log_records: bool = True, + ) -> list[BaseException]: + """Return a list of logged exception matching the given criteria + + Args: + log_level: The level of log to check + exclude_exc_types: Any exception types to ignore + del_log_records: Whether to delete the log records for yielded exceptions + """ + return list_logged_exceptions( + self.log_records, + pattern, + types, + log_level, + del_log_records, + ) + + async def __aenter__(self) -> ServerFixture: + self._exit_stack = AsyncExitStack() + self._records = self._exit_stack.enter_context(capture_idom_logs()) + + app = self._app or self.implementation.create_development_app() + self.implementation.configure(app, self._root_component, self._options) + + started = asyncio.Event() + server_future = asyncio.create_task( + self.implementation.serve_development_app( + app, self.host, self.port, started + ) + ) + + async def stop_server() -> None: + server_future.cancel() + try: + await asyncio.wait_for(server_future, timeout=3) + except asyncio.CancelledError: + pass + + self._exit_stack.push_async_callback(stop_server) + + try: + await asyncio.wait_for(started.wait(), timeout=3) + except Exception: # pragma: no cover + # see if we can await the future for a more helpful error + await asyncio.wait_for(server_future, timeout=3) + raise + + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc_value: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + await self._exit_stack.aclose() + + self.mount(None) # reset the view + + logged_errors = self.list_logged_exceptions(del_log_records=False) + if logged_errors: # pragma: no cover + raise LogAssertionError("Unexpected logged exception") from logged_errors[0] + + return None diff --git a/src/idom/types.py b/src/idom/types.py index e979ec8a0..084c38883 100644 --- a/src/idom/types.py +++ b/src/idom/types.py @@ -4,6 +4,7 @@ - :mod:`idom.server.types` """ +from .core.hooks import Context from .core.types import ( ComponentConstructor, ComponentType, @@ -14,6 +15,7 @@ ImportSourceDict, Key, LayoutType, + RootComponentConstructor, VdomAttributes, VdomAttributesAndChildren, VdomChild, @@ -21,12 +23,13 @@ VdomDict, VdomJson, ) -from .server.types import ServerFactory, ServerType +from .server.types import ServerImplementation __all__ = [ "ComponentConstructor", "ComponentType", + "Context", "EventHandlerDict", "EventHandlerFunc", "EventHandlerMapping", @@ -34,12 +37,12 @@ "ImportSourceDict", "Key", "LayoutType", + "RootComponentConstructor", "VdomAttributes", "VdomAttributesAndChildren", "VdomChild", "VdomChildren", "VdomDict", "VdomJson", - "ServerFactory", - "ServerType", + "ServerImplementation", ] diff --git a/src/idom/widgets.py b/src/idom/widgets.py index 167f84696..a089b9d21 100644 --- a/src/idom/widgets.py +++ b/src/idom/widgets.py @@ -107,7 +107,7 @@ def __call__(self, value: str) -> _CastTo: ... -MountFunc = Callable[[ComponentConstructor], None] +MountFunc = Callable[["Callable[[], Any] | None"], None] def hotswap(update_on_change: bool = False) -> Tuple[MountFunc, ComponentConstructor]: @@ -144,7 +144,7 @@ def DivTwo(self): # displaying the output now will show DivTwo """ - constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: {"tagName": "div"}) + constructor_ref: Ref[Callable[[], Any]] = Ref(lambda: None) if update_on_change: set_constructor_callbacks: Set[Callable[[Callable[[], Any]], None]] = set() @@ -162,8 +162,8 @@ def add_callback() -> Callable[[], None]: return constructor() - def swap(constructor: Callable[[], Any]) -> None: - constructor_ref.current = constructor + def swap(constructor: Callable[[], Any] | None) -> None: + constructor = constructor_ref.current = constructor or (lambda: None) for set_constructor in set_constructor_callbacks: set_constructor(constructor) @@ -176,8 +176,8 @@ def swap(constructor: Callable[[], Any]) -> None: def HotSwap() -> Any: return constructor_ref.current() - def swap(constructor: Callable[[], Any]) -> None: - constructor_ref.current = constructor + def swap(constructor: Callable[[], Any] | None) -> None: + constructor_ref.current = constructor or (lambda: None) return None return swap, HotSwap @@ -189,92 +189,3 @@ def swap(constructor: Callable[[], Any]) -> None: def _use_callable(initial_func: _Func) -> Tuple[_Func, Callable[[_Func], None]]: state = hooks.use_state(lambda: initial_func) return state[0], lambda new: state[1](lambda old: new) - - -def multiview() -> Tuple[MultiViewMount, ComponentConstructor]: - """Dynamically add components to a layout on the fly - - Since you can't change the component functions used to create a layout - in an imperative manner, you can use ``multiview`` to do this so - long as you set things up ahead of time. - - Examples: - - .. code-block:: - - import idom - - mount, multiview = idom.widgets.multiview() - - @idom.component - def Hello(): - return idom.html.h1(["hello"]) - - # auto static view ID - mount.add("hello", Hello) - # use the view ID to create the associate component instance - hello_component_instance = multiview("hello") - - @idom.component - def World(): - return idom.html.h1(["world"]) - - generated_view_id = mount.add(None, World) - world_component_instance = multiview(generated_view_id) - - Displaying ``root`` with the parameter ``view_id=hello_world_view_id`` will show - the message 'hello world'. Usually though this is achieved by connecting to the - socket serving up the VDOM with a query parameter for view ID. This allow many - views to be added and then displayed dynamically in, for example, a Jupyter - Notebook where one might want multiple active views which can all be interacted - with at the same time. - - See :func:`idom.server.prefab.multiview_server` for a reference usage. - """ - views: Dict[str, ComponentConstructor] = {} - - @component - def MultiView(view_id: str) -> Any: - try: - return views[view_id]() - except KeyError: - raise ValueError(f"Unknown view {view_id!r}") - - return MultiViewMount(views), MultiView - - -class MultiViewMount: - """Mount point for :func:`multiview`""" - - __slots__ = "_next_auto_id", "_views" - - def __init__(self, views: Dict[str, ComponentConstructor]): - self._next_auto_id = 0 - self._views = views - - def add(self, view_id: Optional[str], constructor: ComponentConstructor) -> str: - """Add a component constructor - - Parameters: - view_id: - The view ID the constructor will be associated with. If ``None`` then - a view ID will be automatically generated. - constructor: - The component constructor to be mounted. It must accept no arguments. - - Returns: - The view ID that was assocaited with the component - most useful for - auto-generated view IDs - """ - if view_id is None: - self._next_auto_id += 1 - view_id = str(self._next_auto_id) - self._views[view_id] = constructor - return view_id - - def remove(self, view_id: str) -> None: - """Remove a mounted component constructor given its view ID""" - del self._views[view_id] - - def __repr__(self) -> str: - return f"{type(self).__name__}({self._views})" diff --git a/tests/conftest.py b/tests/conftest.py index f8281182a..a6cae2a2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,132 +1,78 @@ from __future__ import annotations -import inspect import os -from typing import Any, List import pytest from _pytest.config import Config from _pytest.config.argparsing import Parser -from selenium.webdriver import Chrome, ChromeOptions -from selenium.webdriver.support.ui import WebDriverWait +from playwright.async_api import async_playwright -import idom from idom.testing import ( - ServerMountPoint, + DisplayFixture, + ServerFixture, + capture_idom_logs, clear_idom_web_modules_dir, - create_simple_selenium_web_driver, ) - - -def pytest_collection_modifyitems( - session: pytest.Session, config: pytest.config.Config, items: List[pytest.Item] -) -> None: - _mark_coros_as_async_tests(items) - _skip_web_driver_tests_on_windows(items) +from tests.tooling.loop import open_event_loop def pytest_addoption(parser: Parser) -> None: parser.addoption( - "--headless", - dest="headless", + "--headed", + dest="headed", action="store_true", - help="Whether to run browser tests in headless mode.", + help="Open a browser window when runnging web-based tests", ) - parser.addoption( - "--no-restore", - dest="restore_client", - action="store_false", - help="Whether to restore the client build before testing.", - ) - - -@pytest.fixture -def display(driver, server_mount_point): - display_id = idom.Ref(0) - - def mount_and_display(component_constructor, query=None, check_mount=True): - component_id = f"display-{display_id.set_current(display_id.current + 1)}" - server_mount_point.mount( - lambda: idom.html.div({"id": component_id}, component_constructor()) - ) - driver.get(server_mount_point.url(query=query)) - if check_mount: - driver.find_element("id", component_id) - return component_id - - yield mount_and_display - - -@pytest.fixture -def driver_get(driver, server_mount_point): - return lambda query=None: driver.get(server_mount_point.url(query=query)) @pytest.fixture -def server_mount_point(): - """An IDOM layout mount function and server as a tuple - - The ``mount`` and ``server`` fixtures use this. - """ - with ServerMountPoint() as mount_point: - yield mount_point - - -@pytest.fixture(scope="module") -def driver_wait(driver): - return WebDriverWait(driver, 3) +async def display(server, page): + async with DisplayFixture(server, page) as display: + yield display -@pytest.fixture(scope="module") -def driver(create_driver) -> Chrome: - """A Selenium web driver""" - return create_driver() - +@pytest.fixture(scope="session") +async def server(): + async with ServerFixture() as server: + yield server -@pytest.fixture(scope="module") -def create_driver(driver_is_headless): - """A Selenium web driver""" - drivers = [] - def create(**kwargs: Any): - options = ChromeOptions() - options.headless = driver_is_headless - driver = create_simple_selenium_web_driver(driver_options=options, **kwargs) - drivers.append(driver) - return driver +@pytest.fixture(scope="session") +async def page(browser): + pg = await browser.new_page() + pg.set_default_timeout(5000) + try: + yield pg + finally: + await pg.close() - yield create - for d in drivers: - d.quit() +@pytest.fixture(scope="session") +async def browser(pytestconfig: Config): + if os.name == "nt": # pragma: no cover + pytest.skip("Browser tests not supported on Windows") + async with async_playwright() as pw: + yield await pw.chromium.launch(headless=not bool(pytestconfig.option.headed)) @pytest.fixture(scope="session") -def driver_is_headless(pytestconfig: Config): - return bool(pytestconfig.option.headless) +def event_loop(): + with open_event_loop() as loop: + yield loop @pytest.fixture(autouse=True) -def _clear_web_modules_dir_after_test(): +def clear_web_modules_dir_after_test(): clear_idom_web_modules_dir() -def _mark_coros_as_async_tests(items: List[pytest.Item]) -> None: - for item in items: - if isinstance(item, pytest.Function): - if inspect.iscoroutinefunction(item.function): - item.add_marker(pytest.mark.asyncio) - - -def _skip_web_driver_tests_on_windows(items: List[pytest.Item]) -> None: - if os.name == "nt": - for item in items: - if isinstance(item, pytest.Function): - if {"display", "driver", "create_driver"}.intersection( - item.fixturenames - ): - item.add_marker( - pytest.mark.skip( - reason="WebDriver tests are not working on Windows", - ) - ) +@pytest.fixture(autouse=True) +def assert_no_logged_exceptions(): + with capture_idom_logs() as records: + yield + try: + for r in records: + if r.exc_info is not None: + raise r.exc_info[1] + finally: + records.clear() diff --git a/tests/driver_utils.py b/tests/driver_utils.py deleted file mode 100644 index 93032486a..000000000 --- a/tests/driver_utils.py +++ /dev/null @@ -1,19 +0,0 @@ -from typing import Any - -from selenium.common.exceptions import NoSuchElementException -from selenium.webdriver.remote.webdriver import WebDriver -from selenium.webdriver.remote.webelement import WebElement - - -def send_keys(element: WebElement, keys: Any) -> None: - for char in keys: - element.send_keys(char) - - -def no_such_element(driver: WebDriver, method: str, param: Any) -> bool: - try: - driver.find_element(method, param) - except NoSuchElementException: - return True - else: - return False diff --git a/tests/test_client.py b/tests/test_client.py index ab0cd7413..b17d690cc 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,33 +1,39 @@ import asyncio -import time +from contextlib import AsyncExitStack from pathlib import Path +from playwright.async_api import Browser + import idom -from idom.testing import ServerMountPoint -from tests.driver_utils import send_keys +from idom.testing import DisplayFixture, ServerFixture JS_DIR = Path(__file__).parent / "js" -def test_automatic_reconnect(create_driver): - # we need to wait longer here because the automatic reconnect is not instance - driver = create_driver(implicit_wait_timeout=10, page_load_timeout=10) +async def test_automatic_reconnect(browser: Browser): + page = await browser.new_page() + + # we need to wait longer here because the automatic reconnect is not instant + page.set_default_timeout(10000) @idom.component def OldComponent(): return idom.html.p({"id": "old-component"}, "old") - mount_point = ServerMountPoint() + async with AsyncExitStack() as exit_stack: + server = await exit_stack.enter_async_context(ServerFixture(port=8000)) + display = await exit_stack.enter_async_context( + DisplayFixture(server, driver=page) + ) + + await display.show(OldComponent) - with mount_point: - mount_point.mount(OldComponent) - driver.get(mount_point.url()) # ensure the element is displayed before stopping the server - driver.find_element("id", "old-component") + await page.wait_for_selector("#old-component") # the server is disconnected but the last view state is still shown - driver.find_element("id", "old-component") + await page.wait_for_selector("#old-component") set_state = idom.Ref(None) @@ -36,19 +42,24 @@ def NewComponent(): state, set_state.current = idom.hooks.use_state(0) return idom.html.p({"id": f"new-component-{state}"}, f"new-{state}") - with mount_point: - mount_point.mount(NewComponent) + async with AsyncExitStack() as exit_stack: + server = await exit_stack.enter_async_context(ServerFixture(port=8000)) + display = await exit_stack.enter_async_context( + DisplayFixture(server, driver=page) + ) + + await display.show(NewComponent) # Note the lack of a page refresh before looking up this new component. The # client should attempt to reconnect and display the new view automatically. - driver.find_element("id", "new-component-0") + await page.wait_for_selector("#new-component-0") # check that we can resume normal operation set_state.current(1) - driver.find_element("id", "new-component-1") + await page.wait_for_selector("#new-component-1") -def test_style_can_be_changed(display, driver, driver_wait): +async def test_style_can_be_changed(display: DisplayFixture): """This test was introduced to verify the client does not mutate the model A bug was introduced where the client-side model was mutated and React was relying @@ -70,24 +81,24 @@ def ButtonWithChangingColor(): f"color: {color}", ) - display(ButtonWithChangingColor) + await display.show(ButtonWithChangingColor) - button = driver.find_element("id", "my-button") + button = await display.page.wait_for_selector("#my-button") - assert _get_style(button)["background-color"] == "red" + assert (await _get_style(button))["background-color"] == "red" for color in ["blue", "red"] * 2: - button.click() - driver_wait.until(lambda _: _get_style(button)["background-color"] == color) + await button.click() + assert (await _get_style(button))["background-color"] == color -def _get_style(element): - items = element.get_attribute("style").split(";") +async def _get_style(element): + items = (await element.get_attribute("style")).split(";") pairs = [item.split(":", 1) for item in map(str.strip, items) if item] return {key.strip(): value.strip() for key, value in pairs} -def test_slow_server_response_on_input_change(display, driver, driver_wait): +async def test_slow_server_response_on_input_change(display: DisplayFixture): """A delay server-side could cause input values to be overwritten. For more info see: https://github.com/idom-team/idom/issues/684 @@ -105,13 +116,9 @@ async def handle_change(event): return idom.html.input({"onChange": handle_change, "id": "test-input"}) - display(SomeComponent) - - inp = driver.find_element("id", "test-input") - - text = "hello" - send_keys(inp, text) + await display.show(SomeComponent) - time.sleep(delay * len(text) * 1.1) + inp = await display.page.wait_for_selector("#test-input") + await inp.type("hello") - driver_wait.until(lambda _: inp.get_attribute("value") == "hello") + assert (await inp.evaluate("node => node.value")) == "hello" diff --git a/tests/test_core/test_component.py b/tests/test_core/test_component.py index 6880d9270..28c8b00f2 100644 --- a/tests/test_core/test_component.py +++ b/tests/test_core/test_component.py @@ -1,4 +1,5 @@ import idom +from idom.testing import DisplayFixture def test_component_repr(): @@ -43,17 +44,17 @@ def ComponentWithVarArgsAndKwargs(*args, **kwargs): } -def test_display_simple_hello_world(driver, display): +async def test_display_simple_hello_world(display: DisplayFixture): @idom.component def Hello(): return idom.html.p({"id": "hello"}, ["Hello World"]) - display(Hello) + await display.show(Hello) - assert driver.find_element("id", "hello") + await display.page.wait_for_selector("#hello") -def test_pre_tags_are_rendered_correctly(driver, display): +async def test_pre_tags_are_rendered_correctly(display: DisplayFixture): @idom.component def PreFormated(): return idom.html.pre( @@ -63,11 +64,10 @@ def PreFormated(): " text", ) - display(PreFormated) + await display.show(PreFormated) - pre = driver.find_element("id", "pre-form-test") + pre = await display.page.wait_for_selector("#pre-form-test") assert ( - pre.get_attribute("innerHTML") - == "thisissomepre-formated text" - ) + await pre.evaluate("node => node.innerHTML") + ) == "thisissomepre-formated text" diff --git a/tests/test_core/test_dispatcher.py b/tests/test_core/test_dispatcher.py index 4f0cf34b0..8e3f05ded 100644 --- a/tests/test_core/test_dispatcher.py +++ b/tests/test_core/test_dispatcher.py @@ -1,18 +1,9 @@ import asyncio -import sys from typing import Any, Sequence -import pytest - import idom -from idom.core.dispatcher import ( - VdomJsonPatch, - _create_shared_view_dispatcher, - create_shared_view_dispatcher, - dispatch_single_view, - ensure_shared_view_dispatcher_future, -) from idom.core.layout import Layout, LayoutEvent, LayoutUpdate +from idom.core.serve import VdomJsonPatch, serve_json_patch from idom.testing import StaticEventHandler @@ -95,94 +86,13 @@ def Counter(): return idom.html.div({EVENT_NAME: handler, "count": count}) -async def test_dispatch_single_view(): +async def test_dispatch(): events, expected_model = make_events_and_expected_model() changes, send, recv = make_send_recv_callbacks(events) - await asyncio.wait_for(dispatch_single_view(Layout(Counter()), send, recv), 1) + await asyncio.wait_for(serve_json_patch(Layout(Counter()), send, recv), 1) assert_changes_produce_expected_model(changes, expected_model) -async def test_create_shared_state_dispatcher(): - events, model = make_events_and_expected_model() - changes_1, send_1, recv_1 = make_send_recv_callbacks(events) - changes_2, send_2, recv_2 = make_send_recv_callbacks(events) - - async with create_shared_view_dispatcher(Layout(Counter())) as dispatcher: - dispatcher(send_1, recv_1) - dispatcher(send_2, recv_2) - - assert_changes_produce_expected_model(changes_1, model) - assert_changes_produce_expected_model(changes_2, model) - - -async def test_ensure_shared_view_dispatcher_future(): - events, model = make_events_and_expected_model() - changes_1, send_1, recv_1 = make_send_recv_callbacks(events) - changes_2, send_2, recv_2 = make_send_recv_callbacks(events) - - dispatch_future, dispatch = ensure_shared_view_dispatcher_future(Layout(Counter())) - - await asyncio.gather( - dispatch(send_1, recv_1), - dispatch(send_2, recv_2), - return_exceptions=True, - ) - - # the dispatch future should run forever, until cancelled - with pytest.raises(asyncio.TimeoutError): - await asyncio.wait_for(dispatch_future, timeout=1) - - dispatch_future.cancel() - await asyncio.gather(dispatch_future, return_exceptions=True) - - assert_changes_produce_expected_model(changes_1, model) - assert_changes_produce_expected_model(changes_2, model) - - -async def test_private_create_shared_view_dispatcher_cleans_up_patch_queues(): - """Report an issue if this test breaks - - Some internals of idom.core.dispatcher may need to be changed in order to make some - internal state easier to introspect. - - Ideally we would just check if patch queues are getting cleaned up more directly, - but without having access to that, we use some side effects to try and infer whether - it happens. - """ - - @idom.component - def SomeComponent(): - return idom.html.div() - - async def send(patch): - raise idom.Stop() - - async def recv(): - return LayoutEvent("something", []) - - with idom.Layout(SomeComponent()) as layout: - dispatch_shared_view, push_patch = await _create_shared_view_dispatcher(layout) - - # Dispatch a view that should exit. After exiting its patch queue should be - # cleaned up and removed. Since we only dispatched one view there should be - # no patch queues. - await dispatch_shared_view(send, recv) # this should stop immediately - - # We create a patch and check its ref count. We will check this after attempting - # to push out the change. - patch = VdomJsonPatch("anything", []) - patch_ref_count = sys.getrefcount(patch) - - # We push out this change. - push_patch(patch) - - # Because there should be no patch queues, we expect that the ref count remains - # the same. If the ref count had increased, then we would know that the patch - # queue has not been cleaned up and that the patch we just pushed was added to - # it. - assert not sys.getrefcount(patch) > patch_ref_count - - async def test_dispatcher_handles_more_than_one_event_at_a_time(): block_and_never_set = asyncio.Event() will_block = asyncio.Event() @@ -211,7 +121,7 @@ async def handle_event(): recv_queue = asyncio.Queue() asyncio.ensure_future( - dispatch_single_view( + serve_json_patch( idom.Layout(ComponentWithTwoEventHandlers()), send_queue.put, recv_queue.get, diff --git a/tests/test_core/test_events.py b/tests/test_core/test_events.py index 9be849f4c..29210d19b 100644 --- a/tests/test_core/test_events.py +++ b/tests/test_core/test_events.py @@ -7,6 +7,7 @@ merge_event_handlers, to_event_handler_function, ) +from idom.testing import DisplayFixture, poll def test_event_handler_repr(): @@ -142,7 +143,7 @@ async def some_other_func(data): assert calls == ["some_func", "some_other_func"] -def test_can_prevent_event_default_operation(driver, display): +async def test_can_prevent_event_default_operation(display: DisplayFixture): @idom.component def Input(): @idom.event(prevent_default=True) @@ -151,15 +152,15 @@ async def on_key_down(value): return idom.html.input({"onKeyDown": on_key_down, "id": "input"}) - display(Input) + await display.show(Input) - inp = driver.find_element("id", "input") - inp.send_keys("hello") + inp = await display.page.wait_for_selector("#input") + await inp.type("hello") # the default action of updating the element's value did not take place - assert inp.get_attribute("value") == "" + assert (await inp.evaluate("node => node.value")) == "" -def test_simple_click_event(driver, display): +async def test_simple_click_event(display: DisplayFixture): @idom.component def Button(): clicked, set_clicked = idom.hooks.use_state(False) @@ -172,14 +173,14 @@ async def on_click(event): else: return idom.html.p({"id": "complete"}, ["Complete"]) - display(Button) + await display.show(Button) - button = driver.find_element("id", "click") - button.click() - driver.find_element("id", "complete") + button = await display.page.wait_for_selector("#click") + await button.click() + await display.page.wait_for_selector("#complete") -def test_can_stop_event_propogation(driver, driver_wait, display): +async def test_can_stop_event_propogation(display: DisplayFixture): clicked = idom.Ref(False) @idom.component @@ -215,9 +216,9 @@ def outer_click_is_not_triggered(event): ) return outer - display(DivInDiv) + await display.show(DivInDiv) - inner = driver.find_element("id", "inner") - inner.click() + inner = await display.page.wait_for_selector("#inner") + await inner.click() - driver_wait.until(lambda _: clicked.current) + poll(lambda: clicked.current).until_is(True) diff --git a/tests/test_core/test_hooks.py b/tests/test_core/test_hooks.py index a95724856..744d21a12 100644 --- a/tests/test_core/test_hooks.py +++ b/tests/test_core/test_hooks.py @@ -5,12 +5,12 @@ import idom from idom import html -from idom.core.dispatcher import render_json_patch from idom.core.hooks import COMPONENT_DID_RENDER_EFFECT, LifeCycleHook, current_hook from idom.core.layout import Layout -from idom.testing import HookCatcher, assert_idom_logged +from idom.core.serve import render_json_patch +from idom.testing import DisplayFixture, HookCatcher, assert_idom_logged, poll from idom.utils import Ref -from tests.assert_utils import assert_same_items +from tests.tooling.asserts import assert_same_items async def test_must_be_rendering_in_layout_to_use_hooks(): @@ -150,7 +150,7 @@ def Counter(): await layout.render() -def test_set_state_checks_identity_not_equality(driver, display, driver_wait): +async def test_set_state_checks_identity_not_equality(display: DisplayFixture): r_1 = idom.Ref("value") r_2 = idom.Ref("value") @@ -191,31 +191,34 @@ def TestComponent(): f"Last state: {'r_1' if state is r_1 else 'r_2'}", ) - display(TestComponent) + await display.show(TestComponent) - client_r_1_button = driver.find_element("id", "r_1") - client_r_2_button = driver.find_element("id", "r_2") + client_r_1_button = await display.page.wait_for_selector("#r_1") + client_r_2_button = await display.page.wait_for_selector("#r_2") + + poll_event_count = poll(lambda: event_count.current) + poll_render_count = poll(lambda: render_count.current) assert render_count.current == 1 assert event_count.current == 0 - client_r_1_button.click() + await client_r_1_button.click() - driver_wait.until(lambda d: event_count.current == 1) - driver_wait.until(lambda d: render_count.current == 1) + poll_event_count.until_equals(1) + poll_render_count.until_equals(1) - client_r_2_button.click() + await client_r_2_button.click() - driver_wait.until(lambda d: event_count.current == 2) - driver_wait.until(lambda d: render_count.current == 2) + poll_event_count.until_equals(2) + poll_render_count.until_equals(2) - client_r_2_button.click() + await client_r_2_button.click() - driver_wait.until(lambda d: event_count.current == 3) - driver_wait.until(lambda d: render_count.current == 2) + poll_event_count.until_equals(3) + poll_render_count.until_equals(2) -def test_simple_input_with_use_state(driver, display): +async def test_simple_input_with_use_state(display: DisplayFixture): message_ref = idom.Ref(None) @idom.component @@ -232,16 +235,16 @@ async def on_change(event): else: return idom.html.p({"id": "complete"}, ["Complete"]) - display(Input) + await display.show(Input) - button = driver.find_element("id", "input") - button.send_keys("this is a test") - driver.find_element("id", "complete") + button = await display.page.wait_for_selector("#input") + await button.type("this is a test") + await display.page.wait_for_selector("#complete") assert message_ref.current == "this is a test" -def test_double_set_state(driver, driver_wait, display): +async def test_double_set_state(display: DisplayFixture): @idom.component def SomeComponent(): state_1, set_state_1 = idom.hooks.use_state(0) @@ -252,29 +255,33 @@ def double_set_state(event): set_state_2(state_2 + 1) return idom.html.div( - idom.html.div({"id": "first", "value": state_1}, f"value is: {state_1}"), - idom.html.div({"id": "second", "value": state_2}, f"value is: {state_2}"), + idom.html.div( + {"id": "first", "data-value": state_1}, f"value is: {state_1}" + ), + idom.html.div( + {"id": "second", "data-value": state_2}, f"value is: {state_2}" + ), idom.html.button({"id": "button", "onClick": double_set_state}, "click me"), ) - display(SomeComponent) + await display.show(SomeComponent) - button = driver.find_element("id", "button") - first = driver.find_element("id", "first") - second = driver.find_element("id", "second") + button = await display.page.wait_for_selector("#button") + first = await display.page.wait_for_selector("#first") + second = await display.page.wait_for_selector("#second") - assert first.get_attribute("value") == "0" - assert second.get_attribute("value") == "0" + assert (await first.get_attribute("data-value")) == "0" + assert (await second.get_attribute("data-value")) == "0" - button.click() + await button.click() - assert driver_wait.until(lambda _: first.get_attribute("value") == "1") - assert driver_wait.until(lambda _: second.get_attribute("value") == "1") + assert (await first.get_attribute("data-value")) == "1" + assert (await second.get_attribute("data-value")) == "1" - button.click() + await button.click() - assert driver_wait.until(lambda _: first.get_attribute("value") == "2") - assert driver_wait.until(lambda _: second.get_attribute("value") == "2") + assert (await first.get_attribute("data-value")) == "2" + assert (await second.get_attribute("data-value")) == "2" async def test_use_effect_callback_occurs_after_full_render_is_complete(): @@ -832,16 +839,14 @@ def ComponentWithRef(): assert len(used_refs) == 2 -def test_bad_schedule_render_callback(caplog): +def test_bad_schedule_render_callback(): def bad_callback(): raise ValueError("something went wrong") - hook = LifeCycleHook(bad_callback) - - hook.schedule_render() - - first_log_line = next(iter(caplog.records)).msg.split("\n", 1)[0] - assert re.match(f"Failed to schedule render via {bad_callback}", first_log_line) + with assert_idom_logged( + match_message=f"Failed to schedule render via {bad_callback}" + ): + LifeCycleHook(bad_callback).schedule_render() async def test_use_effect_automatically_infers_closure_values(): diff --git a/tests/test_core/test_layout.py b/tests/test_core/test_layout.py index f0dbbb4d5..ddeb9f4ae 100644 --- a/tests/test_core/test_layout.py +++ b/tests/test_core/test_layout.py @@ -11,9 +11,9 @@ from idom import html from idom.config import IDOM_DEBUG_MODE from idom.core.component import component -from idom.core.dispatcher import render_json_patch from idom.core.hooks import use_effect, use_state from idom.core.layout import Layout, LayoutEvent +from idom.core.serve import render_json_patch from idom.testing import ( HookCatcher, StaticEventHandler, @@ -21,7 +21,7 @@ capture_idom_logs, ) from idom.utils import Ref -from tests.assert_utils import assert_same_items +from tests.tooling.asserts import assert_same_items @pytest.fixture(autouse=True) @@ -181,10 +181,7 @@ def OkChild(): def BadChild(): raise ValueError("error from bad child") - with assert_idom_logged( - match_error="error from bad child", - clear_matched_records=True, - ): + with assert_idom_logged(match_error="error from bad child"): with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) @@ -240,10 +237,7 @@ def OkChild(): def BadChild(): raise ValueError("error from bad child") - with assert_idom_logged( - match_error="error from bad child", - clear_matched_records=True, - ): + with assert_idom_logged(match_error="error from bad child"): with idom.Layout(Main()) as layout: patch = await render_json_patch(layout) @@ -743,7 +737,6 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_matched_records=True, ): await layout.render() @@ -757,7 +750,6 @@ def ComponentReturnsDuplicateKeys(): with assert_idom_logged( error_type=ValueError, match_error=r"Duplicate keys \['duplicate'\] at '/children/0'", - clear_matched_records=True, ): await layout.render() @@ -796,10 +788,7 @@ def raise_error(): return idom.html.button({"onClick": raise_error}) - with assert_idom_logged( - match_error="bad event handler", - clear_matched_records=True, - ): + with assert_idom_logged(match_error="bad event handler"): with idom.Layout(ComponentWithBadEventHandler()) as layout: await layout.render() @@ -807,7 +796,7 @@ def raise_error(): await layout.deliver(event) -async def test_schedule_render_from_unmounted_hook(caplog): +async def test_schedule_render_from_unmounted_hook(): parent_set_state = idom.Ref() @idom.component @@ -1233,7 +1222,6 @@ def bad_should_render(new): match_message=r".* component failed to check if .* should be rendered", error_type=ValueError, match_error="The error message", - clear_matched_records=True, ): with idom.Layout(Root()) as layout: await layout.render() diff --git a/tests/test_html.py b/tests/test_html.py index cc6521efa..c4d74d86b 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,11 +1,12 @@ import pytest from idom import component, config, html, use_state +from idom.testing import DisplayFixture, poll from idom.utils import Ref -def use_toggle(): - state, set_state = use_state(True) +def use_toggle(initial=True): + state, set_state = use_state(initial) return state, lambda: set_state(not state) @@ -14,20 +15,15 @@ def use_counter(initial_value): return state, lambda: set_state(state + 1) -def test_script_mount_unmount(driver, driver_wait, display): +async def test_script_mount_unmount(display: DisplayFixture): toggle_is_mounted = Ref() @component def Root(): is_mounted, toggle_is_mounted.current = use_toggle() - if is_mounted: - el = HasScript() - else: - el = html.div() - return html.div( html.div({"id": "mount-state", "data-value": False}), - el, + HasScript() if is_mounted else html.div(), ) @component @@ -43,22 +39,23 @@ def HasScript(): }""" ) - display(Root) + await display.show(Root) - mount_state = driver.find_element("id", "mount-state") + mount_state = await display.page.wait_for_selector("#mount-state", state="attached") + poll_mount_state = poll(mount_state.get_attribute, "data-value") - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "true") + await poll_mount_state.until_equals("true") toggle_is_mounted.current() - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "false") + await poll_mount_state.until_equals("false") toggle_is_mounted.current() - driver_wait.until(lambda d: mount_state.get_attribute("data-value") == "true") + await poll_mount_state.until_equals("true") -def test_script_re_run_on_content_change(driver, driver_wait, display): +async def test_script_re_run_on_content_change(display: DisplayFixture): incr_count = Ref() @component @@ -77,26 +74,31 @@ def HasScript(): ), ) - display(HasScript) + await display.show(HasScript) - mount_count = driver.find_element("id", "mount-count") - unmount_count = driver.find_element("id", "unmount-count") + mount_count = await display.page.wait_for_selector("#mount-count", state="attached") + poll_mount_count = poll(mount_count.get_attribute, "data-value") - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "1") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "0") + unmount_count = await display.page.wait_for_selector( + "#unmount-count", state="attached" + ) + poll_unmount_count = poll(unmount_count.get_attribute, "data-value") + + await poll_mount_count.until_equals("1") + await poll_unmount_count.until_equals("0") incr_count.current() - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "2") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "1") + await poll_mount_count.until_equals("2") + await poll_unmount_count.until_equals("1") incr_count.current() - driver_wait.until(lambda d: mount_count.get_attribute("data-value") == "3") - driver_wait.until(lambda d: unmount_count.get_attribute("data-value") == "2") + await poll_mount_count.until_equals("3") + await poll_unmount_count.until_equals("2") -def test_script_from_src(driver, driver_wait, display): +async def test_script_from_src(display: DisplayFixture): incr_src_id = Ref() file_name_template = "__some_js_script_{src_id}__.js" @@ -114,7 +116,7 @@ def HasScript(): ), ) - display(HasScript) + await display.show(HasScript) for i in range(1, 4): script_file = config.IDOM_WEB_MODULES_DIR.current / file_name_template.format( @@ -129,9 +131,9 @@ def HasScript(): incr_src_id.current() - run_count = driver.find_element("id", "run-count") - - driver_wait.until(lambda d: (run_count.get_attribute("data-value") == "1")) + run_count = await display.page.wait_for_selector("#run-count", state="attached") + poll_run_count = poll(run_count.get_attribute, "data-value") + await poll_run_count.until_equals("1") def test_script_may_only_have_one_child(): diff --git a/tests/test_sample.py b/tests/test_sample.py index be9135820..cc9f86dd1 100644 --- a/tests/test_sample.py +++ b/tests/test_sample.py @@ -1,15 +1,9 @@ -from idom.sample import run_sample_app -from idom.server.utils import find_available_port +from idom.sample import SampleApp +from idom.testing import DisplayFixture -def test_sample_app(driver): - host = "127.0.0.1" - port = find_available_port(host, allow_reuse_waiting_ports=False) +async def test_sample_app(display: DisplayFixture): + await display.show(SampleApp) - run_sample_app(host=host, port=port, run_in_thread=True) - - driver.get(f"http://{host}:{port}") - - h1 = driver.find_element("tag name", "h1") - - assert h1.get_attribute("innerHTML") == "Sample Application" + h1 = await display.page.wait_for_selector("h1") + assert (await h1.text_content()) == "Sample Application" diff --git a/tests/test_server/test_common.py b/tests/test_server/test_common.py new file mode 100644 index 000000000..ea0850ce9 --- /dev/null +++ b/tests/test_server/test_common.py @@ -0,0 +1,77 @@ +from typing import MutableMapping + +import pytest + +import idom +from idom import html +from idom.server import default as default_implementation +from idom.server.utils import all_implementations +from idom.testing import DisplayFixture, ServerFixture, poll + + +@pytest.fixture( + params=list(all_implementations()) + [default_implementation], + ids=lambda imp: imp.__name__, + scope="module", +) +async def display(page, request): + async with ServerFixture(implementation=request.param) as server: + async with DisplayFixture(server=server, driver=page) as display: + yield display + + +async def test_display_simple_hello_world(display: DisplayFixture): + @idom.component + def Hello(): + return idom.html.p({"id": "hello"}, ["Hello World"]) + + await display.show(Hello) + + await display.page.wait_for_selector("#hello") + + # test that we can reconnect succefully + await display.page.reload() + + await display.page.wait_for_selector("#hello") + + +async def test_display_simple_click_counter(display: DisplayFixture): + @idom.component + def Counter(): + count, set_count = idom.hooks.use_state(0) + return idom.html.button( + { + "id": "counter", + "onClick": lambda event: set_count(lambda old_count: old_count + 1), + }, + f"Count: {count}", + ) + + await display.show(Counter) + + counter = await display.page.wait_for_selector("#counter") + + for i in range(5): + await poll(counter.text_content).until_equals(f"Count: {i}") + await counter.click() + + +async def test_module_from_template(display: DisplayFixture): + victory = idom.web.module_from_template("react", "victory-bar@35.4.0") + VictoryBar = idom.web.export(victory, "VictoryBar") + await display.show(VictoryBar) + await display.page.wait_for_selector(".VictoryContainer") + + +async def test_use_scope(display: DisplayFixture): + scope = idom.Ref() + + @idom.component + def ShowScope(): + scope.current = display.server.implementation.use_scope() + return html.pre({"id": "scope"}, str(scope.current)) + + await display.show(ShowScope) + + await display.page.wait_for_selector("#scope") + assert isinstance(scope.current, MutableMapping) diff --git a/tests/test_server/test_common/test_multiview.py b/tests/test_server/test_common/test_multiview.py deleted file mode 100644 index 56c2deaf8..000000000 --- a/tests/test_server/test_common/test_multiview.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest - -import idom -from idom.server import fastapi as idom_fastapi -from idom.server import flask as idom_flask -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.server import tornado as idom_tornado -from idom.server.prefab import multiview_server -from idom.testing import ServerMountPoint -from tests.driver_utils import no_such_element - - -@pytest.fixture( - params=[ - # add new PerClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_fastapi.PerClientStateServer, - idom_flask.PerClientStateServer, - idom_sanic.PerClientStateServer, - idom_starlette.PerClientStateServer, - idom_tornado.PerClientStateServer, - ], - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", -) -def server_mount_point(request): - with ServerMountPoint( - request.param, - mount_and_server_constructor=multiview_server, - ) as mount_point: - yield mount_point - - -def test_multiview_server(driver_get, driver, server_mount_point): - manual_id = server_mount_point.mount.add( - "manually_set_id", - lambda: idom.html.h1({"id": "e1"}, ["e1"]), - ) - auto_view_id = server_mount_point.mount.add( - None, - lambda: idom.html.h1({"id": "e2"}, ["e2"]), - ) - - driver_get({"view_id": manual_id}) - driver.find_element("id", "e1") - - driver_get({"view_id": auto_view_id}) - driver.find_element("id", "e2") - - server_mount_point.mount.remove(auto_view_id) - server_mount_point.mount.remove(manual_id) - - driver.refresh() - - assert no_such_element(driver, "id", "e1") - assert no_such_element(driver, "id", "e2") - - server_mount_point.log_records.clear() diff --git a/tests/test_server/test_common/test_per_client_state.py b/tests/test_server/test_common/test_per_client_state.py deleted file mode 100644 index 29bd2f26f..000000000 --- a/tests/test_server/test_common/test_per_client_state.py +++ /dev/null @@ -1,74 +0,0 @@ -import pytest - -import idom -from idom.server import fastapi as idom_fastapi -from idom.server import flask as idom_flask -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.server import tornado as idom_tornado -from idom.testing import ServerMountPoint - - -@pytest.fixture( - params=[ - # add new PerClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_fastapi.PerClientStateServer, - idom_flask.PerClientStateServer, - idom_sanic.PerClientStateServer, - idom_starlette.PerClientStateServer, - idom_tornado.PerClientStateServer, - ], - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", -) -def server_mount_point(request): - with ServerMountPoint(request.param) as mount_point: - yield mount_point - - -def test_display_simple_hello_world(driver, display): - @idom.component - def Hello(): - return idom.html.p({"id": "hello"}, ["Hello World"]) - - display(Hello) - - assert driver.find_element("id", "hello") - - # test that we can reconnect succefully - driver.refresh() - - assert driver.find_element("id", "hello") - - -def test_display_simple_click_counter(driver, driver_wait, display): - def increment(count): - return count + 1 - - @idom.component - def Counter(): - count, set_count = idom.hooks.use_state(0) - return idom.html.button( - { - "id": "counter", - "onClick": lambda event: set_count(increment), - }, - f"Count: {count}", - ) - - display(Counter) - - client_counter = driver.find_element("id", "counter") - - for i in range(3): - driver_wait.until( - lambda driver: client_counter.get_attribute("innerHTML") == f"Count: {i}" - ) - client_counter.click() - - -def test_module_from_template(driver, display): - victory = idom.web.module_from_template("react", "victory-bar@35.4.0") - VictoryBar = idom.web.export(victory, "VictoryBar") - display(VictoryBar) - driver.find_element_by_class_name("VictoryContainer") diff --git a/tests/test_server/test_common/test_shared_state_client.py b/tests/test_server/test_common/test_shared_state_client.py deleted file mode 100644 index 440e73d07..000000000 --- a/tests/test_server/test_common/test_shared_state_client.py +++ /dev/null @@ -1,125 +0,0 @@ -from threading import Event -from weakref import finalize - -import pytest - -import idom -from idom.server import fastapi as idom_fastapi -from idom.server import sanic as idom_sanic -from idom.server import starlette as idom_starlette -from idom.testing import ServerMountPoint - - -@pytest.fixture( - params=[ - # add new SharedClientStateServer implementations here to - # run a suite of tests which check basic functionality - idom_sanic.SharedClientStateServer, - idom_fastapi.SharedClientStateServer, - idom_starlette.SharedClientStateServer, - ], - ids=lambda cls: f"{cls.__module__}.{cls.__name__}", -) -def server_mount_point(request): - with ServerMountPoint(request.param, sync_views=True) as mount_point: - yield mount_point - - -def test_shared_client_state(create_driver, server_mount_point): - was_garbage_collected = Event() - - @idom.component - def IncrCounter(): - count, set_count = idom.hooks.use_state(0) - - def incr_on_click(event): - set_count(count + 1) - - button = idom.html.button( - {"onClick": incr_on_click, "id": "incr-button"}, "click to increment" - ) - - counter = Counter(count) - finalize(counter, was_garbage_collected.set) - - return idom.html.div(button, counter) - - @idom.component - def Counter(count): - return idom.html.div({"id": f"count-is-{count}"}, count) - - server_mount_point.mount(IncrCounter) - - driver_1 = create_driver() - driver_2 = create_driver() - - driver_1.get(server_mount_point.url()) - driver_2.get(server_mount_point.url()) - - client_1_button = driver_1.find_element("id", "incr-button") - client_2_button = driver_2.find_element("id", "incr-button") - - driver_1.find_element("id", "count-is-0") - driver_2.find_element("id", "count-is-0") - - client_1_button.click() - - driver_1.find_element("id", "count-is-1") - driver_2.find_element("id", "count-is-1") - - client_2_button.click() - - driver_1.find_element("id", "count-is-2") - driver_2.find_element("id", "count-is-2") - - assert was_garbage_collected.wait(1) - was_garbage_collected.clear() - - # Ensure this continues working after a refresh. In the past dispatchers failed to - # exit when the connections closed. This was due to an expected error that is raised - # when the web socket closes. - driver_1.refresh() - driver_2.refresh() - - client_1_button = driver_1.find_element("id", "incr-button") - client_2_button = driver_2.find_element("id", "incr-button") - - client_1_button.click() - - driver_1.find_element("id", "count-is-3") - driver_2.find_element("id", "count-is-3") - - client_1_button.click() - - driver_1.find_element("id", "count-is-4") - driver_2.find_element("id", "count-is-4") - - client_2_button.click() - - assert was_garbage_collected.wait(1) - - -def test_shared_client_state_server_does_not_support_per_client_parameters( - driver_get, - driver_wait, - server_mount_point, -): - driver_get( - { - "per_client_param": 1, - # we need to stop reconnect attempts to prevent the error from happening - # more than once - "noReconnect": True, - } - ) - - driver_wait.until( - lambda driver: ( - len( - server_mount_point.list_logged_exceptions( - "does not support per-client view parameters", ValueError - ) - ) - == 1 - ) - ) diff --git a/tests/test_server/test_utils.py b/tests/test_server/test_utils.py index 11e5da089..a24d607af 100644 --- a/tests/test_server/test_utils.py +++ b/tests/test_server/test_utils.py @@ -1,22 +1,22 @@ -from threading import Event +import asyncio +import threading +import time +from contextlib import ExitStack import pytest +from playwright.async_api import Page -from idom.server.utils import find_available_port, poll, wait_on_event +from idom.sample import SampleApp as SampleApp +from idom.server import flask as flask_implementation +from idom.server.utils import find_available_port +from idom.server.utils import run as sync_run +from tests.tooling.loop import open_event_loop -def test_poll(): - with pytest.raises(TimeoutError, match="Did not do something within 0.1 seconds"): - poll("do something", 0.01, 0.1, lambda: False) - poll("do something", 0.01, None, [True, False, False].pop) - - -def test_wait_on_event(): - event = Event() - with pytest.raises(TimeoutError, match="Did not do something within 0.1 seconds"): - wait_on_event("do something", event, 0.1) - event.set() - wait_on_event("do something", event, None) +@pytest.fixture +def exit_stack(): + with ExitStack() as es: + yield es def test_find_available_port(): @@ -24,3 +24,28 @@ def test_find_available_port(): with pytest.raises(RuntimeError, match="no available port"): # check that if port range is exhausted we raise find_available_port("localhost", port_min=0, port_max=0) + + +async def test_run(page: Page, exit_stack: ExitStack): + loop = exit_stack.enter_context(open_event_loop(as_current=False)) + + host = "127.0.0.1" + port = find_available_port(host) + url = f"http://{host}:{port}" + + def run_in_thread(): + asyncio.set_event_loop(loop) + sync_run( + SampleApp, + host, + port, + implementation=flask_implementation, + ) + + threading.Thread(target=run_in_thread, daemon=True).start() + + # give the server a moment to start + time.sleep(0.5) + + await page.goto(url) + await page.wait_for_selector("#sample") diff --git a/tests/test_testing.py b/tests/test_testing.py index 8c7529bcd..abd738643 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,9 +1,12 @@ import logging +import os import pytest from idom import testing -from idom.log import ROOT_LOGGER +from idom.logging import ROOT_LOGGER +from idom.sample import SampleApp as SampleApp +from idom.server import starlette as starlette_implementation def test_assert_idom_logged_does_not_supress_errors(): @@ -123,3 +126,39 @@ def test_assert_idom_did_not_log(): raise Exception("something") except Exception: ROOT_LOGGER.exception("something") + + +async def test_simple_display_fixture(): + if os.name == "nt": + pytest.skip("Browser tests not supported on Windows") + async with testing.DisplayFixture() as display: + await display.show(SampleApp) + await display.page.wait_for_selector("#sample") + + +def test_if_app_is_given_implementation_must_be_too(): + with pytest.raises( + ValueError, + match=r"If an application instance its corresponding server implementation must be provided too", + ): + testing.ServerFixture(app=starlette_implementation.create_development_app()) + + testing.ServerFixture( + app=starlette_implementation.create_development_app(), + implementation=starlette_implementation, + ) + + +def test_list_logged_excptions(): + the_error = None + with testing.capture_idom_logs() as records: + ROOT_LOGGER.info("A non-error log message") + + try: + raise ValueError("An error for testing") + except Exception as error: + ROOT_LOGGER.exception("Log the error") + the_error = error + + logged_errors = testing.logs.list_logged_exceptions(records) + assert logged_errors == [the_error] diff --git a/tests/test_web/test_module.py b/tests/test_web/test_module.py index 1e31f5d0a..3f192cf11 100644 --- a/tests/test_web/test_module.py +++ b/tests/test_web/test_module.py @@ -2,20 +2,23 @@ import pytest from sanic import Sanic -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions -from selenium.webdriver.support.ui import WebDriverWait import idom -from idom.server.sanic import PerClientStateServer -from idom.testing import ServerMountPoint, assert_idom_did_not_log, assert_idom_logged +from idom.server import sanic as sanic_implementation +from idom.testing import ( + DisplayFixture, + ServerFixture, + assert_idom_did_not_log, + assert_idom_logged, + poll, +) from idom.web.module import NAME_SOURCE, WebModule JS_FIXTURES_DIR = Path(__file__).parent / "js_fixtures" -def test_that_js_module_unmount_is_called(driver, display): +async def test_that_js_module_unmount_is_called(display: DisplayFixture): SomeComponent = idom.web.export( idom.web.module_from_file( "set-flag-when-unmount-is-called", @@ -33,23 +36,23 @@ def ShowCurrentComponent(): ) return current_component - display(ShowCurrentComponent) + await display.show(ShowCurrentComponent) - driver.find_element("id", "some-component") + await display.page.wait_for_selector("#some-component", state="attached") set_current_component.current( idom.html.h1({"id": "some-other-component"}, "some other component") ) # the new component has been displayed - driver.find_element("id", "some-other-component") + await display.page.wait_for_selector("#some-other-component", state="attached") # the unmount callback for the old component was called - driver.find_element("id", "unmount-flag") + await display.page.wait_for_selector("#unmount-flag", state="attached") -def test_module_from_url(driver): - app = Sanic(__name__) +async def test_module_from_url(browser): + app = Sanic("test_module_from_url") # instead of directing the URL to a CDN, we just point it to this static file app.static( @@ -67,10 +70,11 @@ def test_module_from_url(driver): def ShowSimpleButton(): return SimpleButton({"id": "my-button"}) - with ServerMountPoint(PerClientStateServer, app=app) as mount_point: - mount_point.mount(ShowSimpleButton) - driver.get(mount_point.url()) - driver.find_element("id", "my-button") + async with ServerFixture(app=app, implementation=sanic_implementation) as server: + async with DisplayFixture(server, browser) as display: + await display.show(ShowSimpleButton) + + await display.page.wait_for_selector("#my-button") def test_module_from_template_where_template_does_not_exist(): @@ -78,19 +82,15 @@ def test_module_from_template_where_template_does_not_exist(): idom.web.module_from_template("does-not-exist", "something.js") -def test_module_from_template(driver, display): +async def test_module_from_template(display: DisplayFixture): victory = idom.web.module_from_template("react", "victory-bar@35.4.0") VictoryBar = idom.web.export(victory, "VictoryBar") - display(VictoryBar) - wait = WebDriverWait(driver, 10) - wait.until( - expected_conditions.visibility_of_element_located( - (By.CLASS_NAME, "VictoryContainer") - ) - ) + await display.show(VictoryBar) + + await display.page.wait_for_selector(".VictoryContainer") -def test_module_from_file(driver, driver_wait, display): +async def test_module_from_file(display: DisplayFixture): SimpleButton = idom.web.export( idom.web.module_from_file( "simple-button", JS_FIXTURES_DIR / "simple-button.js" @@ -106,11 +106,11 @@ def ShowSimpleButton(): {"id": "my-button", "onClick": lambda event: is_clicked.set_current(True)} ) - display(ShowSimpleButton) + await display.show(ShowSimpleButton) - button = driver.find_element("id", "my-button") - button.click() - driver_wait.until(lambda d: is_clicked.current) + button = await display.page.wait_for_selector("#my-button") + await button.click() + poll(lambda: is_clicked.current).until_is(True) def test_module_from_file_source_conflict(tmp_path): @@ -188,7 +188,7 @@ def test_module_missing_exports(): idom.web.export(module, ["x", "y"]) -def test_module_exports_multiple_components(driver, display): +async def test_module_exports_multiple_components(display: DisplayFixture): Header1, Header2 = idom.web.export( idom.web.module_from_file( "exports-two-components", JS_FIXTURES_DIR / "exports-two-components.js" @@ -196,22 +196,22 @@ def test_module_exports_multiple_components(driver, display): ["Header1", "Header2"], ) - display(lambda: Header1({"id": "my-h1"}, "My Header 1")) + await display.show(lambda: Header1({"id": "my-h1"}, "My Header 1")) - driver.find_element("id", "my-h1") + await display.page.wait_for_selector("#my-h1", state="attached") - display(lambda: Header2({"id": "my-h2"}, "My Header 2")) + await display.show(lambda: Header2({"id": "my-h2"}, "My Header 2")) - driver.find_element("id", "my-h2") + await display.page.wait_for_selector("#my-h2", state="attached") -def test_imported_components_can_render_children(driver, display): +async def test_imported_components_can_render_children(display: DisplayFixture): module = idom.web.module_from_file( "component-can-have-child", JS_FIXTURES_DIR / "component-can-have-child.js" ) Parent, Child = idom.web.export(module, ["Parent", "Child"]) - display( + await display.show( lambda: Parent( Child({"index": 1}), Child({"index": 2}), @@ -219,13 +219,13 @@ def test_imported_components_can_render_children(driver, display): ) ) - parent = driver.find_element("id", "the-parent") - children = parent.find_elements("tag name", "li") + parent = await display.page.wait_for_selector("#the-parent", state="attached") + children = await parent.query_selector_all("li") assert len(children) == 3 for index, child in enumerate(children): - assert child.get_attribute("id") == f"child-{index + 1}" + assert (await child.get_attribute("id")) == f"child-{index + 1}" def test_module_from_string(): diff --git a/tests/test_web/test_utils.py b/tests/test_web/test_utils.py index ce6badf2b..5286db53d 100644 --- a/tests/test_web/test_utils.py +++ b/tests/test_web/test_utils.py @@ -3,6 +3,7 @@ import pytest import responses +from idom.testing import assert_idom_logged from idom.web.utils import ( module_name_suffix, resolve_module_exports_from_file, @@ -145,9 +146,8 @@ def test_resolve_module_exports_from_source(): ) and references == {"https://source1.com", "https://source2.com"} -def test_log_on_unknown_export_type(caplog): - assert resolve_module_exports_from_source( - "export something unknown;", exclude_default=False - ) == (set(), set()) - assert len(caplog.records) == 1 - assert caplog.records[0].message.startswith("Unknown export type ") +def test_log_on_unknown_export_type(): + with assert_idom_logged(match_message="Unknown export type "): + assert resolve_module_exports_from_source( + "export something unknown;", exclude_default=False + ) == (set(), set()) diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 269dec0ef..ab8d99f99 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -1,20 +1,14 @@ from base64 import b64encode from pathlib import Path -from selenium.webdriver.common.keys import Keys - import idom -from tests.driver_utils import send_keys +from idom.testing import DisplayFixture, poll HERE = Path(__file__).parent -def test_multiview_repr(): - assert str(idom.widgets.MultiViewMount({})) == "MultiViewMount({})" - - -def test_hostwap_update_on_change(driver, display): +async def test_hostwap_update_on_change(display: DisplayFixture): """Ensure shared hotswapping works This basically means that previously rendered views of a hotswap component get updated @@ -48,15 +42,15 @@ async def on_click(event): return idom.html.div(incr, hotswap_view) - display(ButtonSwapsDivs) + await display.show(ButtonSwapsDivs) - client_incr_button = driver.find_element("id", "incr-button") + client_incr_button = await display.page.wait_for_selector("#incr-button") - driver.find_element("id", "hotswap-1") - client_incr_button.click() - driver.find_element("id", "hotswap-2") - client_incr_button.click() - driver.find_element("id", "hotswap-3") + await display.page.wait_for_selector("#hotswap-1") + await client_incr_button.click() + await display.page.wait_for_selector("#hotswap-2") + await client_incr_button.click() + await display.page.wait_for_selector("#hotswap-3") IMAGE_SRC_BYTES = b""" @@ -67,43 +61,44 @@ async def on_click(event): BASE64_IMAGE_SRC = b64encode(IMAGE_SRC_BYTES).decode() -def test_image_from_string(driver, display): +async def test_image_from_string(display: DisplayFixture): src = IMAGE_SRC_BYTES.decode() - display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = driver.find_element("id", "a-circle-1") - assert BASE64_IMAGE_SRC in client_img.get_attribute("src") + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) + client_img = await display.page.wait_for_selector("#a-circle-1") + assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) -def test_image_from_bytes(driver, display): +async def test_image_from_bytes(display: DisplayFixture): src = IMAGE_SRC_BYTES - display(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) - client_img = driver.find_element("id", "a-circle-1") - assert BASE64_IMAGE_SRC in client_img.get_attribute("src") + await display.show(lambda: idom.widgets.image("svg", src, {"id": "a-circle-1"})) + client_img = await display.page.wait_for_selector("#a-circle-1") + assert BASE64_IMAGE_SRC in (await client_img.get_attribute("src")) -def test_use_linked_inputs(driver, driver_wait, display): +async def test_use_linked_inputs(display: DisplayFixture): @idom.component def SomeComponent(): i_1, i_2 = idom.widgets.use_linked_inputs([{"id": "i_1"}, {"id": "i_2"}]) return idom.html.div(i_1, i_2) - display(SomeComponent) + await display.show(SomeComponent) - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") - send_keys(input_1, "hello") + await input_1.type("hello", delay=20) - driver_wait.until(lambda d: input_1.get_attribute("value") == "hello") - driver_wait.until(lambda d: input_2.get_attribute("value") == "hello") + assert (await input_1.evaluate("e => e.value")) == "hello" + assert (await input_2.evaluate("e => e.value")) == "hello" - send_keys(input_2, " world") + await input_2.focus() + await input_2.type(" world", delay=20) - driver_wait.until(lambda d: input_1.get_attribute("value") == "hello world") - driver_wait.until(lambda d: input_2.get_attribute("value") == "hello world") + assert (await input_1.evaluate("e => e.value")) == "hello world" + assert (await input_2.evaluate("e => e.value")) == "hello world" -def test_use_linked_inputs_on_change(driver, driver_wait, display): +async def test_use_linked_inputs_on_change(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -114,21 +109,24 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + await display.show(SomeComponent) - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") - send_keys(input_1, "hello") + await input_1.type("hello", delay=20) - driver_wait.until(lambda d: value.current == "hello") + poll_value = poll(lambda: value.current) - send_keys(input_2, " world") + poll_value.until_equals("hello") - driver_wait.until(lambda d: value.current == "hello world") + await input_2.focus() + await input_2.type(" world", delay=20) + poll_value.until_equals("hello world") -def test_use_linked_inputs_on_change_with_cast(driver, driver_wait, display): + +async def test_use_linked_inputs_on_change_with_cast(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -138,21 +136,24 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + await display.show(SomeComponent) + + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + await input_1.type("1") - send_keys(input_1, "1") + poll_value = poll(lambda: value.current) - driver_wait.until(lambda d: value.current == 1) + poll_value.until_equals(1) - send_keys(input_2, "2") + await input_2.focus() + await input_2.type("2") - driver_wait.until(lambda d: value.current == 12) + poll_value.until_equals(12) -def test_use_linked_inputs_ignore_empty(driver, driver_wait, display): +async def test_use_linked_inputs_ignore_empty(display: DisplayFixture): value = idom.Ref(None) @idom.component @@ -164,19 +165,22 @@ def SomeComponent(): ) return idom.html.div(i_1, i_2) - display(SomeComponent) + await display.show(SomeComponent) + + input_1 = await display.page.wait_for_selector("#i_1") + input_2 = await display.page.wait_for_selector("#i_2") - input_1 = driver.find_element("id", "i_1") - input_2 = driver.find_element("id", "i_2") + await input_1.type("1") - send_keys(input_1, "1") + poll_value = poll(lambda: value.current) - driver_wait.until(lambda d: value.current == "1") + poll_value.until_equals("1") - send_keys(input_2, Keys.BACKSPACE) + await input_2.focus() + await input_2.press("Backspace") - assert value.current == "1" + poll_value.until_equals("1") - send_keys(input_1, "2") + await input_1.type("2") - driver_wait.until(lambda d: value.current == "2") + poll_value.until_equals("2") diff --git a/tests/test_server/test_common/__init__.py b/tests/tooling/__init__.py similarity index 100% rename from tests/test_server/test_common/__init__.py rename to tests/tooling/__init__.py diff --git a/tests/assert_utils.py b/tests/tooling/asserts.py similarity index 100% rename from tests/assert_utils.py rename to tests/tooling/asserts.py diff --git a/tests/tooling/loop.py b/tests/tooling/loop.py new file mode 100644 index 000000000..9b872f1fa --- /dev/null +++ b/tests/tooling/loop.py @@ -0,0 +1,83 @@ +import asyncio +import sys +import threading +from asyncio import wait_for +from contextlib import contextmanager +from typing import Iterator + +from idom.testing import poll + + +TIMEOUT = 3 + + +@contextmanager +def open_event_loop(as_current: bool = True) -> Iterator[asyncio.AbstractEventLoop]: + """Open a new event loop and cleanly stop it + + Args: + as_current: whether to make this loop the current loop in this thread + """ + loop = asyncio.new_event_loop() + try: + if as_current: + asyncio.set_event_loop(loop) + loop.set_debug(True) + yield loop + finally: + try: + _cancel_all_tasks(loop, as_current) + if as_current: + loop.run_until_complete(wait_for(loop.shutdown_asyncgens(), TIMEOUT)) + if sys.version_info >= (3, 9): + # shutdown_default_executor only available in Python 3.9+ + loop.run_until_complete( + wait_for(loop.shutdown_default_executor(), TIMEOUT) + ) + finally: + if as_current: + asyncio.set_event_loop(None) + poll(loop.is_running).until_is(False) + loop.close() + + +def _cancel_all_tasks(loop: asyncio.AbstractEventLoop, is_current: bool) -> None: + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + done = threading.Event() + count = len(to_cancel) + + def one_task_finished(future): + nonlocal count + count -= 1 + if count == 0: + done.set() + + for task in to_cancel: + loop.call_soon_threadsafe(task.cancel) + task.add_done_callback(one_task_finished) + + if is_current: + loop.run_until_complete( + wait_for( + asyncio.gather(*to_cancel, loop=loop, return_exceptions=True), TIMEOUT + ) + ) + else: + # user was responsible for cancelling all tasks + if not done.wait(timeout=3): + raise TimeoutError("Could not stop event loop in time") + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during event loop shutdown", + "exception": task.exception(), + "task": task, + } + )