diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index c450cf9f..328bd1c3 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Use Python ${{ matrix.python-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dda476a..3b42d5fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,9 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- Python 3.12 compatibility ## [3.8.0] - 2024-02-20 @@ -50,7 +52,7 @@ Using the following categories, list your changes in this order: ### Changed -- Simplified code for cascading deletion of UserData. +- Simplified code for cascading deletion of user data. ## [3.7.0] - 2024-01-30 diff --git a/docs/examples/python/configure-asgi-middleware.py b/docs/examples/python/configure-asgi-middleware.py index 1895d651..6df35a39 100644 --- a/docs/examples/python/configure-asgi-middleware.py +++ b/docs/examples/python/configure-asgi-middleware.py @@ -11,10 +11,6 @@ application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": AuthMiddlewareStack( - URLRouter( - [REACTPY_WEBSOCKET_ROUTE], - ) - ), + "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), } ) diff --git a/docs/examples/python/use-channel-layer.py b/docs/examples/python/use-channel-layer.py index 36e3a40b..83a66f19 100644 --- a/docs/examples/python/use-channel-layer.py +++ b/docs/examples/python/use-channel-layer.py @@ -3,26 +3,20 @@ @component -def my_sender_component(): - sender = use_channel_layer("my-channel-name") +def my_component(): + async def receive_message(message): + set_message(message["text"]) - async def submit_event(event): + async def send_message(event): if event["key"] == "Enter": await sender({"text": event["target"]["value"]}) - return html.div( - "Message Sender: ", - html.input({"type": "text", "onKeyDown": submit_event}), - ) - - -@component -def my_receiver_component(): message, set_message = hooks.use_state("") + sender = use_channel_layer("my-channel-name", receiver=receive_message) - async def receive_event(message): - set_message(message["text"]) - - use_channel_layer("my-channel-name", receiver=receive_event) - - return html.div(f"Message Receiver: {message}") + return html.div( + f"Received: {message}", + html.br(), + "Send: ", + html.input({"type": "text", "onKeyDown": send_message}), + ) diff --git a/docs/examples/python/user-passes-test-component-fallback.py b/docs/examples/python/user-passes-test-component-fallback.py index e92035f4..9fb71ea7 100644 --- a/docs/examples/python/user-passes-test-component-fallback.py +++ b/docs/examples/python/user-passes-test-component-fallback.py @@ -7,11 +7,11 @@ def my_component_fallback(): return html.div("I am NOT logged in!") -def auth_check(user): +def is_authenticated(user): return user.is_authenticated -@user_passes_test(auth_check, fallback=my_component_fallback) +@user_passes_test(is_authenticated, fallback=my_component_fallback) @component def my_component(): return html.div("I am logged in!") diff --git a/docs/examples/python/user-passes-test-vdom-fallback.py b/docs/examples/python/user-passes-test-vdom-fallback.py index 337b86f7..5d5c54f4 100644 --- a/docs/examples/python/user-passes-test-vdom-fallback.py +++ b/docs/examples/python/user-passes-test-vdom-fallback.py @@ -2,11 +2,11 @@ from reactpy_django.decorators import user_passes_test -def auth_check(user): +def is_authenticated(user): return user.is_authenticated -@user_passes_test(auth_check, fallback=html.div("I am NOT logged in!")) +@user_passes_test(is_authenticated, fallback=html.div("I am NOT logged in!")) @component def my_component(): return html.div("I am logged in!") diff --git a/docs/examples/python/user-passes-test.py b/docs/examples/python/user-passes-test.py index c43e55c5..201ad831 100644 --- a/docs/examples/python/user-passes-test.py +++ b/docs/examples/python/user-passes-test.py @@ -2,11 +2,11 @@ from reactpy_django.decorators import user_passes_test -def auth_check(user): +def is_authenticated(user): return user.is_authenticated -@user_passes_test(auth_check) +@user_passes_test(is_authenticated) @component def my_component(): return html.div("I am logged in!") diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 6d387466..b3ec7d6e 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -536,15 +536,15 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use ??? example "See Interface" - **Parameters** + **Parameters** - `#!python None` + `#!python None` - **Returns** + **Returns** - | Type | Description | - | --- | --- | - | `#!python str` | A string containing the root component's `#!python id`. | + | Type | Description | + | --- | --- | + | `#!python str` | A string containing the root component's `#!python id`. | --- diff --git a/docs/src/reference/management-commands.md b/docs/src/reference/management-commands.md index 13a94e30..6e09e5a1 100644 --- a/docs/src/reference/management-commands.md +++ b/docs/src/reference/management-commands.md @@ -12,7 +12,7 @@ ReactPy exposes Django management commands that can be used to perform various R Command used to manually clean ReactPy data. -When using this command without arguments, it will perform all cleaning operations. You can specify only performing specific cleaning operations through arguments such as `--sessions`. +When using this command without arguments, it will perform all cleaning operations. You can limit cleaning to specific operations through arguments such as `--sessions`. !!! example "Terminal" diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index bba80402..4751f7ba 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -131,7 +131,7 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne). The default host(s) that can render your ReactPy components. -ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. +ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. This is typically useful for self-hosted applications. You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. @@ -147,9 +147,10 @@ Configures whether to pre-render your components via HTTP, which enables SEO com During pre-rendering, there are some key differences in behavior: -1. Only the component's first render is pre-rendered. +1. Only the component's first paint is pre-rendered. 2. All [`connection` hooks](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#connection-hooks) will provide HTTP variants. 3. The component will be non-interactive until a WebSocket connection is formed. +4. The component is re-rendered once a WebSocket connection is formed. diff --git a/pyproject.toml b/pyproject.toml index 2c8cb227..274a352e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,10 +10,12 @@ warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["src", "tests"] -[tool.ruff] +[tool.ruff.lint] ignore = ["E501"] + +[tool.ruff] extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"] line-length = 120 diff --git a/setup.py b/setup.py index 5a8f9e2d..b99d550b 100644 --- a/setup.py +++ b/setup.py @@ -50,6 +50,7 @@ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", "Intended Audience :: Developers", "Intended Audience :: Science/Research", diff --git a/src/reactpy_django/hooks.py b/src/reactpy_django/hooks.py index d2574751..8015a4ab 100644 --- a/src/reactpy_django/hooks.py +++ b/src/reactpy_django/hooks.py @@ -15,7 +15,7 @@ ) from uuid import uuid4 -import orjson as pickle +import orjson from channels import DEFAULT_CHANNEL_LAYER from channels.db import database_sync_to_async from channels.layers import InMemoryChannelLayer, get_channel_layer @@ -351,7 +351,7 @@ async def _set_user_data(data: dict): pk = get_pk(user) model, _ = await UserDataModel.objects.aget_or_create(user_pk=pk) - model.data = pickle.dumps(data) + model.data = orjson.dumps(data) await model.asave() query: Query[dict | None] = use_query( @@ -471,7 +471,7 @@ async def _get_user_data( pk = get_pk(user) model, _ = await UserDataModel.objects.aget_or_create(user_pk=pk) - data = pickle.loads(model.data) if model.data else {} + data = orjson.loads(model.data) if model.data else {} if not isinstance(data, dict): raise TypeError(f"Expected dict while loading user data, got {type(data)}") @@ -489,7 +489,7 @@ async def _get_user_data( data[key] = new_value changed = True if changed: - model.data = pickle.dumps(data) + model.data = orjson.dumps(data) if save_default_data: await model.asave() diff --git a/src/reactpy_django/http/urls.py b/src/reactpy_django/http/urls.py index 23bf4a7e..def755e4 100644 --- a/src/reactpy_django/http/urls.py +++ b/src/reactpy_django/http/urls.py @@ -7,12 +7,12 @@ urlpatterns = [ path( "web_module/", - views.web_modules_file, # type: ignore[arg-type] + views.web_modules_file, name="web_modules", ), path( "iframe/", - views.view_to_iframe, # type: ignore[arg-type] + views.view_to_iframe, name="view_to_iframe", ), ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 180b0b31..5256fba6 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -3,6 +3,8 @@ from django.db.models.signals import pre_delete from django.dispatch import receiver +from reactpy_django.utils import get_pk + class ComponentSession(models.Model): """A model for storing component sessions.""" @@ -41,6 +43,6 @@ class UserDataModel(models.Model): @receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data") def delete_user_data(sender, instance, **kwargs): """Delete ReactPy's `UserDataModel` when a Django `User` is deleted.""" - pk = getattr(instance, instance._meta.pk.name) + pk = get_pk(instance) UserDataModel.objects.filter(user_pk=pk).delete() diff --git a/src/reactpy_django/router/resolvers.py b/src/reactpy_django/router/resolvers.py index 9732ba37..7c095081 100644 --- a/src/reactpy_django/router/resolvers.py +++ b/src/reactpy_django/router/resolvers.py @@ -52,7 +52,7 @@ def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: pattern += f"{re.escape(path[last_match_end:])}$" # Replace literal `*` with "match anything" regex pattern, if it's at the end of the path - if pattern.endswith("\*$"): + if pattern.endswith(r"\*$"): pattern = f"{pattern[:-3]}.*$" return re.compile(pattern), converters diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 396c850d..d6d5ad5d 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,6 +1,5 @@ from __future__ import annotations -from distutils.util import strtobool from logging import getLogger from uuid import uuid4 @@ -22,7 +21,7 @@ OfflineComponentMissing, ) from reactpy_django.types import ComponentParams -from reactpy_django.utils import SyncLayout, validate_component_args +from reactpy_django.utils import SyncLayout, strtobool, validate_component_args try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 3ed0e2de..86dd64b4 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -366,3 +366,19 @@ def render(self): def get_pk(model): """Returns the value of the primary key for a Django model.""" return getattr(model, model._meta.pk.name) + + +def strtobool(val): + """Convert a string representation of truth to true (1) or false (0). + + True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values + are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if + 'val' is anything else. + """ + val = val.lower() + if val in ("y", "yes", "t", "true", "on", "1"): + return 1 + elif val in ("n", "no", "f", "false", "off", "0"): + return 0 + else: + raise ValueError("invalid truth value %r" % (val,)) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index c7a0d2fd..d92867cd 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -2,7 +2,6 @@ import os import socket import sys -from distutils.util import strtobool from functools import partial from time import sleep @@ -14,6 +13,7 @@ from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright from reactpy_django.models import ComponentSession +from reactpy_django.utils import strtobool GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. @@ -628,7 +628,7 @@ def test_url_router(self): path.get_attribute("data-path"), ) string = new_page.query_selector("#router-string") - self.assertEquals("Path 12", string.text_content()) + self.assertEqual("Path 12", string.text_content()) finally: new_page.close() diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index 07a0dbfd..bf567413 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -99,25 +99,25 @@ def test_comment_regex(self): self.assertNotRegex(r'{% component "my.component" %}', COMMENT_REGEX) # Components surrounded by comments - self.assertEquals( + self.assertEqual( COMMENT_REGEX.sub( "", r'{% component "my.component" %} ' ).strip(), '{% component "my.component" %}', ) - self.assertEquals( + self.assertEqual( COMMENT_REGEX.sub( "", r' {% component "my.component" %}' ).strip(), '{% component "my.component" %}', ) - self.assertEquals( + self.assertEqual( COMMENT_REGEX.sub( "", r' {% component "my.component" %} ' ).strip(), '{% component "my.component" %}', ) - self.assertEquals( + self.assertEqual( COMMENT_REGEX.sub( "", r"""'), "", ) - self.assertEquals( + self.assertEqual( COMMENT_REGEX.sub( "", r"""