Skip to content
Merged
3 changes: 1 addition & 2 deletions .github/workflows/test-src.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ jobs:
python-version: ["3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v3
- uses: nanasess/setup-chromedriver@master
- uses: actions/setup-node@v3
with:
node-version: "14"
node-version: "14.x"
- name: Use Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ Using the following categories, list your changes in this order:

### Added

- Added system checks for a variety of common ReactPy misconfigurations.
- `REACTPY_BACKHAUL_THREAD` setting to enable/disable threading behavior.

### Changed

- If using `settings.py:REACTPY_DATABASE`, `reactpy_django.database.Router` must now be registered in `settings.py:DATABASE_ROUTERS`.
- By default, ReactPy will now use a backhaul thread to increase performance.
- Minimum Python version required is now `3.9`

Expand Down
2 changes: 1 addition & 1 deletion docs/python/configure-urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@


urlpatterns = [
path("reactpy/", include("reactpy_django.http.urls")),
...,
path("reactpy/", include("reactpy_django.http.urls")),
]
14 changes: 10 additions & 4 deletions docs/python/settings.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
# Cache used to store ReactPy web modules.
# ReactPy requires a multiprocessing-safe and thread-safe cache.
# We recommend redis or python-diskcache.
REACTPY_CACHE = "default"

# Database ReactPy uses to store session data.
# ReactPy requires a multiprocessing-safe and thread-safe database.
# DATABASE_ROUTERS is mandatory if REACTPY_DATABASE is configured.
REACTPY_DATABASE = "default"
DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]

# Maximum seconds between reconnection attempts before giving up.
# Use `0` to prevent component reconnection.
REACTPY_RECONNECT_MAX = 259200

# The URL for ReactPy to serve the component rendering websocket
# The URL for ReactPy to serve the component rendering websocket.
REACTPY_WEBSOCKET_URL = "reactpy/"

# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function, or `None`
# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function,
# or `None`.
REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor"

# Dotted path to the Django authentication backend to use for ReactPy components
# Dotted path to the Django authentication backend to use for ReactPy components.
# This is only needed if:
# 1. You are using `AuthMiddlewareStack` and...
# 2. You are using Django's `AUTHENTICATION_BACKENDS` setting and...
# 3. Your Django user model does not define a `backend` attribute
REACTPY_AUTH_BACKEND = None
REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend"

