diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml index ac8d77b6..a27b8015 100644 --- a/.github/workflows/test-python.yml +++ b/.github/workflows/test-python.yml @@ -15,7 +15,7 @@ jobs: runs-on: ${{ matrix.operating-system }} strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] settings-module: ["single_db", "multi_db"] operating-system: ["ubuntu-latest", "windows-latest"] steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 53609f21..9934c339 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,92 +19,113 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] +### Added + +- Automatic serve ReactPy wheel from Django's static directory when using PyScript. + ### Changed -- Updated the interface for `reactpy.hooks.use_channel_layer` to be more intuitive. - - Arguments now must be provided as keyworded arguments. - - The `name` argument has been renamed to `channel`. - - The `group_name` argument has been renamed to `group`. - - The `group_add` and `group_discard` arguments have been removed for simplicity. -- To improve performance, `preact` is now used as the default client-side library instead of `react`. +- Updated dependencies: `reactpy>=2.0.0, <3.0.0` and `reactpy-router>=3.0.0, <4.0.0`. +- Updated Python support to 3.11–3.14. +- Replaced PyScript `` tags with standard ` diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index 547a672a..85b26c81 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -5,4 +5,4 @@ {% endif %} -{{pyscript_layout_handler}} + diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index f74b1ffa..09ec7eff 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,11 +1,16 @@ from __future__ import annotations +import json from logging import getLogger from typing import TYPE_CHECKING from uuid import uuid4 +import reactpy from django import template +from django.templatetags.static import static from django.urls import NoReverseMatch, reverse +from django.utils.safestring import mark_safe +from reactpy.executors.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, pyscript_executor_html from reactpy_django import config as reactpy_config from reactpy_django.exceptions import ( @@ -15,8 +20,8 @@ InvalidHostError, OfflineComponentMissingError, ) -from reactpy_django.pyscript.utils import PYSCRIPT_LAYOUT_HANDLER, extend_pyscript_config, render_pyscript_template from reactpy_django.utils import ( + fetch_cached_python_file, prerender_component, reactpy_to_string, save_component_params, @@ -27,7 +32,7 @@ if TYPE_CHECKING: from django.http import HttpRequest - from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict + from reactpy.types import Component, ComponentConstructor, VdomDict register = template.Library() @@ -91,8 +96,8 @@ def component( class_ = kwargs.pop("class", "") has_args = bool(args or kwargs) user_component: ComponentConstructor | None = None - _prerender_html = "" - _offline_html = "" + prerender_html = "" + offline_html = "" # Validate the host if host and DJANGO_DEBUG: @@ -148,7 +153,7 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - _prerender_html = prerender_component(user_component, args, kwargs, uuid, request) + prerender_html = prerender_component(user_component, args, kwargs, uuid, request) # Fetch the offline component's HTML, if requested if offline: @@ -164,7 +169,7 @@ def component( ) _logger.error(msg) return failure_context(dotted_path, ComponentCarrierError(msg)) - _offline_html = prerender_component(offline_component, [], {}, uuid, request) + offline_html = prerender_component(offline_component, [], {}, uuid, request) # Return the template rendering context return { @@ -173,13 +178,13 @@ def component( "reactpy_host": host or perceived_host, "reactpy_url_prefix": reactpy_config.REACTPY_URL_PREFIX, "reactpy_component_path": f"{dotted_path}/{uuid}/{int(has_args)}/", - "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_resolved_web_modules_path": f"/{RESOLVED_WEB_MODULES_PATH.strip('/')}/", "reactpy_reconnect_interval": reactpy_config.REACTPY_RECONNECT_INTERVAL, "reactpy_reconnect_max_interval": reactpy_config.REACTPY_RECONNECT_MAX_INTERVAL, "reactpy_reconnect_backoff_multiplier": reactpy_config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, "reactpy_reconnect_max_retries": reactpy_config.REACTPY_RECONNECT_MAX_RETRIES, - "reactpy_prerender_html": _prerender_html, - "reactpy_offline_html": _offline_html, + "reactpy_prerender_html": mark_safe(prerender_html), + "reactpy_offline_html": mark_safe(offline_html), } @@ -187,7 +192,7 @@ def component( def pyscript_component( context: template.RequestContext, *file_paths: str, - initial: str | VdomDict | ComponentType = "", + initial: str | VdomDict | Component = "", root: str = "root", ): """ @@ -208,12 +213,12 @@ def pyscript_component( uuid = uuid4().hex request: HttpRequest | None = context.get("request") initial = reactpy_to_string(initial, request=request, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) + executor = pyscript_executor_html(file_paths, uuid, root, fetch_cached_python_file) return { - "pyscript_executor": executor, + "pyscript_executor": mark_safe(executor), "pyscript_uuid": uuid, - "pyscript_initial_html": initial, + "pyscript_initial_html": mark_safe(initial), } @@ -238,9 +243,21 @@ def pyscript_setup( """ from reactpy_django.config import DJANGO_DEBUG + pyscript_config_str = extend_pyscript_config( + list(extra_py), + extra_js, + config, + {static("reactpy_django/morphdom/morphdom-esm.js"): "morphdom"}, + ) + pyscript_config = json.loads(pyscript_config_str) + local_reactpy_wheel = static(f"reactpy_django/wheels/reactpy-{reactpy.__version__}-py3-none-any.whl") + for i, pkg in enumerate(pyscript_config["packages"]): + if "reactpy" in pkg.lower(): + pyscript_config["packages"][i] = local_reactpy_wheel + break return { - "pyscript_config": extend_pyscript_config(extra_py, extra_js, config), - "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, + "pyscript_config": mark_safe(json.dumps(pyscript_config)), + "pyscript_layout_handler": mark_safe(PYSCRIPT_LAYOUT_HANDLER), "django_debug": DJANGO_DEBUG, } diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 2703523d..e71f9c63 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 ComponentType, Connection, Key +from reactpy.types import Component, Connection, Key from typing_extensions import ParamSpec if TYPE_CHECKING: @@ -104,11 +104,11 @@ 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: ... + ) -> Component: ... class ViewToIframeConstructor(Protocol): - def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> ComponentType: ... + def __call__(self, *args: Any, key: Key | None = None, **kwargs: Any) -> Component: ... class UseAuthLogin(Protocol): diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 775ba0f9..38b7fa66 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -13,6 +13,7 @@ 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 uuid import UUID, uuid4 @@ -26,10 +27,10 @@ from django.http import HttpRequest, HttpResponse from django.template import engines from django.utils.encoding import smart_str -from reactpy import vdom_to_html -from reactpy.backend.types import Connection, Location +from reactpy import reactpy_to_string as _reactpy_to_string from reactpy.core.hooks import ConnectionContext from reactpy.core.layout import Layout +from reactpy.types import Connection, Location, VdomDict from reactpy_django.exceptions import ( ComponentDoesNotExistError, @@ -42,7 +43,7 @@ from collections.abc import Awaitable, Mapping, Sequence from django.views import View - from reactpy.types import ComponentConstructor, VdomDict + from reactpy.types import ComponentConstructor from reactpy_django.types import FuncParams, Inferred @@ -359,7 +360,7 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__()).result() + SYNC_LAYOUT_THREAD.submit(self.loop.run_until_complete, self.__aexit__(exc_type, exc_val, exc_tb)).result() self.loop.close() def sync_render(self): @@ -405,21 +406,21 @@ def prerender_component( user_component(*args, **kwargs), value=Connection( scope=scope, - location=Location(pathname=request.path, search=f"?{search}" if search else ""), + location=Location(path=request.path, query_string=f"?{search}" if search else ""), carrier=request, ), ) ) as layout: vdom_tree = layout.sync_render()["model"] - return vdom_to_html(vdom_tree) # type: ignore + return _reactpy_to_string(vdom_tree) # type: ignore 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): - return vdom_to_html(vdom_or_component) # type: ignore + return _reactpy_to_string(vdom_or_component) # type: ignore if hasattr(vdom_or_component, "render"): if not request: @@ -452,7 +453,7 @@ def save_component_params(args, kwargs, uuid) -> None: def validate_host(host: str) -> None: """Validates the host string to ensure it does not contain a protocol.""" if "://" in host: - protocol = host.split("://")[0] + protocol = host.split("://", maxsplit=1)[0] msg = f"Invalid host provided to component. Contains a protocol '{protocol}://'." _logger.error(msg) raise InvalidHostError(msg) @@ -520,7 +521,7 @@ def cached_static_file(static_path: str) -> str: def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: - """Transform intended for use with `html_to_vdom`. + """Transform intended for use with `string_to_reactpy `. Removes ``, ``, and `` while preserving their children. @@ -529,5 +530,24 @@ def del_html_head_body_transform(vdom: VdomDict) -> VdomDict: The VDOM dictionary to transform. """ if vdom["tagName"] in {"html", "body", "head"}: - return {"tagName": "", "children": vdom.setdefault("children", [])} + return VdomDict(tagName="", children=vdom.setdefault("children", [])) return vdom + + +def fetch_cached_python_file(file_path: str, minify: bool = True) -> str: + from reactpy.executors.pyscript.utils import minify_python + + from reactpy_django.config import REACTPY_CACHE + + # 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: + return file_contents + + file_contents = Path(file_path).read_text(encoding="utf-8").strip() + if minify: + file_contents = minify_python(file_contents) + caches[REACTPY_CACHE].set(cache_key, file_contents, version=int(last_modified_time)) + return file_contents diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 47ccc717..c63b192b 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -8,7 +8,7 @@ import traceback from datetime import timedelta from threading import Thread -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from urllib.parse import parse_qs import dill @@ -16,10 +16,10 @@ from channels.auth import login from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.utils import timezone -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 +from reactpy.types import Connection, Location from reactpy_django.tasks import clean from reactpy_django.utils import ensure_async @@ -28,8 +28,6 @@ from collections.abc import MutableMapping, Sequence from concurrent.futures import Future - from django.contrib.auth.models import AbstractUser - from reactpy_django import models from reactpy_django.types import ComponentParams @@ -70,17 +68,17 @@ async def connect(self) -> None: await super().connect() # Automatically re-login the user, if needed - user: AbstractUser | None = self.scope.get("user") + user = self.scope.get("user") if REACTPY_AUTO_RELOGIN and user and user.is_authenticated and user.is_active: try: - await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) + await login(self.scope, user, backend=REACTPY_AUTH_BACKEND) # type: ignore[reportArgumentType] except Exception: await asyncio.to_thread( _logger.error, f"ReactPy websocket authentication has failed!\n{traceback.format_exc()}", ) try: - await ensure_async(self.scope["session"].save)() + await ensure_async(self.scope["session"].save)() # type: ignore[reportTypedDictNotRequiredAccess] except Exception: await asyncio.to_thread( _logger.error, @@ -151,17 +149,17 @@ async def run_dispatcher(self): ) scope = self.scope - self.dotted_path = scope["url_route"]["kwargs"]["dotted_path"] - uuid = scope["url_route"]["kwargs"].get("uuid") - has_args = scope["url_route"]["kwargs"].get("has_args") - scope["reactpy"] = {"id": str(uuid)} + self.dotted_path = scope["url_route"]["kwargs"]["dotted_path"] # type: ignore[reportTypedDictNotRequiredAccess] + uuid = scope["url_route"]["kwargs"].get("uuid") # type: ignore[reportTypedDictNotRequiredAccess] + has_args = scope["url_route"]["kwargs"].get("has_args") # type: ignore[reportTypedDictNotRequiredAccess] + scope["reactpy"] = {"id": str(uuid)} # type: ignore[reportGeneralTypeIssues] query_string = parse_qs(scope["query_string"].decode(), strict_parsing=True) - http_pathname = query_string.get("http_pathname", [""])[0] - http_search = query_string.get("http_search", [""])[0] + http_path = query_string.get("path", [""])[0] + http_query_string = query_string.get("qs", [""])[0] self.recv_queue = asyncio.Queue() connection = Connection( # For `use_connection` - scope=scope, - location=Location(pathname=http_pathname, search=http_search), + scope=cast("dict[str, Any]", scope), + location=Location(path=http_path, query_string=http_query_string), carrier=self, ) now = timezone.now() @@ -212,7 +210,7 @@ async def run_dispatcher(self): # Start the ReactPy component rendering loop with contextlib.suppress(Exception): await serve_layout( - Layout( # type: ignore + Layout( ConnectionContext( auth_manager(), root_manager(root_component), diff --git a/src/build_scripts/copy_dir.py b/src/scripts/copy_dir.py similarity index 76% rename from src/build_scripts/copy_dir.py rename to src/scripts/copy_dir.py index 0a2cafab..bfd4c2db 100644 --- a/src/build_scripts/copy_dir.py +++ b/src/scripts/copy_dir.py @@ -1,9 +1,10 @@ -# ruff: noqa: INP001 import logging import shutil import sys from pathlib import Path +logger = logging.getLogger(__name__) + def copy_files(source: Path, destination: Path) -> None: if destination.exists(): @@ -19,7 +20,7 @@ def copy_files(source: Path, destination: Path) -> None: if __name__ == "__main__": if len(sys.argv) != 3: - logging.error("Script used incorrectly!\nUsage: python copy_dir.py ") + logger.error("Script used incorrectly!\nUsage: python copy_dir.py ") sys.exit(1) root_dir = Path(__file__).parent.parent.parent @@ -27,7 +28,7 @@ def copy_files(source: Path, destination: Path) -> None: dest = Path(root_dir / sys.argv[2]) if not src.exists(): - logging.error("Source directory %s does not exist", src) + logger.error("Source directory %s does not exist", src) sys.exit(1) copy_files(src, dest) diff --git a/src/scripts/install_deps.py b/src/scripts/install_deps.py new file mode 100644 index 00000000..b31b632b --- /dev/null +++ b/src/scripts/install_deps.py @@ -0,0 +1,42 @@ +""" +Development/debug script to parse pyproject.toml to find dependencies, then install them in the +current Python environment via `python -m uv pip install -U `. +""" + +import subprocess +import sys +from pathlib import Path + +import tomllib as toml + +DEPENDENCIES = set() + + +def find_deps(data): + """Recurse through all categories and find any list with `dependencies` in the name, then combine + all dependencies into a single list""" + if isinstance(data, dict): + for key, value in data.items(): + if "dependencies" in key and isinstance(value, list) and value and isinstance(value[0], str): + DEPENDENCIES.update(value) + else: + find_deps(value) + elif isinstance(data, list): + for item in data: + find_deps(item) + + +def install_deps(): + pyproject_path = Path(__file__).parent.parent / "pyproject.toml" + with open(pyproject_path, "rb") as f: + pyproject_data = toml.load(f) + find_deps(pyproject_data) + DEPENDENCIES.discard("ruff") # ruff only exists in dev dependencies for CI purposes. + subprocess.run( + [sys.executable, "-m", "uv", "pip", "install", "-U", *sorted(DEPENDENCIES)], + check=True, + ) + + +if __name__ == "__main__": + install_deps() diff --git a/src/scripts/run_django.py b/src/scripts/run_django.py new file mode 100644 index 00000000..e64b401b --- /dev/null +++ b/src/scripts/run_django.py @@ -0,0 +1,16 @@ +""" +Development/debug script to run Django's development server in the local environment. +You should run the `install_deps.py` script before this to ensure all dependencies are installed. +""" + +import subprocess +import sys +from pathlib import Path + +if __name__ == "__main__": + # Run server and pass through any additional command line arguments (e.g. for specifying a different port) + subprocess.run( + [sys.executable, "manage.py", "runserver", *sys.argv[1:]], + check=True, + cwd=Path(__file__).parent.parent.parent / "tests", + ) diff --git a/tests/test_app/__init__.py b/tests/test_app/__init__.py index fb3ee7d4..df44ad6c 100644 --- a/tests/test_app/__init__.py +++ b/tests/test_app/__init__.py @@ -8,14 +8,7 @@ assert subprocess.run(["bun", "install"], cwd=str(js_dir), check=True).returncode == 0 assert ( subprocess.run( - [ - "bun", - "build", - "./src/index.ts", - f"--outdir={static_dir}", - "--minify", - "--sourcemap=linked", - ], + ["bun", "build", "./src/index.ts", f"--outdir={static_dir}", "--sourcemap=linked"], cwd=str(js_dir), check=True, ).returncode diff --git a/tests/test_app/components.py b/tests/test_app/components.py index bcdf8276..d35cb89e 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -7,7 +7,7 @@ from channels.db import database_sync_to_async from django.contrib.auth import get_user_model from django.http import HttpRequest -from reactpy import component, hooks, html, web +from reactpy import component, hooks, html, reactjs import reactpy_django from reactpy_django.components import view_to_component, view_to_iframe @@ -28,13 +28,13 @@ @component def hello_world(): - return html._(html.div({"id": "hello-world"}, "Hello World!")) + return html(html.div({"id": "hello-world"}, "Hello World!")) @component def button(): count, set_count = hooks.use_state(0) - return html._( + return html( html.div( "button:", html.button( @@ -49,7 +49,7 @@ def button(): @component def parameterized_component(x, y): total = x + y - return html._( + return html( html.div( {"id": "parametrized-component", "data-value": total}, f"parameterized_component: {total}", @@ -61,21 +61,17 @@ def parameterized_component(x, y): def object_in_templatetag(my_object: TestObject): success = bool(my_object and my_object.value) co_name = inspect.currentframe().f_code.co_name - return html._(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object))) + return html(html.div({"id": co_name, "data-success": success}, f"{co_name}: ", str(my_object))) -SimpleButtonModule = web.module_from_file( - "SimpleButton", - Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", - resolve_exports=False, - fallback="...", +SimpleButton = reactjs.component_from_file( + Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", import_names="SimpleButton", fallback="..." ) -SimpleButton = web.export(SimpleButtonModule, "SimpleButton") @component def button_from_js_module(): - return html._("button_from_js_module:", SimpleButton({"id": "button-from-js-module"})) + return html("button_from_js_module:", SimpleButton({"id": "button-from-js-module"})) @component @@ -125,7 +121,7 @@ def django_css(): @component def django_js(): success = False - return html._( + return html( html.div( {"id": "django-js", "data-success": success}, f"django_js: {success}", @@ -319,7 +315,7 @@ def on_change(event): elif items.data is None: rendered_items = html.h2("Loading...") else: - rendered_items = html._( + rendered_items = html( html.h3("Not Done"), _render_todo_items([i for i in items.data if not i.done], toggle_item), html.h3("Done"), @@ -390,7 +386,7 @@ async def on_change(event): elif items.data is None: rendered_items = html.h2("Loading...") else: - rendered_items = html._( + rendered_items = html( html.h3("Not Done"), _render_todo_items([i for i in items.data if not i.done], toggle_item), html.h3("Done"), @@ -503,7 +499,7 @@ def on_click(_): post_request.method = "POST" set_request(post_request) - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -522,7 +518,7 @@ def view_to_component_args(): def on_click(_): set_success("") - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -541,7 +537,7 @@ def view_to_component_kwargs(): def on_click(_): set_success("") - return html._( + return html( html.button( { "id": f"{inspect.currentframe().f_code.co_name}_btn", @@ -696,17 +692,17 @@ async def on_submit(event): @component def use_auth(): - _login, _logout = reactpy_django.hooks.use_auth() + login_, logout_ = reactpy_django.hooks.use_auth() uuid = hooks.use_ref(str(uuid4())).current current_user = reactpy_django.hooks.use_user() connection = reactpy_django.hooks.use_connection() async def login_user(event): new_user, _created = await get_user_model().objects.aget_or_create(username="user_4") - await _login(new_user) + await login_(new_user) async def logout_user(event): - await _logout() + await logout_() async def disconnect(event): await connection.carrier.close() @@ -728,17 +724,17 @@ async def disconnect(event): @component def use_auth_no_rerender(): - _login, _logout = reactpy_django.hooks.use_auth() + login_, logout_ = reactpy_django.hooks.use_auth() uuid = hooks.use_ref(str(uuid4())).current current_user = reactpy_django.hooks.use_user() connection = reactpy_django.hooks.use_connection() async def login_user(event): new_user, _created = await get_user_model().objects.aget_or_create(username="user_5") - await _login(new_user, rerender=False) + await login_(new_user, rerender=False) async def logout_user(event): - await _logout(rerender=False) + await logout_(rerender=False) async def disconnect(event): await connection.carrier.close() diff --git a/tests/test_app/forms/forms.py b/tests/test_app/forms/forms.py index 6fe79dc2..b5592798 100644 --- a/tests/test_app/forms/forms.py +++ b/tests/test_app/forms/forms.py @@ -30,7 +30,7 @@ class BasicForm(forms.Form): typed_multiple_choice_field = forms.TypedMultipleChoiceField( label="typed multiple choice", choices=[("1", "One"), ("2", "Two")] ) - url_field = forms.URLField(label="URL") + url_field = forms.URLField(label="URL", assume_scheme="http") uuid_field = forms.UUIDField(label="UUID") combo_field = forms.ComboField(label="combo", fields=[forms.CharField(), forms.EmailField()]) password_field = forms.CharField(label="password", widget=forms.PasswordInput) diff --git a/tests/test_app/pyscript/components/multifile_parent.py b/tests/test_app/pyscript/components/multifile_parent.py index c54d7719..3773e9a4 100644 --- a/tests/test_app/pyscript/components/multifile_parent.py +++ b/tests/test_app/pyscript/components/multifile_parent.py @@ -1,4 +1,4 @@ -# ruff: noqa: TCH004 +# ruff: noqa: TC004 from typing import TYPE_CHECKING from reactpy import component, html diff --git a/tests/test_app/pyscript/components/server_side.py b/tests/test_app/pyscript/components/server_side.py index 682411d5..d5a6af4d 100644 --- a/tests/test_app/pyscript/components/server_side.py +++ b/tests/test_app/pyscript/components/server_side.py @@ -1,13 +1,13 @@ -from reactpy import component, html, use_state - -from reactpy_django.components import pyscript_component +from reactpy import component, html, pyscript_component, use_state @component def parent(): return html.div( {"id": "parent"}, - pyscript_component("./test_app/pyscript/components/child.py"), + pyscript_component( + "./test_app/pyscript/components/child.py", + ), ) @@ -30,5 +30,7 @@ def parent_toggle(): {"onClick": lambda _: set_state(not state)}, "Click to show/hide", ), - pyscript_component("./test_app/pyscript/components/child.py"), + pyscript_component( + "./test_app/pyscript/components/child.py", + ), ) diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index ea95c5f2..63a6d2eb 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -1,5 +1,7 @@ -from reactpy import component, html, use_location -from reactpy_router import route, use_params, use_search_params +from uuid import uuid4 + +from reactpy import component, html, use_location, use_state +from reactpy_router import link, route, use_params, use_search_params from reactpy_router.types import Route from reactpy_django.router import django_router @@ -11,14 +13,14 @@ def display_params(string: str): search_params = use_search_params() url_params = use_params() - return html._( + return html( html.div({"id": "router-string"}, string), html.div( - {"id": "router-path", "data-path": location.pathname}, - f"path: {location.pathname}", + {"id": "router-path", "data-path": location.path}, + f"path: {location.path}", ), html.div(f"url_params: {url_params}"), - html.div(f"location.search: {location.search}"), + html.div(f"location.query_string: {location.query_string}"), html.div(f"search_params: {search_params}"), ) @@ -27,6 +29,21 @@ def show_route(path: str, *children: Route) -> Route: return route(path, display_params(path), *children) +@component +def next_page(): + url_params = use_params() + state, _set_state = use_state(uuid4) + page = url_params.get("page", 0) + next_page = page + 1 + return html.fragment( + display_params("/router/next//"), + html.div({"id": "router-uuid", "data-uuid": state.hex}, f"UUID: {state.hex}"), + html.button( + link({"to": f"/router/next/{next_page}/"}, "Next Page"), + ), + ) + + @component def main(): return django_router( @@ -39,4 +56,5 @@ def main(): show_route("/router/uuid//"), show_route("/router/any/"), show_route("/router/two///"), + route("/router/next//", next_page()), ) diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index 514ca1be..28a52904 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -17,7 +17,7 @@ # Application definition INSTALLED_APPS = [ - "servestatic.runserver_nostatic", + "servestatic", "daphne", # Overrides `runserver` command with an ASGI server "django.contrib.admin", "django.contrib.auth", @@ -132,12 +132,7 @@ "version": 1, "disable_existing_loggers": False, "handlers": { - "console": {"class": "logging.StreamHandler"}, - }, - "loggers": { - "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL}, - "reactpy": {"handlers": ["console"], "level": LOG_LEVEL}, - "django.request": {"handlers": ["console"], "level": LOG_LEVEL}, + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, }, } diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index 2f7f3e00..34a7ff5a 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -17,7 +17,7 @@ # Application definition INSTALLED_APPS = [ - "servestatic.runserver_nostatic", + "servestatic", "daphne", # Overrides `runserver` command with an ASGI server "django.contrib.admin", "django.contrib.auth", @@ -118,12 +118,7 @@ "version": 1, "disable_existing_loggers": False, "handlers": { - "console": {"class": "logging.StreamHandler"}, - }, - "loggers": { - "reactpy_django": {"handlers": ["console"], "level": LOG_LEVEL}, - "reactpy": {"handlers": ["console"], "level": LOG_LEVEL}, - "django.request": {"handlers": ["console"], "level": LOG_LEVEL}, + "console": {"class": "logging.StreamHandler", "level": LOG_LEVEL}, }, } diff --git a/tests/test_app/templates/async_event_form.html b/tests/test_app/templates/async_event_form.html index af77e5c1..6a07c916 100644 --- a/tests/test_app/templates/async_event_form.html +++ b/tests/test_app/templates/async_event_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index aaef6cb7..c2c9dd45 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/bootstrap_form.html b/tests/test_app/templates/bootstrap_form.html index 0ef218db..f985bbb7 100644 --- a/tests/test_app/templates/bootstrap_form.html +++ b/tests/test_app/templates/bootstrap_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} {% load django_bootstrap5 %} +{% load static %} {% load reactpy %} {% load django_bootstrap5 %} diff --git a/tests/test_app/templates/channel_layers.html b/tests/test_app/templates/channel_layers.html index 26361861..8720668e 100644 --- a/tests/test_app/templates/channel_layers.html +++ b/tests/test_app/templates/channel_layers.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/errors.html b/tests/test_app/templates/errors.html index 0d4ab161..67590666 100644 --- a/tests/test_app/templates/errors.html +++ b/tests/test_app/templates/errors.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/event_timing.html b/tests/test_app/templates/event_timing.html index 4f75dafb..f0dc5c3c 100644 --- a/tests/test_app/templates/event_timing.html +++ b/tests/test_app/templates/event_timing.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -74,7 +74,7 @@

ReactPy Event Timing Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateEventTiming(); + calculateEventTiming();
diff --git a/tests/test_app/templates/events_renders_per_second.html b/tests/test_app/templates/events_renders_per_second.html index cb1ed5c8..715340d8 100644 --- a/tests/test_app/templates/events_renders_per_second.html +++ b/tests/test_app/templates/events_renders_per_second.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/form.html b/tests/test_app/templates/form.html index ecffc1ac..e6794521 100644 --- a/tests/test_app/templates/form.html +++ b/tests/test_app/templates/form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html index 1eb2be2a..17273987 100644 --- a/tests/test_app/templates/host_port.html +++ b/tests/test_app/templates/host_port.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html index ad2dada0..407a16ae 100644 --- a/tests/test_app/templates/host_port_roundrobin.html +++ b/tests/test_app/templates/host_port_roundrobin.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/mixed_time_to_load.html b/tests/test_app/templates/mixed_time_to_load.html index 3b23050c..5fa03438 100644 --- a/tests/test_app/templates/mixed_time_to_load.html +++ b/tests/test_app/templates/mixed_time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/model_form.html b/tests/test_app/templates/model_form.html index 3c28eb07..ca169a9f 100644 --- a/tests/test_app/templates/model_form.html +++ b/tests/test_app/templates/model_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/net_io_time_to_load.html b/tests/test_app/templates/net_io_time_to_load.html index c5d79050..45e86dce 100644 --- a/tests/test_app/templates/net_io_time_to_load.html +++ b/tests/test_app/templates/net_io_time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -37,7 +37,7 @@

ReactPy Network IO Intensive Time To Load Per Second Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateTTL(); + calculateTTL();
diff --git a/tests/test_app/templates/offline.html b/tests/test_app/templates/offline.html index e7c39106..34e6d903 100644 --- a/tests/test_app/templates/offline.html +++ b/tests/test_app/templates/offline.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/prerender.html b/tests/test_app/templates/prerender.html index dab4ba01..6c59042e 100644 --- a/tests/test_app/templates/prerender.html +++ b/tests/test_app/templates/prerender.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/pyscript.html b/tests/test_app/templates/pyscript.html index 57a5dd15..1f77f290 100644 --- a/tests/test_app/templates/pyscript.html +++ b/tests/test_app/templates/pyscript.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/renders_per_second.html b/tests/test_app/templates/renders_per_second.html index 2ec2f868..9253f543 100644 --- a/tests/test_app/templates/renders_per_second.html +++ b/tests/test_app/templates/renders_per_second.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/router.html b/tests/test_app/templates/router.html index ee15fb64..292da720 100644 --- a/tests/test_app/templates/router.html +++ b/tests/test_app/templates/router.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/sync_event_form.html b/tests/test_app/templates/sync_event_form.html index c22955d1..5b9d8c00 100644 --- a/tests/test_app/templates/sync_event_form.html +++ b/tests/test_app/templates/sync_event_form.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} diff --git a/tests/test_app/templates/time_to_load.html b/tests/test_app/templates/time_to_load.html index 4e30f3eb..411ba2df 100644 --- a/tests/test_app/templates/time_to_load.html +++ b/tests/test_app/templates/time_to_load.html @@ -1,5 +1,5 @@ -{% load static %} {% load reactpy %} +{% load static %} {% load reactpy %} @@ -37,7 +37,7 @@

ReactPy Time To Load Test Page

await new Promise((resolve) => setTimeout(resolve, 50)); } } - calculateTTL(); + calculateTTL();
diff --git a/tests/test_app/templates/view_to_component_script.html b/tests/test_app/templates/view_to_component_script.html index c9c5d263..b191179a 100644 --- a/tests/test_app/templates/view_to_component_script.html +++ b/tests/test_app/templates/view_to_component_script.html @@ -1,16 +1,11 @@ -{% extends "view_to_component.html" %} - -{% block top %} +{% extends "view_to_component.html" %} {% block top %} -{% endblock %} - -{% block bottom %} +{% endblock %} {% block bottom %} {% endblock %} diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 9f4fc495..66efbad8 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,18 +1,17 @@ # type: ignore -# ruff: noqa: RUF012, N802 +# ruff: noqa: RUF012 import os import socket from uuid import uuid4 import pytest -from playwright.sync_api import TimeoutError, expect +from playwright.sync_api import TimeoutError as PlaywrightTimeoutError +from playwright.sync_api import expect +from reactpy.testing import DEFAULT_TYPE_DELAY as DELAY from reactpy_django.models import ComponentSession -from reactpy_django.utils import str_to_bool -from .utils import GITHUB_ACTIONS, PlaywrightTestCase, navigate_to_page - -CLICK_DELAY = 250 if str_to_bool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. +from .utils import PlaywrightTestCase, navigate_to_page class ComponentTests(PlaywrightTestCase): @@ -30,7 +29,7 @@ def test_component_hello_world(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(delay=CLICK_DELAY) + self.page.locator("#counter-inc").click(delay=DELAY) @navigate_to_page("/") def test_component_parametrized_component(self): @@ -75,13 +74,13 @@ def test_component_static_js(self): @navigate_to_page("/") def test_component_unauthorized_user(self): - with pytest.raises(TimeoutError): + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector("#unauthorized-user", timeout=1) self.page.wait_for_selector("#unauthorized-user-fallback") @navigate_to_page("/") def test_component_authorized_user(self): - with pytest.raises(TimeoutError): + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector("#authorized-user-fallback", timeout=1) self.page.wait_for_selector("#authorized-user") @@ -102,11 +101,11 @@ def test_component_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}", delay=CLICK_DELAY) - todo_input.press("Enter", delay=CLICK_DELAY) + todo_input.type(f"sample-{i}", delay=DELAY) + todo_input.press("Enter", delay=DELAY) self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) - with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY) + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector(f"#todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") @@ -116,11 +115,11 @@ def test_component_async_use_query_and_mutation(self): item_ids = list(range(5)) for i in item_ids: - todo_input.type(f"sample-{i}", delay=CLICK_DELAY) - todo_input.press("Enter", delay=CLICK_DELAY) + todo_input.type(f"sample-{i}", delay=DELAY) + todo_input.press("Enter", delay=DELAY) self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}") - self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=CLICK_DELAY) - with pytest.raises(TimeoutError): + self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}-checkbox").click(delay=DELAY) + with pytest.raises(PlaywrightTimeoutError): self.page.wait_for_selector(f"#async-todo-list #todo-item-sample-{i}", timeout=1) @navigate_to_page("/") @@ -146,7 +145,7 @@ def test_component_view_to_component_template_view_class(self): @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(delay=CLICK_DELAY) + self.page.wait_for_selector(f"#{name}_btn").click(delay=DELAY) self.page.locator(f"#{name}[data-success=true]").wait_for() @navigate_to_page("/") @@ -242,42 +241,42 @@ def test_component_use_user_data(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_1.click(delay=CLICK_DELAY) + login_1.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) assert "Data: {}" in user_data_div.text_content() - text_input.type("test", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.type("test", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_1]" ) assert "Data: {'test': 'test'}" in user_data_div.text_content() # Test second user's data - login_2.click(delay=CLICK_DELAY) + login_2.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {}" in user_data_div.text_content() - text_input.press("Control+A", delay=CLICK_DELAY) - text_input.press("Backspace", delay=CLICK_DELAY) - text_input.type("test 2", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.press("Control+A", delay=DELAY) + text_input.press("Backspace", delay=DELAY) + text_input.type("test 2", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=true][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {'test 2': 'test 2'}" in user_data_div.text_content() # Attempt to clear data - clear.click(delay=CLICK_DELAY) + clear.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_2]" ) assert "Data: {}" in user_data_div.text_content() # Attempt to logout - logout.click(delay=CLICK_DELAY) + logout.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data[data-success=false][data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=AnonymousUser]" ) @@ -296,13 +295,13 @@ def test_component_use_user_data_with_default(self): assert "Data: None" in user_data_div.text_content() # Test first user's data - login_3.click(delay=CLICK_DELAY) + login_3.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) assert "Data: {'default1': 'value', 'default2': 'value2', 'default3': 'value3'}" in user_data_div.text_content() - text_input.type("test", delay=CLICK_DELAY) - text_input.press("Enter", delay=CLICK_DELAY) + text_input.type("test", delay=DELAY) + text_input.press("Enter", delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) @@ -312,7 +311,7 @@ def test_component_use_user_data_with_default(self): ) # Attempt to clear data - clear.click(delay=CLICK_DELAY) + clear.click(delay=DELAY) user_data_div = self.page.wait_for_selector( "#use-user-data-with-default[data-fetch-error=false][data-mutation-error=false][data-loading=false][data-username=user_3]" ) @@ -325,25 +324,25 @@ def test_component_use_auth(self): uuid = self.page.wait_for_selector("#use-auth").get_attribute("data-uuid") assert len(uuid) == 36 - self.page.wait_for_selector("#use-auth .login").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .login").click(delay=DELAY) # Wait for #use-auth[data-username="user_4"] to appear self.page.wait_for_selector("#use-auth[data-username='user_4']") self.page.wait_for_selector(f"#use-auth[data-uuid='{uuid}']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the same user is logged in self.page.wait_for_selector("#use-auth[data-username='user_4']") # Press logout and wait for #use-auth[data-username="AnonymousUser"] to appear - self.page.wait_for_selector("#use-auth .logout").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .logout").click(delay=DELAY) self.page.wait_for_selector("#use-auth[data-username='AnonymousUser']") # Press disconnect and wait for #use-auth[data-uuid=...] to disappear - self.page.wait_for_selector("#use-auth .disconnect").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#use-auth .disconnect").click(delay=DELAY) expect(self.page.locator(f"#use-auth[data-uuid='{uuid}']")).to_have_count(0) # Double check that the user stayed logged out @@ -357,22 +356,22 @@ def test_component_use_auth(self): # uuid = self.page.wait_for_selector("#use-auth-no-rerender").get_attribute("data-uuid") # assert len(uuid) == 36 - # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .login").click(delay=DELAY) # # Make sure #use-auth[data-username="user_5"] does not appear - # with pytest.raises(TimeoutError): + # with pytest.raises(PlaywrightTimeoutError): # self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']", timeout=1) # # Press disconnect and see if #use-auth[data-username="user_5"] appears - # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY) # self.page.wait_for_selector("#use-auth-no-rerender[data-username='user_5']") # # Press logout and make sure #use-auth[data-username="AnonymousUser"] does not appear - # with pytest.raises(TimeoutError): + # with pytest.raises(PlaywrightTimeoutError): # self.page.wait_for_selector("#use-auth-no-rerender[data-username='AnonymousUser']", timeout=1) # # Press disconnect and see if #use-auth[data-username="AnonymousUser"] appears - # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#use-auth-no-rerender .disconnect").click(delay=DELAY) @navigate_to_page("/") def test_component_use_rerender(self): @@ -380,7 +379,7 @@ def test_component_use_rerender(self): assert len(initial_uuid) == 36 rerender_button = self.page.wait_for_selector("#use-rerender button") - rerender_button.click(delay=CLICK_DELAY) + rerender_button.click(delay=DELAY) # Wait for #use-rerender[data-uuid=...] to disappear expect(self.page.locator(f"#use-rerender[data-uuid='{initial_uuid}']")).to_have_count(0) @@ -536,6 +535,19 @@ def test_url_router_int_and_string(self): string = self.page.query_selector("#router-string") assert string.text_content() == "/router/two///" + def test_url_router_navigation_state(self): + self.page.goto(f"{self.live_server_url}/router/next/1/") + uuid1 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + self.page.locator("button").click() + self.page.wait_for_selector("#router-path[data-path='/router/next/2/']") + uuid2 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + assert uuid1 == uuid2 + self.page.go_back() + self.page.wait_for_selector("#router-path[data-path='/router/next/1/']") + uuid3 = self.page.wait_for_selector("#router-uuid").get_attribute("data-uuid") + # When going back, it should also be a new mount if navigation always remounts + assert uuid1 == uuid3 + ####################### # Channel Layer Tests # ####################### @@ -543,14 +555,14 @@ def test_url_router_int_and_string(self): @navigate_to_page("/channel-layers/") def test_channel_layer_components(self): sender = self.page.wait_for_selector("#sender") - sender.type("test", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) + sender.type("test", delay=DELAY) + sender.press("Enter", delay=DELAY) receiver = self.page.wait_for_selector("#receiver[data-message='test']") assert receiver is not None sender = self.page.wait_for_selector("#group-sender") - sender.type("1234", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) + sender.type("1234", delay=DELAY) + sender.press("Enter", delay=DELAY) receiver_1 = self.page.wait_for_selector("#group-receiver-1[data-message='1234']") receiver_2 = self.page.wait_for_selector("#group-receiver-2[data-message='1234']") receiver_3 = self.page.wait_for_selector("#group-receiver-3[data-message='1234']") @@ -565,7 +577,7 @@ def test_channel_layer_components(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-loading", timeout=30000) self.page.wait_for_selector("#hello-world") @navigate_to_page("/pyscript/") @@ -581,11 +593,11 @@ def test_pyscript_1_multifile(self): 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) + self.page.wait_for_selector("#counter .plus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='1']") - self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter .plus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='2']") - self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter .minus").click(delay=DELAY) self.page.wait_for_selector("#counter pre[data-value='1']") @navigate_to_page("/pyscript/") @@ -593,24 +605,24 @@ 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']") - self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .plus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='1']") - self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .plus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='2']") - self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child .minus").click(delay=DELAY) self.page.wait_for_selector("#child pre[data-value='1']") @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 button").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle") self.page.wait_for_selector("#parent-toggle pre[data-value='0']") - self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='1']") - self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .plus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='2']") - self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle .minus").click(delay=DELAY) self.page.wait_for_selector("#parent-toggle pre[data-value='1']") @navigate_to_page("/pyscript/") @@ -662,7 +674,7 @@ def test_distributed_custom_host_wrong_port(self): 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): + with pytest.raises(PlaywrightTimeoutError): self.page.locator(".custom_host").wait_for(timeout=1000) ################# @@ -724,7 +736,7 @@ def test_form_basic(self): self.page.wait_for_selector("#id_password_field") self.page.wait_for_selector("#id_model_choice_field") self.page.wait_for_selector("#id_model_multiple_choice_field") - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".errorlist") # Submitting an empty form should result in 22 error elements. @@ -732,35 +744,35 @@ def test_form_basic(self): assert len(self.page.query_selector_all(".errorlist")) == 22 # Fill out the form - self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY) expect(self.page.locator("#id_boolean_field")).to_be_checked() - self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY) + self.page.locator("#id_char_field").type("test", delay=DELAY) self.page.locator("#id_choice_field").select_option("2") - self.page.locator("#id_date_field").type("2021-01-01", delay=CLICK_DELAY) - self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=CLICK_DELAY) - self.page.locator("#id_decimal_field").type("0.123", delay=CLICK_DELAY) - self.page.locator("#id_duration_field").type("1", delay=CLICK_DELAY) - self.page.locator("#id_email_field").type("test@example.com", delay=CLICK_DELAY) + self.page.locator("#id_date_field").type("2021-01-01", delay=DELAY) + self.page.locator("#id_date_time_field").type("2021-01-01 01:01:00", delay=DELAY) + self.page.locator("#id_decimal_field").type("0.123", delay=DELAY) + self.page.locator("#id_duration_field").type("1", delay=DELAY) + self.page.locator("#id_email_field").type("test@example.com", delay=DELAY) file_path_field_options = self.page.query_selector_all("#id_file_path_field option") file_path_field_values: list[str] = [option.get_attribute("value") for option in file_path_field_options] self.page.locator("#id_file_path_field").select_option(file_path_field_values[1]) - self.page.locator("#id_float_field").type("1.2345", delay=CLICK_DELAY) - self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=CLICK_DELAY) - self.page.locator("#id_integer_field").type("123", delay=CLICK_DELAY) + self.page.locator("#id_float_field").type("1.2345", delay=DELAY) + self.page.locator("#id_generic_ip_address_field").type("127.0.0.1", delay=DELAY) + self.page.locator("#id_integer_field").type("123", delay=DELAY) self.page.locator("#id_json_field").clear() - self.page.locator("#id_json_field").type('{"key": "value"}', delay=CLICK_DELAY) + self.page.locator("#id_json_field").type('{"key": "value"}', delay=DELAY) self.page.locator("#id_multiple_choice_field").select_option(["2", "3"]) self.page.locator("#id_null_boolean_field").select_option("false") - self.page.locator("#id_regex_field").type("12", delay=CLICK_DELAY) - self.page.locator("#id_slug_field").type("my-slug-text", delay=CLICK_DELAY) - self.page.locator("#id_time_field").type("01:01:00", delay=CLICK_DELAY) + self.page.locator("#id_regex_field").type("12", delay=DELAY) + self.page.locator("#id_slug_field").type("my-slug-text", delay=DELAY) + self.page.locator("#id_time_field").type("01:01:00", delay=DELAY) self.page.locator("#id_typed_choice_field").select_option("2") self.page.locator("#id_typed_multiple_choice_field").select_option(["1", "2"]) - self.page.locator("#id_url_field").type("http://example.com", delay=CLICK_DELAY) - self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=CLICK_DELAY) - self.page.locator("#id_combo_field").type("test@example.com", delay=CLICK_DELAY) - self.page.locator("#id_password_field").type("password", delay=CLICK_DELAY) + self.page.locator("#id_url_field").type("http://example.com", delay=DELAY) + self.page.locator("#id_uuid_field").type("550e8400-e29b-41d4-a716-446655440000", delay=DELAY) + self.page.locator("#id_combo_field").type("test@example.com", delay=DELAY) + self.page.locator("#id_password_field").type("password", delay=DELAY) model_choice_field_options = self.page.query_selector_all("#id_model_multiple_choice_field option") model_choice_field_values: list[str] = [option.get_attribute("value") for option in model_choice_field_options] self.page.locator("#id_model_choice_field").select_option(model_choice_field_values[0]) @@ -771,7 +783,7 @@ def test_form_basic(self): # Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered) invalid_feedback = self.page.locator(".errorlist").all()[0] - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) expect(invalid_feedback).not_to_be_attached() # Make sure no errors remain assert len(self.page.query_selector_all(".errorlist")) == 0 @@ -794,7 +806,7 @@ def test_form_bootstrap(self): self.page.wait_for_selector("#id_boolean_field") self.page.wait_for_selector("#id_char_field") self.page.wait_for_selector("#id_choice_field") - self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("button[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".invalid-feedback") # Submitting an empty form should result in 2 error elements. @@ -802,14 +814,14 @@ def test_form_bootstrap(self): assert len(self.page.query_selector_all(".invalid-feedback")) == 2 # Fill out the form - self.page.wait_for_selector("#id_boolean_field").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#id_boolean_field").click(delay=DELAY) expect(self.page.locator("#id_boolean_field")).to_be_checked() - self.page.locator("#id_char_field").type("test", delay=CLICK_DELAY) + self.page.locator("#id_char_field").type("test", delay=DELAY) self.page.locator("#id_choice_field").select_option("2") # Submit and wait for one of the error messages to disappear (indicating that the form has been re-rendered) invalid_feedback = self.page.locator(".invalid-feedback").all()[0] - self.page.wait_for_selector("button[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("button[type=submit]").click(delay=DELAY) expect(invalid_feedback).not_to_be_attached() # Make sure no errors remain assert len(self.page.query_selector_all(".invalid-feedback")) == 0 @@ -818,7 +830,7 @@ def test_form_bootstrap(self): def test_form_orm_model(self): uuid = uuid4().hex self.page.wait_for_selector("form") - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) self.page.wait_for_selector(".errorlist") # Submitting an empty form should result in 1 error element. @@ -826,10 +838,10 @@ def test_form_orm_model(self): assert len(error_list) == 1 # Fill out the form - self.page.locator("#id_text").type(uuid, delay=CLICK_DELAY) + self.page.locator("#id_text").type(uuid, delay=DELAY) # Submit the form - self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # Wait for the error message to disappear (indicating that the form has been re-rendered) expect(error_list[0]).not_to_be_attached() @@ -861,7 +873,7 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # The empty form was submitted, should result in an error # self.page.wait_for_selector("#success[data-value='false']") @@ -870,8 +882,8 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # Form should have been successfully submitted # self.page.wait_for_selector("#success[data-value='true']") @@ -890,7 +902,7 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Submit empty the form - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # The empty form was submitted, should result in an error # self.page.wait_for_selector("#success[data-value='false']") @@ -899,8 +911,8 @@ def test_form_orm_model(self): # self.page.wait_for_selector("#change[data-value='false']") # # Fill out the form and re-submit - # self.page.wait_for_selector("#id_char_field").type("test", delay=CLICK_DELAY) - # self.page.wait_for_selector("input[type=submit]").click(delay=CLICK_DELAY) + # self.page.wait_for_selector("#id_char_field").type("test", delay=DELAY) + # self.page.wait_for_selector("input[type=submit]").click(delay=DELAY) # # Form should have been successfully submitted # self.page.wait_for_selector("#success[data-value='true']") diff --git a/tests/test_app/tests/test_static_wheels.py b/tests/test_app/tests/test_static_wheels.py new file mode 100644 index 00000000..e16d1c31 --- /dev/null +++ b/tests/test_app/tests/test_static_wheels.py @@ -0,0 +1,167 @@ +"""Tests for ``reactpy_django._static_wheels``. + +These tests verify that the wheel-sync logic is safe to run under +multi-process servers (the original concern this module was written +to address) and that it produces a clean, atomic result. +""" + +from __future__ import annotations + +import json +import threading + +import pytest + +from reactpy_django import _static_wheels # noqa: PLC2701 + + +@pytest.fixture +def isolated_dest(tmp_path, monkeypatch): + """Redirect the destination directory to a temp path for the test. + + We can't just delete the real destination because other tests in + the suite rely on the wheel being there; we instead point the + module at a scratch directory via monkeypatch and let it create + its own lock and marker files there. + """ + scratch = tmp_path / "wheels" + monkeypatch.setattr(_static_wheels, "_destination_dir", lambda: scratch) + return scratch + + +def test_creates_destination_and_copies_wheel(isolated_dest): + # First call should copy the wheel into the scratch directory. + copied = _static_wheels.sync_static_wheels() + + assert copied is True + wheels = sorted(isolated_dest.glob("reactpy-*-py3-none-any.whl")) + assert wheels, "expected at least one wheel in the destination" + # Marker must have been written so subsequent calls are cheap. + marker = json.loads((isolated_dest / _static_wheels._MARKER_FILE_NAME).read_text()) + assert "wheels" in marker + assert all("size" in info and "mtime_ns" in info and "sha256" in info for info in marker["wheels"].values()) + + +def test_idempotent_when_marker_matches(isolated_dest): + _static_wheels.sync_static_wheels() + + # Marker-driven fast path: nothing has changed, so the second + # call should return False without touching the file lock. + assert _static_wheels.sync_static_wheels() is False + + +def test_force_copy_re_runs_even_when_marker_matches(isolated_dest): + _static_wheels.sync_static_wheels() + assert _static_wheels.sync_static_wheels(force=True) is True + + +def test_copies_are_atomic_writes(isolated_dest): + """The destination wheel must never be observed as a half-written file. + + We simulate a reader racing with a writer by snapshotting the + destination size while the writer is still mid-stream. If the + writer is truly atomic (temp + ``os.replace``), the size we read + will either be 0 (no wheel yet) or the final size — never an + intermediate value. + """ + src = _static_wheels._source_dir().glob("reactpy-*-py3-none-any.whl") + src_wheel = next(src) + final_size = src_wheel.stat().st_size + + # Snapshot thread: read the size of the destination wheel + # continuously while the writer runs. Record every observed size. + observed_sizes: set[int] = set() + + def reader(): + # The destination directory may not exist yet; that's fine — + # we treat "no file" as size 0. + for _ in range(200): + dest_wheels = list(isolated_dest.glob("reactpy-*-py3-none-any.whl")) + if dest_wheels: + try: + observed_sizes.add(dest_wheels[0].stat().st_size) + except OSError: + # ``os.replace`` may briefly remove the file; treat + # as size 0. + observed_sizes.add(0) + else: + observed_sizes.add(0) + + reader_thread = threading.Thread(target=reader) + reader_thread.start() + + try: + _static_wheels.sync_static_wheels(force=True) + finally: + reader_thread.join() + + # The reader must only have observed either 0 (no file) or the + # final size. Any intermediate size proves the write is not atomic. + assert observed_sizes <= {0, final_size}, f"Reader observed torn writes: sizes={sorted(observed_sizes)}" + + +def test_lock_serializes_concurrent_workers(isolated_dest): + """Two threads calling sync_static_wheels concurrently must not corrupt state. + + The advisory lock should serialize the writers so that the marker + always agrees with what's on disk. + """ + results: list[bool] = [] + errors: list[BaseException] = [] + + def worker(): + try: + results.append(_static_wheels.sync_static_wheels(force=True)) + except BaseException as exc: # pragma: no cover - defensive + errors.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert not errors, f"workers raised: {errors}" + + # At least one writer had to do real work. (We don't assert an + # exact count because the lock may collapse multiple workers into + # "no-op after first one finishes" — and that's fine.) + assert any(results) is True + + # Marker must be consistent: every wheel on disk is listed and + # matches the source fingerprint. + marker = json.loads((isolated_dest / _static_wheels._MARKER_FILE_NAME).read_text()) + on_disk = {p.name: p.stat().st_size for p in isolated_dest.glob("reactpy-*-py3-none-any.whl")} + assert set(marker["wheels"].keys()) == set(on_disk.keys()) + for name, info in marker["wheels"].items(): + assert info["size"] == on_disk[name] + + +def test_prunes_stale_wheels_on_upgrade(isolated_dest): + """After the source is upgraded, old wheels in the destination are removed.""" + # First copy lands the current wheel. + _static_wheels.sync_static_wheels() + + # Plant a fake "old" wheel in the destination to simulate an + # upgrade that left a stale file behind. + stale = isolated_dest / "reactpy-0.0.0-py3-none-any.whl" + stale.write_bytes(b"not a real wheel") + assert stale.exists() + + _static_wheels.sync_static_wheels() + + assert not stale.exists(), "stale wheel should have been pruned" + + +def test_handles_missing_source_gracefully(tmp_path, monkeypatch): + """If the installed ReactPy has no wheels (corrupt install), don't crash.""" + fake_src = tmp_path / "empty-static" + fake_src.mkdir() + monkeypatch.setattr(_static_wheels, "_source_dir", lambda: fake_src) + + fake_dest = tmp_path / "wheels-dest" + fake_dest.mkdir() + monkeypatch.setattr(_static_wheels, "_destination_dir", lambda: fake_dest) + + # Should log a warning and return False instead of raising. + assert _static_wheels.sync_static_wheels() is False diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py index de43d958..5a58ea64 100644 --- a/tests/test_app/tests/utils.py +++ b/tests/test_app/tests/utils.py @@ -1,4 +1,4 @@ -# ruff: noqa: N802, RUF012, T201 +# ruff: noqa: RUF012, T201 import asyncio import os import sys @@ -143,10 +143,10 @@ def navigate_to_page(path: str, *, server_num=0): def _decorator(func: Callable): @decorator.decorator def _wrapper(func: Callable, self: PlaywrightTestCase, *args, **kwargs): - _port = getattr(self, f"_port_{server_num}") - _path = f"http://{self.host}:{_port}/{path.lstrip('/')}" - if self.page.url != _path: - self.page.goto(_path) + 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)