From cfde802060c2ab8715f3f645b2bc1a31deb29caa Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Tue, 10 Dec 2024 23:21:44 -0800 Subject: [PATCH 01/28] Use `ensure_async` instead of `database_sync_to_async` --- src/reactpy_django/forms/components.py | 3 +-- src/reactpy_django/hooks.py | 30 +++++++----------------- src/reactpy_django/utils.py | 15 ++++-------- src/reactpy_django/websocket/consumer.py | 6 ++--- 4 files changed, 17 insertions(+), 37 deletions(-) diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index d19c0bbb..20cfe092 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any, Callable, Union, cast from uuid import uuid4 -from channels.db import database_sync_to_async from django.forms import Form, ModelForm from reactpy import component, hooks, html, utils from reactpy.core.events import event @@ -79,7 +78,7 @@ def _django_form( async def render_form(): """Forms must be rendered in an async loop to allow database fields to execute.""" if submitted_data: - await database_sync_to_async(initialized_form.full_clean)() + await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() success = not initialized_form.errors.as_data() if success and on_success: await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index ba3642f6..270ae55c 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -14,7 +14,6 @@ import orjson from channels import DEFAULT_CHANNEL_LAYER -from channels.db import database_sync_to_async from channels.layers import InMemoryChannelLayer, get_channel_layer from reactpy import use_callback, use_effect, use_memo, use_ref, use_state from reactpy import use_connection as _use_connection @@ -34,7 +33,7 @@ SyncPostprocessor, UserData, ) -from reactpy_django.utils import django_query_postprocessor, generate_obj_name, get_pk +from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk if TYPE_CHECKING: from collections.abc import Awaitable, Sequence @@ -138,19 +137,13 @@ async def execute_query() -> None: """The main running function for `use_query`""" try: # Run the query - if asyncio.iscoroutinefunction(query): - new_data = await query(**kwargs) - else: - new_data = await database_sync_to_async(query, thread_sensitive=thread_sensitive)(**kwargs) + new_data = await ensure_async(query, thread_sensitive=thread_sensitive)(**kwargs) # Run the postprocessor if postprocessor: - if asyncio.iscoroutinefunction(postprocessor): - new_data = await postprocessor(new_data, **postprocessor_kwargs) - else: - new_data = await database_sync_to_async(postprocessor, thread_sensitive=thread_sensitive)( - new_data, **postprocessor_kwargs - ) + new_data = await ensure_async(postprocessor, thread_sensitive=thread_sensitive)( + new_data, **postprocessor_kwargs + ) # Log any errors and set the error state except Exception as e: @@ -240,12 +233,7 @@ def use_mutation( async def execute_mutation(exec_args, exec_kwargs) -> None: # Run the mutation try: - if asyncio.iscoroutinefunction(mutation): - should_refetch = await mutation(*exec_args, **exec_kwargs) - else: - should_refetch = await database_sync_to_async(mutation, thread_sensitive=thread_sensitive)( - *exec_args, **exec_kwargs - ) + should_refetch = await ensure_async(mutation, thread_sensitive=thread_sensitive)(*exec_args, **exec_kwargs) # Log any errors and set the error state except Exception as e: @@ -444,10 +432,8 @@ async def _get_user_data(user: AbstractUser, default_data: None | dict, save_def for key, value in default_data.items(): if key not in data: new_value: Any = value - if asyncio.iscoroutinefunction(value): - new_value = await value() - elif callable(value): - new_value = value() + if callable(value): + new_value = await ensure_async(value)() data[key] = new_value changed = True if changed: diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 72fa5439..d8e46903 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -82,17 +82,12 @@ async def render_view( if getattr(view, "as_view", None): view = view.as_view() - # Async function view - if iscoroutinefunction(view): - response = await view(request, *args, **kwargs) + # Sync/Async function view + response = await ensure_async(view)(request, *args, **kwargs) - # Sync function view - else: - response = await database_sync_to_async(view)(request, *args, **kwargs) - - # TemplateView + # TemplateView needs an extra render step if getattr(response, "render", None): - response = await database_sync_to_async(response.render)() + response = await ensure_async(response.render)() return response @@ -565,7 +560,7 @@ def ensure_async( def wrapper(*args, **kwargs): return ( func(*args, **kwargs) - if inspect.iscoroutinefunction(func) + if iscoroutinefunction(func) else database_sync_to_async(func, thread_sensitive=thread_sensitive)(*args, **kwargs) ) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index d877679b..050c840e 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -14,7 +14,6 @@ import dill import orjson from channels.auth import login -from channels.db import database_sync_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone from reactpy.backend.hooks import ConnectionContext @@ -23,6 +22,7 @@ from reactpy.core.serve import serve_layout from reactpy_django.clean import clean +from reactpy_django.utils import ensure_async if TYPE_CHECKING: from collections.abc import MutableMapping, Sequence @@ -80,7 +80,7 @@ async def connect(self) -> None: f"ReactPy websocket authentication has failed!\n{traceback.format_exc()}", ) try: - await database_sync_to_async(self.scope["session"].save)() + await ensure_async(self.scope["session"].save)() except Exception: await asyncio.to_thread( _logger.error, @@ -116,7 +116,7 @@ async def disconnect(self, code: int) -> None: # Queue a cleanup, if needed if REACTPY_CLEAN_INTERVAL is not None: try: - await database_sync_to_async(clean)() + await ensure_async(clean)() except Exception: await asyncio.to_thread( _logger.error, From 1070b801b0c4941a77b7f317aaa853ed30fa76f6 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:12:56 -0800 Subject: [PATCH 02/28] Handle all pyright warnings --- docs/examples/python/example/models.py | 4 ++++ src/reactpy_django/clean.py | 2 +- src/reactpy_django/components.py | 24 +++++++++---------- src/reactpy_django/decorators.py | 4 ++-- src/reactpy_django/forms/transforms.py | 1 + src/reactpy_django/hooks.py | 19 +++++++++------ src/reactpy_django/http/views.py | 2 +- src/reactpy_django/pyscript/layout_handler.py | 1 + src/reactpy_django/types.py | 12 +++++++++- src/reactpy_django/utils.py | 21 ++++++++-------- src/reactpy_django/websocket/consumer.py | 4 ++-- src/reactpy_django/websocket/paths.py | 2 +- 12 files changed, 58 insertions(+), 38 deletions(-) create mode 100644 docs/examples/python/example/models.py diff --git a/docs/examples/python/example/models.py b/docs/examples/python/example/models.py new file mode 100644 index 00000000..2bf062b9 --- /dev/null +++ b/docs/examples/python/example/models.py @@ -0,0 +1,4 @@ +from django.db import models + + +class TodoItem(models.Model): ... diff --git a/src/reactpy_django/clean.py b/src/reactpy_django/clean.py index 0a7e9017..facf3b82 100644 --- a/src/reactpy_django/clean.py +++ b/src/reactpy_django/clean.py @@ -84,7 +84,7 @@ def clean_user_data(verbosity: int = 1): start_time = timezone.now() user_model = get_user_model() all_users = user_model.objects.all() - all_user_pks = all_users.values_list(user_model._meta.pk.name, flat=True) + all_user_pks = all_users.values_list(user_model._meta.pk.name, flat=True) # type: ignore # Django doesn't support using QuerySets as an argument with cross-database relations. if user_model.objects.db != UserDataModel.objects.db: diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 7e821c1c..1599d896 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -32,14 +32,14 @@ from django.forms import Form, ModelForm from django.views import View - from reactpy_django.types import AsyncFormEvent, SyncFormEvent + from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor def view_to_component( view: Callable | View | str, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: +) -> ViewToComponentConstructor: """Converts a Django view to a ReactPy component. Keyword Args: @@ -58,7 +58,7 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): + ) -> ComponentType: return _view_to_component( view=view, transforms=transforms, @@ -72,7 +72,7 @@ def constructor( return constructor -def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None): +def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None) -> ViewToIframeConstructor: """ Args: view: The view function or class to convert, or the dotted path to the view. @@ -88,13 +88,13 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): + ) -> ComponentType: return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key) return constructor -def django_css(static_path: str, key: Key | None = None): +def django_css(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -107,7 +107,7 @@ def django_css(static_path: str, key: Key | None = None): return _django_css(static_path=static_path, key=key) -def django_js(static_path: str, key: Key | None = None): +def django_js(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -135,7 +135,7 @@ def django_form( top_children: Sequence[Any] = (), bottom_children: Sequence[Any] = (), key: Key | None = None, -): +) -> ComponentType: """Converts a Django form to a ReactPy component. Args: @@ -182,7 +182,7 @@ def pyscript_component( *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", -): +) -> ComponentType: """ Args: file_paths: File path to your client-side component. If multiple paths are \ @@ -219,7 +219,7 @@ def _view_to_component( else: _request = HttpRequest() _request.method = "GET" - resolved_view: Callable = import_module(view) if isinstance(view, str) else view + resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore # Render the view render within a hook @hooks.use_effect( @@ -251,12 +251,12 @@ def _view_to_iframe( extra_props: dict[str, Any] | None, args: Sequence, kwargs: dict, -) -> VdomDict: +): """The actual component. Used to prevent pollution of acceptable kwargs keys.""" from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS if hasattr(view, "view_class"): - view = view.view_class + view = view.view_class # type: ignore dotted_path = view if isinstance(view, str) else generate_obj_name(view) registered_view = REACTPY_REGISTERED_IFRAME_VIEWS.get(dotted_path) diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 804e10bb..6b3d220e 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -35,10 +35,10 @@ def _wrapper(*args, **kwargs): return _wrapper - return decorator + return decorator # type: ignore -@component +@component # type: ignore def _user_passes_test(component_constructor, fallback, test_func, *args, **kwargs): """Dedicated component for `user_passes_test` to allow us to always have access to hooks.""" user = use_user() diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py index 2d527209..58e98de4 100644 --- a/src/reactpy_django/forms/transforms.py +++ b/src/reactpy_django/forms/transforms.py @@ -1,3 +1,4 @@ +# type: ignore # TODO: Almost everything in this module should be moved to `reactpy.utils._mutate_vdom()`. from __future__ import annotations diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index 270ae55c..c9d783af 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -3,6 +3,7 @@ import asyncio import logging from collections import defaultdict +from collections.abc import Awaitable from typing import ( TYPE_CHECKING, Any, @@ -36,7 +37,7 @@ from reactpy_django.utils import django_query_postprocessor, ensure_async, generate_obj_name, get_pk if TYPE_CHECKING: - from collections.abc import Awaitable, Sequence + from collections.abc import Sequence from channels_redis.core import RedisChannelLayer from django.contrib.auth.models import AbstractUser @@ -137,13 +138,17 @@ async def execute_query() -> None: """The main running function for `use_query`""" try: # Run the query - new_data = await ensure_async(query, thread_sensitive=thread_sensitive)(**kwargs) + query_async = cast( + Callable[..., Awaitable[Inferred]], ensure_async(query, thread_sensitive=thread_sensitive) + ) + new_data = await query_async(**kwargs) # Run the postprocessor if postprocessor: - new_data = await ensure_async(postprocessor, thread_sensitive=thread_sensitive)( - new_data, **postprocessor_kwargs + async_postprocessor = cast( + Callable[..., Awaitable[Any]], ensure_async(postprocessor, thread_sensitive=thread_sensitive) ) + new_data = await async_postprocessor(new_data, **postprocessor_kwargs) # Log any errors and set the error state except Exception as e: @@ -230,7 +235,7 @@ def use_mutation( async_task_refs = use_ref(set()) # The main "running" function for `use_mutation` - async def execute_mutation(exec_args, exec_kwargs) -> None: + async def execute_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwargs) -> None: # Run the mutation try: should_refetch = await ensure_async(mutation, thread_sensitive=thread_sensitive)(*exec_args, **exec_kwargs) @@ -262,7 +267,7 @@ def schedule_mutation(*exec_args: FuncParams.args, **exec_kwargs: FuncParams.kwa set_loading(True) # Execute the mutation in the background - task = asyncio.ensure_future(execute_mutation(exec_args=exec_args, exec_kwargs=exec_kwargs)) + task = asyncio.ensure_future(execute_mutation(*exec_args, **exec_kwargs)) # Add the task to a set to prevent it from being garbage collected async_task_refs.current.add(task) @@ -359,7 +364,7 @@ def use_channel_layer( layer: The channel layer to use. This layer must be defined in \ `settings.py:CHANNEL_LAYERS`. """ - channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer) + channel_layer: InMemoryChannelLayer | RedisChannelLayer = get_channel_layer(layer) # type: ignore channel_name = use_memo(lambda: str(name or uuid4())) if not name and not group_name: diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 25315479..e0c5fc1d 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -8,7 +8,7 @@ from reactpy_django.utils import FileAsyncIterator, render_view -def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: +def web_modules_file(request: HttpRequest, file: str) -> FileResponse: """Gets JavaScript required for ReactPy modules at runtime.""" web_modules_dir = REACTPY_WEB_MODULES_DIR.current diff --git a/src/reactpy_django/pyscript/layout_handler.py b/src/reactpy_django/pyscript/layout_handler.py index 77aa8c81..6a7a430b 100644 --- a/src/reactpy_django/pyscript/layout_handler.py +++ b/src/reactpy_django/pyscript/layout_handler.py @@ -1,3 +1,4 @@ +# type: ignore import asyncio import logging diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 53e6e9ad..83cabf5b 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -13,7 +13,7 @@ ) from django.http import HttpRequest -from reactpy.types import Connection +from reactpy.types import ComponentType, Connection, Key from typing_extensions import ParamSpec if TYPE_CHECKING: @@ -98,3 +98,13 @@ async def __call__(self, message: dict) -> None: ... class AsyncMessageSender(Protocol): async def __call__(self, message: dict) -> None: ... + + +class ViewToComponentConstructor(Protocol): + def __call__( + self, request: HttpRequest | None = None, *args: Any, key: Key | None = None, **kwargs: Any + ) -> ComponentType: ... + + +class ViewToIframeConstructor(Protocol): + def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ... diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index d8e46903..cc06e1ef 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -80,10 +80,10 @@ async def render_view( """Ingests a Django view (class or function) and returns an HTTP response object.""" # Convert class-based view to function-based view if getattr(view, "as_view", None): - view = view.as_view() + view = view.as_view() # type: ignore # Sync/Async function view - response = await ensure_async(view)(request, *args, **kwargs) + response = await ensure_async(view)(request, *args, **kwargs) # type: ignore # TemplateView needs an extra render step if getattr(response, "render", None): @@ -122,7 +122,7 @@ def register_iframe(view: Callable | View | str): from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS if hasattr(view, "view_class"): - view = view.view_class + view = view.view_class # type: ignore dotted_path = view if isinstance(view, str) else generate_obj_name(view) try: REACTPY_REGISTERED_IFRAME_VIEWS[dotted_path] = import_dotted_path(dotted_path) @@ -165,7 +165,7 @@ def get_loaders(self): template_source_loaders = [] for e in engines.all(): if hasattr(e, "engine"): - template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) + template_source_loaders.extend(e.engine.get_template_loaders(e.engine.loaders)) # type: ignore loaders = [] for loader in template_source_loaders: if hasattr(loader, "loaders"): @@ -366,7 +366,7 @@ def __enter__(self): def __exit__(self, *_): async_to_sync(self.__aexit__)(*_) - def render(self): + def sync_render(self): return async_to_sync(super().render)() @@ -413,9 +413,9 @@ def prerender_component( ), ) ) as layout: - vdom_tree = layout.render()["model"] + vdom_tree = layout.sync_render()["model"] - return vdom_to_html(vdom_tree) + return vdom_to_html(vdom_tree) # type: ignore def vdom_or_component_to_string( @@ -424,7 +424,7 @@ def vdom_or_component_to_string( """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be automatically returned.""" if isinstance(vdom_or_component, dict): - return vdom_to_html(vdom_or_component) + return vdom_to_html(vdom_or_component) # type: ignore if hasattr(vdom_or_component, "render"): if not request: @@ -536,17 +536,16 @@ def __init__(self, file_path: str): self.file_path = file_path async def __aiter__(self): - file_opened = False + file_handle = None try: file_handle = FILE_ASYNC_ITERATOR_THREAD.submit(open, self.file_path, "rb").result() - file_opened = True while True: chunk = FILE_ASYNC_ITERATOR_THREAD.submit(file_handle.read, 8192).result() if not chunk: break yield chunk finally: - if file_opened: + if file_handle: file_handle.close() diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 050c840e..ecfd8431 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -16,8 +16,8 @@ from channels.auth import login from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone -from reactpy.backend.hooks import ConnectionContext from reactpy.backend.types import Connection, Location +from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout @@ -210,7 +210,7 @@ async def run_dispatcher(self): # Start the ReactPy component rendering loop with contextlib.suppress(Exception): await serve_layout( - Layout(ConnectionContext(root_component, value=connection)), + Layout(ConnectionContext(root_component, value=connection)), # type: ignore self.send_json, self.recv_queue.get, ) diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index 258c58f2..7ed5d900 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -5,7 +5,7 @@ REACTPY_WEBSOCKET_ROUTE = path( f"{REACTPY_URL_PREFIX}////", - ReactpyAsyncWebsocketConsumer.as_asgi(), + ReactpyAsyncWebsocketConsumer.as_asgi(), # type: ignore ) """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. From 8ec20b7bfc535b955a77a36e7844406f81aba246 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:17:20 -0800 Subject: [PATCH 03/28] Add CI workflow for pyright --- .github/workflows/test-python.yml | 15 +++++++++++++++ docs/src/about/contributing.md | 1 + pyproject.toml | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index cdc0b89e..3e4f29b0 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -46,3 +46,18 @@ jobs: run: pip install --upgrade pip hatch uv - name: Check Python formatting run: hatch fmt src tests --check + + python-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install --upgrade pip hatch uv + - name: Check Python formatting + run: hatch run python:type_check diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md index 59f4f989..b78a9508 100644 --- a/docs/src/about/contributing.md +++ b/docs/src/about/contributing.md @@ -62,6 +62,7 @@ By utilizing `hatch`, the following commands are available to manage the develop | `hatch fmt --formatter` | Run only formatters | | `hatch run javascript:check` | Run the JavaScript linter/formatter | | `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | +| `hatch run python:type_check` | Run the Python type checker | ??? tip "Configure your IDE for linting" diff --git a/pyproject.toml b/pyproject.toml index c25929bf..95802e7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -185,6 +185,16 @@ linkcheck = [ deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] deploy_develop = ["cd docs && mike deploy --push develop"] +################################ +# >>> Hatch Python Scripts <<< # +################################ + +[tool.hatch.envs.python] +extra-dependencies = ["django-stubs", "channels-redis", "pyright"] + +[tool.hatch.envs.python.scripts] +type_check = ["pyright src"] + ############################ # >>> Hatch JS Scripts <<< # ############################ From 37118039f59872c5cf79daf699bc405976d5d8b0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:21:06 -0800 Subject: [PATCH 04/28] Grouo pyscript related code into module --- src/reactpy_django/components.py | 32 +------- src/reactpy_django/pyscript/components.py | 37 +++++++++ src/reactpy_django/pyscript/utils.py | 89 ++++++++++++++++++++++ src/reactpy_django/templatetags/reactpy.py | 4 +- src/reactpy_django/utils.py | 80 +------------------ 5 files changed, 130 insertions(+), 112 deletions(-) create mode 100644 src/reactpy_django/pyscript/components.py create mode 100644 src/reactpy_django/pyscript/utils.py diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 1599d896..c3794b1a 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -6,7 +6,6 @@ import os from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode -from uuid import uuid4 from django.contrib.staticfiles.finders import find from django.core.cache import caches @@ -17,13 +16,11 @@ from reactpy_django.exceptions import ViewNotRegisteredError from reactpy_django.forms.components import _django_form -from reactpy_django.html import pyscript +from reactpy_django.pyscript.components import _pyscript_component from reactpy_django.utils import ( generate_obj_name, import_module, - render_pyscript_template, render_view, - vdom_or_component_to_string, ) if TYPE_CHECKING: @@ -316,30 +313,3 @@ def _cached_static_contents(static_path: str) -> str: caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) return file_contents - - -@component -def _pyscript_component( - *file_paths: str, - initial: str | VdomDict | ComponentType = "", - root: str = "root", -): - rendered, set_rendered = hooks.use_state(False) - uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) - uuid = uuid_ref.current - initial = vdom_or_component_to_string(initial, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) - - if not rendered: - # FIXME: This is needed to properly re-render PyScript during a WebSocket - # disconnection / reconnection. There may be a better way to do this in the future. - set_rendered(True) - return None - - return html._( - html.div( - {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, - initial, - ), - pyscript({"async": ""}, executor), - ) diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py new file mode 100644 index 00000000..2a6cbbed --- /dev/null +++ b/src/reactpy_django/pyscript/components.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from uuid import uuid4 + +from reactpy import component, hooks, html +from reactpy.types import ComponentType, VdomDict + +from reactpy_django.html import pyscript +from reactpy_django.pyscript.utils import render_pyscript_template +from reactpy_django.utils import vdom_or_component_to_string + + +@component +def _pyscript_component( + *file_paths: str, + initial: str | VdomDict | ComponentType = "", + root: str = "root", +): + rendered, set_rendered = hooks.use_state(False) + uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) + uuid = uuid_ref.current + initial = vdom_or_component_to_string(initial, uuid=uuid) + executor = render_pyscript_template(file_paths, uuid, root) + + if not rendered: + # FIXME: This is needed to properly re-render PyScript during a WebSocket + # disconnection / reconnection. There may be a better way to do this in the future. + set_rendered(True) + return None + + return html._( + html.div( + {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, + initial, + ), + pyscript({"async": ""}, executor), + ) diff --git a/src/reactpy_django/pyscript/utils.py b/src/reactpy_django/pyscript/utils.py new file mode 100644 index 00000000..6484a5dc --- /dev/null +++ b/src/reactpy_django/pyscript/utils.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import json +import os +import textwrap +from copy import deepcopy +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import jsonpointer +import orjson +import reactpy +from django.templatetags.static import static + +from reactpy_django.utils import create_cache_key + +if TYPE_CHECKING: + from collections.abc import Sequence + + +PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "component_template.py").read_text(encoding="utf-8") +PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "layout_handler.py").read_text(encoding="utf-8") +PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {} + + +def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): + """Inserts the user's code into the PyScript template using pattern matching.""" + from django.core.cache import caches + + from reactpy_django.config import REACTPY_CACHE + + # Create a valid PyScript executor by replacing the template values + executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) + executor = executor.replace("return root()", f"return {root}()") + + # Fetch the user's PyScript code + all_file_contents: list[str] = [] + for file_path in file_paths: + # Try to get user code from cache + cache_key = create_cache_key("pyscript", file_path) + last_modified_time = os.stat(file_path).st_mtime + file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) + if file_contents: + all_file_contents.append(file_contents) + + # If not cached, read from file system + else: + file_contents = Path(file_path).read_text(encoding="utf-8").strip() + all_file_contents.append(file_contents) + caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) + + # Prepare the PyScript code block + user_code = "\n".join(all_file_contents) # Combine all user code + user_code = user_code.replace("\t", " ") # Normalize the text + user_code = textwrap.indent(user_code, " ") # Add indentation to match template + + # Insert the user code into the PyScript template + return executor.replace(" def root(): ...", user_code) + + +def extend_pyscript_config(extra_py: Sequence, extra_js: dict | str, config: dict | str) -> str: + """Extends ReactPy's default PyScript config with user provided values.""" + # Lazily set up the initial config in to wait for Django's static file system + if not PYSCRIPT_DEFAULT_CONFIG: + PYSCRIPT_DEFAULT_CONFIG.update({ + "packages": [ + f"reactpy=={reactpy.__version__}", + f"jsonpointer=={jsonpointer.__version__}", + "ssl", + ], + "js_modules": {"main": {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}}, + }) + + # Extend the Python dependency list + pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) + pyscript_config["packages"].extend(extra_py) + + # Extend the JavaScript dependency list + if extra_js and isinstance(extra_js, str): + pyscript_config["js_modules"]["main"].update(json.loads(extra_js)) + elif extra_js and isinstance(extra_js, dict): + pyscript_config["js_modules"]["main"].update(extra_py) + + # Update the config + if config and isinstance(config, str): + pyscript_config.update(json.loads(config)) + elif config and isinstance(config, dict): + pyscript_config.update(config) + return orjson.dumps(pyscript_config).decode("utf-8") diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 70b7fa5e..21885309 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -15,11 +15,9 @@ InvalidHostError, OfflineComponentMissingError, ) +from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template from reactpy_django.utils import ( - PYSCRIPT_LAYOUT_HANDLER, - extend_pyscript_config, prerender_component, - render_pyscript_template, save_component_params, strtobool, validate_component_args, diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index cc06e1ef..947f03be 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -2,25 +2,18 @@ import contextlib import inspect -import json import logging import os import re -import textwrap from asyncio import iscoroutinefunction from concurrent.futures import ThreadPoolExecutor -from copy import deepcopy from fnmatch import fnmatch from functools import wraps from importlib import import_module -from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable +from typing import TYPE_CHECKING, Any, Awaitable, Callable from uuid import UUID, uuid4 import dill -import jsonpointer -import orjson -import reactpy from asgiref.sync import async_to_sync from channels.db import database_sync_to_async from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects @@ -28,7 +21,6 @@ from django.db.models.query import QuerySet from django.http import HttpRequest, HttpResponse from django.template import engines -from django.templatetags.static import static from django.utils.encoding import smart_str from reactpy import vdom_to_html from reactpy.backend.types import Connection, Location @@ -41,6 +33,7 @@ InvalidHostError, ViewDoesNotExistError, ) +from reactpy_django.types import FuncParams, Inferred if TYPE_CHECKING: from collections.abc import Awaitable, Mapping, Sequence @@ -65,9 +58,6 @@ + rf"({_OFFLINE_KWARG_PATTERN}|{_GENERIC_KWARG_PATTERN})*?" + r"\s*%}" ) -PYSCRIPT_COMPONENT_TEMPLATE = (Path(__file__).parent / "pyscript" / "component_template.py").read_text(encoding="utf-8") -PYSCRIPT_LAYOUT_HANDLER = (Path(__file__).parent / "pyscript" / "layout_handler.py").read_text(encoding="utf-8") -PYSCRIPT_DEFAULT_CONFIG: dict[str, Any] = {} FILE_ASYNC_ITERATOR_THREAD = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ReactPy-Django-FileAsyncIterator") @@ -441,72 +431,6 @@ def vdom_or_component_to_string( raise ValueError(msg) -def render_pyscript_template(file_paths: Sequence[str], uuid: str, root: str): - """Inserts the user's code into the PyScript template using pattern matching.""" - from django.core.cache import caches - - from reactpy_django.config import REACTPY_CACHE - - # Create a valid PyScript executor by replacing the template values - executor = PYSCRIPT_COMPONENT_TEMPLATE.replace("UUID", uuid) - executor = executor.replace("return root()", f"return {root}()") - - # Fetch the user's PyScript code - all_file_contents: list[str] = [] - for file_path in file_paths: - # Try to get user code from cache - cache_key = create_cache_key("pyscript", file_path) - last_modified_time = os.stat(file_path).st_mtime - file_contents: str = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents: - all_file_contents.append(file_contents) - - # If not cached, read from file system - else: - file_contents = Path(file_path).read_text(encoding="utf-8").strip() - all_file_contents.append(file_contents) - caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) - - # Prepare the PyScript code block - user_code = "\n".join(all_file_contents) # Combine all user code - user_code = user_code.replace("\t", " ") # Normalize the text - user_code = textwrap.indent(user_code, " ") # Add indentation to match template - - # Insert the user code into the PyScript template - return executor.replace(" def root(): ...", user_code) - - -def extend_pyscript_config(extra_py: Sequence, extra_js: dict | str, config: dict | str) -> str: - """Extends ReactPy's default PyScript config with user provided values.""" - # Lazily set up the initial config in to wait for Django's static file system - if not PYSCRIPT_DEFAULT_CONFIG: - PYSCRIPT_DEFAULT_CONFIG.update({ - "packages": [ - f"reactpy=={reactpy.__version__}", - f"jsonpointer=={jsonpointer.__version__}", - "ssl", - ], - "js_modules": {"main": {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}}, - }) - - # Extend the Python dependency list - pyscript_config = deepcopy(PYSCRIPT_DEFAULT_CONFIG) - pyscript_config["packages"].extend(extra_py) - - # Extend the JavaScript dependency list - if extra_js and isinstance(extra_js, str): - pyscript_config["js_modules"]["main"].update(json.loads(extra_js)) - elif extra_js and isinstance(extra_js, dict): - pyscript_config["js_modules"]["main"].update(extra_py) - - # Update the config - if config and isinstance(config, str): - pyscript_config.update(json.loads(config)) - elif config and isinstance(config, dict): - pyscript_config.update(config) - return orjson.dumps(pyscript_config).decode("utf-8") - - def save_component_params(args, kwargs, uuid) -> None: """Saves the component parameters to the database. This is used within our template tag in order to propogate From 1ee2799b4a5b9826dd14071c9ed4e474172cbee7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:27:45 -0800 Subject: [PATCH 05/28] cached_static_contents -> cached_static_file --- src/reactpy_django/components.py | 39 +++----------------------------- src/reactpy_django/utils.py | 26 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index c3794b1a..6616cf4b 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -3,12 +3,9 @@ from __future__ import annotations import json -import os from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode -from django.contrib.staticfiles.finders import find -from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse from reactpy import component, hooks, html, utils @@ -17,11 +14,7 @@ from reactpy_django.exceptions import ViewNotRegisteredError from reactpy_django.forms.components import _django_form from reactpy_django.pyscript.components import _pyscript_component -from reactpy_django.utils import ( - generate_obj_name, - import_module, - render_view, -) +from reactpy_django.utils import cached_static_file, generate_obj_name, import_module, render_view if TYPE_CHECKING: from collections.abc import Sequence @@ -207,7 +200,6 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None)) _args: Sequence = args or () _kwargs: dict = kwargs or {} @@ -249,7 +241,6 @@ def _view_to_iframe( args: Sequence, kwargs: dict, ): - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS if hasattr(view, "view_class"): @@ -283,33 +274,9 @@ def _view_to_iframe( @component def _django_css(static_path: str): - return html.style(_cached_static_contents(static_path)) + return html.style(cached_static_file(static_path)) @component def _django_js(static_path: str): - return html.script(_cached_static_contents(static_path)) - - -def _cached_static_contents(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE - - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - msg = f"Could not find static file {static_path} within Django's static files." - raise FileNotFoundError(msg) - if isinstance(abs_path, (list, tuple)): - abs_path = abs_path[0] - - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) - - return file_contents + return html.script(cached_static_file(static_path)) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 947f03be..881bfa8d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -16,6 +16,8 @@ import dill from asgiref.sync import async_to_sync from channels.db import database_sync_to_async +from django.contrib.staticfiles.finders import find +from django.core.cache import caches from django.db.models import ManyToManyField, ManyToOneRel, prefetch_related_objects from django.db.models.base import Model from django.db.models.query import QuerySet @@ -488,3 +490,27 @@ def wrapper(*args, **kwargs): ) return wrapper + + +def cached_static_file(static_path: str) -> str: + from reactpy_django.config import REACTPY_CACHE + + # Try to find the file within Django's static files + abs_path = find(static_path) + if not abs_path: + msg = f"Could not find static file {static_path} within Django's static files." + raise FileNotFoundError(msg) + if isinstance(abs_path, (list, tuple)): + abs_path = abs_path[0] + + # Fetch the file from cache, if available + last_modified_time = os.stat(abs_path).st_mtime + cache_key = f"reactpy_django:static_contents:{static_path}" + file_contents: str | None = caches[REACTPY_CACHE].get(cache_key, version=int(last_modified_time)) + if file_contents is None: + with open(abs_path, encoding="utf-8") as static_file: + file_contents = static_file.read() + caches[REACTPY_CACHE].delete(cache_key) + caches[REACTPY_CACHE].set(cache_key, file_contents, timeout=None, version=int(last_modified_time)) + + return file_contents From e7d176499937dae7358331432a0c7a20afd1ad09 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:31:16 -0800 Subject: [PATCH 06/28] vdom_or_component_to_string -> reactpy_to_string --- src/reactpy_django/pyscript/components.py | 4 ++-- src/reactpy_django/templatetags/reactpy.py | 4 ++-- src/reactpy_django/utils.py | 4 +--- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py index 2a6cbbed..5c831771 100644 --- a/src/reactpy_django/pyscript/components.py +++ b/src/reactpy_django/pyscript/components.py @@ -7,7 +7,7 @@ from reactpy_django.html import pyscript from reactpy_django.pyscript.utils import render_pyscript_template -from reactpy_django.utils import vdom_or_component_to_string +from reactpy_django.utils import reactpy_to_string @component @@ -19,7 +19,7 @@ def _pyscript_component( rendered, set_rendered = hooks.use_state(False) uuid_ref = hooks.use_ref(uuid4().hex.replace("-", "")) uuid = uuid_ref.current - initial = vdom_or_component_to_string(initial, uuid=uuid) + initial = reactpy_to_string(initial, uuid=uuid) executor = render_pyscript_template(file_paths, uuid, root) if not rendered: diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 21885309..65e08150 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -18,11 +18,11 @@ from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template from reactpy_django.utils import ( prerender_component, + reactpy_to_string, save_component_params, strtobool, validate_component_args, validate_host, - vdom_or_component_to_string, ) if TYPE_CHECKING: @@ -203,7 +203,7 @@ def pyscript_component( uuid = uuid4().hex request: HttpRequest | None = context.get("request") - initial = vdom_or_component_to_string(initial, request=request, uuid=uuid) + initial = reactpy_to_string(initial, request=request, uuid=uuid) executor = render_pyscript_template(file_paths, uuid, root) return { diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 881bfa8d..39c3b5c6 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -410,9 +410,7 @@ def prerender_component( return vdom_to_html(vdom_tree) # type: ignore -def vdom_or_component_to_string( - vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None -) -> str: +def reactpy_to_string(vdom_or_component: Any, request: HttpRequest | None = None, uuid: str | None = None) -> str: """Converts a VdomDict or component to an HTML string. If a string is provided instead, it will be automatically returned.""" if isinstance(vdom_or_component, dict): From 67966e25c77c596f746c88d6e13f434ce66422b3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:31:39 -0800 Subject: [PATCH 07/28] strtobool -> str_to_bool --- src/reactpy_django/templatetags/reactpy.py | 4 ++-- src/reactpy_django/utils.py | 2 +- tests/test_app/tests/test_components.py | 4 ++-- tests/test_app/tests/utils.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 65e08150..029e7d8c 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -20,7 +20,7 @@ prerender_component, reactpy_to_string, save_component_params, - strtobool, + str_to_bool, validate_component_args, validate_host, ) @@ -128,7 +128,7 @@ def component( return failure_context(dotted_path, e) # Pre-render the component, if requested - if strtobool(prerender): + if str_to_bool(prerender): if not is_local: msg = "Cannot pre-render non-local components." _logger.error(msg) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 39c3b5c6..6f470150 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -367,7 +367,7 @@ def get_pk(model): return getattr(model, model._meta.pk.name) -def strtobool(val: str) -> bool: +def str_to_bool(val: str) -> bool: """Convert a string representation of truth to true (1) or false (0). True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index bd4507e1..dfbee605 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -8,11 +8,11 @@ from playwright.sync_api import TimeoutError, expect from reactpy_django.models import ComponentSession -from reactpy_django.utils import strtobool +from reactpy_django.utils import str_to_bool from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page -CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. +CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. class GenericComponentTests(PlaywrightTestCase): diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index c96cc23a..e43fe56e 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -12,7 +12,7 @@ from django.test.utils import modify_settings from playwright.sync_api import sync_playwright -from reactpy_django.utils import strtobool +from reactpy_django.utils import str_to_bool GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") @@ -41,7 +41,7 @@ def setUpClass(cls): if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() - headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) + headless = str_to_bool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) cls.page = cls.browser.new_page() cls.page.set_default_timeout(10000) From 8ab7f2364b4882d14fa73715961cc54c78559371 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 02:34:27 -0800 Subject: [PATCH 08/28] Fix borked type hints --- src/reactpy_django/pyscript/components.py | 5 ++++- src/reactpy_django/utils.py | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/pyscript/components.py b/src/reactpy_django/pyscript/components.py index 5c831771..00db19e4 100644 --- a/src/reactpy_django/pyscript/components.py +++ b/src/reactpy_django/pyscript/components.py @@ -1,14 +1,17 @@ from __future__ import annotations +from typing import TYPE_CHECKING from uuid import uuid4 from reactpy import component, hooks, html -from reactpy.types import ComponentType, VdomDict from reactpy_django.html import pyscript from reactpy_django.pyscript.utils import render_pyscript_template from reactpy_django.utils import reactpy_to_string +if TYPE_CHECKING: + from reactpy.types import ComponentType, VdomDict + @component def _pyscript_component( diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 6f470150..888b0cf7 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -10,7 +10,7 @@ from fnmatch import fnmatch from functools import wraps from importlib import import_module -from typing import TYPE_CHECKING, Any, Awaitable, Callable +from typing import TYPE_CHECKING, Any, Callable from uuid import UUID, uuid4 import dill @@ -35,7 +35,6 @@ InvalidHostError, ViewDoesNotExistError, ) -from reactpy_django.types import FuncParams, Inferred if TYPE_CHECKING: from collections.abc import Awaitable, Mapping, Sequence From 5d2dd0e9605523e7d5618f7b637e6ac8348152d8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:50:55 -0800 Subject: [PATCH 09/28] More generic function for form field conversion --- src/reactpy_django/forms/components.py | 5 ++--- src/reactpy_django/forms/utils.py | 29 +++++++------------------- 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index 20cfe092..c427b038 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -17,7 +17,7 @@ set_value_prop_on_select_element, transform_value_prop_on_input_element, ) -from reactpy_django.forms.utils import convert_boolean_fields, convert_multiple_choice_fields +from reactpy_django.forms.utils import convert_form_fields from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent from reactpy_django.utils import ensure_async @@ -95,8 +95,7 @@ async def render_form(): async def on_submit_callback(new_data: dict[str, Any]): """Callback function provided directly to the client side listener. This is responsible for transmitting the submitted form data to the server for processing.""" - convert_multiple_choice_fields(new_data, initialized_form) - convert_boolean_fields(new_data, initialized_form) + convert_form_fields(new_data, initialized_form) if on_receive_data: new_form_event = FormEventData( diff --git a/src/reactpy_django/forms/utils.py b/src/reactpy_django/forms/utils.py index 59e43d14..75c205f9 100644 --- a/src/reactpy_django/forms/utils.py +++ b/src/reactpy_django/forms/utils.py @@ -5,27 +5,12 @@ from django.forms import BooleanField, Form, ModelForm, ModelMultipleChoiceField, MultipleChoiceField, NullBooleanField -def convert_multiple_choice_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None: - multi_choice_fields = { - field_name - for field_name, field in initialized_form.fields.items() - if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)) - } +def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None: + for field_name, field in initialized_form.fields.items(): + value = data.get(field_name) - # Convert multiple choice field text into a list of values - for choice_field_name in multi_choice_fields: - value = data.get(choice_field_name) - if value is not None and not isinstance(value, list): - data[choice_field_name] = [value] + if isinstance(field, (MultipleChoiceField, ModelMultipleChoiceField)) and value is not None: + data[field_name] = value if isinstance(value, list) else [value] - -def convert_boolean_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None: - boolean_fields = { - field_name - for field_name, field in initialized_form.fields.items() - if isinstance(field, BooleanField) and not isinstance(field, NullBooleanField) - } - - # Convert boolean field text into actual booleans - for boolean_field_name in boolean_fields: - data[boolean_field_name] = boolean_field_name in data + elif isinstance(field, BooleanField) and not isinstance(field, NullBooleanField): + data[field_name] = field_name in data From 16f076d4adfa8d57ac39600f63df56d003cf7c43 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:26:14 -0800 Subject: [PATCH 10/28] validate_form_args function --- src/reactpy_django/forms/components.py | 14 ++------------ src/reactpy_django/forms/utils.py | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py index c427b038..aa39cd0d 100644 --- a/src/reactpy_django/forms/components.py +++ b/src/reactpy_django/forms/components.py @@ -17,7 +17,7 @@ set_value_prop_on_select_element, transform_value_prop_on_input_element, ) -from reactpy_django.forms.utils import convert_form_fields +from reactpy_django.forms.utils import convert_form_fields, validate_form_args from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent from reactpy_django.utils import ensure_async @@ -56,18 +56,8 @@ def _django_form( rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None)) uuid = uuid_ref.current - # Validate the provided arguments - if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current: - msg = "Dynamically changing the number of top or bottom children is not allowed." - raise ValueError(msg) - if not isinstance(form, (type(Form), type(ModelForm))): - msg = ( - "The provided form must be an uninitialized Django Form. " - "Do NOT initialize your form by calling it (ex. `MyForm()`)." - ) - raise TypeError(msg) - # Initialize the form with the provided data + validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form) initialized_form = form(data=submitted_data) form_event = FormEventData( form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data diff --git a/src/reactpy_django/forms/utils.py b/src/reactpy_django/forms/utils.py index 75c205f9..6bcf5cc6 100644 --- a/src/reactpy_django/forms/utils.py +++ b/src/reactpy_django/forms/utils.py @@ -1,8 +1,9 @@ from __future__ import annotations -from typing import Any +from typing import Any, Sequence from django.forms import BooleanField, Form, ModelForm, ModelMultipleChoiceField, MultipleChoiceField, NullBooleanField +from reactpy import Ref def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None: @@ -14,3 +15,22 @@ def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm elif isinstance(field, BooleanField) and not isinstance(field, NullBooleanField): data[field_name] = field_name in data + + +def validate_form_args( + top_children: Sequence, + top_children_count: Ref[int], + bottom_children: Sequence, + bottom_children_count: Ref[int], + form: type[Form] | type[ModelForm], +) -> None: + # Validate the provided arguments + if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current: + msg = "Dynamically changing the number of top or bottom children is not allowed." + raise ValueError(msg) + if not isinstance(form, (type(Form), type(ModelForm))): + msg = ( + "The provided form must be an uninitialized Django Form. " + "Do NOT initialize your form by calling it (ex. `MyForm()`)." + ) + raise TypeError(msg) From 1a4fcaa8376308e878a848683df483a009a571df Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:44:56 -0800 Subject: [PATCH 11/28] Refactor javascript --- pyproject.toml | 2 +- src/js/src/client.ts | 4 +- src/js/src/components.ts | 64 ++++++++++++++++++++++++++++ src/js/src/index.ts | 2 + src/js/src/{index.tsx => mount.tsx} | 65 +---------------------------- tests/test_app/__init__.py | 2 +- 6 files changed, 71 insertions(+), 68 deletions(-) create mode 100644 src/js/src/components.ts create mode 100644 src/js/src/index.ts rename src/js/src/{index.tsx => mount.tsx} (60%) diff --git a/pyproject.toml b/pyproject.toml index 95802e7e..cdfc01b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,7 +75,7 @@ installer = "uv" [[tool.hatch.build.hooks.build-scripts.scripts]] commands = [ "bun install --cwd src/js", - "bun build src/js/src/index.tsx --outfile src/reactpy_django/static/reactpy_django/client.js --minify", + "bun build src/js/src/index.ts --outfile src/reactpy_django/static/reactpy_django/client.js --minify", 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', ] diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 93d2f00b..a856fb3a 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -30,14 +30,14 @@ export class ReactPyDjangoClient this.prerenderElement.remove(); this.prerenderElement = null; } - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.mountElement.hidden = true; this.offlineElement.hidden = false; } }, onOpen: () => { // If offlineElement exists, hide it and show the mountElement - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.offlineElement.hidden = true; this.mountElement.hidden = false; } diff --git a/src/js/src/components.ts b/src/js/src/components.ts new file mode 100644 index 00000000..e5c62f72 --- /dev/null +++ b/src/js/src/components.ts @@ -0,0 +1,64 @@ +import { DjangoFormProps } from "./types"; +import React from "react"; +import ReactDOM from "react-dom"; +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +export function DjangoForm({ + onSubmitCallback, + formId, +}: DjangoFormProps): null { + React.useEffect(() => { + const form = document.getElementById(formId) as HTMLFormElement; + + // Submission event function + const onSubmitEvent = (event) => { + event.preventDefault(); + const formData = new FormData(form); + + // Convert the FormData object to a plain object by iterating through it + // If duplicate keys are present, convert the value into an array of values + const entries = formData.entries(); + const formDataArray = Array.from(entries); + const formDataObject = formDataArray.reduce((acc, [key, value]) => { + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value); + } else { + acc[key] = [acc[key], value]; + } + } else { + acc[key] = value; + } + return acc; + }, {}); + + onSubmitCallback(formDataObject); + }; + + // Bind the event listener + if (form) { + form.addEventListener("submit", onSubmitEvent); + } + + // Unbind the event listener when the component dismounts + return () => { + if (form) { + form.removeEventListener("submit", onSubmitEvent); + } + }; + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..1ffff551 --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,2 @@ +export { DjangoForm, bind } from "./components"; +export { mountComponent } from "./mount"; diff --git a/src/js/src/index.tsx b/src/js/src/mount.tsx similarity index 60% rename from src/js/src/index.tsx rename to src/js/src/mount.tsx index 742ca79f..81115f9e 100644 --- a/src/js/src/index.tsx +++ b/src/js/src/mount.tsx @@ -2,21 +2,6 @@ import { ReactPyDjangoClient } from "./client"; import React from "react"; import ReactDOM from "react-dom"; import { Layout } from "@reactpy/client/src/components"; -import { DjangoFormProps } from "./types"; - -/** - * Interface used to bind a ReactPy node to React. - */ -export function bind(node) { - return { - create: (type, props, children) => - React.createElement(type, props, ...children), - render: (element) => { - ReactDOM.render(element, node); - }, - unmount: () => ReactDOM.unmountComponentAtNode(node), - }; -} export function mountComponent( mountElement: HTMLElement, @@ -84,7 +69,7 @@ export function mountComponent( // Replace the prerender element with the real element on the first layout update if (client.prerenderElement) { client.onMessage("layout-update", ({ path, model }) => { - if (client.prerenderElement) { + if (client.prerenderElement && client.mountElement) { client.prerenderElement.replaceWith(client.mountElement); client.prerenderElement = null; } @@ -94,51 +79,3 @@ export function mountComponent( // Start rendering the component ReactDOM.render(, client.mountElement); } - -export function DjangoForm({ - onSubmitCallback, - formId, -}: DjangoFormProps): null { - React.useEffect(() => { - const form = document.getElementById(formId) as HTMLFormElement; - - // Submission event function - const onSubmitEvent = (event) => { - event.preventDefault(); - const formData = new FormData(form); - - // Convert the FormData object to a plain object by iterating through it - // If duplicate keys are present, convert the value into an array of values - const entries = formData.entries(); - const formDataArray = Array.from(entries); - const formDataObject = formDataArray.reduce((acc, [key, value]) => { - if (acc[key]) { - if (Array.isArray(acc[key])) { - acc[key].push(value); - } else { - acc[key] = [acc[key], value]; - } - } else { - acc[key] = value; - } - return acc; - }, {}); - - onSubmitCallback(formDataObject); - }; - - // Bind the event listener - if (form) { - form.addEventListener("submit", onSubmitEvent); - } - - // Unbind the event listener when the component dismounts - return () => { - if (form) { - form.removeEventListener("submit", onSubmitEvent); - } - }; - }, []); - - return null; -} diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index 06cebfe6..fa162b21 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -8,7 +8,7 @@ assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 assert ( subprocess.run( - ["bun", "build", "./src/index.tsx", "--outfile", str(static_dir / "client.js")], + ["bun", "build", "./src/index.ts", "--outfile", str(static_dir / "client.js")], cwd=str(js_dir), check=True, ).returncode From 90a630845162874dd60d282e174833ae5725b49a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:49:42 -0800 Subject: [PATCH 12/28] rename `clean` module to `tasks` --- src/reactpy_django/management/commands/clean_reactpy.py | 2 +- src/reactpy_django/{clean.py => tasks.py} | 0 src/reactpy_django/websocket/consumer.py | 2 +- tests/test_app/tests/test_database.py | 8 ++++---- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/reactpy_django/{clean.py => tasks.py} (100%) diff --git a/src/reactpy_django/management/commands/clean_reactpy.py b/src/reactpy_django/management/commands/clean_reactpy.py index 1b1fe9a2..acfd7976 100644 --- a/src/reactpy_django/management/commands/clean_reactpy.py +++ b/src/reactpy_django/management/commands/clean_reactpy.py @@ -10,7 +10,7 @@ class Command(BaseCommand): help = "Manually clean ReactPy data. When using this command without args, it will perform all cleaning operations." def handle(self, **options): - from reactpy_django.clean import clean + from reactpy_django.tasks import clean verbosity = options.get("verbosity", 1) diff --git a/src/reactpy_django/clean.py b/src/reactpy_django/tasks.py similarity index 100% rename from src/reactpy_django/clean.py rename to src/reactpy_django/tasks.py diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index ecfd8431..4e7f3578 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -21,7 +21,7 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy_django.clean import clean +from reactpy_django.tasks import clean from reactpy_django.utils import ensure_async if TYPE_CHECKING: diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 5d613ad5..78856a89 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -6,7 +6,7 @@ import dill from django.test import TransactionTestCase -from reactpy_django import clean +from reactpy_django import tasks from reactpy_django.models import ComponentSession, UserDataModel from reactpy_django.types import ComponentParams @@ -29,7 +29,7 @@ def test_component_params(self): config.REACTPY_CLEAN_USER_DATA = False try: - clean.clean(immediate=True) + tasks.clean(immediate=True) # Make sure the ComponentParams table is empty assert ComponentSession.objects.count() == 0 @@ -48,7 +48,7 @@ def test_component_params(self): # Try to delete the `params_1` via cleaning (it should be expired) # Note: We don't use `immediate` here in order to test timestamping logic - clean.clean() + tasks.clean() # Make sure `params_1` has expired, but `params_2` is still there assert ComponentSession.objects.count() == 1 @@ -98,7 +98,7 @@ def test_user_data_cleanup(self): # Make sure the orphaned user data object is deleted assert UserDataModel.objects.count() == initial_count + 1 - clean.clean_user_data() + tasks.clean_user_data() assert UserDataModel.objects.count() == initial_count # Check if deleting a user deletes the associated UserData From 0b593a5aef474f004837356ad8e401bd6b3b1748 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:55:27 -0800 Subject: [PATCH 13/28] Fix one instance of test flakiness --- tests/test_app/tests/test_components.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index dfbee605..fd1e7b7a 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -791,7 +791,8 @@ def test_model_form(self): self.page.wait_for_selector(".errorlist") # Submitting an empty form should result in 1 error element. - assert len(self.page.query_selector_all(".errorlist")) == 1 + error_list = self.page.locator(".errorlist").all() + assert len(error_list) == 1 # Fill out the form self.page.locator("#id_text").type(uuid, delay=CLICK_DELAY) @@ -800,7 +801,7 @@ def test_model_form(self): self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) # Wait for the error message to disappear (indicating that the form has been re-rendered) - expect(self.page.locator(".errorlist").all()[0]).not_to_be_attached() + expect(error_list[0]).not_to_be_attached() # Make sure no errors remain assert len(self.page.query_selector_all(".errorlist")) == 0 From 0cd82188a6f778b850defcba47ba1b8d70bc24ab Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 20:29:10 -0800 Subject: [PATCH 14/28] Add test retries --- pyproject.toml | 3 +++ src/reactpy_django/forms/transforms.py | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cdfc01b5..44be8029 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,9 @@ extra-dependencies = [ "django-bootstrap5", ] matrix-name-format = "{variable}-{value}" +# TODO: Remove the `retries` value once we fix flakiness of `test_sync_form_events` +# https://github.com/reactive-python/reactpy-django/issues/272 +retries = 5 # Django 4.2 [[tool.hatch.envs.hatch-test.matrix]] diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py index 58e98de4..4eadbd83 100644 --- a/src/reactpy_django/forms/transforms.py +++ b/src/reactpy_django/forms/transforms.py @@ -131,8 +131,8 @@ def _do_nothing_event(*args, **kwargs): """A placeholder event function that does nothing.""" -# TODO: After the bulk of this file to ReactPy core, we should create some kind of script that will -# auto-generate this into a file dump. The current implementation of manually copy-pasting it isn't ideal. +# TODO: Create a script that will auto-generate this into a file dump. +# The current implementation of manually copy-pasting it isn't ideal. # https://react.dev/reference/react-dom/components/common#common-props SPECIAL_PROPS = r""" children: A React node (an element, a string, a number, a portal, an empty node like null, undefined and booleans, or an array of other React nodes). Specifies the content inside the component. When you use JSX, you will usually specify the children prop implicitly by nesting tags like
. From 1e1bf36024916d06eefd84124197f8af454a40ce Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 20:30:06 -0800 Subject: [PATCH 15/28] hatch fmt --- src/reactpy_django/forms/utils.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/forms/utils.py b/src/reactpy_django/forms/utils.py index 6bcf5cc6..6feef4b1 100644 --- a/src/reactpy_django/forms/utils.py +++ b/src/reactpy_django/forms/utils.py @@ -1,9 +1,13 @@ from __future__ import annotations -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any from django.forms import BooleanField, Form, ModelForm, ModelMultipleChoiceField, MultipleChoiceField, NullBooleanField -from reactpy import Ref + +if TYPE_CHECKING: + from collections.abc import Sequence + + from reactpy import Ref def convert_form_fields(data: dict[str, Any], initialized_form: Form | ModelForm) -> None: @@ -22,7 +26,7 @@ def validate_form_args( top_children_count: Ref[int], bottom_children: Sequence, bottom_children_count: Ref[int], - form: type[Form] | type[ModelForm], + form: type[Form | ModelForm], ) -> None: # Validate the provided arguments if len(top_children) != top_children_count.current or len(bottom_children) != bottom_children_count.current: From 2a1fa06bb2f585da3340bee9d2997bcdcdd64c1e Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 11 Dec 2024 23:03:56 -0800 Subject: [PATCH 16/28] rename hook async_render -> render_view --- src/reactpy_django/components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 6616cf4b..f2ca561c 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -217,7 +217,7 @@ def _view_to_component( json.dumps([_args, _kwargs], default=generate_obj_name), ] ) - async def async_render(): + async def _render_view(): """Render the view in an async hook to avoid blocking the main thread.""" # Render the view response = await render_view(resolved_view, _request, _args, _kwargs) From c8d6cf9b9c296c33de102021d523bf0b5292a67b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 12 Dec 2024 13:49:29 -0800 Subject: [PATCH 17/28] Turn navigate_to_page into a decorator --- pyproject.toml | 2 ++ tests/test_app/tests/test_components.py | 13 +++++-------- tests/test_app/tests/utils.py | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 44be8029..f6d859df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,8 @@ extra-dependencies = [ "tblib", "servestatic", "django-bootstrap5", + "decorator", + ] matrix-name-format = "{variable}-{value}" # TODO: Remove the `retries` value once we fix flakiness of `test_sync_form_events` diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index fd1e7b7a..e6a6191e 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -585,9 +585,8 @@ def test_offline_components(self): class FormTests(PlaywrightTestCase): + @navigate_to_page("/form/") def test_basic_form(self): - navigate_to_page(self, "/form/") - try: from test_app.models import TodoItem @@ -682,9 +681,8 @@ def test_basic_form(self): # Make sure no errors remain assert len(self.page.query_selector_all(".errorlist")) == 0 + @navigate_to_page("/form/bootstrap/") def test_bootstrap_form(self): - navigate_to_page(self, "/form/bootstrap/") - try: from test_app.models import TodoItem @@ -780,9 +778,8 @@ def test_bootstrap_form(self): # Make sure no errors remain assert len(self.page.query_selector_all(".invalid-feedback")) == 0 + @navigate_to_page("/form/model/") def test_model_form(self): - navigate_to_page(self, "/form/model/") - uuid = uuid4().hex self.page.wait_for_selector("form") @@ -816,8 +813,8 @@ def test_model_form(self): finally: os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") + @navigate_to_page("/form/sync_event/") def test_sync_form_events(self): - navigate_to_page(self, "/form/sync_event/") self.page.wait_for_selector("form") # Check initial state @@ -846,8 +843,8 @@ def test_sync_form_events(self): self.page.wait_for_selector("#receive_data[data-value='true']") self.page.wait_for_selector("#change[data-value='true']") + @navigate_to_page("/form/async_event/") def test_async_form_events(self): - navigate_to_page(self, "/form/async_event/") self.page.wait_for_selector("form") # Check initial state diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index e43fe56e..17dcfc88 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -3,7 +3,9 @@ import os import sys from functools import partial +from typing import Callable +import decorator from channels.testing import ChannelsLiveServerTestCase from channels.testing.live import make_application from django.core.exceptions import ImproperlyConfigured @@ -89,7 +91,14 @@ def _post_teardown(self): occurring due to a bug within `ChannelsLiveServerTestCase`.""" -def navigate_to_page(self: PlaywrightTestCase, path: str): - """Redirect the page's URL to the given link, if the page is not already there.""" - if self.page.url != path: - self.page.goto(f"http://{self.host}:{self._port}/{path.lstrip('/')}") +def navigate_to_page(path: str): + def _decorator(func: Callable): + @decorator.decorator + def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs): + if self.page.url != path: + self.page.goto(f"http://{self.host}:{self._port}/{path.lstrip('/')}") + return func(self, *args, **kwargs) + + return _wrapper(func) + + return _decorator From 9f9f99d956c99acf517a0a3d32584f76f7e371c8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 13 Dec 2024 01:56:01 -0800 Subject: [PATCH 18/28] Refactor PlaywrightTestCase --- tests/test_app/tests/test_components.py | 42 +++----- tests/test_app/tests/utils.py | 132 ++++++++++++++++-------- 2 files changed, 102 insertions(+), 72 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index e6a6191e..979174c4 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -21,7 +21,7 @@ class GenericComponentTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}") + cls.page.goto(f"http://{cls.host}:{cls._port_0}") def test_hello_world(self): self.page.wait_for_selector("#hello-world") @@ -288,7 +288,7 @@ class PrerenderTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}/prerender/") + cls.page.goto(f"http://{cls.host}:{cls._port_0}/prerender/") def test_prerender(self): """Verify if round-robin host selection is working.""" @@ -326,7 +326,7 @@ class ErrorTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}/errors/") + cls.page.goto(f"http://{cls.host}:{cls._port_0}/errors/") def test_component_does_not_exist_error(self): broken_component = self.page.locator("#component_does_not_exist_error") @@ -435,7 +435,7 @@ class ChannelLayersTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}/channel-layers/") + cls.page.goto(f"http://{cls.host}:{cls._port_0}/channel-layers/") def test_channel_layer_components(self): sender = self.page.wait_for_selector("#sender") @@ -459,7 +459,7 @@ class PyscriptTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}/pyscript/") + cls.page.goto(f"http://{cls.host}:{cls._port_0}/pyscript/") def test_0_hello_world(self): self.page.wait_for_selector("#hello-world-loading") @@ -510,23 +510,9 @@ def test_1_javascript_module_execution_within_pyscript(self): class DistributedComputingTests(PlaywrightTestCase): - @classmethod - def setUpServer(cls): - super().setUpServer() - cls._server_process2 = cls.ProtocolServerProcess(cls.host, cls.get_application) - cls._server_process2.start() - cls._server_process2.ready.wait() - cls._port2 = cls._server_process2.port.value - - @classmethod - def tearDownServer(cls): - super().tearDownServer() - cls._server_process2.terminate() - cls._server_process2.join() - def test_host_roundrobin(self): """Verify if round-robin host selection is working.""" - self.page.goto(f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8") + self.page.goto(f"{self.live_server_url}/roundrobin/{self._port_0}/{self._port_1}/8") elem0 = self.page.locator(".custom_host-0") elem1 = self.page.locator(".custom_host-1") elem2 = self.page.locator(".custom_host-2") @@ -544,8 +530,8 @@ def test_host_roundrobin(self): elem3.get_attribute("data-port"), } correct_ports = { - str(self._port), - str(self._port2), + str(self._port_0), + str(self._port_1), } # There should only be two ports in the set @@ -554,15 +540,15 @@ def test_host_roundrobin(self): def test_custom_host(self): """Make sure that the component is rendered by a separate server.""" - self.page.goto(f"{self.live_server_url}/port/{self._port2}/") + self.page.goto(f"{self.live_server_url}/port/{self._port_1}/") elem = self.page.locator(".custom_host-0") elem.wait_for() - assert f"Server Port: {self._port2}" in elem.text_content() + assert f"Server Port: {self._port_1}" in elem.text_content() def test_custom_host_wrong_port(self): """Make sure that other ports are not rendering components.""" tmp_sock = socket.socket() - tmp_sock.bind((self._server_process.host, 0)) + tmp_sock.bind((self._server_process_0.host, 0)) random_port = tmp_sock.getsockname()[1] self.page.goto(f"{self.live_server_url}/port/{random_port}/") with pytest.raises(TimeoutError): @@ -573,13 +559,13 @@ class OfflineTests(PlaywrightTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port}/offline/") + cls.page.goto(f"http://{cls.host}:{cls._port_0}/offline/") def test_offline_components(self): self.page.wait_for_selector("div:not([hidden]) > #online") assert self.page.query_selector("div[hidden] > #offline") is not None - self._server_process.terminate() - self._server_process.join() + self._server_process_0.terminate() + self._server_process_0.join() self.page.wait_for_selector("div:not([hidden]) > #offline") assert self.page.query_selector("div[hidden] > #online") is not None diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index 17dcfc88..6a251061 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -2,12 +2,12 @@ import asyncio import os import sys -from functools import partial -from typing import Callable +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Callable import decorator +from channels.routing import get_default_application from channels.testing import ChannelsLiveServerTestCase -from channels.testing.live import make_application from django.core.exceptions import ImproperlyConfigured from django.core.management import call_command from django.db import connections @@ -16,30 +16,97 @@ from reactpy_django.utils import str_to_bool +if TYPE_CHECKING: + from daphne.testing import DaphneProcess + GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") class PlaywrightTestCase(ChannelsLiveServerTestCase): - from reactpy_django import config - databases = {"default"} - + total_servers = 3 + _server_process_0: "DaphneProcess" + _server_process_1: "DaphneProcess" + _server_process_2: "DaphneProcess" + _server_process_3: "DaphneProcess" + _port_0: int + _port_1: int + _port_2: int + _port_3: int + + #################################################### + # Overrides for ChannelsLiveServerTestCase methods # + #################################################### @classmethod def setUpClass(cls): # Repurposed from ChannelsLiveServerTestCase._pre_setup for connection in connections.all(): - if cls._is_in_memory_db(cls, connection): + if connection.vendor == "sqlite" and connection.is_in_memory_db(): msg = "ChannelLiveServerTestCase can not be used with in memory databases" raise ImproperlyConfigured(msg) cls._live_server_modified_settings = modify_settings(ALLOWED_HOSTS={"append": cls.host}) cls._live_server_modified_settings.enable() - cls.get_application = partial( - make_application, - static_wrapper=cls.static_wrapper if cls.serve_static else None, - ) - cls.setUpServer() + cls.get_application = get_default_application + + # Start the Django webserver(s) + for i in range(cls.total_servers): + cls.start_django_webserver(i) + + # Wipe the databases + from reactpy_django import config + + cls.flush_databases({"default", config.REACTPY_DATABASE}) # Open a Playwright browser window + cls.start_playwright_client() + + @classmethod + def tearDownClass(cls): + # Close the Playwright browser + cls.shutdown_playwright_client() + + # Shutdown the Django webserver + for i in range(cls.total_servers): + cls.shutdown_django_webserver(i) + cls._live_server_modified_settings.disable() + + # Wipe the databases + from reactpy_django import config + + cls.flush_databases({"default", config.REACTPY_DATABASE}) + + def _pre_setup(self): + """Handled manually in `setUpClass` to speed things up.""" + + def _post_teardown(self): + """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing + database flushing in between tests. This also fixes a `SynchronousOnlyOperation` caused + by a bug within `ChannelsLiveServerTestCase`.""" + + @property + def live_server_url(self): + """Provides the URL to the FIRST SPAWNED Django webserver.""" + return f"http://{self.host}:{self._port_0}" + + ######################### + # Custom helper methods # + ######################### + @classmethod + def start_django_webserver(cls, num=0): + setattr(cls, f"_server_process_{num}", cls.ProtocolServerProcess(cls.host, cls.get_application)) + server_process: DaphneProcess = getattr(cls, f"_server_process_{num}") + server_process.start() + server_process.ready.wait() + setattr(cls, f"_port_{num}", server_process.port.value) + + @classmethod + def shutdown_django_webserver(cls, num=0): + server_process: DaphneProcess = getattr(cls, f"_server_process_{num}") + server_process.terminate() + server_process.join() + + @classmethod + def start_playwright_client(cls): if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() @@ -49,26 +116,13 @@ def setUpClass(cls): cls.page.set_default_timeout(10000) @classmethod - def setUpServer(cls): - cls._server_process = cls.ProtocolServerProcess(cls.host, cls.get_application) - cls._server_process.start() - cls._server_process.ready.wait() - cls._port = cls._server_process.port.value - - @classmethod - def tearDownClass(cls): - from reactpy_django import config - - # Close the Playwright browser + def shutdown_playwright_client(cls): + cls.browser.close() cls.playwright.stop() - # Close the other server processes - cls.tearDownServer() - - # Repurposed from ChannelsLiveServerTestCase._post_teardown - cls._live_server_modified_settings.disable() - # Using set to prevent duplicates - for db_name in {"default", config.REACTPY_DATABASE}: # noqa: PLC0208 + @staticmethod + def flush_databases(db_names: Iterable[Any]): + for db_name in db_names: call_command( "flush", verbosity=0, @@ -77,26 +131,16 @@ def tearDownClass(cls): reset_sequences=False, ) - @classmethod - def tearDownServer(cls): - cls._server_process.terminate() - cls._server_process.join() - - def _pre_setup(self): - """Handled manually in `setUpClass` to speed things up.""" - - def _post_teardown(self): - """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing - database flushing. This is needed to prevent a `SynchronousOnlyOperation` from - occurring due to a bug within `ChannelsLiveServerTestCase`.""" +def navigate_to_page(path: str, *, server_num=0): + """Decorator to make sure the browser is on a specific page before running a test.""" -def navigate_to_page(path: str): def _decorator(func: Callable): @decorator.decorator def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs): if self.page.url != path: - self.page.goto(f"http://{self.host}:{self._port}/{path.lstrip('/')}") + _port = getattr(self, f"_port_{server_num}") + self.page.goto(f"http://{self.host}:{_port}/{path.lstrip('/')}") return func(self, *args, **kwargs) return _wrapper(func) From c5f8d68672eb4ec5c182fbd41e192809e383a1fc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 13 Dec 2024 01:56:14 -0800 Subject: [PATCH 19/28] Move flaky marker to sync form test --- pyproject.toml | 3 --- tests/test_app/tests/test_components.py | 3 +++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f6d859df..57ee16ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,9 +99,6 @@ extra-dependencies = [ ] matrix-name-format = "{variable}-{value}" -# TODO: Remove the `retries` value once we fix flakiness of `test_sync_form_events` -# https://github.com/reactive-python/reactpy-django/issues/272 -retries = 5 # Django 4.2 [[tool.hatch.envs.hatch-test.matrix]] diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 979174c4..d798af93 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -799,6 +799,9 @@ def test_model_form(self): finally: os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") + # TODO: Remove the `reruns` value once we fix flakiness of `test_sync_form_events` + # https://github.com/reactive-python/reactpy-django/issues/272 + @pytest.mark.flaky(reruns=5) @navigate_to_page("/form/sync_event/") def test_sync_form_events(self): self.page.wait_for_selector("form") From fa136974354e4c49f8d62749ac8b9fb588755556 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 13 Dec 2024 20:43:38 -0800 Subject: [PATCH 20/28] Apparently I forgot PyScript FFI docs --- docs/examples/python/pyscript_ffi.py | 14 ++++++++++++++ docs/src/reference/template-tag.md | 12 ++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 docs/examples/python/pyscript_ffi.py diff --git a/docs/examples/python/pyscript_ffi.py b/docs/examples/python/pyscript_ffi.py new file mode 100644 index 00000000..d744dd88 --- /dev/null +++ b/docs/examples/python/pyscript_ffi.py @@ -0,0 +1,14 @@ +from pyscript import document, window +from reactpy import component, html + + +@component +def root(): + def on_click(event): + my_element = document.querySelector("#example") + my_element.innerText = window.location.hostname + + return html.div( + {"id": "example"}, + html.button({"onClick": on_click}, "Click Me!"), + ) diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index f969eb00..cee0865a 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -214,9 +214,17 @@ The entire file path provided is loaded directly into the browser, and must have {% include "../../examples/python/pyodide_js_module.py" %} ``` - **PyScript FFI** + **PyScript Foriegn Function Interface (FFI)** - ... + PyScript FFI has similar functionality to Pyodide's `js` module, but utilizes a different API. + + There are two importable modules available that are available within the FFI interface: `window` and `document`. + + === "root.py" + + ```python + {% include "../../examples/python/pyscript_ffi.py" %} + ``` **PyScript JS Modules** From a607daa22aca28fbd883c4bcacb96eabfd581fae Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 01:09:05 -0800 Subject: [PATCH 21/28] Test performance optimization: Use same class for alll component tests --- tests/test_app/tests/test_components.py | 146 +++++++++++++++--------- tests/test_app/tests/utils.py | 18 +-- 2 files changed, 103 insertions(+), 61 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index d798af93..5e2fbe2d 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -15,43 +15,48 @@ CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. -class GenericComponentTests(PlaywrightTestCase): +class ComponentTests(PlaywrightTestCase): databases = {"default"} - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}") - + @navigate_to_page("/") def test_hello_world(self): self.page.wait_for_selector("#hello-world") + @navigate_to_page("/") def test_counter(self): for i in range(5): self.page.locator(f"#counter-num[data-count={i}]") self.page.locator("#counter-inc").click() + @navigate_to_page("/") def test_parametrized_component(self): self.page.locator("#parametrized-component[data-value='579']").wait_for() + @navigate_to_page("/") def test_object_in_templatetag(self): self.page.locator("#object_in_templatetag[data-success=true]").wait_for() + @navigate_to_page("/") def test_component_from_web_module(self): self.page.wait_for_selector("#button-from-js-module") + @navigate_to_page("/") def test_use_connection(self): self.page.locator("#use-connection[data-success=true]").wait_for() + @navigate_to_page("/") def test_use_scope(self): self.page.locator("#use-scope[data-success=true]").wait_for() + @navigate_to_page("/") def test_use_location(self): self.page.locator("#use-location[data-success=true]").wait_for() + @navigate_to_page("/") def test_use_origin(self): self.page.locator("#use-origin[data-success=true]").wait_for() + @navigate_to_page("/") def test_static_css(self): assert ( self.page.wait_for_selector("#django-css button").evaluate( @@ -60,27 +65,33 @@ def test_static_css(self): == "rgb(0, 0, 255)" ) + @navigate_to_page("/") def test_static_js(self): self.page.locator("#django-js[data-success=true]").wait_for() + @navigate_to_page("/") def test_unauthorized_user(self): with pytest.raises(TimeoutError): self.page.wait_for_selector("#unauthorized-user", timeout=1) self.page.wait_for_selector("#unauthorized-user-fallback") + @navigate_to_page("/") def test_authorized_user(self): with pytest.raises(TimeoutError): self.page.wait_for_selector("#authorized-user-fallback", timeout=1) self.page.wait_for_selector("#authorized-user") + @navigate_to_page("/") def test_relational_query(self): self.page.locator("#relational-query").wait_for() self.page.locator("#relational-query[data-success=true]").wait_for() + @navigate_to_page("/") def test_async_relational_query(self): self.page.locator("#async-relational-query").wait_for() self.page.locator("#async-relational-query[data-success=true]").wait_for() + @navigate_to_page("/") def test_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#todo-input") @@ -94,6 +105,7 @@ def test_use_query_and_mutation(self): with pytest.raises(TimeoutError): self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) + @navigate_to_page("/") def test_async_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#async-todo-input") @@ -107,68 +119,85 @@ def test_async_use_query_and_mutation(self): with pytest.raises(TimeoutError): self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) + @navigate_to_page("/") def test_view_to_component_sync_func(self): self.page.locator("#view_to_component_sync_func[data-success=true]").wait_for() + @navigate_to_page("/") def test_view_to_component_async_func(self): self.page.locator("#view_to_component_async_func[data-success=true]").wait_for() + @navigate_to_page("/") def test_view_to_component_sync_class(self): self.page.locator("#ViewToComponentSyncClass[data-success=true]").wait_for() + @navigate_to_page("/") def test_view_to_component_async_class(self): self.page.locator("#ViewToComponentAsyncClass[data-success=true]").wait_for() + @navigate_to_page("/") def test_view_to_component_template_view_class(self): self.page.locator("#ViewToComponentTemplateViewClass[data-success=true]").wait_for() + @navigate_to_page("/") def _click_btn_and_check_success(self, name): self.page.locator(f"#{name}:not([data-success=true])").wait_for() self.page.wait_for_selector(f"#{name}_btn").click() self.page.locator(f"#{name}[data-success=true]").wait_for() + @navigate_to_page("/") def test_view_to_component_script(self): self._click_btn_and_check_success("view_to_component_script") + @navigate_to_page("/") def test_view_to_component_request(self): self._click_btn_and_check_success("view_to_component_request") + @navigate_to_page("/") def test_view_to_component_args(self): self._click_btn_and_check_success("view_to_component_args") + @navigate_to_page("/") def test_view_to_component_kwargs(self): self._click_btn_and_check_success("view_to_component_kwargs") + @navigate_to_page("/") def test_view_to_iframe_sync_func(self): self.page.frame_locator("#view_to_iframe_sync_func > iframe").locator( "#view_to_iframe_sync_func[data-success=true]" ).wait_for() + @navigate_to_page("/") def test_view_to_iframe_async_func(self): self.page.frame_locator("#view_to_iframe_async_func > iframe").locator( "#view_to_iframe_async_func[data-success=true]" ).wait_for() + @navigate_to_page("/") def test_view_to_iframe_sync_class(self): self.page.frame_locator("#view_to_iframe_sync_class > iframe").locator( "#ViewToIframeSyncClass[data-success=true]" ).wait_for() + @navigate_to_page("/") def test_view_to_iframe_async_class(self): self.page.frame_locator("#view_to_iframe_async_class > iframe").locator( "#ViewToIframeAsyncClass[data-success=true]" ).wait_for() + @navigate_to_page("/") def test_view_to_iframe_template_view_class(self): self.page.frame_locator("#view_to_iframe_template_view_class > iframe").locator( "#ViewToIframeTemplateViewClass[data-success=true]" ).wait_for() + @navigate_to_page("/") def test_view_to_iframe_args(self): self.page.frame_locator("#view_to_iframe_args > iframe").locator( "#view_to_iframe_args[data-success=Success]" ).wait_for() + @navigate_to_page("/") def test_component_session_exists(self): """Session should exist for components with args/kwargs.""" component = self.page.locator("#parametrized-component") @@ -181,6 +210,7 @@ def test_component_session_exists(self): os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") assert query_exists + @navigate_to_page("/") def test_component_session_missing(self): """No session should exist for components that don't have args/kwargs.""" component = self.page.locator("#button-from-js-module") @@ -193,6 +223,7 @@ def test_component_session_missing(self): os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") assert not query_exists + @navigate_to_page("/") def test_use_user_data(self): text_input = self.page.wait_for_selector("#use-user-data input") login_1 = self.page.wait_for_selector("#use-user-data .login-1") @@ -248,6 +279,7 @@ def test_use_user_data(self): ) assert "Data: None" in user_data_div.text_content() + @navigate_to_page("/") def test_use_user_data_with_default(self): text_input = self.page.wait_for_selector("#use-user-data-with-default input") login_3 = self.page.wait_for_selector("#use-user-data-with-default .login-3") @@ -283,13 +315,11 @@ def test_use_user_data_with_default(self): ) assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() + ################### + # Prerender Tests # + ################### -class PrerenderTests(PlaywrightTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}/prerender/") - + @navigate_to_page("/prerender/") def test_prerender(self): """Verify if round-robin host selection is working.""" string = self.page.locator("#prerender_string") @@ -321,45 +351,50 @@ def test_prerender(self): use_user_ws.wait_for() assert use_root_id_ws.get_attribute("data-value") == root_id_value + ############### + # Error Tests # + ############### -class ErrorTests(PlaywrightTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}/errors/") - + @navigate_to_page("/errors/") def test_component_does_not_exist_error(self): broken_component = self.page.locator("#component_does_not_exist_error") broken_component.wait_for() assert "ComponentDoesNotExistError:" in broken_component.text_content() + @navigate_to_page("/errors/") def test_component_param_error(self): broken_component = self.page.locator("#component_param_error") broken_component.wait_for() assert "ComponentParamError:" in broken_component.text_content() + @navigate_to_page("/errors/") def test_invalid_host_error(self): broken_component = self.page.locator("#invalid_host_error") broken_component.wait_for() assert "InvalidHostError:" in broken_component.text_content() + @navigate_to_page("/errors/") def test_synchronous_only_operation_error(self): broken_component = self.page.locator("#broken_postprocessor_query pre") broken_component.wait_for() assert "SynchronousOnlyOperation:" in broken_component.text_content() + @navigate_to_page("/errors/") def test_view_not_registered_error(self): broken_component = self.page.locator("#view_to_iframe_not_registered pre") broken_component.wait_for() assert "ViewNotRegisteredError:" in broken_component.text_content() + @navigate_to_page("/errors/") def test_decorator_param_error(self): broken_component = self.page.locator("#incorrect_user_passes_test_decorator") broken_component.wait_for() assert "DecoratorParamError:" in broken_component.text_content() + #################### + # URL Router Tests # + #################### -class UrlRouterTests(PlaywrightTestCase): def test_url_router(self): self.page.goto(f"{self.live_server_url}/router/") path = self.page.wait_for_selector("#router-path") @@ -430,13 +465,11 @@ def test_url_router_int_and_string(self): string = self.page.query_selector("#router-string") assert string.text_content() == "/router/two///" + ####################### + # Channel Layer Tests # + ####################### -class ChannelLayersTests(PlaywrightTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}/channel-layers/") - + @navigate_to_page("/channel-layers/") def test_channel_layer_components(self): sender = self.page.wait_for_selector("#sender") sender.type("test", delay=CLICK_DELAY) @@ -454,25 +487,27 @@ def test_channel_layer_components(self): assert receiver_2 is not None assert receiver_3 is not None + ################## + # PyScript Tests # + ################## -class PyscriptTests(PlaywrightTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}/pyscript/") - - def test_0_hello_world(self): + @navigate_to_page("/pyscript/") + def test_pyscript_0_hello_world(self): + # Use this test to wait for PyScript to fully load on the page self.page.wait_for_selector("#hello-world-loading") self.page.wait_for_selector("#hello-world") - def test_1_custom_root(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_custom_root(self): self.page.wait_for_selector("#custom-root") - def test_1_multifile(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_multifile(self): self.page.wait_for_selector("#multifile-parent") self.page.wait_for_selector("#multifile-child") - def test_1_counter(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_counter(self): self.page.wait_for_selector("#counter") self.page.wait_for_selector("#counter pre[data-value='0']") self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) @@ -482,7 +517,8 @@ def test_1_counter(self): self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) self.page.wait_for_selector("#counter pre[data-value='1']") - def test_1_server_side_parent(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_server_side_parent(self): self.page.wait_for_selector("#parent") self.page.wait_for_selector("#child") self.page.wait_for_selector("#child pre[data-value='0']") @@ -493,7 +529,8 @@ def test_1_server_side_parent(self): self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) self.page.wait_for_selector("#child pre[data-value='1']") - def test_1_server_side_parent_with_toggle(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_server_side_parent_with_toggle(self): self.page.wait_for_selector("#parent-toggle") self.page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) self.page.wait_for_selector("#parent-toggle") @@ -505,14 +542,17 @@ def test_1_server_side_parent_with_toggle(self): self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='1']") - def test_1_javascript_module_execution_within_pyscript(self): + @navigate_to_page("/pyscript/") + def test_pyscript_1_javascript_module_execution_within_pyscript(self): self.page.wait_for_selector("#moment[data-success=true]") + ############################### + # Distributed Computing Tests # + ############################### -class DistributedComputingTests(PlaywrightTestCase): def test_host_roundrobin(self): """Verify if round-robin host selection is working.""" - self.page.goto(f"{self.live_server_url}/roundrobin/{self._port_0}/{self._port_1}/8") + self.page.goto(f"{self.live_server_url}/roundrobin/{self._port_2}/{self._port_3}/8") elem0 = self.page.locator(".custom_host-0") elem1 = self.page.locator(".custom_host-1") elem2 = self.page.locator(".custom_host-2") @@ -530,8 +570,8 @@ def test_host_roundrobin(self): elem3.get_attribute("data-port"), } correct_ports = { - str(self._port_0), - str(self._port_1), + str(self._port_2), + str(self._port_3), } # There should only be two ports in the set @@ -540,10 +580,10 @@ def test_host_roundrobin(self): def test_custom_host(self): """Make sure that the component is rendered by a separate server.""" - self.page.goto(f"{self.live_server_url}/port/{self._port_1}/") + self.page.goto(f"{self.live_server_url}/port/{self._port_2}/") elem = self.page.locator(".custom_host-0") elem.wait_for() - assert f"Server Port: {self._port_1}" in elem.text_content() + assert f"Server Port: {self._port_2}" in elem.text_content() def test_custom_host_wrong_port(self): """Make sure that other ports are not rendering components.""" @@ -554,23 +594,23 @@ def test_custom_host_wrong_port(self): with pytest.raises(TimeoutError): self.page.locator(".custom_host").wait_for(timeout=1000) + ################# + # Offline Tests # + ################# -class OfflineTests(PlaywrightTestCase): - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.page.goto(f"http://{cls.host}:{cls._port_0}/offline/") - + @navigate_to_page("/offline/", server_num=1) def test_offline_components(self): self.page.wait_for_selector("div:not([hidden]) > #online") assert self.page.query_selector("div[hidden] > #offline") is not None - self._server_process_0.terminate() - self._server_process_0.join() + self._server_process_1.terminate() + self._server_process_1.join() self.page.wait_for_selector("div:not([hidden]) > #offline") assert self.page.query_selector("div[hidden] > #online") is not None + ############## + # Form Tests # + ############## -class FormTests(PlaywrightTestCase): @navigate_to_page("/form/") def test_basic_form(self): try: diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index 6a251061..158f7e85 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -3,6 +3,7 @@ import os import sys from collections.abc import Iterable +from functools import partial from typing import TYPE_CHECKING, Any, Callable import decorator @@ -24,11 +25,11 @@ class PlaywrightTestCase(ChannelsLiveServerTestCase): databases = {"default"} - total_servers = 3 + total_servers = 4 _server_process_0: "DaphneProcess" - _server_process_1: "DaphneProcess" - _server_process_2: "DaphneProcess" - _server_process_3: "DaphneProcess" + _server_process_1: "DaphneProcess" # For Offline Tests + _server_process_2: "DaphneProcess" # For Distributed Computing Tests + _server_process_3: "DaphneProcess" # For Distributed Computing Tests _port_0: int _port_1: int _port_2: int @@ -46,7 +47,7 @@ def setUpClass(cls): raise ImproperlyConfigured(msg) cls._live_server_modified_settings = modify_settings(ALLOWED_HOSTS={"append": cls.host}) cls._live_server_modified_settings.enable() - cls.get_application = get_default_application + cls.get_application = partial(get_default_application) # Start the Django webserver(s) for i in range(cls.total_servers): @@ -138,9 +139,10 @@ def navigate_to_page(path: str, *, server_num=0): def _decorator(func: Callable): @decorator.decorator def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs): - if self.page.url != path: - _port = getattr(self, f"_port_{server_num}") - self.page.goto(f"http://{self.host}:{_port}/{path.lstrip('/')}") + _port = getattr(self, f"_port_{server_num}") + _path = f"http://{self.host}:{_port}/{path.lstrip('/')}" + if self.page.url != _path: + self.page.goto(_path) return func(self, *args, **kwargs) return _wrapper(func) From 4165542db4529d09d1263a72044c5110b0b7f33a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 01:11:56 -0800 Subject: [PATCH 22/28] Fix typo --- docs/src/reference/template-tag.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index cee0865a..b7137f87 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -214,7 +214,7 @@ The entire file path provided is loaded directly into the browser, and must have {% include "../../examples/python/pyodide_js_module.py" %} ``` - **PyScript Foriegn Function Interface (FFI)** + **PyScript Foreign Function Interface (FFI)** PyScript FFI has similar functionality to Pyodide's `js` module, but utilizes a different API. From f99e58541f3ba6b468e1b0cd3b173d0f805a98f0 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 01:33:51 -0800 Subject: [PATCH 23/28] Increase prerender sleepy time --- tests/test_app/prerender/components.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index bc8f900b..4f4a1190 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -4,17 +4,18 @@ import reactpy_django -SLEEP_TIME = 0.25 +SLEEP_TIME = 0.5 @component def prerender_string(): scope = reactpy_django.hooks.use_scope() - if scope.get("type") != "http": - sleep(SLEEP_TIME) + if scope.get("type") == "http": + return "prerender_string: Prerendered" - return "prerender_string: Fully Rendered" if scope.get("type") == "websocket" else "prerender_string: Prerendered" + sleep(SLEEP_TIME) + return "prerender_string: Fully Rendered" @component @@ -32,13 +33,13 @@ def prerender_component(): scope = reactpy_django.hooks.use_scope() @component - def inner(value): + def inner_component(value): return html.div(value) if scope.get("type") == "http": - return inner("prerender_component: Prerendered") + return inner_component("prerender_component: Prerendered") - return inner("prerender_component: Fully Rendered") + return inner_component("prerender_component: Fully Rendered") @component From 62383833955c7b43db67c8568e2f165d86188507 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 16:39:35 -0800 Subject: [PATCH 24/28] more sleepy time --- tests/test_app/prerender/components.py | 2 +- tests/test_app/tests/test_components.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index 4f4a1190..314f77c5 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -4,7 +4,7 @@ import reactpy_django -SLEEP_TIME = 0.5 +SLEEP_TIME = 1 @component diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 5e2fbe2d..f18dcbb4 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,3 +1,4 @@ +# type: ignore # ruff: noqa: RUF012, N802 import os import socket From 207cf191e4628b3568c20cda1cc49b1cf32ec74a Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 21:40:27 -0800 Subject: [PATCH 25/28] variable name and docstring cleanup --- src/reactpy_django/forms/transforms.py | 13 +++++++------ src/reactpy_django/utils.py | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py index 4eadbd83..1a757b77 100644 --- a/src/reactpy_django/forms/transforms.py +++ b/src/reactpy_django/forms/transforms.py @@ -74,14 +74,14 @@ def infer_key_from_attributes(vdom_tree: VdomDict) -> VdomDict: attributes = vdom_tree.get("attributes", {}) # Infer 'key' from 'id' - _id = attributes.get("id") + key = attributes.get("id") # Fallback: Infer 'key' from 'name' - if not _id and vdom_tree["tagName"] in {"input", "select", "textarea"}: - _id = attributes.get("name") + if not key and vdom_tree["tagName"] in {"input", "select", "textarea"}: + key = attributes.get("name") - if _id: - vdom_tree["key"] = _id + if key: + vdom_tree["key"] = key return vdom_tree @@ -479,7 +479,8 @@ def _do_nothing_event(*args, **kwargs): + SCRIPT_PROPS ) -# lowercase the prop name as the key, and have values be the original react prop name +# Old Prop (Key) : New Prop (Value) +# Also includes some special cases like 'class' -> 'className' REACT_PROP_SUBSTITUTIONS = {prop.lower(): prop for prop in KNOWN_REACT_PROPS} | { "for": "htmlFor", "class": "className", diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 888b0cf7..1ea4f0ea 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -1,3 +1,5 @@ +"""Generic functions that are used throughout the ReactPy Django package.""" + from __future__ import annotations import contextlib From 08fd30cced7c2f3cb4f3ea22395bbdfe325fa321 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:28:41 -0800 Subject: [PATCH 26/28] Add changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f819750b..21a31b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ Don't forget to remove deprecated code on each major release! - Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component! +### Changed + +- Refactoring of internal code to improve maintainability. No changes to public/documented API. + ## [5.1.1] - 2024-12-02 ### Fixed From 39b29fc3648ac5d388bd442838bb1ade8afc625b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:46:50 -0800 Subject: [PATCH 27/28] prevent test pages from jumping around --- tests/test_app/tests/test_components.py | 100 ++++++++++++------------ 1 file changed, 52 insertions(+), 48 deletions(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index f18dcbb4..115c904d 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -19,22 +19,26 @@ class ComponentTests(PlaywrightTestCase): databases = {"default"} + ########################### + # Generic Component Tests # + ########################### + @navigate_to_page("/") - def test_hello_world(self): + def test_component_hello_world(self): self.page.wait_for_selector("#hello-world") @navigate_to_page("/") - def test_counter(self): + def test_component_counter(self): for i in range(5): self.page.locator(f"#counter-num[data-count={i}]") self.page.locator("#counter-inc").click() @navigate_to_page("/") - def test_parametrized_component(self): + def test_component_parametrized_component(self): self.page.locator("#parametrized-component[data-value='579']").wait_for() @navigate_to_page("/") - def test_object_in_templatetag(self): + def test_component_object_in_templatetag(self): self.page.locator("#object_in_templatetag[data-success=true]").wait_for() @navigate_to_page("/") @@ -42,23 +46,23 @@ def test_component_from_web_module(self): self.page.wait_for_selector("#button-from-js-module") @navigate_to_page("/") - def test_use_connection(self): + def test_component_use_connection(self): self.page.locator("#use-connection[data-success=true]").wait_for() @navigate_to_page("/") - def test_use_scope(self): + def test_component_use_scope(self): self.page.locator("#use-scope[data-success=true]").wait_for() @navigate_to_page("/") - def test_use_location(self): + def test_component_use_location(self): self.page.locator("#use-location[data-success=true]").wait_for() @navigate_to_page("/") - def test_use_origin(self): + def test_component_use_origin(self): self.page.locator("#use-origin[data-success=true]").wait_for() @navigate_to_page("/") - def test_static_css(self): + def test_component_static_css(self): assert ( self.page.wait_for_selector("#django-css button").evaluate( "e => window.getComputedStyle(e).getPropertyValue('color')" @@ -67,33 +71,33 @@ def test_static_css(self): ) @navigate_to_page("/") - def test_static_js(self): + def test_component_static_js(self): self.page.locator("#django-js[data-success=true]").wait_for() @navigate_to_page("/") - def test_unauthorized_user(self): + def test_component_unauthorized_user(self): with pytest.raises(TimeoutError): self.page.wait_for_selector("#unauthorized-user", timeout=1) self.page.wait_for_selector("#unauthorized-user-fallback") @navigate_to_page("/") - def test_authorized_user(self): + def test_component_authorized_user(self): with pytest.raises(TimeoutError): self.page.wait_for_selector("#authorized-user-fallback", timeout=1) self.page.wait_for_selector("#authorized-user") @navigate_to_page("/") - def test_relational_query(self): + def test_component_relational_query(self): self.page.locator("#relational-query").wait_for() self.page.locator("#relational-query[data-success=true]").wait_for() @navigate_to_page("/") - def test_async_relational_query(self): + def test_component_async_relational_query(self): self.page.locator("#async-relational-query").wait_for() self.page.locator("#async-relational-query[data-success=true]").wait_for() @navigate_to_page("/") - def test_use_query_and_mutation(self): + def test_component_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#todo-input") item_ids = list(range(5)) @@ -107,7 +111,7 @@ def test_use_query_and_mutation(self): self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") - def test_async_use_query_and_mutation(self): + def test_component_async_use_query_and_mutation(self): todo_input = self.page.wait_for_selector("#async-todo-input") item_ids = list(range(5)) @@ -121,23 +125,23 @@ def test_async_use_query_and_mutation(self): self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") - def test_view_to_component_sync_func(self): + def test_component_view_to_component_sync_func(self): self.page.locator("#view_to_component_sync_func[data-success=true]").wait_for() @navigate_to_page("/") - def test_view_to_component_async_func(self): + def test_component_view_to_component_async_func(self): self.page.locator("#view_to_component_async_func[data-success=true]").wait_for() @navigate_to_page("/") - def test_view_to_component_sync_class(self): + def test_component_view_to_component_sync_class(self): self.page.locator("#ViewToComponentSyncClass[data-success=true]").wait_for() @navigate_to_page("/") - def test_view_to_component_async_class(self): + def test_component_view_to_component_async_class(self): self.page.locator("#ViewToComponentAsyncClass[data-success=true]").wait_for() @navigate_to_page("/") - def test_view_to_component_template_view_class(self): + def test_component_view_to_component_template_view_class(self): self.page.locator("#ViewToComponentTemplateViewClass[data-success=true]").wait_for() @navigate_to_page("/") @@ -147,53 +151,53 @@ def _click_btn_and_check_success(self, name): self.page.locator(f"#{name}[data-success=true]").wait_for() @navigate_to_page("/") - def test_view_to_component_script(self): + def test_component_view_to_component_script(self): self._click_btn_and_check_success("view_to_component_script") @navigate_to_page("/") - def test_view_to_component_request(self): + def test_component_view_to_component_request(self): self._click_btn_and_check_success("view_to_component_request") @navigate_to_page("/") - def test_view_to_component_args(self): + def test_component_view_to_component_args(self): self._click_btn_and_check_success("view_to_component_args") @navigate_to_page("/") - def test_view_to_component_kwargs(self): + def test_component_view_to_component_kwargs(self): self._click_btn_and_check_success("view_to_component_kwargs") @navigate_to_page("/") - def test_view_to_iframe_sync_func(self): + def test_component_view_to_iframe_sync_func(self): self.page.frame_locator("#view_to_iframe_sync_func > iframe").locator( "#view_to_iframe_sync_func[data-success=true]" ).wait_for() @navigate_to_page("/") - def test_view_to_iframe_async_func(self): + def test_component_view_to_iframe_async_func(self): self.page.frame_locator("#view_to_iframe_async_func > iframe").locator( "#view_to_iframe_async_func[data-success=true]" ).wait_for() @navigate_to_page("/") - def test_view_to_iframe_sync_class(self): + def test_component_view_to_iframe_sync_class(self): self.page.frame_locator("#view_to_iframe_sync_class > iframe").locator( "#ViewToIframeSyncClass[data-success=true]" ).wait_for() @navigate_to_page("/") - def test_view_to_iframe_async_class(self): + def test_component_view_to_iframe_async_class(self): self.page.frame_locator("#view_to_iframe_async_class > iframe").locator( "#ViewToIframeAsyncClass[data-success=true]" ).wait_for() @navigate_to_page("/") - def test_view_to_iframe_template_view_class(self): + def test_component_view_to_iframe_template_view_class(self): self.page.frame_locator("#view_to_iframe_template_view_class > iframe").locator( "#ViewToIframeTemplateViewClass[data-success=true]" ).wait_for() @navigate_to_page("/") - def test_view_to_iframe_args(self): + def test_component_view_to_iframe_args(self): self.page.frame_locator("#view_to_iframe_args > iframe").locator( "#view_to_iframe_args[data-success=Success]" ).wait_for() @@ -225,7 +229,7 @@ def test_component_session_missing(self): assert not query_exists @navigate_to_page("/") - def test_use_user_data(self): + def test_component_use_user_data(self): text_input = self.page.wait_for_selector("#use-user-data input") login_1 = self.page.wait_for_selector("#use-user-data .login-1") login_2 = self.page.wait_for_selector("#use-user-data .login-2") @@ -281,7 +285,7 @@ def test_use_user_data(self): assert "Data: None" in user_data_div.text_content() @navigate_to_page("/") - def test_use_user_data_with_default(self): + def test_component_use_user_data_with_default(self): text_input = self.page.wait_for_selector("#use-user-data-with-default input") login_3 = self.page.wait_for_selector("#use-user-data-with-default .login-3") clear = self.page.wait_for_selector("#use-user-data-with-default .clear") @@ -357,37 +361,37 @@ def test_prerender(self): ############### @navigate_to_page("/errors/") - def test_component_does_not_exist_error(self): + def test_error_component_does_not_exist(self): broken_component = self.page.locator("#component_does_not_exist_error") broken_component.wait_for() assert "ComponentDoesNotExistError:" in broken_component.text_content() @navigate_to_page("/errors/") - def test_component_param_error(self): + def test_error_component_param(self): broken_component = self.page.locator("#component_param_error") broken_component.wait_for() assert "ComponentParamError:" in broken_component.text_content() @navigate_to_page("/errors/") - def test_invalid_host_error(self): + def test_error_invalid_host(self): broken_component = self.page.locator("#invalid_host_error") broken_component.wait_for() assert "InvalidHostError:" in broken_component.text_content() @navigate_to_page("/errors/") - def test_synchronous_only_operation_error(self): + def test_error_synchronous_only_operation(self): broken_component = self.page.locator("#broken_postprocessor_query pre") broken_component.wait_for() assert "SynchronousOnlyOperation:" in broken_component.text_content() @navigate_to_page("/errors/") - def test_view_not_registered_error(self): + def test_error_view_not_registered(self): broken_component = self.page.locator("#view_to_iframe_not_registered pre") broken_component.wait_for() assert "ViewNotRegisteredError:" in broken_component.text_content() @navigate_to_page("/errors/") - def test_decorator_param_error(self): + def test_error_decorator_param(self): broken_component = self.page.locator("#incorrect_user_passes_test_decorator") broken_component.wait_for() assert "DecoratorParamError:" in broken_component.text_content() @@ -551,7 +555,7 @@ def test_pyscript_1_javascript_module_execution_within_pyscript(self): # Distributed Computing Tests # ############################### - def test_host_roundrobin(self): + def test_distributed_host_roundrobin(self): """Verify if round-robin host selection is working.""" self.page.goto(f"{self.live_server_url}/roundrobin/{self._port_2}/{self._port_3}/8") elem0 = self.page.locator(".custom_host-0") @@ -579,14 +583,14 @@ def test_host_roundrobin(self): assert current_ports == correct_ports assert len(current_ports) == 2 - def test_custom_host(self): + def test_distributed_custom_host(self): """Make sure that the component is rendered by a separate server.""" self.page.goto(f"{self.live_server_url}/port/{self._port_2}/") elem = self.page.locator(".custom_host-0") elem.wait_for() assert f"Server Port: {self._port_2}" in elem.text_content() - def test_custom_host_wrong_port(self): + def test_distributed_custom_host_wrong_port(self): """Make sure that other ports are not rendering components.""" tmp_sock = socket.socket() tmp_sock.bind((self._server_process_0.host, 0)) @@ -600,7 +604,7 @@ def test_custom_host_wrong_port(self): ################# @navigate_to_page("/offline/", server_num=1) - def test_offline_components(self): + def test_offline_component(self): self.page.wait_for_selector("div:not([hidden]) > #online") assert self.page.query_selector("div[hidden] > #offline") is not None self._server_process_1.terminate() @@ -613,7 +617,7 @@ def test_offline_components(self): ############## @navigate_to_page("/form/") - def test_basic_form(self): + def test_form_basic(self): try: from test_app.models import TodoItem @@ -709,7 +713,7 @@ def test_basic_form(self): assert len(self.page.query_selector_all(".errorlist")) == 0 @navigate_to_page("/form/bootstrap/") - def test_bootstrap_form(self): + def test_form_bootstrap(self): try: from test_app.models import TodoItem @@ -806,7 +810,7 @@ def test_bootstrap_form(self): assert len(self.page.query_selector_all(".invalid-feedback")) == 0 @navigate_to_page("/form/model/") - def test_model_form(self): + def test_form_orm_model(self): uuid = uuid4().hex self.page.wait_for_selector("form") @@ -844,7 +848,7 @@ def test_model_form(self): # https://github.com/reactive-python/reactpy-django/issues/272 @pytest.mark.flaky(reruns=5) @navigate_to_page("/form/sync_event/") - def test_sync_form_events(self): + def test_form_sync_events(self): self.page.wait_for_selector("form") # Check initial state @@ -874,7 +878,7 @@ def test_sync_form_events(self): self.page.wait_for_selector("#change[data-value='true']") @navigate_to_page("/form/async_event/") - def test_async_form_events(self): + def test_form_async_events(self): self.page.wait_for_selector("form") # Check initial state From 9d1c65fc329767231dc293d9b0215ab37a151acc Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 18 Dec 2024 23:47:01 -0800 Subject: [PATCH 28/28] More sleepy time on github --- tests/test_app/prerender/components.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index 314f77c5..9dbfeb8a 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -4,7 +4,9 @@ import reactpy_django -SLEEP_TIME = 1 +from ..tests.utils import GITHUB_ACTIONS + +SLEEP_TIME = 1.5 if GITHUB_ACTIONS else 0.5 @component