From aae9c7a67a7586bca657f400716c56a366487097 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sun, 20 Aug 2023 05:22:43 -0700 Subject: [PATCH 1/6] Bump ReactPy, refactor template tag, and pretty WS URLs. (#174) - Bumped the minimum ReactPy version to `1.0.2`. - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. --- CHANGELOG.md | 6 +- requirements/pkg-deps.txt | 2 +- src/reactpy_django/templatetags/reactpy.py | 79 ++++++++++++---------- src/reactpy_django/utils.py | 7 +- src/reactpy_django/websocket/consumer.py | 6 +- src/reactpy_django/websocket/paths.py | 10 ++- 6 files changed, 60 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd5eab3f..fe62c373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,11 @@ Using the following categories, list your changes in this order: ## [Unreleased] -- Nothing (yet)! +### Changed + +- Bumped the minimum ReactPy version to `1.0.2`. +- Prettier websocket URLs for components that do not have sessions. +- Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. ## [3.4.0] - 2023-08-18 diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index c5958398..ee7156c3 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,6 +1,6 @@ channels >=4.0.0 django >=4.1.0 -reactpy >=1.0.0, <1.1.0 +reactpy >=1.0.2, <1.1.0 aiofile >=3.0 dill >=0.3.5 orjson >=3.6.0 diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index c174d1b1..1ae88413 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -7,6 +7,7 @@ from django import template from django.http import HttpRequest from django.urls import NoReverseMatch, reverse +from reactpy.core.types import ComponentConstructor from reactpy_django import config, models from reactpy_django.exceptions import ( @@ -15,7 +16,7 @@ InvalidHostError, ) from reactpy_django.types import ComponentParamData -from reactpy_django.utils import check_component_args, func_has_args +from reactpy_django.utils import validate_component_args try: RESOLVED_WEB_MODULES_PATH = reverse("reactpy:web_modules", args=["/"]).strip("/") @@ -55,60 +56,52 @@ def component( """ - - # Determine the host request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").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 + is_local = not host or host.startswith(perceived_host) 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 - if host.find("://") != -1: - protocol = host.split("://")[0] - msg = ( - f"Invalid host provided to component. Contains a protocol '{protocol}://'." - ) - _logger.error(msg) - return failure_context(dotted_path, InvalidHostError(msg)) - - # Fetch the component if needed - if use_current_app: + kwargs.pop("key", "") # `key` is useless for the root node + component_has_args = args or kwargs + user_component: ComponentConstructor | None = None + + # Validate the host + if host and config.REACTPY_DEBUG_MODE: + try: + validate_host(host) + except InvalidHostError as e: + return failure_context(dotted_path, e) + + # Fetch the component + if is_local: 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): + # Validate the component + if is_local and config.REACTPY_DEBUG_MODE: + try: + validate_component_args(user_component, *args, **kwargs) + except ComponentParamError as e: _logger.error(str(e)) - else: + return failure_context(dotted_path, e) + + # Store args & kwargs in the database (fetched by our websocket later) + if component_has_args: + try: + save_component_params(args, kwargs, uuid) + except Exception as e: _logger.exception( "An unknown error has occurred while saving component params for '%s'.", dotted_path, ) - return failure_context(dotted_path, e) + return failure_context(dotted_path, e) # Return the template rendering context return { @@ -117,7 +110,9 @@ def component( "reactpy_host": host or perceived_host, "reactpy_url_prefix": config.REACTPY_URL_PREFIX, "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, - "reactpy_component_path": f"{dotted_path}/{uuid}/", + "reactpy_component_path": f"{dotted_path}/{uuid}/" + if component_has_args + else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, } @@ -136,3 +131,13 @@ def save_component_params(args, kwargs, uuid): model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save() + + +def validate_host(host: str): + if "://" in host: + protocol = host.split("://")[0] + msg = ( + f"Invalid host provided to component. Contains a protocol '{protocol}://'." + ) + _logger.error(msg) + raise InvalidHostError(msg) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 22844610..0776bc3b 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -297,12 +297,7 @@ def django_query_postprocessor( return data -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, *args, **kwargs): +def validate_component_args(func, *args, **kwargs): """ Validate whether a set of args/kwargs would work on the given function. diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index aa0c2006..a2a76cab 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -22,7 +22,7 @@ from reactpy.core.serve import serve_layout from reactpy_django.types import ComponentParamData, ComponentWebsocket -from reactpy_django.utils import db_cleanup, func_has_args +from reactpy_django.utils import db_cleanup _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -124,7 +124,7 @@ async def run_dispatcher(self): scope = self.scope dotted_path = scope["url_route"]["kwargs"]["dotted_path"] - uuid = scope["url_route"]["kwargs"]["uuid"] + uuid = scope["url_route"]["kwargs"].get("uuid") search = scope["query_string"].decode() self.recv_queue: asyncio.Queue = asyncio.Queue() connection = Connection( # For `use_connection` @@ -151,7 +151,7 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: - if func_has_args(component_constructor): + if uuid: # Always clean up expired entries first await database_sync_to_async(db_cleanup, thread_sensitive=False)() diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index 039ee5ba..fa185565 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -1,3 +1,4 @@ +from channels.routing import URLRouter # noqa: E402 from django.urls import path from reactpy_django.config import REACTPY_URL_PREFIX @@ -5,8 +6,13 @@ from .consumer import ReactpyAsyncWebsocketConsumer REACTPY_WEBSOCKET_ROUTE = path( - f"{REACTPY_URL_PREFIX}///", - ReactpyAsyncWebsocketConsumer.as_asgi(), + f"{REACTPY_URL_PREFIX}//", + URLRouter( + [ + path("/", ReactpyAsyncWebsocketConsumer.as_asgi()), + path("", ReactpyAsyncWebsocketConsumer.as_asgi()), + ] + ), ) """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. From 085cebe264dc28cf08573803330cb228501a25a2 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 13:54:04 -0700 Subject: [PATCH 2/6] Docs version control and new theme (#176) - Version controlled docs - Every time something is committed to `main`, it will update the `develop` docs. - Every time a version is released, it will push a new version number to the docs. - Use our new ReactPy docs theme, which is an even better imitation of ReactJS docs - Fix `GITHUB_ACTIONS` env type conversion --- .github/workflows/publish-develop-docs.yml | 18 + .github/workflows/publish-docs.yml | 17 - .github/workflows/publish-release-docs.yml | 19 ++ CHANGELOG.md | 4 + README.md | 9 +- docs/overrides/main.html | 7 + docs/src/changelog/index.md | 8 +- docs/src/contribute/code.md | 12 +- docs/src/contribute/docs.md | 8 +- docs/src/contribute/running-tests.md | 25 +- docs/src/features/components.md | 14 +- docs/src/features/decorators.md | 10 +- docs/src/features/hooks.md | 14 +- docs/src/features/settings.md | 24 +- docs/src/features/template-tag.md | 30 +- docs/src/features/utils.md | 8 +- docs/src/get-started/choose-django-app.md | 19 +- docs/src/get-started/create-component.md | 29 +- docs/src/get-started/installation.md | 34 +- docs/src/get-started/learn-more.md | 16 +- docs/src/get-started/register-view.md | 16 +- docs/src/get-started/run-webserver.md | 18 +- docs/src/get-started/use-template-tag.md | 14 +- docs/src/static/css/extra.css | 376 +++++++++++++++++++++ docs/src/static/js/extra.js | 19 ++ docs/src/stylesheets/extra.css | 246 -------------- mkdocs.yml | 16 +- noxfile.py | 6 +- requirements/build-docs.txt | 2 + src/reactpy_django/components.py | 4 +- src/reactpy_django/templatetags/reactpy.py | 1 - tests/test_app/tests/test_components.py | 9 +- 32 files changed, 678 insertions(+), 374 deletions(-) create mode 100644 .github/workflows/publish-develop-docs.yml delete mode 100644 .github/workflows/publish-docs.yml create mode 100644 .github/workflows/publish-release-docs.yml create mode 100644 docs/src/static/css/extra.css create mode 100644 docs/src/static/js/extra.js delete mode 100644 docs/src/stylesheets/extra.css diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml new file mode 100644 index 00000000..0269e82c --- /dev/null +++ b/.github/workflows/publish-develop-docs.yml @@ -0,0 +1,18 @@ +name: Publish Develop Docs +on: + push: + branches: + - main +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install -r requirements/build-docs.txt + - name: Publish Develop Docs + run: mike deploy --push develop diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml deleted file mode 100644 index d6339c6b..00000000 --- a/.github/workflows/publish-docs.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Publish Docs -on: - push: - branches: - - main -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - uses: actions/setup-python@v4 - with: - python-version: 3.x - - run: pip install -r requirements/build-docs.txt - - run: mkdocs gh-deploy --force diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml new file mode 100644 index 00000000..15ffb8c7 --- /dev/null +++ b/.github/workflows/publish-release-docs.yml @@ -0,0 +1,19 @@ +name: Publish Release Docs + +on: + release: + types: [published] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: pip install -r requirements/build-docs.txt + - name: Publish ${{ github.event.release.name }} Docs + run: mike deploy --push --update-aliases ${{ github.event.release.name }} latest diff --git a/CHANGELOG.md b/CHANGELOG.md index fe62c373..013688c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ Using the following categories, list your changes in this order: ## [Unreleased] +### Added + +- [ReactPy-Django docs](https://reactive-python.github.io/reactpy-django/) are now version controlled via [mike](https://github.com/jimporter/mike)! + ### Changed - Bumped the minimum ReactPy version to `1.0.2`. diff --git a/README.md b/README.md index f2d0f592..281290e3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ +[ReactPy-Django](https://github.com/reactive-python/reactpy-django) is used to add used to add [ReactPy](https://reactpy.dev/) support to an existing **Django project**. + [ReactPy](https://reactpy.dev/) is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in [ReactJS](https://reactjs.org/). Designed with simplicity in mind, ReactPy can be used by those without web development experience while also being powerful enough to grow with your ambitions. @@ -77,11 +79,12 @@ def hello_world(recipient: str): -In your **Django app**'s HTML template, you can now embed your ReactPy component using the `component` template tag. Within this tag, you will need to type in your dotted path to the component function as the first argument. - -Additionally, you can pass in `args` and `kwargs` into your component function. For example, after reading the code below, pay attention to how the function definition for `hello_world` (_in the previous example_) accepts a `recipient` argument. +In your **Django app**'s HTML template, you can now embed your ReactPy component using the `component` template tag. Within this tag, you will need to type in the dotted path to the component. + +Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` (_from the previous example_) accepts a `recipient` argument. + ```jinja diff --git a/docs/overrides/main.html b/docs/overrides/main.html index e70aa10c..0b173292 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -11,3 +11,10 @@ {% endif %} {% endblock %} + +{% block outdated %} +You're not viewing the latest version. + + Click here to go to latest. + +{% endblock %} diff --git a/docs/src/changelog/index.md b/docs/src/changelog/index.md index 9c2bef97..1ecf88e2 100644 --- a/docs/src/changelog/index.md +++ b/docs/src/changelog/index.md @@ -3,8 +3,12 @@ hide: - toc --- -!!! summary "Attribution" +

- {% include-markdown "../../../CHANGELOG.md" start="" end="" %} +{% include-markdown "../../../CHANGELOG.md" start="" end="" %} + +

+ +--- {% include-markdown "../../../CHANGELOG.md" start="" %} diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index e53f4ecc..cc5b8e58 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -1,12 +1,18 @@ ## Overview -!!! summary "Overview" +

You will need to set up a Python environment to develop ReactPy-Django. -??? tip "Looking to contribute features that are not Django specific?" +

- Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as: components, hooks, events, and more. +!!! note + + Looking to contribute features that are not Django specific? + + Everything within the `reactpy-django` repository must be specific to Django integration. Check out the [ReactPy Core documentation](https://reactpy.dev/docs/about/contributor-guide.html) to contribute general features such as components, hooks, and events. + +--- ## Modifying Code diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index 8312a26a..33b83fb7 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- You will need to set up a Python environment to preview docs changes. +You will need to set up a Python environment to create, test, and preview docs changes. + +

+ +--- ## Modifying Docs diff --git a/docs/src/contribute/running-tests.md b/docs/src/contribute/running-tests.md index 79d3b114..fe01be26 100644 --- a/docs/src/contribute/running-tests.md +++ b/docs/src/contribute/running-tests.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- You will need to set up a Python environment to run out test suite. +You will need to set up a Python environment to run the ReactPy-Django test suite. + +

+ +--- ## Running Tests @@ -29,17 +33,26 @@ By running the command below you can run the full test suite: nox -s test ``` -Or, if you want to run the tests in the foreground: +Or, if you want to run the tests in the background: ```bash linenums="0" -nox -s test -- --headed +nox -s test -- --headless ``` -## Only Django Tests +## Django Tests -Alternatively, if you want to only run Django related tests, you can use the following command: +If you want to only run our Django tests in your current environment, you can use the following command: ```bash linenums="0" cd tests python manage.py test ``` + +## Django Test Webserver + +If you want to manually run the Django test application, you can use the following command: + +```bash linenums="0" +cd tests +python manage.py runserver +``` diff --git a/docs/src/features/components.md b/docs/src/features/components.md index 900b9fe2..f4dae25a 100644 --- a/docs/src/features/components.md +++ b/docs/src/features/components.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Prefabricated components can be used within your `components.py` to help simplify development. +We supply some pre-designed that components can be used to help simplify development. + +

+ +--- ## View To Component @@ -29,7 +33,7 @@ Convert any Django view into a ReactPy component by using this decorator. Compat | Type | Description | | --- | --- | - | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns an ReactPy component. All parameters are directly provided to your view, besides `key` which is used by ReactPy. | + | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns a ReactPy component. All parameters are directly provided to your view, besides `key` which is used by ReactPy. | ??? Warning "Potential information exposure when using `compatibility = True`" @@ -180,7 +184,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | ??? question "Should I put `django_css` at the top of my HTML?" @@ -235,7 +239,7 @@ Allows you to defer loading JavaScript until a component begins rendering. This | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | ??? question "Should I put `django_js` at the bottom of my HTML?" diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md index 45548799..ee79a4e8 100644 --- a/docs/src/features/decorators.md +++ b/docs/src/features/decorators.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Decorator utilities can be used within your `components.py` to help simplify development. +Decorator functions can be used within your `components.py` to help simplify development. + +

+ +--- ## Auth Required @@ -31,7 +35,7 @@ This decorator is commonly used to selectively render a component only if a user | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | | `VdomDict` | An `reactpy.html` snippet. | | `None` | No component render. | diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md index 5916776a..6c57fda3 100644 --- a/docs/src/features/hooks.md +++ b/docs/src/features/hooks.md @@ -1,14 +1,18 @@ ## Overview -!!! summary "Overview" +

- Prefabricated hooks can be used within your `components.py` to help simplify development. +Prefabricated hooks can be used within your `components.py` to help simplify development. -??? tip "Looking for standard React hooks?" +

- The `reactpy-django` package only contains django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://github.com/reactive-python/reactpy). Since `reactpy` is installed alongside `reactpy-django`, you can import them at any time. +!!! note - Check out the [ReactPy Core docs](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks) to see what hooks are available! + Looking for standard React hooks? + + This package only contains Django specific hooks. Standard hooks can be found within [`reactive-python/reactpy`](https://reactpy.dev/docs/reference/hooks-api.html#basic-hooks). + +--- ## Use Query diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 29ca81ad..8cf11815 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -1,8 +1,18 @@ ## Overview -!!! summary "Overview" +

- Your **Django project's** `settings.py` can modify the behavior of ReactPy. +Your **Django project's** `settings.py` can modify the behavior of ReactPy. + +

+ +!!! note + + The default configuration of ReactPy is suitable for the vast majority of use cases. + + You should only consider changing settings when the necessity arises. + +--- ## Primary Configuration @@ -13,18 +23,12 @@ These are ReactPy-Django's default settings values. You can modify these values | 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_DATABASE` | `#!python "default"` | `#!python "my-reactpy-database"` | Database used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router 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`. | -| `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. | +| `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | - -??? question "Do I need to modify my settings?" - - 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/docs/src/features/template-tag.md b/docs/src/features/template-tag.md index f1424059..e04c5bb9 100644 --- a/docs/src/features/template-tag.md +++ b/docs/src/features/template-tag.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Template tags can be used within your Django templates such as `my-template.html` to import ReactPy features. +Django template tags can be used within your HTML templates to provide ReactPy features. + +

+ +--- ## Component @@ -20,6 +24,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 | + | `class` | `str | None` | The HTML class to apply to the top-level component div. | `None` | + | `key` | `str | None` | Force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. | `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 | @@ -27,7 +33,7 @@ The `component` template tag can be used to insert any number of ReactPy compone | Type | Description | | --- | --- | - | `Component` | An ReactPy component. | + | `Component` | A ReactPy component. | @@ -56,24 +62,6 @@ The `component` template tag can be used to insert any number of ReactPy compone ``` - - -??? info "Reserved keyword arguments: `class` and `key`" - - For this template tag, there are two reserved keyword arguments: `class` and `key` - - - `class` allows you to apply a HTML class to the top-level component div. This is useful for styling purposes. - - `key` allows you to force the component's root node to use a [specific key value](https://reactpy.dev/docs/guides/creating-interfaces/rendering-data/index.html#organizing-items-with-keys). Using `key` within a template tag is effectively useless. - - === "my-template.html" - - ```jinja - ... - {% component "example.components.my_component" class="my-html-class" key=123 %} - ... - ``` - - ??? question "Can I render components on a different server (distributed computing)?" diff --git a/docs/src/features/utils.md b/docs/src/features/utils.md index dfadb9f9..9ba8e312 100644 --- a/docs/src/features/utils.md +++ b/docs/src/features/utils.md @@ -1,8 +1,12 @@ ## Overview -!!! summary "Overview" +

- Utility functions that you can use when needed. +Utility functions provide various miscellaneous functionality. These are typically not used, but are available for advanced use cases. + +

+ +--- ## Django Query Postprocessor diff --git a/docs/src/get-started/choose-django-app.md b/docs/src/get-started/choose-django-app.md index 61dcfdba..1594baa5 100644 --- a/docs/src/get-started/choose-django-app.md +++ b/docs/src/get-started/choose-django-app.md @@ -1,16 +1,25 @@ ## Overview -!!! summary "Overview" +

- Set up a **Django Project** with at least one app. +Set up a **Django Project** with at least one app. -## Choose a Django App +

-If you have reached this point, you should have already [installed ReactPy-Django](../get-started/installation.md) through the previous steps. +!!! note + + If you have reached this point, you should have already [installed ReactPy-Django](../get-started/installation.md) through the previous steps. + +--- + +## Deciding which Django App to use You will now need to pick at least one **Django app** to start using ReactPy-Django on. -For the examples within this section, we will assume you have placed the files [generated by `startapp`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app) directly into your **Django project** folder. This is common for small projects. +For the following examples, we will assume the following: + +1. You have a **Django app** named `my_app`, which was created by Django's [`startapp` command](https://docs.djangoproject.com/en/dev/intro/tutorial01/#creating-the-polls-app). +2. You have placed `my_app` directly into your **Django project** folder (`./example_project/my_app`). This is common for small projects. ??? question "How do I organize my Django project for ReactPy?" diff --git a/docs/src/get-started/create-component.md b/docs/src/get-started/create-component.md index 032906e7..1f94c308 100644 --- a/docs/src/get-started/create-component.md +++ b/docs/src/get-started/create-component.md @@ -1,12 +1,20 @@ ## Overview -!!! summary "Overview" +

- Create a component function using our decorator. +You can let ReactPy know what functions are components by using the `#!python @component` decorator. -## Create a Component +

-{% include-markdown "../../../README.md" start="" end="" %} +--- + +## Declaring a function as a root component + +You will need a file to start creating ReactPy components. + +We recommend creating a `components.py` file within your chosen **Django app** to start out. For this example, the file path will look like this: `./example_project/my_app/components.py`. + +Within this file, you can define your component functions and then add ReactPy's `#!python @component` decorator. === "components.py" @@ -18,4 +26,15 @@ We recommend creating a `components.py` for small **Django apps**. If your app has a lot of components, you should consider breaking them apart into individual modules such as `components/navbar.py`. - Ultimately, components are referenced by Python dotted path in `my-template.html` (_see next step_). So, at minimum this path needs to be valid to Python's `importlib`. + Ultimately, components are referenced by Python dotted path in `my-template.html` ([_see next step_](./use-template-tag.md)). So, at minimum your component path needs to be valid to Python's `importlib`. + +??? question "What does the decorator actually do?" + + While not all components need to be decorated, there are a few features this decorator adds to your components. + + 1. The ability to be used as a root component. + - The decorator is required for any component that you want to reference in your Django templates ([_see next step_](./use-template-tag.md)). + 2. The ability to use [hooks](../features/hooks.md). + - The decorator is required on any component where hooks are defined. + 3. Scoped failures. + - If a decorated component generates an exception, then only that one component will fail to render. diff --git a/docs/src/get-started/installation.md b/docs/src/get-started/installation.md index bd368ec1..be727947 100644 --- a/docs/src/get-started/installation.md +++ b/docs/src/get-started/installation.md @@ -1,12 +1,18 @@ ## Overview -!!! summary "Overview" +

- ReactPy-Django can be installed from PyPI to an existing **Django project** with minimal configuration. +[ReactPy-Django](https://github.com/reactive-python/reactpy-django) can be used to add used to add [ReactPy](https://github.com/reactive-python/reactpy) support to an existing **Django project**. Minimal configuration is required to get started. -## Step 0: Create a Django Project +

-These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. If not, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. +!!! note + + These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/dev/intro/tutorial01/), which involves creating and installing at least one **Django app**. + + If do not have a **Django project**, check out this [9 minute YouTube tutorial](https://www.youtube.com/watch?v=ZsJRXS_vrw0) created by _IDG TECHtalk_. + +--- ## Step 1: Install from PyPI @@ -84,7 +90,7 @@ Register ReactPy's Websocket using `REACTPY_WEBSOCKET_ROUTE`. If you do not have an `asgi.py`, follow the [`channels` installation guide](https://channels.readthedocs.io/en/stable/installation.html). -## Step 5: Run Migrations +## Step 5: Run database migrations Run Django's database migrations to initialize ReactPy-Django's database table. @@ -99,3 +105,21 @@ Run Django's check command to verify if ReactPy was set up correctly. ```bash linenums="0" python manage.py check ``` + +## Step 7: Create your first component! + +The [following steps](./choose-django-app.md) will show you how to create your first ReactPy component. + +Prefer a quick summary? Read the **At a Glance** section below. + +!!! info "At a Glance" + + **`my_app/components.py`** + + {% include-markdown "../../../README.md" start="" end="" %} + + --- + + **`my_app/templates/my-template.html`** + + {% include-markdown "../../../README.md" start="" end="" %} diff --git a/docs/src/get-started/learn-more.md b/docs/src/get-started/learn-more.md index 8b58400d..3ee45968 100644 --- a/docs/src/get-started/learn-more.md +++ b/docs/src/get-started/learn-more.md @@ -1,11 +1,17 @@ # :confetti_ball: Congratulations :confetti_ball: -If you followed the previous steps, you have now created a "Hello World" component! +

-The docs you are reading only covers our Django integration. To learn more about features, such as interactive events and hooks, check out the [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html)! +If you followed the previous steps, you have now created a "Hello World" component using ReactPy-Django! -Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. +

-=== "Learn More" +!!! info "Deep Dive" - [ReactPy-Django Advanced Usage](../features/components.md){ .md-button .md-button--primary} [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html){ .md-button .md-button--primary } [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X){ .md-button .md-button--primary } + The docs you are reading only covers our Django integration. To learn more, check out one of the following links: + + - [ReactPy-Django Feature Reference](../features/components.md) + - [ReactPy Core Documentation](https://reactpy.dev/docs/guides/creating-interfaces/index.html) + - [Ask Questions on Discord](https://discord.gg/uNb5P4hA9X) + + Additionally, the vast majority of tutorials/guides you find for ReactJS can be applied to ReactPy. diff --git a/docs/src/get-started/register-view.md b/docs/src/get-started/register-view.md index 7f21bd66..472d722b 100644 --- a/docs/src/get-started/register-view.md +++ b/docs/src/get-started/register-view.md @@ -1,16 +1,22 @@ ## Overview -!!! summary "Overview" +

- Select your template containing an ReactPy component, and render it using a Django view. +Render your template containing your ReactPy component using a Django view. -## Register a View +

-We will assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but here's a simple example below. +!!! Note + + We assume you have [created a Django View](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) before, but we have included a simple example below. + +--- + +## Creating a Django view and URL path Within your **Django app**'s `views.py` file, you will need to create a function to render the HTML template containing your ReactPy components. -In this example, we will create a view that renders `my-template.html` (_from the previous step_). +In this example, we will create a view that renders `my-template.html` ([_from the previous step_](./use-template-tag.md)). === "views.py" diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md index 5a1d27dd..cefeafc3 100644 --- a/docs/src/get-started/run-webserver.md +++ b/docs/src/get-started/run-webserver.md @@ -1,10 +1,14 @@ ## Overview -!!! summary "Overview" +

- Run a webserver to display your Django view. +Run a webserver to display your Django view. -## Run the Webserver +

+ +--- + +## Viewing your component using a webserver To test your new Django view, run the following command to start up a development webserver. @@ -12,10 +16,12 @@ To test your new Django view, run the following command to start up a developmen python manage.py runserver ``` -Now you can navigate to your **Django project** URL that contains an ReactPy component, such as `http://127.0.0.1:8000/example/` (_from the previous step_). +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)). If you copy-pasted our example component, you will now see your component display "Hello World". -??? warning "Do not use `manage.py runserver` for production." +!!! warning "Pitfall" + + Do not use `manage.py runserver` for production. - The webserver contained within `manage.py runserver` is only intended for development and testing purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). + This command is only intended for development purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/dev/howto/deployment/). diff --git a/docs/src/get-started/use-template-tag.md b/docs/src/get-started/use-template-tag.md index e108e407..8ef210fd 100644 --- a/docs/src/get-started/use-template-tag.md +++ b/docs/src/get-started/use-template-tag.md @@ -1,21 +1,25 @@ ## Overview -!!! summary "Overview" +

- Decide where the component will be displayed by using our template tag. +Decide where the component will be displayed by using our template tag. -## Use the Template Tag +

+ +--- + +## Embedding a component in a template {% include-markdown "../../../README.md" start="" end="" %} +Additionally, you can pass in `args` and `kwargs` into your component function. After reading the code below, pay attention to how the function definition for `hello_world` ([_from the previous step_](./create-component.md)) accepts a `recipient` argument. + === "my-template.html" {% include-markdown "../../../README.md" start="" end="" %} {% include-markdown "../features/template-tag.md" start="" end="" %} -{% include-markdown "../features/template-tag.md" start="" end="" %} - {% include-markdown "../features/template-tag.md" start="" end="" %} ??? question "Where is my templates folder?" diff --git a/docs/src/static/css/extra.css b/docs/src/static/css/extra.css new file mode 100644 index 00000000..d3967666 --- /dev/null +++ b/docs/src/static/css/extra.css @@ -0,0 +1,376 @@ +/* Variable overrides */ +:root { + --code-max-height: 17.25rem; +} + +[data-md-color-scheme="slate"] { + --md-code-hl-color: #ffffcf1c; + --md-hue: 225; + --md-default-bg-color: hsla(var(--md-hue), 15%, 16%, 1); + --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); + --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); + --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); + --md-code-bg-color: #16181d; + --md-primary-fg-color: #2b3540; + --md-default-fg-color--light: #fff; + --md-typeset-a-color: #00b0f0; + --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); + --tabbed-labels-color: rgb(52 58 70); +} + +[data-md-color-scheme="default"] { + --tabbed-labels-color: #7d829e26; +} + +/* General admonition styling */ +/* TODO: Write this in a way that supports the light theme */ +[data-md-color-scheme="slate"] .md-typeset details, +[data-md-color-scheme="slate"] .md-typeset .admonition { + border-color: transparent !important; +} + +.md-typeset :is(.admonition, details) { + margin: 0.55em 0; +} + +.md-typeset .admonition { + font-size: 0.7rem; +} + +.md-typeset .admonition:focus-within, +.md-typeset details:focus-within { + box-shadow: var(--md-shadow-z1) !important; +} + +/* Colors for "summary" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.summary { + background: #353a45; + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title { + font-size: 1rem; + background: transparent; + padding-left: 0.6rem; + padding-bottom: 0; +} + +[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title:before { + display: none; +} + +[data-md-color-scheme="slate"] .md-typeset .admonition.summary { + border-color: #ffffff17 !important; +} + +/* Colors for "note" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.note { + background: rgb(43 110 98/ 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .note .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(68 172 153); +} + +[data-md-color-scheme="slate"] .md-typeset .note .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(68 172 153); +} + +.md-typeset .note > .admonition-title:before, +.md-typeset .note > summary:before { + -webkit-mask-image: var(--md-admonition-icon--abstract); + mask-image: var(--md-admonition-icon--abstract); +} + +/* Colors for "warning" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.warning { + background: rgb(182 87 0 / 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(219 125 39); +} + +[data-md-color-scheme="slate"] .md-typeset .warning .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(219 125 39); +} + +/* Colors for "info" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.info { + background: rgb(43 52 145 / 0.2); + padding: 0.8rem 1.4rem; + border-radius: 0.8rem; +} + +[data-md-color-scheme="slate"] .md-typeset .info .admonition-title { + font-size: 1rem; + background: transparent; + padding-bottom: 0; + color: rgb(136 145 236); +} + +[data-md-color-scheme="slate"] .md-typeset .info .admonition-title:before { + font-size: 1.1rem; + background-color: rgb(136 145 236); +} + +/* Colors for "example" admonition */ +[data-md-color-scheme="slate"] .md-typeset .admonition.example { + background: rgb(94 104 126); + border-radius: 0.4rem; +} + +[data-md-color-scheme="slate"] .md-typeset .example .admonition-title { + background: rgb(78 87 105); + color: rgb(246 247 249); +} + +[data-md-color-scheme="slate"] .md-typeset .example .admonition-title:before { + background-color: rgb(246 247 249); +} + +[data-md-color-scheme="slate"] .md-typeset .admonition.example code { + background: transparent; + color: #fff; +} + +/* Move the sidebars to the edges of the page */ +.md-main__inner.md-grid { + margin-left: 0; + margin-right: 0; + max-width: unset; + display: flex; + justify-content: center; +} + +.md-sidebar--primary { + margin-right: auto; +} + +.md-sidebar.md-sidebar--secondary { + margin-left: auto; +} + +.md-content { + max-width: 56rem; +} + +/* Maintain content positioning even if sidebars are disabled */ +@media screen and (min-width: 76.1875em) { + .md-sidebar { + display: block; + } + + .md-sidebar[hidden] { + visibility: hidden; + } +} + +/* Sidebar styling */ +@media screen and (min-width: 76.1875em) { + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + text-transform: uppercase; + } + + .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + } + + .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { + color: rgb(133 142 159); + margin: 0.5rem; + } + + .md-nav__item .md-nav__link { + position: relative; + } + + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { + color: unset; + } + + .md-nav__item + .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.2; + z-index: -1; + background-color: grey; + } + + .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background-color: var(--md-typeset-a-color); + } + + .md-nav__link { + padding: 0.5rem 0.5rem 0.5rem 1rem; + margin: 0; + border-radius: 0 10px 10px 0; + font-weight: 600; + overflow: hidden; + } + + .md-sidebar__scrollwrap { + margin: 0; + } + + [dir="ltr"] + .md-nav--lifted + .md-nav[data-md-level="1"] + > .md-nav__list + > .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item { + padding: 0; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 300; + } + + .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { + font-weight: 400; + padding-left: 1.25rem; + } +} + +/* Table of Contents styling */ +@media screen and (min-width: 60em) { + [data-md-component="sidebar"] .md-nav__title[for="__toc"] { + text-transform: uppercase; + margin: 0.5rem; + margin-left: 0; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active { + position: relative; + } + + [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.15; + z-index: -1; + background-color: var(--md-typeset-a-color); + } + + [data-md-component="toc"] .md-nav__link { + padding: 0.5rem 0.5rem; + margin: 0; + border-radius: 10px 0 0 10px; + } + [dir="ltr"] .md-sidebar__inner { + padding: 0; + } + + .md-nav__item { + padding: 0; + } +} + +/* Font changes */ +.md-typeset { + font-weight: 300; +} + +.md-typeset h1 { + font-weight: 500; + margin: 0; + font-size: 2.5em; +} + +.md-typeset h2 { + font-weight: 500; +} + +.md-typeset h3 { + font-weight: 600; +} + +/* Intro section styling */ +p.intro { + font-size: 0.9rem; + font-weight: 500; +} + +/* Hide invisible jump selectors */ +h2#overview { + visibility: hidden; + height: 0; + margin: 0; + padding: 0; +} + +/* Code blocks */ +.md-typeset pre > code { + border-radius: 16px; +} + +.md-typeset .highlighttable .linenos { + max-height: var(--code-max-height); + overflow: hidden; +} + +.md-typeset .tabbed-block .highlighttable code { + border-radius: 0; +} + +.md-typeset .tabbed-block { + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + overflow: hidden; +} + +.js .md-typeset .tabbed-labels { + background: var(--tabbed-labels-color); + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} + +.md-typeset .tabbed-labels > label { + font-weight: 400; + font-size: 0.7rem; + padding-top: 0.55em; + padding-bottom: 0.35em; +} + +.md-typeset pre > code { + max-height: var(--code-max-height); +} + +/* Reduce height of outdated banner */ +.md-banner__inner { + margin: 0.45rem auto; +} diff --git a/docs/src/static/js/extra.js b/docs/src/static/js/extra.js new file mode 100644 index 00000000..50e2dda3 --- /dev/null +++ b/docs/src/static/js/extra.js @@ -0,0 +1,19 @@ +// Sync scrolling between the code node and the line number node +// Event needs to be a separate function, otherwise the event will be triggered multiple times +let code_with_lineno_scroll_event = function () { + let tr = this.parentNode.parentNode.parentNode.parentNode; + let lineno = tr.querySelector(".linenos"); + lineno.scrollTop = this.scrollTop; +}; + +const observer = new MutationObserver((mutations) => { + let lineno = document.querySelectorAll(".linenos~.code"); + lineno.forEach(function (element) { + let code = element.parentNode.querySelector("code"); + code.addEventListener("scroll", code_with_lineno_scroll_event); + }); +}); + +observer.observe(document.body, { + childList: true, +}); diff --git a/docs/src/stylesheets/extra.css b/docs/src/stylesheets/extra.css deleted file mode 100644 index 1599d525..00000000 --- a/docs/src/stylesheets/extra.css +++ /dev/null @@ -1,246 +0,0 @@ -/* Reduce the insane amounts of white space the default theme has */ -.md-typeset :is(.admonition, details) { - margin: 0.55em 0; -} - -.md-typeset .tabbed-labels > label { - padding-top: 0; - padding-bottom: 0.35em; -} - -/* Font size for admonitions */ -.md-typeset .admonition.summary, -.md-typeset details.summary { - font-size: 0.7rem; -} - -/* Colors for admonitions */ -[data-md-color-scheme="slate"] - .md-typeset - details:not(.warning, .failure, .danger, .bug) - > .admonition-title, -[data-md-color-scheme="slate"] - .md-typeset - details:not(.warning, .failure, .danger, .bug) - > summary { - background: var(--md-primary-fg-color) !important; -} - -[data-md-color-scheme="slate"] .md-typeset .admonition, -[data-md-color-scheme="slate"] .md-typeset details { - border-color: transparent !important; -} - -[data-md-color-scheme="slate"] .md-typeset details > .admonition-title:after, -[data-md-color-scheme="slate"] .md-typeset details > summary:after { - color: var(--md-admonition-fg-color) !important; -} - -/* Colors for summary admonition */ -[data-md-color-scheme="slate"] .md-typeset .admonition.summary, -[data-md-color-scheme="slate"] .md-typeset details.summary { - background: #353a45; - padding: 0.8rem 1.4rem; - border-radius: 0.8rem; -} - -[data-md-color-scheme="slate"] .md-typeset details.summary > .admonition-title, -[data-md-color-scheme="slate"] .md-typeset details.summary > summary { - background: #353a45 !important; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title, -[data-md-color-scheme="slate"] .md-typeset .summary summary { - font-size: 1rem; - background: transparent; - padding-left: 0.6rem; - padding-bottom: 0; -} - -[data-md-color-scheme="slate"] .md-typeset .summary .admonition-title:before { - display: none; -} - -[data-md-color-scheme="slate"] .md-typeset .admonition, -[data-md-color-scheme="slate"] .md-typeset details { - border-color: #ffffff17 !important; -} - -/* Move the sidebars to the edges of the page */ -.md-main__inner.md-grid { - margin-left: 0; - margin-right: 0; - max-width: unset; - display: flex; - justify-content: center; -} - -.md-sidebar--primary { - margin-right: auto; -} - -.md-sidebar.md-sidebar--secondary { - margin-left: auto; -} - -.md-content { - max-width: 56rem; -} - -/* Maintain content positioning even if sidebars are disabled */ -@media screen and (min-width: 76.1875em) { - .md-sidebar { - display: block; - } - - .md-sidebar[hidden] { - visibility: hidden; - } -} - -/* Sidebar styling */ -@media screen and (min-width: 76.1875em) { - .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - } - - .md-nav--lifted > .md-nav__list > .md-nav__item--active > .md-nav__link { - color: rgb(133 142 159); - margin: 0.5rem; - } - - .md-nav__item .md-nav__link { - position: relative; - } - - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active) { - color: unset; - } - - .md-nav__item - .md-nav__link:is(:focus, :hover):not(.md-nav__link--active):before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.2; - z-index: -1; - background-color: grey; - } - - .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - .md-nav__link { - padding: 0.5rem 0.5rem 0.5rem 1rem; - margin: 0; - border-radius: 0 10px 10px 0; - font-weight: 500; - } - - .md-sidebar__scrollwrap { - margin: 0; - } - - [dir="ltr"] - .md-nav--lifted - .md-nav[data-md-level="1"] - > .md-nav__list - > .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item { - padding: 0; - } - - .md-nav__item--nested .md-nav__item .md-nav__item .md-nav__link { - font-weight: 400; - padding-left: 1.5rem; - } -} - -/* Table of Contents styling */ -@media screen and (min-width: 60em) { - [data-md-component="sidebar"] .md-nav__title[for="__toc"] { - text-transform: uppercase; - margin: 0.5rem; - margin-left: 0; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active { - position: relative; - } - - [data-md-component="toc"] .md-nav__item .md-nav__link--active:before { - content: ""; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - opacity: 0.15; - z-index: -1; - background-color: var(--md-typeset-a-color); - } - - [data-md-component="toc"] .md-nav__link { - padding: 0.5rem 0.5rem; - margin: 0; - border-radius: 10px 0 0 10px; - } - [dir="ltr"] .md-sidebar__inner { - padding: 0; - } - - .md-nav__item { - padding: 0; - } -} - -/* Page background color */ -[data-md-color-scheme="slate"] { - --md-hue: 225; - --md-default-bg-color: hsla(var(--md-hue), 15%, 16%, 1); - --md-default-bg-color--light: hsla(var(--md-hue), 15%, 16%, 0.54); - --md-default-bg-color--lighter: hsla(var(--md-hue), 15%, 16%, 0.26); - --md-default-bg-color--lightest: hsla(var(--md-hue), 15%, 16%, 0.07); - --md-code-bg-color: #16181d; - --md-primary-fg-color: #2b3540; - --md-default-fg-color--light: #fff; - --md-typeset-a-color: #00b0f0; - --md-code-hl-comment-color: hsla(var(--md-hue), 75%, 90%, 0.43); -} - -/* Font changes */ -.md-typeset h1 { - font-weight: 500; -} - -.md-typeset h1:not([id]) { - display: none; -} - -.md-typeset h2 { - font-weight: 400; -} - -/* Hide invisible jump selectors */ -h2#overview { - visibility: hidden; - height: 0; - margin: 0; - padding: 0; -} diff --git a/mkdocs.yml b/mkdocs.yml index fa7b5f90..e5b08921 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -49,6 +49,8 @@ theme: - content.code.copy icon: repo: fontawesome/brands/github + logo: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg + favicon: https://raw.githubusercontent.com/reactive-python/reactpy/main/branding/svg/reactpy-logo-square.svg markdown_extensions: - toc: @@ -65,11 +67,14 @@ markdown_extensions: - pymdownx.inlinehilite - admonition - attr_list + - md_in_html + - pymdownx.keys plugins: - search - - git-authors - include-markdown + - section-index + - git-authors - minify: minify_html: true minify_js: true @@ -84,9 +89,14 @@ plugins: extra: generator: false + version: + provider: mike + +extra_javascript: + - static/js/extra.js extra_css: - - stylesheets/extra.css + - static/css/extra.css watch: - docs @@ -94,7 +104,7 @@ watch: - README.md - CHANGELOG.md -site_name: ReactPy-Django Docs +site_name: ReactPy-Django site_author: Archmonger site_description: React for Django developers. copyright: Copyright © 2023 Reactive Python diff --git a/noxfile.py b/noxfile.py index 43fb2a40..accaf6a1 100644 --- a/noxfile.py +++ b/noxfile.py @@ -39,9 +39,9 @@ def test_suite(session: Session) -> None: session.env["REACTPY_DEBUG_MODE"] = "1" posargs = session.posargs[:] - if "--headed" in posargs: - posargs.remove("--headed") - session.env["PLAYWRIGHT_HEADED"] = "1" + if "--headless" in posargs: + posargs.remove("--headless") + session.env["PLAYWRIGHT_HEADLESS"] = "1" if "--no-debug-mode" not in posargs: posargs.append("--debug-mode") diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index 9ae8fcf1..0e7af6eb 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -6,3 +6,5 @@ linkcheckmd mkdocs-spellcheck[all] mkdocs-git-authors-plugin mkdocs-minify-plugin +mkdocs-section-index +mike diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 9d6e6a98..31df3e2e 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -120,7 +120,7 @@ def view_to_component( transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, ) -> _ViewComponentConstructor | Callable[[Callable], _ViewComponentConstructor]: - """Converts a Django view to an ReactPy component. + """Converts a Django view to a ReactPy component. Keyword Args: view: The view function or class to convert. @@ -134,7 +134,7 @@ def view_to_component( Returns: A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` - and returns an ReactPy component. + and returns a ReactPy component. """ def decorator(view: Callable | View): diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 1ae88413..b174fa9a 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -65,7 +65,6 @@ def component( is_local = not host or host.startswith(perceived_host) uuid = uuid4().hex class_ = kwargs.pop("class", "") - kwargs.pop("key", "") # `key` is useless for the root node component_has_args = args or kwargs user_component: ComponentConstructor | None = None diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 58e6c053..e78d8963 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -2,6 +2,7 @@ import os import socket import sys +from distutils.util import strtobool from functools import partial from channels.testing import ChannelsLiveServerTestCase @@ -13,8 +14,8 @@ from playwright.sync_api import TimeoutError, sync_playwright from reactpy_django.models import ComponentSession -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS") -CLICK_DELAY = 250 if GITHUB_ACTIONS else 25 # Delay in miliseconds. +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") +CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. class ComponentTests(ChannelsLiveServerTestCase): @@ -51,8 +52,8 @@ 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", not GITHUB_ACTIONS))) - cls.browser = cls.playwright.chromium.launch(headless=not headed) + headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) + cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) cls.page = cls.browser.new_page() @classmethod From e5f27da14628c0cb50b7e15354c8bdd47e05f683 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 14:03:53 -0700 Subject: [PATCH 3/6] Add git user to mike deployment (#177) Mike requires a git user otherwise it will fail to deploy. --- .github/workflows/publish-develop-docs.yml | 5 ++++- .github/workflows/publish-release-docs.yml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 0269e82c..3fdb2bab 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -15,4 +15,7 @@ jobs: python-version: 3.x - run: pip install -r requirements/build-docs.txt - name: Publish Develop Docs - run: mike deploy --push develop + run: | + git config user.name github-actions + git config user.email github-actions@github.com + mike deploy --push develop diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index 15ffb8c7..a0c8861d 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -16,4 +16,7 @@ jobs: python-version: 3.x - run: pip install -r requirements/build-docs.txt - name: Publish ${{ github.event.release.name }} Docs - run: mike deploy --push --update-aliases ${{ github.event.release.name }} latest + run: | + git config user.name github-actions + git config user.email github-actions@github.com + mike deploy --push --update-aliases ${{ github.event.release.name }} latest From cd0ebf5646d563ba83edf2804357785100812770 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 14:19:02 -0700 Subject: [PATCH 4/6] Run `mike set-default` on release deployment (#178) Looks like mike deployment won't stick without the set-default call --- .github/workflows/publish-release-docs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index a0c8861d..b58cb0ed 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -20,3 +20,4 @@ jobs: git config user.name github-actions git config user.email github-actions@github.com mike deploy --push --update-aliases ${{ github.event.release.name }} latest + mike set-default --push latest From 4a973b68c16afbcff289f9af56a11e38c922336c Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 21:01:59 -0700 Subject: [PATCH 5/6] Create new `ReactPyDjangoClient` (#175) - Move from JavaScript to TypeScript - Bumped the minimum `@reactpy/client` version to `0.3.1` - Create a Django-specific client - Minor refactoring to various bits of code - Bumped minimum Django version to `4.2`. - More customization for reconnection behavior through new settings! - `REACTPY_RECONNECT_INTERVAL` - `REACTPY_RECONNECT_MAX_INTERVAL` - `REACTPY_RECONNECT_MAX_RETRIES` - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` --- .github/workflows/publish-py.yml | 56 ++--- .github/workflows/publish-release-docs.yml | 1 - .github/workflows/test-src.yml | 2 +- CHANGELOG.md | 13 ++ docs/src/contribute/code.md | 2 +- docs/src/contribute/docs.md | 2 +- docs/src/features/settings.md | 6 +- docs/src/get-started/run-webserver.md | 2 +- pyproject.toml | 1 - requirements/pkg-deps.txt | 2 +- src/js/package-lock.json | 189 ++++++++++------- src/js/package.json | 12 +- src/js/rollup.config.mjs | 4 +- src/js/src/client.ts | 31 +++ src/js/src/index.js | 59 ------ src/js/src/index.ts | 59 ++++++ src/js/src/types.ts | 17 ++ src/js/src/utils.ts | 77 +++++++ src/js/tsconfig.json | 7 + src/reactpy_django/checks.py | 191 +++++++++++++++++- src/reactpy_django/config.py | 24 ++- ...05_alter_componentsession_last_accessed.py | 17 ++ src/reactpy_django/models.py | 2 +- .../templates/reactpy/component.html | 9 +- src/reactpy_django/templatetags/reactpy.py | 11 +- src/reactpy_django/types.py | 4 +- src/reactpy_django/utils.py | 26 +-- src/reactpy_django/websocket/consumer.py | 59 ++++-- tests/test_app/tests/test_database.py | 14 +- 29 files changed, 672 insertions(+), 227 deletions(-) create mode 100644 src/js/src/client.ts delete mode 100644 src/js/src/index.js create mode 100644 src/js/src/index.ts create mode 100644 src/js/src/types.ts create mode 100644 src/js/src/utils.ts create mode 100644 src/js/tsconfig.json create mode 100644 src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml index 4439cb5e..09be8866 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -4,33 +4,33 @@ name: Publish Python on: - release: - types: [published] + release: + types: [published] jobs: - release-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 - with: - node-version: "14.x" - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: "3.x" - - name: Install NPM - run: | - npm install -g npm@7.22.0 - npm --version - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/build-pkg.txt - - name: Build and publish - env: - TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} - TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} - run: | - python -m build --sdist --wheel --outdir dist . - twine upload dist/* + release-package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "20.x" + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: "3.x" + - name: Install NPM + run: | + npm install -g npm@latest + npm --version + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements/build-pkg.txt + - name: Build and publish + env: + TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} + TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} + run: | + python -m build --sdist --wheel --outdir dist . + twine upload dist/* diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-release-docs.yml index b58cb0ed..a0c8861d 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-release-docs.yml @@ -20,4 +20,3 @@ jobs: git config user.name github-actions git config user.email github-actions@github.com mike deploy --push --update-aliases ${{ github.event.release.name }} latest - mike set-default --push latest diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f945ea9d..4a03c49c 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: - node-version: "14.x" + node-version: "20.x" - name: Use Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 013688c7..da060bbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,11 @@ Using the following categories, list your changes in this order: ### Added +- More customization for reconnection behavior through new settings! + - `REACTPY_RECONNECT_INTERVAL` + - `REACTPY_RECONNECT_MAX_INTERVAL` + - `REACTPY_RECONNECT_MAX_RETRIES` + - `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` - [ReactPy-Django docs](https://reactive-python.github.io/reactpy-django/) are now version controlled via [mike](https://github.com/jimporter/mike)! ### Changed @@ -43,6 +48,14 @@ Using the following categories, list your changes in this order: - Bumped the minimum ReactPy version to `1.0.2`. - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. +- Bumped the minimum `@reactpy/client` version to `0.3.1` +- Use TypeScript instead of JavaScript for this repository. +- Bumped minimum Django version to `4.2`. + - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions. + +### Removed + +- `settings.py:REACTPY_RECONNECT_MAX` is removed. See the docs for the new `REACTPY_RECONNECT_*` settings. ## [3.4.0] - 2023-08-18 diff --git a/docs/src/contribute/code.md b/docs/src/contribute/code.md index cc5b8e58..a5b52955 100644 --- a/docs/src/contribute/code.md +++ b/docs/src/contribute/code.md @@ -45,7 +45,7 @@ cd tests python manage.py runserver ``` -Navigate to `http://127.0.0.1:8000` to see if the tests are rendering correctly. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to see if the tests are rendering correctly. ## GitHub Pull Request diff --git a/docs/src/contribute/docs.md b/docs/src/contribute/docs.md index 33b83fb7..913bc0a6 100644 --- a/docs/src/contribute/docs.md +++ b/docs/src/contribute/docs.md @@ -37,7 +37,7 @@ Finally, to verify that everything is working properly, you can manually run the mkdocs serve ``` -Navigate to `http://127.0.0.1:8000` to view a preview of the documentation. +Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation. ## GitHub Pull Request diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md index 8cf11815..00662976 100644 --- a/docs/src/features/settings.md +++ b/docs/src/features/settings.md @@ -24,11 +24,15 @@ These are ReactPy-Django's default settings values. You can modify these values | --- | --- | --- | --- | | `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 used to store ReactPy session data. ReactPy requires a multiprocessing-safe and thread-safe database.
If configuring `REACTPY_DATABASE`, it is mandatory to use our database router 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_SESSION_MAX_AGE` | `#!python 259200` | `#!python 0`, `#!python 60`, `#!python 96000` | Maximum seconds to store ReactPy session data, such as `args` and `kwargs` passed into your component template tag.
Use `#!python 0` to not store any session data. | | `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`. | | `REACTPY_DEFAULT_HOSTS` | `#!python None` | `#!python ["localhost:8000", "localhost:8001", "localhost:8002/subdir" ]` | The default host(s) that can render your ReactPy components. ReactPy will use these hosts in a round-robin fashion, allowing for easy distributed computing.
You can use the `host` argument in your [template tag](../features/template-tag.md#component) as a manual override. | +| `REACTPY_RECONNECT_INTERVAL` | `#!python 750` | `#!python 100`, `#!python 2500`, `#!python 6000` | Milliseconds between client reconnection attempts. This value will gradually increase if `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` is greater than `#!python 1`. | +| `REACTPY_RECONNECT_MAX_INTERVAL` | `#!python 60000` | `#!python 10000`, `#!python 25000`, `#!python 900000` | Maximum milliseconds between client reconnection attempts. This allows setting an upper bound on how high `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` can increase the time between reconnection attempts. | +| `REACTPY_RECONNECT_MAX_RETRIES` | `#!python 150` | `#!python 0`, `#!python 5`, `#!python 300` | Maximum number of reconnection attempts before the client gives up. | +| `REACTPY_RECONNECT_BACKOFF_MULTIPLIER` | `#!python 1.25` | `#!python 1`, `#!python 1.5`, `#!python 3` | Multiplier for the time between client reconnection attempts. On each reconnection attempt, the `REACTPY_RECONNECT_INTERVAL` will be multiplied by this to increase the time between attempts. You can keep time between each reconnection the same by setting this to `#!python 1`. | diff --git a/docs/src/get-started/run-webserver.md b/docs/src/get-started/run-webserver.md index cefeafc3..cb4f87f1 100644 --- a/docs/src/get-started/run-webserver.md +++ b/docs/src/get-started/run-webserver.md @@ -16,7 +16,7 @@ To test your new Django view, run the following command to start up a developmen python manage.py runserver ``` -Now you can navigate to your **Django project** URL that contains a ReactPy component, such as `http://127.0.0.1:8000/example/` ([_from the previous step_](./register-view.md)). +Now you can navigate to your **Django project** URL that contains a ReactPy component, such as [`http://127.0.0.1:8000/example/`](http://127.0.0.1:8000/example/) ([_from the previous step_](./register-view.md)). If you copy-pasted our example component, you will now see your component display "Hello World". diff --git a/pyproject.toml b/pyproject.toml index 0f9e87a3..250a64a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,6 @@ warn_unused_configs = true warn_redundant_casts = true warn_unused_ignores = true check_untyped_defs = true -incremental = true [tool.ruff.isort] known-first-party = ["src", "tests"] diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index ee7156c3..8eecf7bc 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,5 +1,5 @@ channels >=4.0.0 -django >=4.1.0 +django >=4.2.0 reactpy >=1.0.2, <1.1.0 aiofile >=3.0 dill >=0.3.5 diff --git a/src/js/package-lock.json b/src/js/package-lock.json index 024084a6..84321d16 100644 --- a/src/js/package-lock.json +++ b/src/js/package-lock.json @@ -5,14 +5,16 @@ "packages": { "": { "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", "@rollup/plugin-replace": "^5.0.2", - "prettier": "^2.8.3", - "rollup": "^3.12.0" + "prettier": "^3.0.2", + "rollup": "^3.28.1" } }, "node_modules/@jridgewell/sourcemap-codec": { @@ -22,16 +24,16 @@ "dev": true }, "node_modules/@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "dependencies": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" + "react": ">=16 <18", + "react-dom": ">=16 <18" } }, "node_modules/@rollup/plugin-commonjs": { @@ -105,11 +107,35 @@ } } }, + "node_modules/@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.14.0||^3.0.0", + "tslib": "*", + "typescript": ">=3.7.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + }, + "tslib": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -130,8 +156,7 @@ "node_modules/@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "node_modules/@types/resolve": { "version": "1.20.2", @@ -184,8 +209,15 @@ "node_modules/estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "dependencies": { + "json-pointer": "^0.6.2" + } }, "node_modules/foreach": { "version": "2.0.6", @@ -215,8 +247,7 @@ "node_modules/function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "node_modules/glob": { "version": "8.1.0", @@ -241,7 +272,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "dependencies": { "function-bind": "^1.1.1" }, @@ -249,11 +279,6 @@ "node": ">= 0.4.0" } }, - "node_modules/htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -289,7 +314,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "dependencies": { "has": "^1.0.3" }, @@ -383,14 +407,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -399,15 +421,15 @@ } }, "node_modules/prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" @@ -444,7 +466,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "dependencies": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -458,10 +479,10 @@ } }, "node_modules/rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -487,7 +508,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -495,6 +515,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -510,11 +548,11 @@ "dev": true }, "@reactpy/client": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.1.0.tgz", - "integrity": "sha512-GVsP23Re29JAbLNOBJytcem8paNhLj+2SZ8n9GlnlHPWuV6chAofT0aGveepCj1I9DdeVfRjDL6hfTreJEaDdg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@reactpy/client/-/client-0.3.1.tgz", + "integrity": "sha512-mvFwAvmRMgo7lTjkhkEJzBep6HX/wfm5BaNbtEMOUzto7G/h+z1AmqlOMXLH37DSI0iwfmCuNwy07EJM0JWZ0g==", "requires": { - "htm": "^3.0.3", + "event-to-object": "^0.1.2", "json-pointer": "^0.6.2" } }, @@ -556,11 +594,19 @@ "magic-string": "^0.27.0" } }, + "@rollup/plugin-typescript": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-11.1.2.tgz", + "integrity": "sha512-0ghSOCMcA7fl1JM+0gYRf+Q/HWyg+zg7/gDSc+fRLmlJWcW5K1I+CLRzaRhXf4Y3DRyPnnDo4M2ktw+a6JcDEg==", + "requires": { + "@rollup/pluginutils": "^5.0.1", + "resolve": "^1.22.1" + } + }, "@rollup/pluginutils": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.0.2.tgz", "integrity": "sha512-pTd9rIsP92h+B6wWwFbW8RkZv4hiR/xKsqre4SIuAOaOEQRxi0lqLke9k2/7WegC85GgUs9pjmOjCUi3In4vwA==", - "dev": true, "requires": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -570,8 +616,7 @@ "@types/estree": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.0.tgz", - "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==", - "dev": true + "integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ==" }, "@types/resolve": { "version": "1.20.2", @@ -615,8 +660,15 @@ "estree-walker": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "event-to-object": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/event-to-object/-/event-to-object-0.1.2.tgz", + "integrity": "sha512-+fUmp1XOCZiYomwe5Zxp4IlchuZZfdVdjFUk5MbgRT4M+V2TEWKc0jJwKLCX/nxlJ6xM5VUb/ylzERh7YDCRrg==", + "requires": { + "json-pointer": "^0.6.2" + } }, "foreach": { "version": "2.0.6", @@ -639,8 +691,7 @@ "function-bind": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", - "dev": true + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" }, "glob": { "version": "8.1.0", @@ -659,16 +710,10 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dev": true, "requires": { "function-bind": "^1.1.1" } }, - "htm": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/htm/-/htm-3.1.0.tgz", - "integrity": "sha512-L0s3Sid5r6YwrEvkig14SK3Emmc+kIjlfLhEGn2Vy3bk21JyDEes4MoDsbJk6luaPp8bugErnxPz86ZuAw6e5Q==" - }, "inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -698,7 +743,6 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.11.0.tgz", "integrity": "sha512-RRjxlvLDkD1YJwDbroBHMb+cukurkDWNyHx7D3oNB5x9rb5ogcksMC5wHCadcXoo67gVr/+3GFySh3134zi6rw==", - "dev": true, "requires": { "has": "^1.0.3" } @@ -777,19 +821,17 @@ "path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" }, "prettier": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", - "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.0.2.tgz", + "integrity": "sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==", "dev": true }, "react": { @@ -817,7 +859,6 @@ "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", - "dev": true, "requires": { "is-core-module": "^2.9.0", "path-parse": "^1.0.7", @@ -825,10 +866,10 @@ } }, "rollup": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.12.0.tgz", - "integrity": "sha512-4MZ8kA2HNYahIjz63rzrMMRvDqQDeS9LoriJvMuV0V6zIGysP36e9t4yObUfwdT9h/szXoHQideICftcdZklWg==", - "dev": true, + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.28.1.tgz", + "integrity": "sha512-R9OMQmIHJm9znrU3m3cpE8uhN0fGdXiawME7aZIpQqvpS/85+Vt1Hq1/yVIcYfOmaQiHjvXkQAoJukvLpau6Yw==", + "devOptional": true, "requires": { "fsevents": "~2.3.2" } @@ -846,8 +887,18 @@ "supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==" + }, + "tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "typescript": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.1.6.tgz", + "integrity": "sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==", + "peer": true }, "wrappy": { "version": "1.0.2", diff --git a/src/js/package.json b/src/js/package.json index 92890671..40596a0d 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -1,6 +1,6 @@ { "description": "reactpy-django client", - "main": "src/index.js", + "main": "src/index.ts", "type": "module", "files": [ "src/**/*.js" @@ -10,13 +10,15 @@ "format": "prettier --ignore-path .gitignore --write ." }, "devDependencies": { - "prettier": "^2.8.3", - "rollup": "^3.12.0", "@rollup/plugin-commonjs": "^24.0.1", "@rollup/plugin-node-resolve": "^15.0.1", - "@rollup/plugin-replace": "^5.0.2" + "@rollup/plugin-replace": "^5.0.2", + "prettier": "^3.0.2", + "rollup": "^3.28.1" }, "dependencies": { - "@reactpy/client": "^0.1.0" + "@reactpy/client": "^0.3.1", + "@rollup/plugin-typescript": "^11.1.2", + "tslib": "^2.6.2" } } diff --git a/src/js/rollup.config.mjs b/src/js/rollup.config.mjs index 50fbc637..79f93839 100644 --- a/src/js/rollup.config.mjs +++ b/src/js/rollup.config.mjs @@ -1,9 +1,10 @@ import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import replace from "@rollup/plugin-replace"; +import typescript from "@rollup/plugin-typescript"; export default { - input: "src/index.js", + input: "src/index.ts", output: { file: "../reactpy_django/static/reactpy_django/client.js", format: "esm", @@ -14,6 +15,7 @@ export default { replace({ "process.env.NODE_ENV": JSON.stringify("production"), }), + typescript(), ], onwarn: function (warning) { console.warn(warning.message); diff --git a/src/js/src/client.ts b/src/js/src/client.ts new file mode 100644 index 00000000..6f79df77 --- /dev/null +++ b/src/js/src/client.ts @@ -0,0 +1,31 @@ +import { BaseReactPyClient, ReactPyClient, ReactPyModule } from "@reactpy/client"; +import { createReconnectingWebSocket } from "./utils"; +import { ReactPyDjangoClientProps, ReactPyUrls } from "./types"; + +export class ReactPyDjangoClient + extends BaseReactPyClient + implements ReactPyClient +{ + urls: ReactPyUrls; + socket: { current?: WebSocket }; + + constructor(props: ReactPyDjangoClientProps) { + super(); + this.urls = props.urls; + this.socket = createReconnectingWebSocket({ + readyPromise: this.ready, + url: this.urls.componentUrl, + onMessage: async ({ data }) => + this.handleIncoming(JSON.parse(data)), + ...props.reconnectOptions, + }); + } + + sendMessage(message: any): void { + this.socket.current?.send(JSON.stringify(message)); + } + + loadModule(moduleName: string): Promise { + return import(`${this.urls.jsModules}/${moduleName}`); + } +} diff --git a/src/js/src/index.js b/src/js/src/index.js deleted file mode 100644 index 2ee74e07..00000000 --- a/src/js/src/index.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mountLayoutWithWebSocket } from "@reactpy/client"; - -// Set up a websocket at the base endpoint -let HTTP_PROTOCOL = window.location.protocol; -let WS_PROTOCOL = ""; -if (HTTP_PROTOCOL == "https:") { - WS_PROTOCOL = "wss:"; -} else { - WS_PROTOCOL = "ws:"; -} - -export function mountViewToElement( - mountElement, - reactpyHost, - reactpyUrlPrefix, - reactpyReconnectMax, - reactpyComponentPath, - reactpyResolvedWebModulesPath -) { - // Determine the Websocket route - let wsOrigin; - if (reactpyHost) { - wsOrigin = `${WS_PROTOCOL}//${reactpyHost}`; - } else { - wsOrigin = `${WS_PROTOCOL}//${window.location.host}`; - } - const websocketUrl = `${wsOrigin}/${reactpyUrlPrefix}/${reactpyComponentPath}`; - - // Determine the HTTP route - let httpOrigin; - let webModulesPath; - if (reactpyHost) { - httpOrigin = `${HTTP_PROTOCOL}//${reactpyHost}`; - 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" ? `${webModuleUrl}/${source}` : source - ); - }; - - // Start rendering the component - mountLayoutWithWebSocket( - mountElement, - websocketUrl, - loadImportSource, - reactpyReconnectMax - ); -} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..53d67c6f --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,59 @@ +import { mount } from "@reactpy/client"; +import { ReactPyDjangoClient } from "./client"; + +export function mountComponent( + mountElement: HTMLElement, + host: string, + urlPrefix: string, + componentPath: string, + resolvedJsModulesPath: string, + reconnectStartInterval: number, + reconnectMaxInterval: number, + reconnectMaxRetries: number, + reconnectBackoffMultiplier: number +) { + // Protocols + let httpProtocol = window.location.protocol; + let wsProtocol = `ws${httpProtocol === "https:" ? "s" : ""}:`; + + // WebSocket route (for Python components) + let wsOrigin: string; + if (host) { + wsOrigin = `${wsProtocol}//${host}`; + } else { + wsOrigin = `${wsProtocol}//${window.location.host}`; + } + + // HTTP route (for JavaScript modules) + let httpOrigin: string; + let jsModulesPath: string; + if (host) { + httpOrigin = `${httpProtocol}//${host}`; + jsModulesPath = `${urlPrefix}/web_module`; + } else { + httpOrigin = `${httpProtocol}//${window.location.host}`; + if (resolvedJsModulesPath) { + jsModulesPath = resolvedJsModulesPath; + } else { + jsModulesPath = `${urlPrefix}/web_module`; + } + } + + // Configure a new ReactPy client + const client = new ReactPyDjangoClient({ + urls: { + componentUrl: `${wsOrigin}/${urlPrefix}/${componentPath}`, + query: document.location.search, + jsModules: `${httpOrigin}/${jsModulesPath}`, + }, + reconnectOptions: { + startInterval: reconnectStartInterval, + maxInterval: reconnectMaxInterval, + backoffMultiplier: reconnectBackoffMultiplier, + maxRetries: reconnectMaxRetries, + }, + }); + + // Start rendering the component + mount(mountElement, client); +} diff --git a/src/js/src/types.ts b/src/js/src/types.ts new file mode 100644 index 00000000..54a0b604 --- /dev/null +++ b/src/js/src/types.ts @@ -0,0 +1,17 @@ +export type ReconnectOptions = { + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +} + +export type ReactPyUrls = { + componentUrl: string; + query: string; + jsModules: string; +} + +export type ReactPyDjangoClientProps = { + urls: ReactPyUrls; + reconnectOptions: ReconnectOptions; +} diff --git a/src/js/src/utils.ts b/src/js/src/utils.ts new file mode 100644 index 00000000..a3f653ce --- /dev/null +++ b/src/js/src/utils.ts @@ -0,0 +1,77 @@ +export function createReconnectingWebSocket(props: { + url: string; + readyPromise: Promise; + onOpen?: () => void; + onMessage: (message: MessageEvent) => void; + onClose?: () => void; + startInterval: number; + maxInterval: number; + maxRetries: number; + backoffMultiplier: number; +}) { + const { startInterval, maxInterval, maxRetries, backoffMultiplier } = props; + let retries = 0; + let interval = startInterval; + let everConnected = false; + const closed = false; + const socket: { current?: WebSocket } = {}; + + const connect = () => { + if (closed) { + return; + } + socket.current = new WebSocket(props.url); + socket.current.onopen = () => { + everConnected = true; + console.info("ReactPy connected!"); + interval = startInterval; + retries = 0; + if (props.onOpen) { + props.onOpen(); + } + }; + socket.current.onmessage = props.onMessage; + socket.current.onclose = () => { + if (!everConnected) { + console.info("ReactPy failed to connect!"); + return; + } + console.info("ReactPy disconnected!"); + if (props.onClose) { + props.onClose(); + } + if (retries >= maxRetries) { + console.info("ReactPy connection max retries exhausted!"); + return; + } + console.info( + `ReactPy reconnecting in ${(interval / 1000).toPrecision( + 4 + )} seconds...` + ); + setTimeout(connect, interval); + interval = nextInterval(interval, backoffMultiplier, maxInterval); + retries++; + }; + }; + + props.readyPromise + .then(() => console.info("Starting ReactPy client...")) + .then(connect); + + return socket; +} + +export function nextInterval( + currentInterval: number, + backoffMultiplier: number, + maxInterval: number +): number { + return Math.min( + currentInterval * + // increase interval by backoff multiplier + backoffMultiplier, + // don't exceed max interval + maxInterval + ); +} diff --git a/src/js/tsconfig.json b/src/js/tsconfig.json new file mode 100644 index 00000000..7da4aa77 --- /dev/null +++ b/src/js/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "esnext", + "moduleResolution": "node", + }, +} diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 7ab9546e..754b81be 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,4 +1,5 @@ import contextlib +import math import sys from django.contrib.staticfiles.finders import find @@ -104,7 +105,7 @@ def reactpy_warnings(app_configs, **kwargs): # 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 + # Removed REACTPY_WEBSOCKET_URL setting if getattr(settings, "REACTPY_WEBSOCKET_URL", None): warnings.append( Warning( @@ -159,6 +160,87 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Removed REACTPY_RECONNECT_MAX setting + if getattr(settings, "REACTPY_RECONNECT_MAX", None): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX has been removed.", + hint="See the docs for the new REACTPY_RECONNECT_* settings.", + id="reactpy_django.W013", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL > 30000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_INTERVAL is set to >30 seconds. Are you sure this is intentional? " + "This may cause unexpected delays between reconnection.", + hint="Check your value for REACTPY_RECONNECT_INTERVAL or suppress this warning.", + id="reactpy_django.W014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES > 5000 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value. Are you sure this is intentional? " + "This may leave your clients attempting reconnections for a long time.", + hint="Check your value for REACTPY_RECONNECT_MAX_RETRIES or suppress this warning.", + id="reactpy_django.W015", + ) + ) + + # Check if the value is too large (greater than 50) + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100 + ): + warnings.append( + Warning( + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a very large value. Are you sure this is intentional?", + hint="Check your value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER or suppress this warning.", + id="reactpy_django.W016", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_INTERVAL > 0 + and config.REACTPY_RECONNECT_MAX_RETRIES > 0 + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 1 + and ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + < config.REACTPY_RECONNECT_MAX_INTERVAL + ): + max_value = math.floor( + ( + config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER + ** config.REACTPY_RECONNECT_MAX_RETRIES + ) + * config.REACTPY_RECONNECT_INTERVAL + ) + warnings.append( + Warning( + "Your current ReactPy configuration can never reach REACTPY_RECONNECT_MAX_INTERVAL. At most you will reach " + f"{max_value} miliseconds, which is less than {config.REACTPY_RECONNECT_MAX_INTERVAL} (REACTPY_RECONNECT_MAX_INTERVAL).", + hint="Check your ReactPy REACTPY_RECONNECT_* settings.", + id="reactpy_django.W017", + ) + ) + return warnings @@ -166,6 +248,8 @@ def reactpy_warnings(app_configs, **kwargs): def reactpy_errors(app_configs, **kwargs): from django.conf import settings + from reactpy_django import config + errors = [] # Make sure ASGI is enabled @@ -204,12 +288,12 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E003", ) ) - if not isinstance(getattr(settings, "REACTPY_RECONNECT_MAX", 0), int): + if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int): errors.append( Error( - "Invalid type for REACTPY_RECONNECT_MAX.", - hint="REACTPY_RECONNECT_MAX should be an integer.", - obj=settings.REACTPY_RECONNECT_MAX, + "Invalid type for REACTPY_SESSION_MAX_AGE.", + hint="REACTPY_SESSION_MAX_AGE should be an integer.", + obj=settings.REACTPY_SESSION_MAX_AGE, id="reactpy_django.E004", ) ) @@ -278,4 +362,101 @@ def reactpy_errors(app_configs, **kwargs): ) break + if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be an integer.", + id="reactpy_django.E012", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_INTERVAL should be a positive integer.", + id="reactpy_django.E013", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be an integer.", + id="reactpy_django.E014", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be a positive integer.", + id="reactpy_django.E015", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) + and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) + and config.REACTPY_RECONNECT_MAX_INTERVAL < config.REACTPY_RECONNECT_INTERVAL + ): + errors.append( + Error( + "REACTPY_RECONNECT_MAX_INTERVAL is less than REACTPY_RECONNECT_INTERVAL.", + hint="REACTPY_RECONNECT_MAX_INTERVAL should be greater than or equal to REACTPY_RECONNECT_INTERVAL.", + id="reactpy_django.E016", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be an integer.", + id="reactpy_django.E017", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) + and config.REACTPY_RECONNECT_MAX_RETRIES < 0 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_MAX_RETRIES.", + hint="REACTPY_RECONNECT_MAX_RETRIES should be a positive integer.", + id="reactpy_django.E018", + ) + ) + + if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)): + errors.append( + Error( + "Invalid type for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be an integer or float.", + id="reactpy_django.E019", + ) + ) + + if ( + isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) + and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1 + ): + errors.append( + Error( + "Invalid value for REACTPY_RECONNECT_BACKOFF_MULTIPLIER.", + hint="REACTPY_RECONNECT_BACKOFF_MULTIPLIER should be greater than or equal to 1.", + id="reactpy_django.E020", + ) + ) + return errors diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 24aff5f6..d811f7dc 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -35,9 +35,9 @@ "REACTPY_URL_PREFIX", REACTPY_WEBSOCKET_URL, ).strip("/") -REACTPY_RECONNECT_MAX: int = getattr( +REACTPY_SESSION_MAX_AGE: int = getattr( settings, - "REACTPY_RECONNECT_MAX", + "REACTPY_SESSION_MAX_AGE", 259200, # Default to 3 days ) REACTPY_CACHE: str = getattr( @@ -82,3 +82,23 @@ if _default_hosts else None ) +REACTPY_RECONNECT_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_INTERVAL", + 750, # Default to 0.75 seconds +) +REACTPY_RECONNECT_MAX_INTERVAL: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_INTERVAL", + 60000, # Default to 60 seconds +) +REACTPY_RECONNECT_MAX_RETRIES: int = getattr( + settings, + "REACTPY_RECONNECT_MAX_RETRIES", + 150, +) +REACTPY_RECONNECT_BACKOFF_MULTIPLIER: float | int = getattr( + settings, + "REACTPY_RECONNECT_BACKOFF_MULTIPLIER", + 1.25, # Default to 25% backoff per connection attempt +) diff --git a/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py new file mode 100644 index 00000000..488f660d --- /dev/null +++ b/src/reactpy_django/migrations/0005_alter_componentsession_last_accessed.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.3 on 2023-08-23 19:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("reactpy_django", "0004_config"), + ] + + operations = [ + migrations.AlterField( + model_name="componentsession", + name="last_accessed", + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/reactpy_django/models.py b/src/reactpy_django/models.py index 65152126..1fa69d2c 100644 --- a/src/reactpy_django/models.py +++ b/src/reactpy_django/models.py @@ -8,7 +8,7 @@ class ComponentSession(models.Model): uuid = models.UUIDField(primary_key=True, editable=False, unique=True) # type: ignore params = models.BinaryField(editable=False) # type: ignore - last_accessed = models.DateTimeField(auto_now_add=True) # type: ignore + last_accessed = models.DateTimeField(auto_now=True) # type: ignore class Config(models.Model): diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 4010b80f..75a65dbe 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -6,15 +6,18 @@ {% else %}
{% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index b174fa9a..82640ced 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -15,7 +15,7 @@ ComponentParamError, InvalidHostError, ) -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams from reactpy_django.utils import validate_component_args try: @@ -83,7 +83,7 @@ def component( _logger.error(msg) return failure_context(dotted_path, ComponentDoesNotExistError(msg)) - # Validate the component + # Validate the component args & kwargs if is_local and config.REACTPY_DEBUG_MODE: try: validate_component_args(user_component, *args, **kwargs) @@ -108,11 +108,14 @@ def component( "reactpy_uuid": uuid, "reactpy_host": host or perceived_host, "reactpy_url_prefix": config.REACTPY_URL_PREFIX, - "reactpy_reconnect_max": config.REACTPY_RECONNECT_MAX, "reactpy_component_path": f"{dotted_path}/{uuid}/" if component_has_args else f"{dotted_path}/", "reactpy_resolved_web_modules_path": RESOLVED_WEB_MODULES_PATH, + "reactpy_reconnect_interval": config.REACTPY_RECONNECT_INTERVAL, + "reactpy_reconnect_max_interval": config.REACTPY_RECONNECT_MAX_INTERVAL, + "reactpy_reconnect_backoff_multiplier": config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, + "reactpy_reconnect_max_retries": config.REACTPY_RECONNECT_MAX_RETRIES, } @@ -126,7 +129,7 @@ def failure_context(dotted_path: str, error: Exception): def save_component_params(args, kwargs, uuid): - params = ComponentParamData(args, kwargs) + params = ComponentParams(args, kwargs) model = models.ComponentSession(uuid=uuid, params=pickle.dumps(params)) model.full_clean() model.save() diff --git a/src/reactpy_django/types.py b/src/reactpy_django/types.py index 02fdec6e..ac6205e0 100644 --- a/src/reactpy_django/types.py +++ b/src/reactpy_django/types.py @@ -33,7 +33,7 @@ "SyncPostprocessor", "QueryOptions", "MutationOptions", - "ComponentParamData", + "ComponentParams", ] _Result = TypeVar("_Result", bound=Union[Model, QuerySet[Any]]) @@ -127,7 +127,7 @@ class MutationOptions: @dataclass -class ComponentParamData: +class ComponentParams: """Container used for serializing component parameters. This dataclass is pickled & stored in the database, then unpickled when needed.""" diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 0776bc3b..755b3a05 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -324,29 +324,31 @@ def create_cache_key(*args): return f"reactpy_django:{':'.join(str(arg) for arg in args)}" -def db_cleanup(immediate: bool = False): +def delete_expired_sessions(immediate: bool = False): """Deletes expired component sessions from the database. - This function may be expanded in the future to include additional cleanup tasks.""" - from .config import REACTPY_DEBUG_MODE, REACTPY_RECONNECT_MAX + As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds. + """ + from .config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE from .models import ComponentSession, Config config = Config.load() start_time = timezone.now() cleaned_at = config.cleaned_at - clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_RECONNECT_MAX) + clean_needed_by = cleaned_at + timedelta(seconds=REACTPY_SESSION_MAX_AGE) # Delete expired component parameters if immediate or timezone.now() >= clean_needed_by: - expiration_date = timezone.now() - timedelta(seconds=REACTPY_RECONNECT_MAX) + expiration_date = timezone.now() - timedelta(seconds=REACTPY_SESSION_MAX_AGE) ComponentSession.objects.filter(last_accessed__lte=expiration_date).delete() config.cleaned_at = timezone.now() config.save() # Check if cleaning took abnormally long - clean_duration = timezone.now() - start_time - if REACTPY_DEBUG_MODE and clean_duration.total_seconds() > 1: - _logger.warning( - "ReactPy has taken %s seconds to clean up expired component sessions. " - "This may indicate a performance issue with your system, cache, or database.", - clean_duration.total_seconds(), - ) + if REACTPY_DEBUG_MODE: + clean_duration = timezone.now() - start_time + if clean_duration.total_seconds() > 1: + _logger.warning( + "ReactPy has taken %s seconds to clean up expired component sessions. " + "This may indicate a performance issue with your system, cache, or database.", + clean_duration.total_seconds(), + ) diff --git a/src/reactpy_django/websocket/consumer.py b/src/reactpy_django/websocket/consumer.py index a2a76cab..c6a47c27 100644 --- a/src/reactpy_django/websocket/consumer.py +++ b/src/reactpy_django/websocket/consumer.py @@ -21,8 +21,8 @@ from reactpy.core.layout import Layout from reactpy.core.serve import serve_layout -from reactpy_django.types import ComponentParamData, ComponentWebsocket -from reactpy_django.utils import db_cleanup +from reactpy_django.types import ComponentParams, ComponentWebsocket +from reactpy_django.utils import delete_expired_sessions _logger = logging.getLogger(__name__) backhaul_loop = asyncio.new_event_loop() @@ -42,6 +42,7 @@ class ReactpyAsyncWebsocketConsumer(AsyncJsonWebsocketConsumer): async def connect(self) -> None: """The browser has connected.""" + from reactpy_django import models from reactpy_django.config import REACTPY_AUTH_BACKEND, REACTPY_BACKHAUL_THREAD await super().connect() @@ -80,6 +81,7 @@ async def connect(self) -> None: # Start the component dispatcher self.dispatcher: Future | asyncio.Task self.threaded = REACTPY_BACKHAUL_THREAD + self.component_session: models.ComponentSession | None = None if self.threaded: if not backhaul_thread.is_alive(): await asyncio.to_thread( @@ -95,6 +97,28 @@ async def connect(self) -> None: async def disconnect(self, code: int) -> None: """The browser has disconnected.""" self.dispatcher.cancel() + + if self.component_session: + # Clean up expired component sessions + try: + await database_sync_to_async( + delete_expired_sessions, thread_sensitive=False + )() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to delete expired component sessions!", + ) + + # Update the last_accessed timestamp + try: + await self.component_session.asave() + except Exception: + await asyncio.to_thread( + _logger.exception, + "ReactPy has failed to save component session!", + ) + await super().disconnect(code) async def receive_json(self, content: Any, **_) -> None: @@ -118,8 +142,8 @@ async def run_dispatcher(self): """Runs the main loop that performs component rendering tasks.""" from reactpy_django import models from reactpy_django.config import ( - REACTPY_RECONNECT_MAX, REACTPY_REGISTERED_COMPONENTS, + REACTPY_SESSION_MAX_AGE, ) scope = self.scope @@ -136,8 +160,8 @@ async def run_dispatcher(self): carrier=ComponentWebsocket(self.close, self.disconnect, dotted_path), ) now = timezone.now() - component_args: Sequence[Any] = () - component_kwargs: MutableMapping[str, Any] = {} + component_session_args: Sequence[Any] = () + component_session_kwargs: MutableMapping[str, Any] = {} # Verify the component has already been registered try: @@ -152,31 +176,24 @@ async def run_dispatcher(self): # Fetch the component's args/kwargs from the database, if needed try: if uuid: - # 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( + # Get the component session from the DB + self.component_session = await models.ComponentSession.objects.aget( uuid=uuid, - last_accessed__gt=now - timedelta(seconds=REACTPY_RECONNECT_MAX), + last_accessed__gt=now - timedelta(seconds=REACTPY_SESSION_MAX_AGE), ) - 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 + params: ComponentParams = pickle.loads(self.component_session.params) + component_session_args = params.args + component_session_kwargs = params.kwargs # Generate the initial component instance component_instance = component_constructor( - *component_args, **component_kwargs + *component_session_args, **component_session_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. " + "session may have already expired beyond REACTPY_SESSION_MAX_AGE. " "If you are using a custom host, you may have forgotten to provide " "args/kwargs.", ) @@ -185,7 +202,7 @@ async def run_dispatcher(self): await asyncio.to_thread( _logger.exception, f"Failed to construct component {component_constructor} " - f"with parameters {component_kwargs}", + f"with args='{component_session_args}' kwargs='{component_session_kwargs}'!", ) return diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 3bd23527..0c9a1b84 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -6,7 +6,7 @@ from django.test import TransactionTestCase from reactpy_django import utils from reactpy_django.models import ComponentSession -from reactpy_django.types import ComponentParamData +from reactpy_django.types import ComponentParams class RoutedDatabaseTests(TransactionTestCase): @@ -15,7 +15,7 @@ class RoutedDatabaseTests(TransactionTestCase): @classmethod def setUpClass(cls): super().setUpClass() - utils.db_cleanup(immediate=True) + utils.delete_expired_sessions(immediate=True) def test_component_params(self): # Make sure the ComponentParams table is empty @@ -31,15 +31,15 @@ def test_component_params(self): # Force `params_1` to expire from reactpy_django import config - config.REACTPY_RECONNECT_MAX = 1 - sleep(config.REACTPY_RECONNECT_MAX + 0.1) + config.REACTPY_SESSION_MAX_AGE = 1 + sleep(config.REACTPY_SESSION_MAX_AGE + 0.1) # Create a new, non-expired component params params_2 = self._save_params_to_db(2) self.assertEqual(ComponentSession.objects.count(), 2) # Delete the first component params based on expiration time - utils.db_cleanup() # Don't use `immediate` to test cache timestamping logic + utils.delete_expired_sessions() # Don't use `immediate` to test timestamping logic # Make sure `params_1` has expired self.assertEqual(ComponentSession.objects.count(), 1) @@ -47,9 +47,9 @@ def test_component_params(self): pickle.loads(ComponentSession.objects.first().params), params_2 # type: ignore ) - def _save_params_to_db(self, value: Any) -> ComponentParamData: + def _save_params_to_db(self, value: Any) -> ComponentParams: db = list(self.databases)[0] - param_data = ComponentParamData((value,), {"test_value": value}) + param_data = ComponentParams((value,), {"test_value": value}) model = ComponentSession(uuid4().hex, params=pickle.dumps(param_data)) model.clean_fields() model.clean() From ceeead668a5e93b471d0c0dd3eb561a4ec5aba13 Mon Sep 17 00:00:00 2001 From: Mark Bakhit Date: Sat, 26 Aug 2023 21:23:50 -0700 Subject: [PATCH 6/6] v3.5.0 (#179) --- .mailmap | 2 ++ CHANGELOG.md | 9 +++++++-- mkdocs.yml | 1 + src/reactpy_django/__init__.py | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .mailmap diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..1e68fb67 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +# .mailmap +Mark Bakhit <16909269+archmonger@users.noreply.github.com> diff --git a/CHANGELOG.md b/CHANGELOG.md index da060bbd..f41b7389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ Using the following categories, list your changes in this order: ## [Unreleased] +- Nothing (yet)! + +## [3.5.0] - 2023-08-26 + ### Added - More customization for reconnection behavior through new settings! @@ -49,8 +53,8 @@ Using the following categories, list your changes in this order: - Prettier websocket URLs for components that do not have sessions. - Template tag will now only validate `args`/`kwargs` if `settings.py:DEBUG` is enabled. - Bumped the minimum `@reactpy/client` version to `0.3.1` +- Bumped the minimum Django version to `4.2`. - Use TypeScript instead of JavaScript for this repository. -- Bumped minimum Django version to `4.2`. - Note: ReactPy-Django will continue bumping minimum Django requirements to versions that increase async support. This "latest-only" trend will continue until Django has all async features that ReactPy benefits from. After this point, ReactPy-Django will begin supporting all maintained Django versions. ### Removed @@ -383,7 +387,8 @@ Using the following categories, list your changes in this order: - Support for IDOM within the Django -[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...HEAD +[unreleased]: https://github.com/reactive-python/reactpy-django/compare/3.5.0...HEAD +[3.5.0]: https://github.com/reactive-python/reactpy-django/compare/3.4.0...3.5.0 [3.4.0]: https://github.com/reactive-python/reactpy-django/compare/3.3.2...3.4.0 [3.3.2]: https://github.com/reactive-python/reactpy-django/compare/3.3.1...3.3.2 [3.3.1]: https://github.com/reactive-python/reactpy-django/compare/3.3.0...3.3.1 diff --git a/mkdocs.yml b/mkdocs.yml index e5b08921..ae5129bc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -103,6 +103,7 @@ watch: - mkdocs.yml - README.md - CHANGELOG.md + - .mailmap site_name: ReactPy-Django site_author: Archmonger diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 7458595f..d5719f97 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -8,7 +8,7 @@ REACTPY_WEBSOCKET_ROUTE, ) -__version__ = "3.4.0" +__version__ = "3.5.0" __all__ = [ "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE",