Skip to content

Commit 4dc81d0

Browse files
authored
Python 3.12 compatibility (#236)
Add compatibility for Python 3.12 and some misc code cleanup
1 parent c80f94d commit 4dc81d0

20 files changed

+73
-60
lines changed

.github/workflows/test-src.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
runs-on: ubuntu-latest
1616
strategy:
1717
matrix:
18-
python-version: ["3.9", "3.10", "3.11"]
18+
python-version: ["3.9", "3.10", "3.11", "3.12"]
1919
steps:
2020
- uses: actions/checkout@v4
2121
- name: Use Python ${{ matrix.python-version }}

CHANGELOG.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ Using the following categories, list your changes in this order:
3434

3535
## [Unreleased]
3636

37-
- Nothing (yet)!
37+
### Added
38+
39+
- Python 3.12 compatibility
3840

3941
## [3.8.0] - 2024-02-20
4042

@@ -50,7 +52,7 @@ Using the following categories, list your changes in this order:
5052

5153
### Changed
5254

53-
- Simplified code for cascading deletion of UserData.
55+
- Simplified code for cascading deletion of user data.
5456

5557
## [3.7.0] - 2024-01-30
5658

docs/examples/python/configure-asgi-middleware.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@
1111
application = ProtocolTypeRouter(
1212
{
1313
"http": django_asgi_app,
14-
"websocket": AuthMiddlewareStack(
15-
URLRouter(
16-
[REACTPY_WEBSOCKET_ROUTE],
17-
)
18-
),
14+
"websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])),
1915
}
2016
)

docs/examples/python/use-channel-layer.py

+11-17
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,20 @@
33

44

55
@component
6-
def my_sender_component():
7-
sender = use_channel_layer("my-channel-name")
6+
def my_component():
7+
async def receive_message(message):
8+
set_message(message["text"])
89

9-
async def submit_event(event):
10+
async def send_message(event):
1011
if event["key"] == "Enter":
1112
await sender({"text": event["target"]["value"]})
1213

13-
return html.div(
14-
"Message Sender: ",
15-
html.input({"type": "text", "onKeyDown": submit_event}),
16-
)
17-
18-
19-
@component
20-
def my_receiver_component():
2114
message, set_message = hooks.use_state("")
15+
sender = use_channel_layer("my-channel-name", receiver=receive_message)
2216

23-
async def receive_event(message):
24-
set_message(message["text"])
25-
26-
use_channel_layer("my-channel-name", receiver=receive_event)
27-
28-
return html.div(f"Message Receiver: {message}")
17+
return html.div(
18+
f"Received: {message}",
19+
html.br(),
20+
"Send: ",
21+
html.input({"type": "text", "onKeyDown": send_message}),
22+
)

docs/examples/python/user-passes-test-component-fallback.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ def my_component_fallback():
77
return html.div("I am NOT logged in!")
88

99

10-
def auth_check(user):
10+
def is_authenticated(user):
1111
return user.is_authenticated
1212

1313

14-
@user_passes_test(auth_check, fallback=my_component_fallback)
14+
@user_passes_test(is_authenticated, fallback=my_component_fallback)
1515
@component
1616
def my_component():
1717
return html.div("I am logged in!")

docs/examples/python/user-passes-test-vdom-fallback.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
from reactpy_django.decorators import user_passes_test
33

44

5-
def auth_check(user):
5+
def is_authenticated(user):
66
return user.is_authenticated
77

88

9-
@user_passes_test(auth_check, fallback=html.div("I am NOT logged in!"))
9+
@user_passes_test(is_authenticated, fallback=html.div("I am NOT logged in!"))
1010
@component
1111
def my_component():
1212
return html.div("I am logged in!")

docs/examples/python/user-passes-test.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
from reactpy_django.decorators import user_passes_test
33

44

5-
def auth_check(user):
5+
def is_authenticated(user):
66
return user.is_authenticated
77

88

9-
@user_passes_test(auth_check)
9+
@user_passes_test(is_authenticated)
1010
@component
1111
def my_component():
1212
return html.div("I am logged in!")

docs/src/reference/hooks.md

+6-6
Original file line numberDiff line numberDiff line change
@@ -536,15 +536,15 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use
536536

537537
??? example "See Interface"
538538

539-
<font size="4">**Parameters**</font>
539+
<font size="4">**Parameters**</font>
540540

541-
`#!python None`
541+
`#!python None`
542542

543-
<font size="4">**Returns**</font>
543+
<font size="4">**Returns**</font>
544544

545-
| Type | Description |
546-
| --- | --- |
547-
| `#!python str` | A string containing the root component's `#!python id`. |
545+
| Type | Description |
546+
| --- | --- |
547+
| `#!python str` | A string containing the root component's `#!python id`. |
548548

549549
---
550550

docs/src/reference/management-commands.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ ReactPy exposes Django management commands that can be used to perform various R
1212

1313
Command used to manually clean ReactPy data.
1414

15-
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`.
15+
When using this command without arguments, it will perform all cleaning operations. You can limit cleaning to specific operations through arguments such as `--sessions`.
1616

1717
!!! example "Terminal"
1818

docs/src/reference/settings.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ This setting is incompatible with [`daphne`](https://github.com/django/daphne).
131131

132132
The default host(s) that can render your ReactPy components.
133133

134-
ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
134+
ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing. This is typically useful for self-hosted applications.
135135

136136
You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) to manually override this default.
137137

@@ -147,9 +147,10 @@ Configures whether to pre-render your components via HTTP, which enables SEO com
147147

148148
During pre-rendering, there are some key differences in behavior:
149149

150-
1. Only the component's first render is pre-rendered.
150+
1. Only the component's first paint is pre-rendered.
151151
2. All [`connection` hooks](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#connection-hooks) will provide HTTP variants.
152152
3. The component will be non-interactive until a WebSocket connection is formed.
153+
4. The component is re-rendered once a WebSocket connection is formed.
153154

154155
<!-- TODO: The comment below will become true when ReactPy no longer strips scripts from the DOM -->
155156
<!-- 4. `#!python html.script` elements are executed twice (pre-render and post-render). -->

pyproject.toml

+4-2
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ warn_redundant_casts = true
1010
warn_unused_ignores = true
1111
check_untyped_defs = true
1212

13-
[tool.ruff.isort]
13+
[tool.ruff.lint.isort]
1414
known-first-party = ["src", "tests"]
1515

16-
[tool.ruff]
16+
[tool.ruff.lint]
1717
ignore = ["E501"]
18+
19+
[tool.ruff]
1820
extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"]
1921
line-length = 120

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"Programming Language :: Python :: 3.9",
5151
"Programming Language :: Python :: 3.10",
5252
"Programming Language :: Python :: 3.11",
53+
"Programming Language :: Python :: 3.12",
5354
"Operating System :: OS Independent",
5455
"Intended Audience :: Developers",
5556
"Intended Audience :: Science/Research",

src/reactpy_django/hooks.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
)
1616
from uuid import uuid4
1717

18-
import orjson as pickle
18+
import orjson
1919
from channels import DEFAULT_CHANNEL_LAYER
2020
from channels.db import database_sync_to_async
2121
from channels.layers import InMemoryChannelLayer, get_channel_layer
@@ -351,7 +351,7 @@ async def _set_user_data(data: dict):
351351

352352
pk = get_pk(user)
353353
model, _ = await UserDataModel.objects.aget_or_create(user_pk=pk)
354-
model.data = pickle.dumps(data)
354+
model.data = orjson.dumps(data)
355355
await model.asave()
356356

357357
query: Query[dict | None] = use_query(
@@ -471,7 +471,7 @@ async def _get_user_data(
471471

472472
pk = get_pk(user)
473473
model, _ = await UserDataModel.objects.aget_or_create(user_pk=pk)
474-
data = pickle.loads(model.data) if model.data else {}
474+
data = orjson.loads(model.data) if model.data else {}
475475

476476
if not isinstance(data, dict):
477477
raise TypeError(f"Expected dict while loading user data, got {type(data)}")
@@ -489,7 +489,7 @@ async def _get_user_data(
489489
data[key] = new_value
490490
changed = True
491491
if changed:
492-
model.data = pickle.dumps(data)
492+
model.data = orjson.dumps(data)
493493
if save_default_data:
494494
await model.asave()
495495

src/reactpy_django/http/urls.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
urlpatterns = [
88
path(
99
"web_module/<path:file>",
10-
views.web_modules_file, # type: ignore[arg-type]
10+
views.web_modules_file,
1111
name="web_modules",
1212
),
1313
path(
1414
"iframe/<str:dotted_path>",
15-
views.view_to_iframe, # type: ignore[arg-type]
15+
views.view_to_iframe,
1616
name="view_to_iframe",
1717
),
1818
]

src/reactpy_django/models.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from django.db.models.signals import pre_delete
44
from django.dispatch import receiver
55

6+
from reactpy_django.utils import get_pk
7+
68

79
class ComponentSession(models.Model):
810
"""A model for storing component sessions."""
@@ -41,6 +43,6 @@ class UserDataModel(models.Model):
4143
@receiver(pre_delete, sender=get_user_model(), dispatch_uid="reactpy_delete_user_data")
4244
def delete_user_data(sender, instance, **kwargs):
4345
"""Delete ReactPy's `UserDataModel` when a Django `User` is deleted."""
44-
pk = getattr(instance, instance._meta.pk.name)
46+
pk = get_pk(instance)
4547

4648
UserDataModel.objects.filter(user_pk=pk).delete()

src/reactpy_django/router/resolvers.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]:
5252
pattern += f"{re.escape(path[last_match_end:])}$"
5353

5454
# Replace literal `*` with "match anything" regex pattern, if it's at the end of the path
55-
if pattern.endswith("\*$"):
55+
if pattern.endswith(r"\*$"):
5656
pattern = f"{pattern[:-3]}.*$"
5757

5858
return re.compile(pattern), converters

src/reactpy_django/templatetags/reactpy.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
from distutils.util import strtobool
43
from logging import getLogger
54
from uuid import uuid4
65

@@ -22,7 +21,7 @@
2221
OfflineComponentMissing,
2322
)
2423
from reactpy_django.types import ComponentParams
25-
from reactpy_django.utils import SyncLayout, validate_component_args
24+
from reactpy_django.utils import SyncLayout, strtobool, validate_component_args
2625

2726
try:
2827
RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/")

src/reactpy_django/utils.py

+16
Original file line numberDiff line numberDiff line change
@@ -366,3 +366,19 @@ def render(self):
366366
def get_pk(model):
367367
"""Returns the value of the primary key for a Django model."""
368368
return getattr(model, model._meta.pk.name)
369+
370+
371+
def strtobool(val):
372+
"""Convert a string representation of truth to true (1) or false (0).
373+
374+
True values are 'y', 'yes', 't', 'true', 'on', and '1'; false values
375+
are 'n', 'no', 'f', 'false', 'off', and '0'. Raises ValueError if
376+
'val' is anything else.
377+
"""
378+
val = val.lower()
379+
if val in ("y", "yes", "t", "true", "on", "1"):
380+
return 1
381+
elif val in ("n", "no", "f", "false", "off", "0"):
382+
return 0
383+
else:
384+
raise ValueError("invalid truth value %r" % (val,))

tests/test_app/tests/test_components.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import os
33
import socket
44
import sys
5-
from distutils.util import strtobool
65
from functools import partial
76
from time import sleep
87

@@ -14,6 +13,7 @@
1413
from django.test.utils import modify_settings
1514
from playwright.sync_api import TimeoutError, sync_playwright
1615
from reactpy_django.models import ComponentSession
16+
from reactpy_django.utils import strtobool
1717

1818
GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False")
1919
CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds.
@@ -628,7 +628,7 @@ def test_url_router(self):
628628
path.get_attribute("data-path"),
629629
)
630630
string = new_page.query_selector("#router-string")
631-
self.assertEquals("Path 12", string.text_content())
631+
self.assertEqual("Path 12", string.text_content())
632632