# Whether to enable rendering ReactPy via a dedicated backhaul thread
# This allows the webserver to process traffic while during ReactPy rendering
Expand Down
2 changes: 2 additions & 0 deletions docs/src/dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,6 @@ backends
backend
frontend
frontends
misconfiguration
misconfigurations
backhaul
2 changes: 1 addition & 1 deletion docs/src/get-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`.

1. Access the `User` that is currently logged in
2. Login or logout the current `User`
3. Access Django's `Sesssion` object
3. Access Django's `Session` object

In these situations will need to ensure you are using `AuthMiddlewareStack` and/or `SessionMiddlewareStack`.

Expand Down
3 changes: 2 additions & 1 deletion src/reactpy_django/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from reactpy_django import components, decorators, hooks, types, utils
from reactpy_django import checks, components, decorators, hooks, types, utils
from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH


Expand All @@ -10,4 +10,5 @@
"decorators",
"types",
"utils",
"checks",
]
153 changes: 153 additions & 0 deletions src/reactpy_django/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from django.core.checks import Error, Tags, Warning, register


@register(Tags.compatibility)
def reactpy_warnings(app_configs, **kwargs):
from django.conf import settings
from django.urls import reverse

warnings = []

# REACTPY_DATABASE is not an in-memory database.
if (
getattr(settings, "DATABASES", {})
.get(getattr(settings, "REACTPY_DATABASE", "default"), {})
.get("NAME", None)
== ":memory:"
):
warnings.append(
Warning(
"Using ReactPy with an in-memory database can cause unexpected "
"behaviors.",
hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a "
"multiprocessing and thread safe database.",
id="reactpy_django.W001",
)
)

# REACTPY_CACHE is not an in-memory cache.
if getattr(settings, "CACHES", {}).get(
getattr(settings, "REACTPY_CACHE", "default"), {}
).get("BACKEND", None) in {
"django.core.cache.backends.dummy.DummyCache",
"django.core.cache.backends.locmem.LocMemCache",
}:
warnings.append(
Warning(
"Using ReactPy with an in-memory cache can cause unexpected "
"behaviors.",
hint="Configure settings.py:CACHES[REACTPY_CACHE], to use a "
"multiprocessing and thread safe cache.",
id="reactpy_django.W002",
)
)

# ReactPy URLs exist
try:
reverse("reactpy:web_modules", kwargs={"file": "example"})
reverse("reactpy:view_to_component", kwargs={"view_path": "example"})
except Exception:
warnings.append(
Warning(
"ReactPy URLs have not been registered.",
hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """
"to your application's urlpatterns.",
id="reactpy_django.W003",
)
)

return warnings


@register(Tags.compatibility)
def reactpy_errors(app_configs, **kwargs):
from django.conf import settings

errors = []

# Make sure ASGI is enabled
if not getattr(settings, "ASGI_APPLICATION", None):
errors.append(
Error(
"ASGI_APPLICATION is not defined."
" ReactPy requires ASGI to be enabled.",
hint="Add ASGI_APPLICATION to settings.py.",
id="reactpy_django.E001",
)
)

# DATABASE_ROUTERS is properly configured when REACTPY_DATABASE is defined
if getattr(
settings, "REACTPY_DATABASE", None
) and "reactpy_django.database.Router" not in getattr(
settings, "DATABASE_ROUTERS", []
):
errors.append(
Error(
"ReactPy database has been changed but the database router is "
"not configured.",
hint="Set settings.py:DATABASE_ROUTERS to "
"['reactpy_django.database.Router', ...]",
id="reactpy_django.E002",
)
)

# All settings in reactpy_django.conf are the correct data type
if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_WEBSOCKET_URL.",
hint="REACTPY_WEBSOCKET_URL should be a string.",
obj=settings.REACTPY_WEBSOCKET_URL,
id="reactpy_django.E003",
)
)
if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int):
errors.append(
Error(
"Invalid type for REACTPY_RECONNECT_MAX.",
hint="REACTPY_RECONNECT_MAX should be an integer.",
obj=settings.REACTPY_RECONNECT_MAX,
id="reactpy_django.E004",
)
)
if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_CACHE.",
hint="REACTPY_CACHE should be a string.",
obj=settings.REACTPY_CACHE,
id="reactpy_django.E005",
)
)
if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_DATABASE.",
hint="REACTPY_DATABASE should be a string.",
obj=settings.REACTPY_DATABASE,
id="reactpy_django.E006",
)
)
if not isinstance(
getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), str
):
errors.append(
Error(
"Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.",
hint="REACTPY_DEFAULT_QUERY_POSTPROCESSOR should be a string.",
obj=settings.REACTPY_DEFAULT_QUERY_POSTPROCESSOR,
id="reactpy_django.E007",
)
)
if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str):
errors.append(
Error(
"Invalid type for REACTPY_AUTH_BACKEND.",
hint="REACTPY_AUTH_BACKEND should be a string.",
obj=settings.REACTPY_AUTH_BACKEND,
id="reactpy_django.E008",
)
)

return errors
16 changes: 9 additions & 7 deletions src/reactpy_django/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,10 @@
)
from reactpy_django.utils import import_dotted_path


_logger = logging.getLogger(__name__)


# Not user configurable settings
# Non-configurable values
REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG"))
REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {}
REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {}
Expand All @@ -47,13 +46,16 @@
"REACTPY_DATABASE",
DEFAULT_DB_ALIAS,
)
_default_query_postprocessor = getattr(
settings,
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
None,
)
REACTPY_DEFAULT_QUERY_POSTPROCESSOR: AsyncPostprocessor | SyncPostprocessor | None = (
import_dotted_path(
getattr(
settings,
"REACTPY_DEFAULT_QUERY_POSTPROCESSOR",
"reactpy_django.utils.django_query_postprocessor",
)
_default_query_postprocessor
if isinstance(_default_query_postprocessor, str)
else "reactpy_django.utils.django_query_postprocessor",
)
)
REACTPY_AUTH_BACKEND: str | None = getattr(
Expand Down
29 changes: 29 additions & 0 deletions src/reactpy_django/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from reactpy_django.config import REACTPY_DATABASE


class Router:
"""
A router to control all database operations on models in the
auth and contenttypes applications.
"""

route_app_labels = {"reactpy_django"}

def db_for_read(self, model, **hints):
"""Attempts to read go to REACTPY_DATABASE."""
if model._meta.app_label in self.route_app_labels:
return REACTPY_DATABASE

def db_for_write(self, model, **hints):
"""Attempts to write go to REACTPY_DATABASE."""
if model._meta.app_label in self.route_app_labels:
return REACTPY_DATABASE

def allow_relation(self, obj1, obj2, **hints):
"""Only relations within the same database are allowed (default behavior)."""
return None

def allow_migrate(self, db, app_label, model_name=None, **hints):
"""Make sure ReactPy models only appear in REACTPY_DATABASE."""
if app_label in self.route_app_labels:
return db == REACTPY_DATABASE
3 changes: 1 addition & 2 deletions src/reactpy_django/templatetags/reactpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from reactpy_django import models
from reactpy_django.config import (
REACTPY_DATABASE,
REACTPY_DEBUG_MODE,
REACTPY_RECONNECT_MAX,
REACTPY_WEBSOCKET_URL,
Expand Down Expand Up @@ -73,7 +72,7 @@ def component(dotted_path: str, *args, **kwargs):
params = ComponentParamData(args, kwargs)
model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params))
model.full_clean()
model.save(using=REACTPY_DATABASE)
model.save()

except Exception as e:
if isinstance(e, ComponentParamError):
Expand Down
13 changes: 3 additions & 10 deletions src/reactpy_django/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,12 +332,7 @@ def create_cache_key(*args):
def db_cleanup(immediate: bool = False):
"""Deletes expired component sessions from the database.
This function may be expanded in the future to include additional cleanup tasks."""
from .config import (
REACTPY_CACHE,
REACTPY_DATABASE,
REACTPY_DEBUG_MODE,
REACTPY_RECONNECT_MAX,
)
from .config import REACTPY_CACHE, REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX
from .models import ComponentSession

clean_started_at = datetime.now()
Expand All @@ -351,7 +346,7 @@ def db_cleanup(immediate: bool = False):
expires_by: datetime = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX)

# Component params exist in the DB, but we don't know when they were last cleaned
if not cleaned_at_str and ComponentSession.objects.using(REACTPY_DATABASE).all():
if not cleaned_at_str and ComponentSession.objects.all():
_logger.warning(
"ReactPy has detected component sessions in the database, "
"but no timestamp was found in cache. This may indicate that "
Expand All @@ -361,9 +356,7 @@ def db_cleanup(immediate: bool = False):
# Delete expired component parameters
# Use timestamps in cache (`cleaned_at_str`) as a no-dependency rate limiter
if immediate or not cleaned_at_str or timezone.now() >= clean_needed_by:
ComponentSession.objects.using(REACTPY_DATABASE).filter(
last_accessed__lte=expires_by
).delete()
ComponentSession.objects.filter(last_accessed__lte=expires_by).delete()
caches[REACTPY_CACHE].set(cache_key, now_str, timeout=None)

# Check if cleaning took abnormally long
Expand Down
5 changes: 1 addition & 4 deletions src/reactpy_django/websocket/consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ async def run_dispatcher(self):
"""Runs the main loop that performs component rendering tasks."""
from reactpy_django import models
from reactpy_django.config import (
REACTPY_DATABASE,
REACTPY_RECONNECT_MAX,
REACTPY_REGISTERED_COMPONENTS,
)
Expand Down Expand Up @@ -149,9 +148,7 @@ async def run_dispatcher(self):
await database_sync_to_async(db_cleanup, thread_sensitive=False)()

# Get the queries from a DB
params_query = await models.ComponentSession.objects.using(
REACTPY_DATABASE
).aget(
params_query = await models.ComponentSession.objects.aget(
uuid=uuid,
last_accessed__gt=now
- timedelta(seconds=REACTPY_RECONNECT_MAX),
Expand Down
Loading