From e7def604d5863d0a1fa1b7b742334ab347d136ad Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 03:47:08 -0700 Subject: [PATCH 01/24] host URL configuration --- CHANGELOG.md | 9 ++- docs/python/configure-asgi-middleware.py | 4 +- docs/python/configure-asgi.py | 4 +- docs/python/settings.py | 9 ++- docs/src/get-started/installation.md | 12 +++- src/js/src/index.js | 54 +++++++++++---- src/reactpy_django/__init__.py | 6 +- src/reactpy_django/checks.py | 66 +++++++++++++------ src/reactpy_django/config.py | 11 +++- .../templates/reactpy/component.html | 10 +-- src/reactpy_django/templatetags/reactpy.py | 31 ++++++--- src/reactpy_django/utils.py | 10 +-- src/reactpy_django/websocket/paths.py | 12 ++-- tests/test_app/asgi.py | 7 +- 14 files changed, 170 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f06d0a..9f794f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,14 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Added + +- The `component` template tag now accepts `ws_host` and `http_host` arguments to override the default hostnames. + +### Deprecated + +- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. Replace with `REACTPY_WEBSOCKET_ROUTE`. +- `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. Replace with `REACTPY_URL_PREFIX`. ## [3.3.2] - 2023-08-13 diff --git a/docs/python/configure-asgi-middleware.py b/docs/python/configure-asgi-middleware.py index 3e8e6523..e817ee48 100644 --- a/docs/python/configure-asgi-middleware.py +++ b/docs/python/configure-asgi-middleware.py @@ -1,6 +1,6 @@ # Broken load order, only used for linting from channels.routing import ProtocolTypeRouter, URLRouter -from reactpy_django import REACTPY_WEBSOCKET_PATH +from reactpy_django import REACTPY_WEBSOCKET_ROUTE django_asgi_app = "" @@ -15,7 +15,7 @@ "websocket": SessionMiddlewareStack( AuthMiddlewareStack( URLRouter( - [REACTPY_WEBSOCKET_PATH], + [REACTPY_WEBSOCKET_ROUTE], ) ) ), diff --git a/docs/python/configure-asgi.py b/docs/python/configure-asgi.py index b574c684..8081d747 100644 --- a/docs/python/configure-asgi.py +++ b/docs/python/configure-asgi.py @@ -10,11 +10,11 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 -from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402 +from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 application = ProtocolTypeRouter( { "http": django_asgi_app, - "websocket": URLRouter([REACTPY_WEBSOCKET_PATH]), + "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), } ) diff --git a/docs/python/settings.py b/docs/python/settings.py index c9a26f5a..ea991de0 100644 --- a/docs/python/settings.py +++ b/docs/python/settings.py @@ -5,7 +5,7 @@ # 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. +# Configuring Django's DATABASE_ROUTERS setting is mandatory if using REACTPY_DATABASE. REACTPY_DATABASE = "default" DATABASE_ROUTERS = ["reactpy_django.database.Router", ...] @@ -13,11 +13,10 @@ # Use `0` to prevent component reconnection. REACTPY_RECONNECT_MAX = 259200 -# The URL for ReactPy to serve the component rendering websocket. -REACTPY_WEBSOCKET_URL = "reactpy/" +# The prefix to be used for all ReactPy API URLs. +REACTPY_URL_PREFIX = "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. REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor" # Dotted path to the Django authentication backend to use for ReactPy components. diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index d9763992..130dcb69 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -44,7 +44,7 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt ??? note "Configure ReactPy settings (Optional)" - Below are a handful of values you can change within `settings.py` to modify the behavior of ReactPy. + Below are the default values for all configurable ReactPy settings within your `settings.py`. ```python linenums="0" {% include "../../python/settings.py" %} @@ -62,7 +62,7 @@ Add ReactPy HTTP paths to your `urlpatterns`. ## Step 4: Configure [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) -Register ReactPy's Websocket using `REACTPY_WEBSOCKET_PATH`. +Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`. === "asgi.py" @@ -95,3 +95,11 @@ Run Django's database migrations to initialize ReactPy-Django's database table. ```bash linenums="0" python manage.py migrate ``` + +## Step 6: Check your configuration + +Run Django's check command to verify if ReactPy was set up correctly. + +```bash linenums="0" +python manage.py check +``` diff --git a/src/js/src/index.js b/src/js/src/index.js index 64684d7a..0b9790d2 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -1,34 +1,60 @@ import { mountLayoutWithWebSocket } from "@reactpy/client"; // Set up a websocket at the base endpoint -const LOCATION = window.location; +let HTTP_PROTOCOL = window.location.protocol; let WS_PROTOCOL = ""; -if (LOCATION.protocol == "https:") { - WS_PROTOCOL = "wss://"; +if (HTTP_PROTOCOL == "https:") { + WS_PROTOCOL = "wss:"; } else { - WS_PROTOCOL = "ws://"; + WS_PROTOCOL = "ws:"; } -const WS_ENDPOINT_URL = WS_PROTOCOL + LOCATION.host + "/"; export function mountViewToElement( mountElement, - reactpyWebsocketUrl, - reactpyWebModulesUrl, - maxReconnectTimeout, - componentPath + reactpyWsHost, + reactpyHttpHost, + reactpyUrlPrefix, + reactpyReconnectMax, + reactpyComponentPath, + reactpyResolvedWebModulesPath ) { - const WS_URL = WS_ENDPOINT_URL + reactpyWebsocketUrl + componentPath; - const WEB_MODULE_URL = LOCATION.origin + "/" + reactpyWebModulesUrl; + // Determine the Websocket route + let wsOrigin; + if (reactpyWsHost) { + wsOrigin = `${WS_PROTOCOL}//${reactpyWsHost}`; + } else { + wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; + } + const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`; + + // Determine the HTTP route + let httpOrigin; + let webModulesPath; + if (reactpyHttpHost) { + httpOrigin = `${HTTP_PROTOCOL}//${reactpyHttpHost}`; + webModulesPath = `${reactpyUrlPrefix}/web_module`; + } else { + httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; + if (reactpyResolvedWebModulesPath) { + webModulesPath = reactpyResolvedWebModulesPath; + } else { + webModulesPath = `${reactpyUrlPrefix}/web_module`; + } + } + const webModuleUrl = `${httpOrigin}/${webModulesPath}`; + + // Function that loads the JavaScript web module, if needed const loadImportSource = (source, sourceType) => { return import( - sourceType == "NAME" ? `${WEB_MODULE_URL}${source}` : source + sourceType == "NAME" ? `${webModuleUrl}/${source}` : source ); }; + // Start rendering the component mountLayoutWithWebSocket( mountElement, - WS_URL, + websocketUrl, loadImportSource, - maxReconnectTimeout + reactpyReconnectMax ); } diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index fc3940e8..cfbbae80 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -3,11 +3,15 @@ import nest_asyncio from reactpy_django import checks, components, decorators, hooks, types, utils -from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_PATH +from reactpy_django.websocket.paths import ( + REACTPY_WEBSOCKET_PATH, + REACTPY_WEBSOCKET_ROUTE, +) __version__ = "3.3.2" __all__ = [ "REACTPY_WEBSOCKET_PATH", + "REACTPY_WEBSOCKET_ROUTE", "hooks", "components", "decorators", diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index fc5a89a6..6979e2ef 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,8 +1,10 @@ +import contextlib import sys from django.contrib.staticfiles.finders import find from django.core.checks import Error, Tags, Warning, register from django.template import loader +from django.urls import NoReverseMatch @register(Tags.compatibility) @@ -10,6 +12,7 @@ def reactpy_warnings(app_configs, **kwargs): from django.conf import settings from django.urls import reverse + from reactpy_django import config from reactpy_django.config import REACTPY_FAILED_COMPONENTS warnings = [] @@ -40,7 +43,8 @@ def reactpy_warnings(app_configs, **kwargs): Warning( "ReactPy URLs have not been registered.", hint="""Add 'path("reactpy/", include("reactpy_django.http.urls"))' """ - "to your application's urlpatterns.", + "to your application's urlpatterns. If this application does not need " + "to render ReactPy components, you add this warning to SILENCED_SYSTEM_CHECKS.", id="reactpy_django.W002", ) ) @@ -96,28 +100,50 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Check if REACTPY_WEBSOCKET_URL doesn't end with a slash - REACTPY_WEBSOCKET_URL = getattr(settings, "REACTPY_WEBSOCKET_URL", "reactpy/") - if isinstance(REACTPY_WEBSOCKET_URL, str): - if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL.endswith("/"): - warnings.append( - Warning( - "REACTPY_WEBSOCKET_URL did not end with a forward slash.", - hint="Change your URL to be written in the following format: 'example_url/'", - id="reactpy_django.W007", - ) + # DELETED W007: Check if REACTPY_WEBSOCKET_URL doesn't end with a slash + # DELETED W008: Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character + + # Removed Settings + if getattr(settings, "REACTPY_WEBSOCKET_URL", None): + warnings.append( + Warning( + "REACTPY_WEBSOCKET_URL has been removed.", + hint="Use REACTPY_URL_PREFIX instead.", + id="reactpy_django.W009", ) + ) - # Check if REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character - if not REACTPY_WEBSOCKET_URL or not REACTPY_WEBSOCKET_URL[0].isalnum(): + # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs + with contextlib.suppress(NoReverseMatch): + full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip( + "/" + ) + reactpy_http_prefix = f'{full_path[: full_path.find("web_module/")].strip("/")}' + if reactpy_http_prefix != config.REACTPY_URL_PREFIX: warnings.append( Warning( - "REACTPY_WEBSOCKET_URL did not start with an alphanumeric character.", - hint="Change your URL to be written in the following format: 'example_url/'", - id="reactpy_django.W008", + "HTTP paths are not prefixed with REACTPY_URL_PREFIX. " + "Some ReactPy features may not work as expected.", + hint="Use one of the following solutions.\n" + "\t1) Utilize REACTPY_URL_PREFIX within your urls.py:\n" + f'\t path("{config.REACTPY_URL_PREFIX}/", include("reactpy_django.http.urls"))\n' + "\t2) Modify settings.py:REACTPY_URL_PREFIX to match your existing HTTP path:\n" + f'\t REACTPY_URL_PREFIX = "{reactpy_http_prefix}/"\n' + "\t3) If you not rendering ReactPy components within this Python application, then " + "remove HTTP and/or websocket routing.\n", + id="reactpy_django.W010", ) ) + if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"): + warnings.append( + Warning( + "REACTPY_URL_PREFIX should not be empty!", + hint="Change your REACTPY_URL_PREFIX to be written in the following format: '/example_url/'", + id="reactpy_django.W011", + ) + ) + return warnings @@ -154,12 +180,12 @@ def reactpy_errors(app_configs, **kwargs): ) # All settings in reactpy_django.conf are the correct data type - if not isinstance(getattr(settings, "REACTPY_WEBSOCKET_URL", ""), str): + if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str): errors.append( Error( - "Invalid type for REACTPY_WEBSOCKET_URL.", - hint="REACTPY_WEBSOCKET_URL should be a string.", - obj=settings.REACTPY_WEBSOCKET_URL, + "Invalid type for REACTPY_URL_PREFIX.", + hint="REACTPY_URL_PREFIX should be a string.", + obj=settings.REACTPY_URL_PREFIX, id="reactpy_django.E003", ) ) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index c0ee14be..c8f2a781 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -20,13 +20,20 @@ REACTPY_VIEW_COMPONENT_IFRAMES: dict[str, ViewComponentIframe] = {} -# Configurable through Django settings.py +# Remove in a future release REACTPY_WEBSOCKET_URL = getattr( settings, "REACTPY_WEBSOCKET_URL", "reactpy/", ) -REACTPY_RECONNECT_MAX = getattr( + +# Configurable through Django settings.py +REACTPY_URL_PREFIX: str = getattr( + settings, + "REACTPY_URL_PREFIX", + REACTPY_WEBSOCKET_URL, +).strip("/") +REACTPY_RECONNECT_MAX: int = getattr( settings, "REACTPY_RECONNECT_MAX", 259200, # Default to 3 days diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 7dae08eb..de1f9922 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -4,16 +4,18 @@ {% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}" {% endif %} {% else %} -
+
{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index d8e94e2b..b083073d 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -3,13 +3,13 @@ import dill as pickle from django import template -from django.urls import reverse +from django.urls import NoReverseMatch, reverse from reactpy_django import models from reactpy_django.config import ( REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX, - REACTPY_WEBSOCKET_URL, + REACTPY_URL_PREFIX, ) from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData @@ -19,13 +19,18 @@ func_has_args, ) -REACTPY_WEB_MODULES_URL = reverse("reactpy:web_modules", args=["x"])[:-1][1:] +try: + RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") +except NoReverseMatch: + RESOLVED_WEB_MODULES_PATH = "" register = template.Library() _logger = getLogger(__name__) @register.inclusion_tag("reactpy/component.html") -def component(dotted_path: str, *args, **kwargs): +def component( + dotted_path: str, *args, ws_host: str = "", http_host: str = "", **kwargs +): """This tag is used to embed an existing ReactPy component into your HTML template. Args: @@ -33,6 +38,14 @@ def component(dotted_path: str, *args, **kwargs): *args: The positional arguments to provide to the component. Keyword Args: + ws_host: The host domain to use for the ReactPy websocket connection. If set to None, \ + the host will be fetched via JavaScript. \ + Note: You typically will not need to register the ReactPy websocket path on any \ + application(s) that do not perform component rendering. + http_host: The host domain to use for the ReactPy HTTP connection. If set to None, \ + the host will be fetched via JavaScript. \ + Note: You typically will not need to register ReactPy HTTP paths on any \ + application(s) that do not perform component rendering. **kwargs: The keyword arguments to provide to the component. Example :: @@ -85,12 +98,14 @@ def component(dotted_path: str, *args, **kwargs): # Return the template rendering context return { - "class": class_, - "reactpy_websocket_url": REACTPY_WEBSOCKET_URL, - "reactpy_web_modules_url": REACTPY_WEB_MODULES_URL, + "reactpy_class": class_, + "reactpy_uuid": uuid, + "reactpy_ws_host": ws_host.strip("/"), + "reactpy_http_host": http_host.strip("/"), + "reactpy_url_prefix": REACTPY_URL_PREFIX, "reactpy_reconnect_max": REACTPY_RECONNECT_MAX, - "reactpy_mount_uuid": uuid, "reactpy_component_path": f"{dotted_path}/{uuid}/", + "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, } diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index f4e0f8e6..57e9e2a9 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -101,7 +101,6 @@ def _register_component(dotted_path: str) -> Callable: raise ComponentDoesNotExistError( f"Error while fetching '{dotted_path}'. {(str(e).capitalize())}." ) from e - _logger.debug("ReactPy has registered component %s", dotted_path) return REACTPY_REGISTERED_COMPONENTS[dotted_path] @@ -204,16 +203,19 @@ def get_components(self, templates: set[str]) -> set[str]: def register_components(self, components: set[str]) -> None: """Registers all ReactPy components in an iterable.""" + if components: + _logger.debug("ReactPy root components:") for component in components: try: - _logger.info("ReactPy preloader has detected component %s", component) + _logger.debug("\t+ %s", component) _register_component(component) except Exception: _logger.exception( "\033[91m" - "ReactPy failed to register component '%s'! " + "ReactPy failed to register component '%s'!\n" "This component path may not be valid, " - "or an exception may have occurred while importing." + "or an exception may have occurred while importing.\n" + "See the traceback below for more information." "\033[0m", component, ) diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index afd410c3..039ee5ba 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -1,15 +1,17 @@ from django.urls import path -from reactpy_django.config import REACTPY_WEBSOCKET_URL +from reactpy_django.config import REACTPY_URL_PREFIX from .consumer import ReactpyAsyncWebsocketConsumer -REACTPY_WEBSOCKET_PATH = path( - f"{REACTPY_WEBSOCKET_URL}//", +REACTPY_WEBSOCKET_ROUTE = path( + f"{REACTPY_URL_PREFIX}///", ReactpyAsyncWebsocketConsumer.as_asgi(), ) - """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. -Required in order for ReactPy to know the websocket path. +Required since the `reverse()` function does not exist for Django Channels, but we need +to know the websocket path. """ + +REACTPY_WEBSOCKET_PATH = REACTPY_WEBSOCKET_ROUTE diff --git a/tests/test_app/asgi.py b/tests/test_app/asgi.py index b49d12ef..e372e42f 100644 --- a/tests/test_app/asgi.py +++ b/tests/test_app/asgi.py @@ -11,7 +11,6 @@ from django.core.asgi import get_asgi_application - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_app.settings") # Fetch ASGI application before importing dependencies that require ORM models. @@ -20,15 +19,13 @@ from channels.auth import AuthMiddlewareStack # noqa: E402 from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 from channels.sessions import SessionMiddlewareStack # noqa: E402 - -from reactpy_django import REACTPY_WEBSOCKET_PATH # noqa: E402 - +from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 application = ProtocolTypeRouter( { "http": http_asgi_app, "websocket": SessionMiddlewareStack( - AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_PATH])) + AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])) ), } ) From 9b2e08ef3baff16c50d8e25d9c6b8d215f44bf44 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 04:33:24 -0700 Subject: [PATCH 02/24] add all venvs to gitignore --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index a59a51e4..07d4c0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -89,8 +89,8 @@ celerybeat-schedule.* *.sage.py # Environments -.env -.venv +.env*/ +.venv*/ env/ venv/ ENV/ From af9b2d8b9d63fd0373d6539c1e92e7b02b856af8 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 04:41:58 -0700 Subject: [PATCH 03/24] more broad daphne warning --- src/reactpy_django/checks.py | 17 +++++++++++------ tests/test_app/settings.py | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 6979e2ef..1e7fe7f0 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -49,17 +49,22 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Warn if REACTPY_BACKHAUL_THREAD is set to True on Linux with Daphne + # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne if ( sys.argv - and sys.argv[0].endswith("daphne") + and ( + sys.argv[0].endswith("daphne") + or ( + "runserver" in sys.argv + and "daphne" in getattr(settings, "INSTALLED_APPS", []) + ) + ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False) - and sys.platform == "linux" ): warnings.append( Warning( - "REACTPY_BACKHAUL_THREAD is enabled but you running with Daphne on Linux. " - "This configuration is known to be unstable.", + "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " + "and you running with Daphne. ", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.", id="reactpy_django.W003", ) @@ -238,7 +243,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check for dependencies - if "channels" not in settings.INSTALLED_APPS: + if "channels" not in getattr(settings, "INSTALLED_APPS", []): errors.append( Error( "Django Channels is not installed.", diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 10cd8f6b..8e7fc08f 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -180,4 +180,4 @@ # ReactPy Django Settings REACTPY_AUTH_BACKEND = "django.contrib.auth.backends.ModelBackend" -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv +REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv From 017be37377165ca67b41b6554e4b26008820c168 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 19:42:55 -0700 Subject: [PATCH 04/24] add changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f794f68..f2adb559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,11 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts `ws_host` and `http_host` arguments to override the default hostnames. +- The `component` template tag now accepts `ws_host` and `http_host` arguments to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by a completely separate Django application(s). + +### Changed + +- ReactPy will now provide a warning if HTTP URLs are not using the same URL prefix as websockets. ### Deprecated From fa547fe20ba49a7b965175108378be19a1960b7f Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 19:43:13 -0700 Subject: [PATCH 05/24] update checks --- src/reactpy_django/checks.py | 37 ++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 1e7fe7f0..4ee947e2 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -51,16 +51,12 @@ def reactpy_warnings(app_configs, **kwargs): # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne if ( - sys.argv - and ( - sys.argv[0].endswith("daphne") - or ( - "runserver" in sys.argv - and "daphne" in getattr(settings, "INSTALLED_APPS", []) - ) + sys.argv[0].endswith("daphne") + or ( + "runserver" in sys.argv + and "daphne" in getattr(settings, "INSTALLED_APPS", []) ) - and getattr(settings, "REACTPY_BACKHAUL_THREAD", False) - ): + ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False): warnings.append( Warning( "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " @@ -140,6 +136,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_URL_PREFIX is empty if not getattr(settings, "REACTPY_URL_PREFIX", "reactpy/"): warnings.append( Warning( @@ -149,6 +146,18 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if `daphne` is not in installed apps when using `runserver` + if "runserver" in sys.argv and "daphne" not in getattr( + settings, "INSTALLED_APPS", [] + ): + warnings.append( + Warning( + "You have not configured runserver to use ASGI.", + hint="Add daphne to settings.py:INSTALLED_APPS.", + id="reactpy_django.W012", + ) + ) + return warnings @@ -242,14 +251,6 @@ def reactpy_errors(app_configs, **kwargs): ) ) - # Check for dependencies - if "channels" not in getattr(settings, "INSTALLED_APPS", []): - errors.append( - Error( - "Django Channels is not installed.", - hint="Add 'channels' to settings.py:INSTALLED_APPS.", - id="reactpy_django.E009", - ) - ) + # DELETED E009: Check if `channels` is in INSTALLED_APPS return errors From 772c3ac2296abcfc932f2886eb1e1ee06cc6ccbe Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 20:12:21 -0700 Subject: [PATCH 06/24] change settings docs --- docs/python/settings.py | 31 ------------------------- docs/src/features/settings.md | 18 ++++++++++---- docs/src/get-started/installation.md | 6 +---- tests/test_app/settings.py | 1 - tests/test_app/tests/test_components.py | 2 -- 5 files changed, 14 insertions(+), 44 deletions(-) delete mode 100644 docs/python/settings.py diff --git a/docs/python/settings.py b/docs/python/settings.py deleted file mode 100644 index ea991de0..00000000 --- a/docs/python/settings.py +++ /dev/null @@ -1,31 +0,0 @@ -# Cache used to store ReactPy web modules. -# ReactPy benefits from a fast, well indexed 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. -# Configuring Django's DATABASE_ROUTERS setting is mandatory if using REACTPY_DATABASE. -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 prefix to be used for all ReactPy API URLs. -REACTPY_URL_PREFIX = "reactpy/" - -# Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. -REACTPY_DEFAULT_QUERY_POSTPROCESSOR = "reactpy_django.utils.django_query_postprocessor" - -# 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 = "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. -REACTPY_BACKHAUL_THREAD = False diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 3abd6575..26ca78b7 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -6,13 +6,21 @@ ## Primary Configuration -These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. + -=== "settings.py" +These are ReactPy-Django's default settings values. You can modify these values in your **Django project's** `settings.py` to change the behavior of ReactPy. - ```python - {% include "../../python/settings.py" %} - ``` +| Setting | Default Value | Example Value(s) | Description | +| --- | --- | --- | --- | +| `REACTPY_CACHE` | `#!python "default"` | `#!python "my-reactpy-cache"` | Cache used to store ReactPy web modules. ReactPy benefits from a fast, well indexed cache.
We recommend installing [`redis`](https://redis.io/) or [`python-diskcache`](https://grantjenks.com/docs/diskcache/tutorial.html#djangocache). | +| `REACTPY_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database ReactPy uses to store session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to also configure `DATABASE_ROUTERS` like such:
`#!python DATABASE_ROUTERS = ["reactpy_django.database.Router", ...]` | +| `REACTPY_RECONNECT_MAX` | `#!python 259200` | `#!python 96000`, `#!python 60`, `#!python 0` | Maximum seconds between reconnection attempts before giving up.
Use `#!python 0` to prevent reconnection. | +| `REACTPY_URL_PREFIX` | `#!python "reactpy/"` | `#!python "rp/"`, `#!python "render/reactpy/"` | The prefix to be used for all ReactPy websocket and HTTP URLs. | +| `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | +| `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | 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_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | + + ??? question "Do I need to modify my settings?" diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index 130dcb69..bd368ec1 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -44,11 +44,7 @@ In your settings you will need to add `reactpy_django` to [`INSTALLED_APPS`](htt ??? note "Configure ReactPy settings (Optional)" - Below are the default values for all configurable ReactPy settings within your `settings.py`. - - ```python linenums="0" - {% include "../../python/settings.py" %} - ``` + {% include "../features/settings.md" start="" end="" %} ## Step 3: Configure [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index 8e7fc08f..be9a4186 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -40,7 +40,6 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - "channels", # Websocket library "reactpy_django", # Django compatiblity layer for ReactPy "test_app", # This test application ] diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 0801e28e..fc71091a 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -10,10 +10,8 @@ from django.db import connections from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright - from reactpy_django.models import ComponentSession - CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. From 11e42d625f34e923ab9ce260e07853775fdf1f24 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 22:31:28 -0700 Subject: [PATCH 07/24] better func_has_args --- src/reactpy_django/utils.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 57e9e2a9..40135a2d 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -299,11 +299,8 @@ def django_query_postprocessor( def func_has_args(func: Callable) -> bool: - """Checks if a function has any args or kwarg.""" - signature = inspect.signature(func) - - # Check if the function has any args/kwargs - return str(signature) != "()" + """Checks if a function has any args or kwargs.""" + return bool(inspect.signature(func).parameters) def check_component_args(func: Callable, *args, **kwargs): From 078a100af42126b9ee03e8128457552f9eb68d95 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 22:46:47 -0700 Subject: [PATCH 08/24] merged everything into one `host_domain` arg --- CHANGELOG.md | 2 +- docs/src/features/template-tag.md | 2 + src/js/src/index.js | 11 +- .../templates/reactpy/component.html | 3 +- src/reactpy_django/templatetags/reactpy.py | 105 ++++++++++-------- src/reactpy_django/utils.py | 8 +- 6 files changed, 68 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2adb559..edcc2790 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts `ws_host` and `http_host` arguments to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by a completely separate Django application(s). +- The `component` template tag now accepts a `host_domain` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application(s). ### Changed diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 9d4ca18c..4c1f47be 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -20,6 +20,8 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | + | `host_domain` | `str | None` | The host domain to use for the ReactPy connections. If set to `None`, the host will be automatically configured.

_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | + | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | **Returns** diff --git a/src/js/src/index.js b/src/js/src/index.js index 0b9790d2..326e8da1 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -11,8 +11,7 @@ if (HTTP_PROTOCOL == "https:") { export function mountViewToElement( mountElement, - reactpyWsHost, - reactpyHttpHost, + reactpyHostDomain, reactpyUrlPrefix, reactpyReconnectMax, reactpyComponentPath, @@ -20,8 +19,8 @@ export function mountViewToElement( ) { // Determine the Websocket route let wsOrigin; - if (reactpyWsHost) { - wsOrigin = `${WS_PROTOCOL}//${reactpyWsHost}`; + if (reactpyHostDomain) { + wsOrigin = `${WS_PROTOCOL}//${reactpyHostDomain}`; } else { wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; } @@ -30,8 +29,8 @@ export function mountViewToElement( // Determine the HTTP route let httpOrigin; let webModulesPath; - if (reactpyHttpHost) { - httpOrigin = `${HTTP_PROTOCOL}//${reactpyHttpHost}`; + if (reactpyHostDomain) { + httpOrigin = `${HTTP_PROTOCOL}//${reactpyHostDomain}`; webModulesPath = `${reactpyUrlPrefix}/web_module`; } else { httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index de1f9922..9015f359 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -10,8 +10,7 @@ const mountElement = document.getElementById("{{ reactpy_uuid }}"); mountViewToElement( mountElement, - "{{ reactpy_ws_host }}", - "{{ reactpy_http_host }}", + "{{ reactpy_host_domain }}", "{{ reactpy_url_prefix }}", "{{ reactpy_reconnect_max }}", "{{ reactpy_component_path }}", diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index b083073d..64f4f7e4 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -3,6 +3,7 @@ import dill as pickle from django import template +from django.http import HttpRequest from django.urls import NoReverseMatch, reverse from reactpy_django import models @@ -14,9 +15,9 @@ from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError from reactpy_django.types import ComponentParamData from reactpy_django.utils import ( - _register_component, check_component_args, func_has_args, + register_component, ) try: @@ -27,9 +28,13 @@ _logger = getLogger(__name__) -@register.inclusion_tag("reactpy/component.html") +@register.inclusion_tag("reactpy/component.html", takes_context=True) def component( - dotted_path: str, *args, ws_host: str = "", http_host: str = "", **kwargs + context: template.RequestContext, + dotted_path: str, + *args, + host_domain: str | None = None, + **kwargs, ): """This tag is used to embed an existing ReactPy component into your HTML template. @@ -38,14 +43,10 @@ def component( *args: The positional arguments to provide to the component. Keyword Args: - ws_host: The host domain to use for the ReactPy websocket connection. If set to None, \ - the host will be fetched via JavaScript. \ - Note: You typically will not need to register the ReactPy websocket path on any \ - application(s) that do not perform component rendering. - http_host: The host domain to use for the ReactPy HTTP connection. If set to None, \ - the host will be fetched via JavaScript. \ - Note: You typically will not need to register ReactPy HTTP paths on any \ - application(s) that do not perform component rendering. + host_domain: The host domain to use for the ReactPy connections. If set to `None`, \ + the host will be automatically configured. \ + Note: You typically will not need to register the ReactPy HTTP and/or websocket \ + paths on any application(s) that do not perform component rendering. **kwargs: The keyword arguments to provide to the component. Example :: @@ -59,49 +60,55 @@ def component( """ - # Register the component if needed - try: - component = _register_component(dotted_path) - uuid = uuid4().hex - class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is effectively useless for the root node - - except Exception as e: - if isinstance(e, ComponentDoesNotExistError): - _logger.error(str(e)) - else: - _logger.exception( - "An unknown error has occurred while registering component '%s'.", - dotted_path, - ) - return failure_context(dotted_path, e) - - # Store the component's args/kwargs in the database if needed - # This will be fetched by the websocket consumer later - try: - check_component_args(component, *args, **kwargs) - if func_has_args(component): - params = ComponentParamData(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) - model.full_clean() - model.save() - - except Exception as e: - if isinstance(e, ComponentParamError): - _logger.error(str(e)) - else: - _logger.exception( - "An unknown error has occurred while saving component params for '%s'.", - dotted_path, - ) - return failure_context(dotted_path, e) + # Determine the host domain + request: HttpRequest | None = context.get("request") + perceived_host_domain = (request.get_host() if request else "").strip("/") + host_domain = (host_domain or "").strip("/") + + # Create context variables + uuid = uuid4().hex + class_ = kwargs.pop("class", "") + kwargs.pop("key", "") # `key` is effectively useless for the root node + + # Only handle this component if host domain is unset, or the host domains match + if not host_domain or (host_domain == perceived_host_domain): + # Register the component if needed + try: + component = register_component(dotted_path) + except Exception as e: + if isinstance(e, ComponentDoesNotExistError): + _logger.error(str(e)) + else: + _logger.exception( + "An unknown error has occurred while registering component '%s'.", + dotted_path, + ) + return failure_context(dotted_path, e) + + # Store the component's args/kwargs in the database, if needed + # These will be fetched by the websocket consumer later + try: + check_component_args(component, *args, **kwargs) + if func_has_args(component): + params = ComponentParamData(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) + model.full_clean() + model.save() + except Exception as e: + if isinstance(e, ComponentParamError): + _logger.error(str(e)) + else: + _logger.exception( + "An unknown error has occurred while saving component params for '%s'.", + dotted_path, + ) + return failure_context(dotted_path, e) # Return the template rendering context return { "reactpy_class": class_, "reactpy_uuid": uuid, - "reactpy_ws_host": ws_host.strip("/"), - "reactpy_http_host": http_host.strip("/"), + "reactpy_host_domain": host_domain or perceived_host_domain, "reactpy_url_prefix": REACTPY_URL_PREFIX, "reactpy_reconnect_max": REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/", diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 40135a2d..c6f44de5 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -82,10 +82,8 @@ async def render_view( return response -def _register_component(dotted_path: str) -> Callable: - """Adds a component to the mapping of registered components. - This should only be called on startup to maintain synchronization during mulitprocessing. - """ +def register_component(dotted_path: str) -> Callable: + """Adds a component to the list of known registered components.""" from reactpy_django.config import ( REACTPY_FAILED_COMPONENTS, REACTPY_REGISTERED_COMPONENTS, @@ -208,7 +206,7 @@ def register_components(self, components: set[str]) -> None: for component in components: try: _logger.debug("\t+ %s", component) - _register_component(component) + register_component(component) except Exception: _logger.exception( "\033[91m" From 5e09dbcdd1510212a463faa3e4b5eab906a462ea Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Wed, 16 Aug 2023 23:21:45 -0700 Subject: [PATCH 09/24] import annotations --- src/reactpy_django/templatetags/reactpy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 64f4f7e4..02a2153a 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from logging import getLogger from uuid import uuid4 From b8236ea4296881146380972c31eda6e21e8e371b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 00:57:05 -0700 Subject: [PATCH 10/24] add tests for alternative host domains --- tests/test_app/components.py | 16 +++++++++--- tests/test_app/templates/host_port.html | 21 +++++++++++++++ tests/test_app/tests/test_components.py | 34 +++++++++++++++++++++++++ tests/test_app/urls.py | 3 ++- tests/test_app/views.py | 6 +++++ 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 tests/test_app/templates/host_port.html diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 433bd9e4..4512b99a 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -2,10 +2,13 @@ import inspect from pathlib import Path +import reactpy_django from channels.db import database_sync_to_async from django.http import HttpRequest from django.shortcuts import render from reactpy import component, hooks, html, web +from reactpy_django.components import view_to_component + from test_app.models import ( AsyncForiegnChild, AsyncRelationalChild, @@ -17,9 +20,6 @@ TodoItem, ) -import reactpy_django -from reactpy_django.components import view_to_component - from . import views from .types import TestObject @@ -588,3 +588,13 @@ def view_to_component_decorator_args(request): "view_to_component.html", {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore ) + + +@component +def custom_host_domain(): + scope = reactpy_django.hooks.use_scope() + port = scope["server"][1] + + return html.div( + {"id": inspect.currentframe().f_code.co_name}, f"Server Port: {port}" + ) diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html new file mode 100644 index 00000000..4f10b1d3 --- /dev/null +++ b/tests/test_app/templates/host_port.html @@ -0,0 +1,21 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ Custom Host Domain ({{new_host}}): + {% component "test_app.components.custom_host_domain" host_domain=new_host %} +
+ + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index fc71091a..0c773260 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,5 +1,6 @@ import asyncio import os +import socket import sys from functools import partial @@ -39,6 +40,12 @@ def setUpClass(cls): cls._server_process.ready.wait() cls._port = cls._server_process.port.value + # Open the second server process + cls._server_process2 = cls.ProtocolServerProcess(cls.host, get_application) + cls._server_process2.start() + cls._server_process2.ready.wait() + cls._port2 = cls._server_process2.port.value + # Open a Playwright browser window if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) @@ -52,6 +59,10 @@ def tearDownClass(cls): # Close the Playwright browser cls.playwright.stop() + # Close the second server process + cls._server_process2.terminate() + cls._server_process2.join() + # Repurposed from ChannelsLiveServerTestCase._post_teardown cls._server_process.terminate() cls._server_process.join() @@ -291,3 +302,26 @@ def test_component_session_missing(self): query_exists = query.exists() os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) + + def test_custom_host_domain(self): + """Components that can be rendered by separate ASGI server (`self._server_process2`).""" + new_page = self.browser.new_page() + new_page.goto(f"{self.live_server_url}/port/{self._port2}/") + + try: + # Make sure that the component is rendered by the new server + new_page.locator("#custom_host_domain").wait_for() + self.assertIn( + f"Server Port: {self._port2}", + new_page.locator("#custom_host_domain").text_content(), + ) + + # Make sure that other ports are not rendering components + tmp_sock = socket.socket() + tmp_sock.bind(("", 0)) + random_port = tmp_sock.getsockname()[1] + new_page.goto(f"{self.live_server_url}/port/{random_port}/") + with self.assertRaises(TimeoutError): + new_page.locator("#custom_host_domain").wait_for(timeout=1000) + finally: + new_page.close() diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index f3621b3e..09a31370 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -20,7 +20,7 @@ from django.contrib import admin from django.urls import include, path -from .views import base_template +from .views import base_template, host_port_template class AccessUser: @@ -31,6 +31,7 @@ class AccessUser: urlpatterns = [ path("", base_template), + path("port//", host_port_template), path("", include("test_app.performance.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 0d013b1a..53c721f9 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,6 +1,7 @@ import inspect from channels.db import database_sync_to_async +from django.http import HttpRequest from django.shortcuts import render from django.views.generic import TemplateView, View @@ -11,6 +12,11 @@ def base_template(request): return render(request, "base.html", {"my_object": TestObject(1)}) +def host_port_template(request: HttpRequest, port: int): + host = request.get_host().replace(str(request.get_port()), str(port)) + return render(request, "host_port.html", {"new_host": host}) + + def view_to_component_sync_func(request): return render( request, From 2d8316697529b90f7ff5dd8aad617ce08d74f907 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:20:53 -0700 Subject: [PATCH 11/24] minor tweaks after self review --- CHANGELOG.md | 9 ++++++++- docs/src/features/template-tag.md | 2 +- src/reactpy_django/checks.py | 7 ++++--- src/reactpy_django/exceptions.py | 4 ++++ src/reactpy_django/templatetags/reactpy.py | 15 ++++++++++++++- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edcc2790..b581a718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,13 +40,20 @@ Using the following categories, list your changes in this order: ### Changed -- ReactPy will now provide a warning if HTTP URLs are not using the same URL prefix as websockets. +- ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets. +- Cleaner logging output for detected ReactPy root components. ### Deprecated - `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. Replace with `REACTPY_WEBSOCKET_ROUTE`. - `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. Replace with `REACTPY_URL_PREFIX`. +### Removed + +- Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes. +- Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles these scenarios. +- Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver. + ## [3.3.2] - 2023-08-13 ### Added diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 4c1f47be..03180750 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -20,7 +20,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | - | `host_domain` | `str | None` | The host domain to use for the ReactPy connections. If set to `None`, the host will be automatically configured.

_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | + | `host_domain` | `str | None` | The host domain to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 4ee947e2..d77819b4 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -60,7 +60,7 @@ def reactpy_warnings(app_configs, **kwargs): warnings.append( Warning( "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " - "and you running with Daphne. ", + "and you running with Daphne.", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different webserver.", id="reactpy_django.W003", ) @@ -130,8 +130,9 @@ def reactpy_warnings(app_configs, **kwargs): f'\t path("{config.REACTPY_URL_PREFIX}/", include("reactpy_django.http.urls"))\n' "\t2) Modify settings.py:REACTPY_URL_PREFIX to match your existing HTTP path:\n" f'\t REACTPY_URL_PREFIX = "{reactpy_http_prefix}/"\n' - "\t3) If you not rendering ReactPy components within this Python application, then " - "remove HTTP and/or websocket routing.\n", + "\t3) If you not rendering components by this ASGI application, then remove " + "ReactPy HTTP and websocket routing. This is common for configurations that " + "rely entirely on `host_domain` configuration in your template tag.", id="reactpy_django.W010", ) ) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 072f1d4f..5cdcb719 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -4,3 +4,7 @@ class ComponentParamError(TypeError): class ComponentDoesNotExistError(AttributeError): ... + + +class InvalidHostError(ValueError): + ... diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 02a2153a..f2dc07c7 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -14,7 +14,11 @@ REACTPY_RECONNECT_MAX, REACTPY_URL_PREFIX, ) -from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError +from reactpy_django.exceptions import ( + ComponentDoesNotExistError, + ComponentParamError, + InvalidHostError, +) from reactpy_django.types import ComponentParamData from reactpy_django.utils import ( check_component_args, @@ -47,6 +51,7 @@ def component( Keyword Args: host_domain: The host domain to use for the ReactPy connections. If set to `None`, \ the host will be automatically configured. \ + Example values include: `localhost:8000`, `example.com`, `example.com/subdir` \ Note: You typically will not need to register the ReactPy HTTP and/or websocket \ paths on any application(s) that do not perform component rendering. **kwargs: The keyword arguments to provide to the component. @@ -72,6 +77,14 @@ def component( class_ = kwargs.pop("class", "") kwargs.pop("key", "") # `key` is effectively useless for the root node + # Fail if user has a method in their host_domain + if host_domain.find("://") != -1: + protocol = host_domain.split("://")[0] + return failure_context( + dotted_path, + InvalidHostError(f"The provided host contains a protocol '{protocol}'."), + ) + # Only handle this component if host domain is unset, or the host domains match if not host_domain or (host_domain == perceived_host_domain): # Register the component if needed From 86959f6cbca07cf784552692049294b846076076 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:23:26 -0700 Subject: [PATCH 12/24] only bind to localhost --- tests/test_app/tests/test_components.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 0c773260..beba9fd3 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -318,7 +318,7 @@ def test_custom_host_domain(self): # Make sure that other ports are not rendering components tmp_sock = socket.socket() - tmp_sock.bind(("", 0)) + tmp_sock.bind((self._server_process.host, 0)) random_port = tmp_sock.getsockname()[1] new_page.goto(f"{self.live_server_url}/port/{random_port}/") with self.assertRaises(TimeoutError): From 31b09d8536c96543323634c92954f5419f9bbc14 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:24:42 -0700 Subject: [PATCH 13/24] fix types --- tests/test_app/components.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 4512b99a..a0d6aee4 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -596,5 +596,6 @@ def custom_host_domain(): port = scope["server"][1] return html.div( - {"id": inspect.currentframe().f_code.co_name}, f"Server Port: {port}" + {"id": inspect.currentframe().f_code.co_name}, # type: ignore + f"Server Port: {port}", ) From 15e1e67b8dd1c1e9f38888e5913288bf7241b539 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 01:36:06 -0700 Subject: [PATCH 14/24] host domain -> host --- CHANGELOG.md | 2 +- docs/src/features/template-tag.md | 2 +- src/js/src/index.js | 10 ++++----- src/reactpy_django/checks.py | 2 +- .../templates/reactpy/component.html | 2 +- src/reactpy_django/templatetags/reactpy.py | 22 +++++++++---------- tests/test_app/components.py | 2 +- tests/test_app/templates/host_port.html | 4 ++-- tests/test_app/tests/test_components.py | 8 +++---- 9 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b581a718..51a3e14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts a `host_domain` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application(s). +- The `component` template tag now accepts a `host` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application(s). ### Changed diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 03180750..f2ef6774 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -20,7 +20,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | - | `host_domain` | `str | None` | The host domain to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | + | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | diff --git a/src/js/src/index.js b/src/js/src/index.js index 326e8da1..2ee74e07 100644 --- a/src/js/src/index.js +++ b/src/js/src/index.js @@ -11,7 +11,7 @@ if (HTTP_PROTOCOL == "https:") { export function mountViewToElement( mountElement, - reactpyHostDomain, + reactpyHost, reactpyUrlPrefix, reactpyReconnectMax, reactpyComponentPath, @@ -19,8 +19,8 @@ export function mountViewToElement( ) { // Determine the Websocket route let wsOrigin; - if (reactpyHostDomain) { - wsOrigin = `${WS_PROTOCOL}//${reactpyHostDomain}`; + if (reactpyHost) { + wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`; } else { wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; } @@ -29,8 +29,8 @@ export function mountViewToElement( // Determine the HTTP route let httpOrigin; let webModulesPath; - if (reactpyHostDomain) { - httpOrigin = `${HTTP_PROTOCOL}//${reactpyHostDomain}`; + if (reactpyHost) { + httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`; webModulesPath = `${reactpyUrlPrefix}/web_module`; } else { httpOrigin = `${HTTP_PROTOCOL}//${window.location.host}`; diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index d77819b4..95eb9041 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -132,7 +132,7 @@ def reactpy_warnings(app_configs, **kwargs): f'\t REACTPY_URL_PREFIX = "{reactpy_http_prefix}/"\n' "\t3) If you not rendering components by this ASGI application, then remove " "ReactPy HTTP and websocket routing. This is common for configurations that " - "rely entirely on `host_domain` configuration in your template tag.", + "rely entirely on `host` configuration in your template tag.", id="reactpy_django.W010", ) ) diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 9015f359..4010b80f 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -10,7 +10,7 @@ const mountElement = document.getElementById("{{ reactpy_uuid }}"); mountViewToElement( mountElement, - "{{ reactpy_host_domain }}", + "{{ reactpy_host }}", "{{ reactpy_url_prefix }}", "{{ reactpy_reconnect_max }}", "{{ reactpy_component_path }}", diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index f2dc07c7..f4d41063 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -39,7 +39,7 @@ def component( context: template.RequestContext, dotted_path: str, *args, - host_domain: str | None = None, + host: str | None = None, **kwargs, ): """This tag is used to embed an existing ReactPy component into your HTML template. @@ -49,7 +49,7 @@ def component( *args: The positional arguments to provide to the component. Keyword Args: - host_domain: The host domain to use for the ReactPy connections. If set to `None`, \ + host: The host to use for the ReactPy connections. If set to `None`, \ the host will be automatically configured. \ Example values include: `localhost:8000`, `example.com`, `example.com/subdir` \ Note: You typically will not need to register the ReactPy HTTP and/or websocket \ @@ -67,26 +67,26 @@ def component( """ - # Determine the host domain + # Determine the host request: HttpRequest | None = context.get("request") - perceived_host_domain = (request.get_host() if request else "").strip("/") - host_domain = (host_domain or "").strip("/") + perceived_host = (request.get_host() if request else "").strip("/") + host = (host or "").strip("/") # Create context variables uuid = uuid4().hex class_ = kwargs.pop("class", "") kwargs.pop("key", "") # `key` is effectively useless for the root node - # Fail if user has a method in their host_domain - if host_domain.find("://") != -1: - protocol = host_domain.split("://")[0] + # Fail if user has a method in their host + if host.find("://") != -1: + protocol = host.split("://")[0] return failure_context( dotted_path, InvalidHostError(f"The provided host contains a protocol '{protocol}'."), ) - # Only handle this component if host domain is unset, or the host domains match - if not host_domain or (host_domain == perceived_host_domain): + # Only handle this component if host is unset, or the hosts match + if not host or (host == perceived_host): # Register the component if needed try: component = register_component(dotted_path) @@ -123,7 +123,7 @@ def component( return { "reactpy_class": class_, "reactpy_uuid": uuid, - "reactpy_host_domain": host_domain or perceived_host_domain, + "reactpy_host": host or perceived_host, "reactpy_url_prefix": REACTPY_URL_PREFIX, "reactpy_reconnect_max": REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/", diff --git a/tests/test_app/components.py b/tests/test_app/components.py index a0d6aee4..a27f4536 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -591,7 +591,7 @@ def view_to_component_decorator_args(request): @component -def custom_host_domain(): +def custom_host(): scope = reactpy_django.hooks.use_scope() port = scope["server"][1] diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html index 4f10b1d3..5205311c 100644 --- a/tests/test_app/templates/host_port.html +++ b/tests/test_app/templates/host_port.html @@ -13,8 +13,8 @@

ReactPy Test Page


- Custom Host Domain ({{new_host}}): - {% component "test_app.components.custom_host_domain" host_domain=new_host %} + Custom Host ({{new_host}}): + {% component "test_app.components.custom_host" host=new_host %}
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index beba9fd3..aa62c40e 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -303,17 +303,17 @@ def test_component_session_missing(self): os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) - def test_custom_host_domain(self): + def test_custom_host(self): """Components that can be rendered by separate ASGI server (`self._server_process2`).""" new_page = self.browser.new_page() new_page.goto(f"{self.live_server_url}/port/{self._port2}/") try: # Make sure that the component is rendered by the new server - new_page.locator("#custom_host_domain").wait_for() + new_page.locator("#custom_host").wait_for() self.assertIn( f"Server Port: {self._port2}", - new_page.locator("#custom_host_domain").text_content(), + new_page.locator("#custom_host").text_content(), ) # Make sure that other ports are not rendering components @@ -322,6 +322,6 @@ def test_custom_host_domain(self): random_port = tmp_sock.getsockname()[1] new_page.goto(f"{self.live_server_url}/port/{random_port}/") with self.assertRaises(TimeoutError): - new_page.locator("#custom_host_domain").wait_for(timeout=1000) + new_page.locator("#custom_host").wait_for(timeout=1000) finally: new_page.close() From ea9155d00794ba68c28ff1d8d51750adc8ff15f7 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 02:23:57 -0700 Subject: [PATCH 15/24] `register_component` documentation --- CHANGELOG.md | 3 ++- docs/python/register-component.py | 8 ++++++++ docs/src/features/components.md | 2 +- docs/src/features/template-tag.md | 1 - docs/src/features/utils.md | 22 ++++++++++++++++++++++ src/reactpy_django/utils.py | 3 ++- 6 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 docs/python/register-component.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a3e14e..8856ae18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,8 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts a `host` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application(s). +- The `component` template tag now accepts a `host` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application. +- `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that not referenced within your Django templates. ### Changed diff --git a/docs/python/register-component.py b/docs/python/register-component.py new file mode 100644 index 00000000..c8ad12e9 --- /dev/null +++ b/docs/python/register-component.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from reactpy_django.utils import register_component + + +class ExampleConfig(AppConfig): + def ready(self): + # Add components to the ReactPy component registry when Django is ready + register_component("example_project.my_app.components.hello_world") diff --git a/docs/src/features/components.md b/docs/src/features/components.md index d7926803..900b9fe2 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -37,7 +37,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat It is your responsibility to ensure privileged information is not leaked via this method. - This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views prior to using `view_to_component`. + You must implement a method to ensure only authorized users can access your view. This can be done via directly writing conditionals into your view, or by adding decorators such as [`user_passes_test`](https://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test) to your views. For example... === "Function Based View" diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index f2ef6774..426a3e52 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -21,7 +21,6 @@ The `component` template tag can be used to insert any number of ReactPy compone | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | - | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | **Returns** diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md index 9cec1aa4..dfadb9f9 100644 --- a/docs/src/features/utils.md +++ b/docs/src/features/utils.md @@ -37,3 +37,25 @@ This postprocessor is designed to avoid Django's `SynchronousOnlyException` by r | Type | Description | | --- | --- | | `QuerySet | Model` | The `Model` or `QuerySet` with all fields fetched. | + +## Register Component + +The `register_component` function is used manually register a root component with ReactPy. + +You should always call `register_component` within a Django [`AppConfig.ready()` method](https://docs.djangoproject.com/en/4.2/ref/applications/#django.apps.AppConfig.ready) to retain compatibility with ASGI webserver workers. + +=== "apps.py" + + ```python + {% include "../../python/register-component.py" %} + ``` + +??? question "Do I need to register my components?" + + You typically will not need to use this function. + + For security reasons, ReactPy does not allow non-registered components to be root components. However, all components contained within Django templates are automatically considered root components. + + You only need to use this function if your host application does not contain any HTML templates that [reference](../features/template-tag.md#component) your components. + + A common scenario where this is needed is when you are modifying the [template tag `host = ...` argument](../features/template-tag.md#component) in order to configure a dedicated Django application as a rendering server for ReactPy. On this dedicated rendering server, you would need to manually register your components. diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index c6f44de5..97c143ea 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -20,6 +20,7 @@ from django.utils import timezone from django.utils.encoding import smart_str from django.views import View +from reactpy.types import ComponentConstructor from reactpy_django.exceptions import ComponentDoesNotExistError, ComponentParamError @@ -82,7 +83,7 @@ async def render_view( return response -def register_component(dotted_path: str) -> Callable: +def register_component(dotted_path: str) -> ComponentConstructor: """Adds a component to the list of known registered components.""" from reactpy_django.config import ( REACTPY_FAILED_COMPONENTS, From 0ea09f6b1361308a6df5439781c4cfd4269b5cd2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 02:44:38 -0700 Subject: [PATCH 16/24] InvalidHostError --- CHANGELOG.md | 6 +++--- src/reactpy_django/templatetags/reactpy.py | 7 ++++--- tests/test_app/settings.py | 6 +++++- tests/test_app/templates/base.html | 2 ++ tests/test_app/tests/test_components.py | 5 +++++ 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8856ae18..420dea4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ Using the following categories, list your changes in this order: ### Added - The `component` template tag now accepts a `host` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application. -- `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that not referenced within your Django templates. +- `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that is not referenced within your Django templates. ### Changed @@ -46,8 +46,8 @@ Using the following categories, list your changes in this order: ### Deprecated -- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. Replace with `REACTPY_WEBSOCKET_ROUTE`. -- `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. Replace with `REACTPY_URL_PREFIX`. +- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The similar replacement is `REACTPY_WEBSOCKET_ROUTE`. +- `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. The similar replacement is `REACTPY_URL_PREFIX`. ### Removed diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index f4d41063..65e1f318 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -80,10 +80,11 @@ def component( # Fail if user has a method in their host if host.find("://") != -1: protocol = host.split("://")[0] - return failure_context( - dotted_path, - InvalidHostError(f"The provided host contains a protocol '{protocol}'."), + msg = ( + f"Invalid host provided to component. Contains a protocol '{protocol}://'." ) + _logger.error(msg) + return failure_context(dotted_path, InvalidHostError(msg)) # Only handle this component if host is unset, or the hosts match if not host or (host == perceived_host): diff --git a/tests/test_app/settings.py b/tests/test_app/settings.py index be9a4186..a99da927 100644 --- a/tests/test_app/settings.py +++ b/tests/test_app/settings.py @@ -160,6 +160,10 @@ ] # Logging +LOG_LEVEL = "WARNING" +if DEBUG and ("test" not in sys.argv): + LOG_LEVEL = "DEBUG" + LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -171,7 +175,7 @@ "loggers": { "reactpy_django": { "handlers": ["console"], - "level": "DEBUG" if DEBUG else "WARNING", + "level": LOG_LEVEL, }, }, } diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index 10eaac27..303e99dd 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -89,6 +89,8 @@

ReactPy Test Page


{% component "test_app.components.hello_world" invalid_param="random_value" %}

+
{% component "test_app.components.hello_world" host="https://example.com/" %}
+
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index aa62c40e..610493a0 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -325,3 +325,8 @@ def test_custom_host(self): new_page.locator("#custom_host").wait_for(timeout=1000) finally: new_page.close() + + def test_invalid_host_error(self): + broken_component = self.page.locator("#invalid_host_error") + broken_component.wait_for() + self.assertIn("InvalidHostError:", broken_component.text_content()) From f2a19f0c45eb95d0bf7b7743cf72f97c37f004a3 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 02:53:00 -0700 Subject: [PATCH 17/24] minor tweaks to changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 420dea4b..667ec67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts a `host` argument to override the default host URL, allowing ReactPy websockets and HTTP to be hosted by completely separate Django application. +- The `component` template tag now accepts a `host` argument to override the default host URL. This allows ReactPy to be hosted by completely separate Django application. - `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that is not referenced within your Django templates. ### Changed @@ -52,7 +52,7 @@ Using the following categories, list your changes in this order: ### Removed - Warning W007 (`REACTPY_WEBSOCKET_URL doesn't end with a slash`) has been removed. ReactPy now automatically handles slashes. -- Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles these scenarios. +- Warning W008 (`REACTPY_WEBSOCKET_URL doesn't start with an alphanumeric character`) has been removed. ReactPy now automatically handles this scenario. - Error E009 (`channels is not in settings.py:INSTALLED_APPS`) has been removed. Newer versions of `channels` do not require installation via `INSTALLED_APPS` to receive an ASGI webserver. ## [3.3.2] - 2023-08-13 From 2f6402923bbc9400aab24cd61cc852d08cdbd904 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 03:11:34 -0700 Subject: [PATCH 18/24] docs on sharding --- docs/src/features/template-tag.md | 20 ++++++++++++++++++-- src/reactpy_django/templatetags/reactpy.py | 4 +--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 426a3e52..8b11b206 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -20,7 +20,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | --- | --- | --- | --- | | `dotted_path` | `str` | The dotted path to the component to render. | N/A | | `*args` | `Any` | The positional arguments to provide to the component. | N/A | - | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir`
_Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ | `None` | + | `host` | `str | None` | The host to use for the ReactPy connections. If set to `None`, the host will be automatically configured.
Example values include: `localhost:8000`, `example.com`, `example.com/subdir` | `None` | | `**kwargs` | `Any` | The keyword arguments to provide to the component. | N/A | **Returns** @@ -74,6 +74,23 @@ The `component` template tag can be used to insert any number of ReactPy compone ``` + +??? question "Can I render components on a different ASGI server?" + + Yes! By using the `host` keyword argument, you can render components on a completely separate server. + + === "my-template.html" + + ```jinja + ... + {% component "example_project.my_app.components.do_something" host="127.0.0.1:8001" %} + ... + ``` + + If your host is on a completely different origin ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. + + _Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ + ??? question "Can I use multiple components on one page?" @@ -99,7 +116,6 @@ The `component` template tag can be used to insert any number of ReactPy compone Additionally, in scenarios where you are trying to create a Single Page Application (SPA) within Django, you will only have one component within your `#!html ` tag. - ??? question "Can I use positional arguments instead of keyword arguments?" diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 65e1f318..0f0c446e 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -51,9 +51,7 @@ def component( Keyword Args: host: The host to use for the ReactPy connections. If set to `None`, \ the host will be automatically configured. \ - Example values include: `localhost:8000`, `example.com`, `example.com/subdir` \ - Note: You typically will not need to register the ReactPy HTTP and/or websocket \ - paths on any application(s) that do not perform component rendering. + Example values include: `localhost:8000`, `example.com`, `example.com/subdir` **kwargs: The keyword arguments to provide to the component. Example :: From 91074c77a898846bfe93e35e63762f12fca58a09 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 03:18:23 -0700 Subject: [PATCH 19/24] tweak changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 667ec67e..4fa0d394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts a `host` argument to override the default host URL. This allows ReactPy to be hosted by completely separate Django application. +- The `component` template tag now accepts a `host` argument. This allows rendering ReactPy components from a completely separate Django application. - `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that is not referenced within your Django templates. ### Changed From 6b1355308fa6dcdca828a2dad9c2356ae7eca194 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Thu, 17 Aug 2023 03:34:36 -0700 Subject: [PATCH 20/24] sharding -> distributed computing --- docs/src/features/template-tag.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index 8b11b206..f1424059 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -75,9 +75,9 @@ The `component` template tag can be used to insert any number of ReactPy compone -??? question "Can I render components on a different ASGI server?" +??? question "Can I render components on a different server (distributed computing)?" - Yes! By using the `host` keyword argument, you can render components on a completely separate server. + Yes! By using the `host` keyword argument, you can render components from a completely separate ASGI server. === "my-template.html" @@ -87,9 +87,13 @@ The `component` template tag can be used to insert any number of ReactPy compone ... ``` - If your host is on a completely different origin ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. + This configuration most commonly involves you deploying multiple instances of your project. But, you can also create dedicated Django project(s) that only render specific ReactPy components if you wish. - _Note: You typically will not need to register ReactPy HTTP and/or websocket paths on any application(s) that do not perform any component rendering._ + Here's a couple of things to keep in mind: + + 1. If your host address are completely separate ( `origin1.com != origin2.com` ) you will need to [configure CORS headers](https://pypi.org/project/django-cors-headers/) on your main application during deployment. + 2. You will not need to register ReactPy HTTP or websocket paths on any applications that do not perform any component rendering. + 3. Your component will only be able to access `*args`/`**kwargs` you provide to the template tag if your applications share a common database. From f3fa830f7de6618293c167d81a82bb55c80ae0f4 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:38:38 -0700 Subject: [PATCH 21/24] REACTPY_DEFAULT_HOSTS round-robin --- CHANGELOG.md | 11 ++- docs/src/features/settings.md | 3 +- src/reactpy_django/checks.py | 9 ++ src/reactpy_django/config.py | 10 ++ src/reactpy_django/templatetags/reactpy.py | 95 +++++++++---------- src/reactpy_django/utils.py | 2 +- src/reactpy_django/websocket/consumer.py | 42 ++++---- tests/test_app/components.py | 7 +- tests/test_app/templates/host_port.html | 2 +- .../templates/host_port_roundrobin.html | 23 +++++ tests/test_app/tests/test_components.py | 59 ++++++++++-- tests/test_app/urls.py | 5 +- tests/test_app/views.py | 25 +++++ 13 files changed, 204 insertions(+), 89 deletions(-) create mode 100644 tests/test_app/templates/host_port_roundrobin.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fa0d394..70c5b054 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,17 +36,20 @@ Using the following categories, list your changes in this order: ### Added -- The `component` template tag now accepts a `host` argument. This allows rendering ReactPy components from a completely separate Django application. -- `reactpy_django.utils.register_component` has been added. This function can be used to manually register a root component that is not referenced within your Django templates. +- **Distributed Computing:** ReactPy components can now optionally be rendered by a completely separate server! + - `REACTPY_DEFAULT_HOSTS` setting can round-robin a list of ReactPy rendering hosts. + - `host` argument has been added to the `component` template tag to force components to render on a specific host. +- `reactpy_django.utils.register_component` function to manually register root components. + - Useful if you have dedicated ReactPy rendering application(s) that do not use HTML templates. ### Changed - ReactPy will now provide a warning if your HTTP URLs are not on the same prefix as your websockets. -- Cleaner logging output for detected ReactPy root components. +- Cleaner logging output for auto-detected ReactPy root components. ### Deprecated -- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The similar replacement is `REACTPY_WEBSOCKET_ROUTE`. +- `reactpy_django.REACTPY_WEBSOCKET_PATH` is deprecated. The identical replacement is `REACTPY_WEBSOCKET_ROUTE`. - `settings.py:REACTPY_WEBSOCKET_URL` is deprecated. The similar replacement is `REACTPY_URL_PREFIX`. ### Removed diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 26ca78b7..29ca81ad 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -19,11 +19,12 @@ These are ReactPy-Django's default settings values. You can modify these values | `REACTPY_DEFAULT_QUERY_POSTPROCESSOR` | `#!python "reactpy_django.utils.django_query_postprocessor"` | `#!python "example_project.my_query_postprocessor"` | Dotted path to the default `reactpy_django.hooks.use_query` postprocessor function. | | `REACTPY_AUTH_BACKEND` | `#!python "django.contrib.auth.backends.ModelBackend"` | `#!python "example_project.auth.MyModelBackend"` | 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_BACKHAUL_THREAD` | `#!python False` | `#!python True` | Whether to render ReactPy components in a dedicated thread. This allows the webserver to process web traffic while during ReactPy rendering.
Vastly improves throughput with web servers such as `hypercorn` and `uvicorn`. | +| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | Default host(s) to use for ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) to override this default. | ??? question "Do I need to modify my settings?" - The default configuration of ReactPy is adequate for the majority of use cases. + The default configuration of ReactPy is suitable for the majority of use cases. You should only consider changing settings when the necessity arises. diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 95eb9041..60bd6f4e 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -254,4 +254,13 @@ def reactpy_errors(app_configs, **kwargs): # DELETED E009: Check if `channels` is in INSTALLED_APPS + if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list): + errors.append( + Error( + "Invalid type for REACTPY_DEFAULT_HOSTS.", + hint="REACTPY_DEFAULT_HOSTS should be a list.", + obj=settings.REACTPY_DEFAULT_HOSTS, + id="reactpy_django.E010", + ) + ) return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index c8f2a781..5b9dabae 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +from itertools import cycle + from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS @@ -70,3 +72,11 @@ "REACTPY_BACKHAUL_THREAD", False, ) +_reactpy_default_hosts: list[str] | None = getattr( + settings, + "REACTPY_DEFAULT_HOSTS", + None, +) +REACTPY_DEFAULT_HOSTS = ( + cycle(_reactpy_default_hosts) if _reactpy_default_hosts else None +) diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 0f0c446e..c174d1b1 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -8,23 +8,14 @@ from django.http import HttpRequest from django.urls import NoReverseMatch, reverse -from reactpy_django import models -from reactpy_django.config import ( - REACTPY_DEBUG_MODE, - REACTPY_RECONNECT_MAX, - REACTPY_URL_PREFIX, -) +from reactpy_django import config, models from reactpy_django.exceptions import ( ComponentDoesNotExistError, ComponentParamError, InvalidHostError, ) from reactpy_django.types import ComponentParamData -from reactpy_django.utils import ( - check_component_args, - func_has_args, - register_component, -) +from reactpy_django.utils import check_component_args, func_has_args try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") @@ -68,7 +59,13 @@ def component( # Determine the host request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").strip("/") - host = (host or "").strip("/") + host = ( + host + or (next(config.REACTPY_DEFAULT_HOSTS) if config.REACTPY_DEFAULT_HOSTS else "") + ).strip("/") + + # Check if this this component needs to rendered by the current ASGI app + use_current_app = not host or host.startswith(perceived_host) # Create context variables uuid = uuid4().hex @@ -84,47 +81,42 @@ def component( _logger.error(msg) return failure_context(dotted_path, InvalidHostError(msg)) - # Only handle this component if host is unset, or the hosts match - if not host or (host == perceived_host): - # Register the component if needed - try: - component = register_component(dotted_path) - except Exception as e: - if isinstance(e, ComponentDoesNotExistError): - _logger.error(str(e)) - else: - _logger.exception( - "An unknown error has occurred while registering component '%s'.", - dotted_path, - ) - return failure_context(dotted_path, e) - - # Store the component's args/kwargs in the database, if needed - # These will be fetched by the websocket consumer later - try: - check_component_args(component, *args, **kwargs) - if func_has_args(component): - params = ComponentParamData(args, kwargs) - model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) - model.full_clean() - model.save() - except Exception as e: - if isinstance(e, ComponentParamError): - _logger.error(str(e)) - else: - _logger.exception( - "An unknown error has occurred while saving component params for '%s'.", - dotted_path, - ) - return failure_context(dotted_path, e) + # Fetch the component if needed + if use_current_app: + user_component = config.REACTPY_REGISTERED_COMPONENTS.get(dotted_path) + if not user_component: + msg = f"Component '{dotted_path}' is not registered as a root component. " + _logger.error(msg) + return failure_context(dotted_path, ComponentDoesNotExistError(msg)) + + # Store the component's args/kwargs in the database, if needed + # These will be fetched by the websocket consumer later + try: + if use_current_app: + check_component_args(user_component, *args, **kwargs) + if func_has_args(user_component): + save_component_params(args, kwargs, uuid) + # Can't guarantee args will match up if the component is rendered by a different app. + # So, we just store any provided args/kwargs in the database. + elif args or kwargs: + save_component_params(args, kwargs, uuid) + except Exception as e: + if isinstance(e, ComponentParamError): + _logger.error(str(e)) + else: + _logger.exception( + "An unknown error has occurred while saving component params for '%s'.", + dotted_path, + ) + return failure_context(dotted_path, e) # Return the template rendering context return { "reactpy_class": class_, "reactpy_uuid": uuid, "reactpy_host": host or perceived_host, - "reactpy_url_prefix": REACTPY_URL_PREFIX, - "reactpy_reconnect_max": REACTPY_RECONNECT_MAX, + "reactpy_url_prefix": config.REACTPY_URL_PREFIX, + "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, } @@ -133,7 +125,14 @@ def component( def failure_context(dotted_path: str, error: Exception): return { "reactpy_failure": True, - "reactpy_debug_mode": REACTPY_DEBUG_MODE, + "reactpy_debug_mode": config.REACTPY_DEBUG_MODE, "reactpy_dotted_path": dotted_path, "reactpy_error": type(error).__name__, } + + +def save_component_params(args, kwargs, uuid): + params = ComponentParamData(args, kwargs) + model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) + model.full_clean() + model.save() diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 97c143ea..da726eac 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -203,7 +203,7 @@ def get_components(self, templates: set[str]) -> set[str]: def register_components(self, components: set[str]) -> None: """Registers all ReactPy components in an iterable.""" if components: - _logger.debug("ReactPy root components:") + _logger.debug("Auto-detected ReactPy root components:") for component in components: try: _logger.debug("\t+ %s", component) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index 579f351b..aa0c2006 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -152,27 +152,18 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: if func_has_args(component_constructor): - try: - # Always clean up expired entries first - await database_sync_to_async(db_cleanup, thread_sensitive=False)() - - # Get the queries from a DB - params_query = await models.ComponentSession.objects.aget( - uuid=uuid, - last_accessed__gt=now - - timedelta(seconds=REACTPY_RECONNECT_MAX), - ) - params_query.last_accessed = timezone.now() - await database_sync_to_async( - params_query.save, thread_sensitive=False - )() - except models.ComponentSession.DoesNotExist: - await asyncio.to_thread( - _logger.warning, - f"Component session for '{dotted_path}:{uuid}' not found. The " - "session may have already expired beyond REACTPY_RECONNECT_MAX.", - ) - return + # Always clean up expired entries first + await database_sync_to_async(db_cleanup, thread_sensitive=False)() + + # Get the queries from a DB + params_query = await models.ComponentSession.objects.aget( + uuid=uuid, + last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX), + ) + params_query.last_accessed = timezone.now() + await database_sync_to_async( + params_query.save, thread_sensitive=False + )() component_params: ComponentParamData = pickle.loads(params_query.params) component_args = component_params.args component_kwargs = component_params.kwargs @@ -181,6 +172,15 @@ async def run_dispatcher(self): component_instance = component_constructor( *component_args, **component_kwargs ) + except models.ComponentSession.DoesNotExist: + await asyncio.to_thread( + _logger.warning, + f"Component session for '{dotted_path}:{uuid}' not found. The " + "session may have already expired beyond REACTPY_RECONNECT_MAX. " + "If you are using a custom host, you may have forgotten to provide " + "args/kwargs.", + ) + return except Exception: await asyncio.to_thread( _logger.exception, diff --git a/tests/test_app/components.py b/tests/test_app/components.py index a27f4536..d018cd96 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -591,11 +591,14 @@ def view_to_component_decorator_args(request): @component -def custom_host(): +def custom_host(number=0): scope = reactpy_django.hooks.use_scope() port = scope["server"][1] return html.div( - {"id": inspect.currentframe().f_code.co_name}, # type: ignore + { + "class_name": f"{inspect.currentframe().f_code.co_name}-{number}", # type: ignore + "data-port": port, + }, f"Server Port: {port}", ) diff --git a/tests/test_app/templates/host_port.html b/tests/test_app/templates/host_port.html index 5205311c..1eb2be2a 100644 --- a/tests/test_app/templates/host_port.html +++ b/tests/test_app/templates/host_port.html @@ -14,7 +14,7 @@

ReactPy Test Page


Custom Host ({{new_host}}): - {% component "test_app.components.custom_host" host=new_host %} + {% component "test_app.components.custom_host" host=new_host number=0 %}
diff --git a/tests/test_app/templates/host_port_roundrobin.html b/tests/test_app/templates/host_port_roundrobin.html new file mode 100644 index 00000000..ad2dada0 --- /dev/null +++ b/tests/test_app/templates/host_port_roundrobin.html @@ -0,0 +1,23 @@ +{% load static %} {% load reactpy %} + + + + + + + + + ReactPy + + + +

ReactPy Test Page

+
+ {% for count in count %} + Round-Robin Host: + {% component "test_app.components.custom_host" number=count %} +
+ {% endfor %} + + + diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 610493a0..58e6c053 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -13,7 +13,8 @@ from playwright.sync_api import TimeoutError, sync_playwright from reactpy_django.models import ComponentSession -CLICK_DELAY = 250 if os.getenv("GITHUB_ACTIONS") else 25 # Delay in miliseconds. +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") +CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): @@ -50,7 +51,7 @@ def setUpClass(cls): if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) cls.playwright = sync_playwright().start() - headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", 0))) + headed = bool(int(os.environ.get("PLAYWRIGHT_HEADED", not GITHUB_ACTIONS))) cls.browser = cls.playwright.chromium.launch(headless=not headed) cls.page = cls.browser.new_page() @@ -304,25 +305,63 @@ def test_component_session_missing(self): self.assertFalse(query_exists) def test_custom_host(self): - """Components that can be rendered by separate ASGI server (`self._server_process2`).""" + """Make sure that the component is rendered by a separate server.""" new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/port/{self._port2}/") - try: - # Make sure that the component is rendered by the new server - new_page.locator("#custom_host").wait_for() + new_page.goto(f"{self.live_server_url}/port/{self._port2}/") + elem = new_page.locator(".custom_host-0") + elem.wait_for() self.assertIn( f"Server Port: {self._port2}", - new_page.locator("#custom_host").text_content(), + elem.text_content(), ) + finally: + new_page.close() - # Make sure that other ports are not rendering components + def test_custom_host_wrong_port(self): + """Make sure that other ports are not rendering components.""" + new_page = self.browser.new_page() + try: tmp_sock = socket.socket() tmp_sock.bind((self._server_process.host, 0)) random_port = tmp_sock.getsockname()[1] new_page.goto(f"{self.live_server_url}/port/{random_port}/") with self.assertRaises(TimeoutError): - new_page.locator("#custom_host").wait_for(timeout=1000) + new_page.locator(".custom_host").wait_for(timeout=1000) + finally: + new_page.close() + + def test_host_roundrobin(self): + """Verify if round-robin host selection is working.""" + new_page = self.browser.new_page() + try: + new_page.goto( + f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" + ) + elem0 = new_page.locator(".custom_host-0") + elem1 = new_page.locator(".custom_host-1") + elem2 = new_page.locator(".custom_host-2") + elem3 = new_page.locator(".custom_host-3") + + elem0.wait_for() + elem1.wait_for() + elem2.wait_for() + elem3.wait_for() + + current_ports = { + elem0.get_attribute("data-port"), + elem1.get_attribute("data-port"), + elem2.get_attribute("data-port"), + elem3.get_attribute("data-port"), + } + correct_ports = { + str(self._port), + str(self._port2), + } + + # There should only be two ports in the set + self.assertEqual(current_ports, correct_ports) + self.assertEqual(len(current_ports), 2) finally: new_page.close() diff --git a/tests/test_app/urls.py b/tests/test_app/urls.py index 09a31370..ea185971 100644 --- a/tests/test_app/urls.py +++ b/tests/test_app/urls.py @@ -20,7 +20,7 @@ from django.contrib import admin from django.urls import include, path -from .views import base_template, host_port_template +from .views import base_template, host_port_roundrobin_template, host_port_template class AccessUser: @@ -32,6 +32,9 @@ class AccessUser: urlpatterns = [ path("", base_template), path("port//", host_port_template), + path( + "roundrobin////", host_port_roundrobin_template + ), path("", include("test_app.performance.urls")), path("reactpy/", include("reactpy_django.http.urls")), path("admin/", admin.site.urls), diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 53c721f9..689d8f8c 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -1,4 +1,5 @@ import inspect +from itertools import cycle from channels.db import database_sync_to_async from django.http import HttpRequest @@ -17,6 +18,30 @@ def host_port_template(request: HttpRequest, port: int): return render(request, "host_port.html", {"new_host": host}) +def host_port_roundrobin_template( + request: HttpRequest, port1: int, port2: int, count: int = 1 +): + from reactpy_django import config + + # Override ReactPy config to use round-robin hosts + original = config.REACTPY_DEFAULT_HOSTS + config.REACTPY_DEFAULT_HOSTS = cycle( + [ + f"{request.get_host().split(':')[0]}:{port1}", + f"{request.get_host().split(':')[0]}:{port2}", + ] + ) + html = render( + request, + "host_port_roundrobin.html", + {"count": range(max(count, 1))}, + ) + + # Reset ReactPy config + config.REACTPY_DEFAULT_HOSTS = original + return html + + def view_to_component_sync_func(request): return render( request, From 606ec78ddbc6cc9cf4b6d90fbf0a2bd0aea39470 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:46:38 -0700 Subject: [PATCH 22/24] fix type errors --- pyproject.toml | 6 ++---- src/reactpy_django/utils.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5cd9c3a7..0f9e87a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,15 +3,13 @@ requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [tool.mypy] -exclude = [ - 'migrations/.*', -] +exclude = ['migrations/.*'] ignore_missing_imports = true warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -incremental = false +incremental = true [tool.ruff.isort] known-first-party = ["src", "tests"] diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index da726eac..22844610 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -297,12 +297,12 @@ def django_query_postprocessor( return data -def func_has_args(func: Callable) -> bool: +def func_has_args(func) -> bool: """Checks if a function has any args or kwargs.""" return bool(inspect.signature(func).parameters) -def check_component_args(func: Callable, *args, **kwargs): +def check_component_args(func, *args, **kwargs): """ Validate whether a set of args/kwargs would work on the given function. From 805424226d0b69b80d8b3f819a9291de4bee0753 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 01:59:36 -0700 Subject: [PATCH 23/24] auto strip slashes from REACTPY_DEFAULT_HOSTS --- src/reactpy_django/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 5b9dabae..4e4a7492 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -72,11 +72,11 @@ "REACTPY_BACKHAUL_THREAD", False, ) -_reactpy_default_hosts: list[str] | None = getattr( +_default_hosts: list[str] | None = getattr( settings, "REACTPY_DEFAULT_HOSTS", None, ) -REACTPY_DEFAULT_HOSTS = ( - cycle(_reactpy_default_hosts) if _reactpy_default_hosts else None +REACTPY_DEFAULT_HOSTS: cycle[str] | None = ( + cycle([host.strip("/") for host in _default_hosts]) if _default_hosts else None ) From 546adbcb23ececaf2e9e8dfd6b2c39ce2dc286cd Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 18 Aug 2023 02:06:47 -0700 Subject: [PATCH 24/24] error if REACTPY_DEFAULT_HOSTS is not a list of strings --- src/reactpy_django/checks.py | 15 +++++++++++++++ src/reactpy_django/config.py | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 60bd6f4e..7ab9546e 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -263,4 +263,19 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E010", ) ) + + # Check of all values in the list are strings + if isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", None), list): + for host in settings.REACTPY_DEFAULT_HOSTS: + if not isinstance(host, str): + errors.append( + Error( + f"Invalid type {type(host)} within REACTPY_DEFAULT_HOSTS.", + hint="REACTPY_DEFAULT_HOSTS should be a list of strings.", + obj=settings.REACTPY_DEFAULT_HOSTS, + id="reactpy_django.E011", + ) + ) + break + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 4e4a7492..24aff5f6 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -78,5 +78,7 @@ None, ) REACTPY_DEFAULT_HOSTS: cycle[str] | None = ( - cycle([host.strip("/") for host in _default_hosts]) if _default_hosts else None + cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) + if _default_hosts + else None )