633633
finally:
634634
new_page.close()

tests/test_app/tests/test_regex.py

+6-6
Original file line numberDiff line numberDiff line change
@@ -99,25 +99,25 @@ def test_comment_regex(self):
9999
self.assertNotRegex(r'{% component "my.component" %}', COMMENT_REGEX)
100100

101101
# Components surrounded by comments
102-
self.assertEquals(
102+
self.assertEqual(
103103
COMMENT_REGEX.sub(
104104
"", r'{% component "my.component" %} <!-- comment -->'
105105
).strip(),
106106
'{% component "my.component" %}',
107107
)
108-
self.assertEquals(
108+
self.assertEqual(
109109
COMMENT_REGEX.sub(
110110
"", r'<!-- comment --> {% component "my.component" %}'
111111
).strip(),
112112
'{% component "my.component" %}',
113113
)
114-
self.assertEquals(
114+
self.assertEqual(
115115
COMMENT_REGEX.sub(
116116
"", r'<!-- comment --> {% component "my.component" %} <!-- comment -->'
117117
).strip(),
118118
'{% component "my.component" %}',
119119
)
120-
self.assertEquals(
120+
self.assertEqual(
121121
COMMENT_REGEX.sub(
122122
"",
123123
r"""<!-- comment
@@ -130,11 +130,11 @@ def test_comment_regex(self):
130130
)
131131

132132
# Components surrounded by comments
133-
self.assertEquals(
133+
self.assertEqual(
134134
COMMENT_REGEX.sub("", r'<!-- {% component "my.component" %} -->'),
135135
"",
136136
)
137-
self.assertEquals(
137+
self.assertEqual(
138138
COMMENT_REGEX.sub(
139139
"",
140140
r"""<!--

0 commit comments

Comments
 (0)