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"""