diff --git a/CHANGELOG.md b/CHANGELOG.md index 9652b644..6680348a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,21 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Changed + +- Now using ReactPy-Router v1 for URL routing, which comes with a slightly different API than before. +- Removed dependency on `aiofile`. + +### Removed + +- Removed the following **deprecated** features: + - The `compatibility` argument on `reactpy_django.components.view_to_component` + - `reactpy_django.components.view_to_component` **usage as a decorator** + - `reactpy_django.decorators.auth_required` + - `reactpy_django.REACTPY_WEBSOCKET_PATH` + - `settings.py:REACTPY_WEBSOCKET_URL` -## [4.0.0] +## [4.0.0] - 2024-06-22 ### Added @@ -112,8 +124,8 @@ Don't forget to remove deprecated code on each major release! - New Django `User` related features! - `reactpy_django.hooks.use_user` can be used to access the current user. - `reactpy_django.hooks.use_user_data` provides a simplified interface for storing user key-value data. - - `reactpy_django.decorators.user_passes_test` is inspired by the [equivalent Django decorator](http://docs.djangoproject.com/en/dev/topics/auth/default/#django.contrib.auth.decorators.user_passes_test), but ours works with ReactPy components. - - `settings.py:REACTPY_AUTO_RELOGIN` will cause component WebSocket connections to automatically [re-login](https://channels.readthedocs.io/en/latest/topics/authentication.html#how-to-log-a-user-in-out) users that are already authenticated. This is useful to continuously update `last_login` timestamps and refresh the [Django login session](https://docs.djangoproject.com/en/dev/topics/http/sessions/). + - `reactpy_django.decorators.user_passes_test` is inspired by the [equivalent Django decorator](http://docs.djangoproject.com/en/stable/topics/auth/default/#django.contrib.auth.decorators.user_passes_test), but ours works with ReactPy components. + - `settings.py:REACTPY_AUTO_RELOGIN` will cause component WebSocket connections to automatically [re-login](https://channels.readthedocs.io/en/latest/topics/authentication.html#how-to-log-a-user-in-out) users that are already authenticated. This is useful to continuously update `last_login` timestamps and refresh the [Django login session](https://docs.djangoproject.com/en/stable/topics/http/sessions/). ### Changed diff --git a/README.md b/README.md index 817e684b..d3d2a1a9 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ def hello_world(recipient: str): -## [`my_app/templates/my_template.html`](https://docs.djangoproject.com/en/dev/topics/templates/) +## [`my_app/templates/my_template.html`](https://docs.djangoproject.com/en/stable/topics/templates/) diff --git a/docs/examples/html/pyscript-js-module.html b/docs/examples/html/pyscript-local-import.html similarity index 100% rename from docs/examples/html/pyscript-js-module.html rename to docs/examples/html/pyscript-local-import.html diff --git a/docs/examples/python/pyscript-js-execution.py b/docs/examples/python/pyodide-js-module.py similarity index 100% rename from docs/examples/python/pyscript-js-execution.py rename to docs/examples/python/pyodide-js-module.py diff --git a/docs/examples/python/pyscript-js-module.py b/docs/examples/python/pyscript-local-import.py similarity index 100% rename from docs/examples/python/pyscript-js-module.py rename to docs/examples/python/pyscript-local-import.py diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index e4159640..c1b5922f 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -89,8 +89,6 @@ plugins: - spellcheck: known_words: dictionary.txt allow_unicode: no - ignore_code: yes - # - section-index extra: generator: false diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 66265e78..14aa7a61 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -1,43 +1,45 @@ +asgi +async +backend +backends +backhaul +broadcasted +changelog django -sanic -plotly +frontend +frontends +hello_world +html +iframe +jupyter +keyworded +middleware +misconfiguration +misconfigurations +my_template nox -WebSocket -WebSockets -changelog -async +plotly +postfixed +postprocessing +postprocessor pre prefetch prefetching preloader -whitespace +preprocessor +py +pyodide +pyscript +reactpy refetch refetched refetching -html -jupyter -iframe -keyworded +sanic +serializable stylesheet stylesheets -unstyled -py -reactpy -asgi -postfixed -postprocessing -serializable -postprocessor -preprocessor -middleware -backends -backend -frontend -frontends -misconfiguration -misconfigurations -backhaul sublicense -broadcasted -hello_world -my_template +unstyled +WebSocket +WebSockets +whitespace diff --git a/docs/src/learn/add-reactpy-to-a-django-project.md b/docs/src/learn/add-reactpy-to-a-django-project.md index dd258737..0bf919e2 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -8,7 +8,7 @@ If you want to add some interactivity to your existing **Django project**, you d !!! abstract "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**. + These docs assumes you have already created [a **Django project**](https://docs.djangoproject.com/en/stable/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_. @@ -24,7 +24,7 @@ pip install reactpy-django ## Step 2: Configure `settings.py` -Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-INSTALLED_APPS) in your [`settings.py`](https://docs.djangoproject.com/en/dev/topics/settings/) file. +Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-INSTALLED_APPS) in your [`settings.py`](https://docs.djangoproject.com/en/stable/topics/settings/) file. === "settings.py" @@ -36,7 +36,7 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject ReactPy-Django requires Django ASGI and [Django Channels](https://github.com/django/channels) WebSockets. - If you have not enabled ASGI on your **Django project** yet, here is a summary of the [`django`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) and [`channels`](https://channels.readthedocs.io/en/stable/installation.html) installation docs: + If you have not enabled ASGI on your **Django project** yet, here is a summary of the [`django`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) and [`channels`](https://channels.readthedocs.io/en/stable/installation.html) installation docs: 1. Install `channels[daphne]` 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`. @@ -59,7 +59,7 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject ## Step 3: Configure `urls.py` -Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https://docs.djangoproject.com/en/dev/topics/http/urls/) file. +Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https://docs.djangoproject.com/en/stable/topics/http/urls/) file. === "urls.py" @@ -69,7 +69,7 @@ Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https: ## Step 4: Configure `asgi.py` -Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`asgi.py`](https://docs.djangoproject.com/en/dev/howto/deployment/asgi/) file. +Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [`asgi.py`](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) file. === "asgi.py" @@ -97,7 +97,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` ## Step 5: Run database migrations -Run Django's [`migrate` command](https://docs.djangoproject.com/en/dev/topics/migrations/) to initialize ReactPy-Django's database table. +Run Django's [`migrate` command](https://docs.djangoproject.com/en/stable/topics/migrations/) to initialize ReactPy-Django's database table. ```bash linenums="0" python manage.py migrate @@ -105,7 +105,7 @@ python manage.py migrate ## Step 6: Check your configuration -Run Django's [`check` command](https://docs.djangoproject.com/en/dev/ref/django-admin/#check) to verify if ReactPy was set up correctly. +Run Django's [`check` command](https://docs.djangoproject.com/en/stable/ref/django-admin/#check) to verify if ReactPy was set up correctly. ```bash linenums="0" python manage.py check @@ -113,7 +113,7 @@ python manage.py check ## Step 7: Create your first component -The [next step](./your-first-component.md) will show you how to create your first ReactPy component. +The [next page](./your-first-component.md) will show you how to create your first ReactPy component. Prefer a quick summary? Read the **At a Glance** section below. diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md index 08df6a57..85af4109 100644 --- a/docs/src/learn/your-first-component.md +++ b/docs/src/learn/your-first-component.md @@ -18,7 +18,7 @@ You will now need to pick at least one **Django app** to start using ReactPy-Dja 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). +1. You have a **Django app** named `my_app`, which was created by Django's [`startapp` command](https://docs.djangoproject.com/en/stable/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?" @@ -31,7 +31,7 @@ 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 using ReactPy's `#!python @component` decorator. +Within this file, you will define your component function(s) using the `#!python @component` decorator. === "components.py" @@ -43,7 +43,7 @@ Within this file, you can define your component functions using ReactPy's `#!pyt 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_](#embedding-in-a-template)). This path must be valid to Python's `#!python importlib`. + Ultimately, components are referenced by Python dotted path in `my_template.html` ([_see next step_](#embedding-in-a-template)). This dotted path must be valid to Python's `#!python importlib`. ??? question "What does the decorator actually do?" @@ -66,17 +66,23 @@ Additionally, you can pass in `#!python args` and `#!python kwargs` into your co {% include-markdown "../../../README.md" start="" end="" %} +???+ tip "Components are automatically registered!" + + ReactPy-Django will automatically register any component that is referenced in a Django HTML template. This means you [typically](../reference/utils.md#register-component) do not need to manually register components in your **Django app**. + + Please note that this HTML template must be properly stored within a registered Django app. ReactPy-Django will output a console log message containing all detected components when the server starts up. + {% include-markdown "../reference/template-tag.md" start="" end="" %} {% include-markdown "../reference/template-tag.md" start="" end="" %} ??? question "Where is my templates folder?" - If you do not have a `./templates/` folder in your **Django app**, you can simply create one! Keep in mind, templates within this folder will not be detected by Django unless you [add the corresponding **Django app** to `settings.py:INSTALLED_APPS`](https://docs.djangoproject.com/en/dev/ref/applications/#configuring-applications). + If you do not have a `./templates/` folder in your **Django app**, you can simply create one! Keep in mind, templates within this folder will not be detected by Django unless you [add the corresponding **Django app** to `settings.py:INSTALLED_APPS`](https://docs.djangoproject.com/en/stable/ref/applications/#configuring-applications). ## Setting up a Django view -Within your **Django app**'s `views.py` file, you will need to [create a view function](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) to render the HTML template `my_template.html` ([_from the previous step_](#embedding-in-a-template)). +Within your **Django app**'s `views.py` file, you will need to [create a view function](https://docs.djangoproject.com/en/stable/intro/tutorial01/#write-your-first-view) to render the HTML template `my_template.html` ([_from the previous step_](#embedding-in-a-template)). === "views.py" @@ -84,7 +90,7 @@ Within your **Django app**'s `views.py` file, you will need to [create a view fu {% include "../../examples/python/example/views.py" %} ``` -We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/dev/intro/tutorial01/#write-your-first-view) and define what URL it should be accessible at. +We will add this new view into your [`urls.py`](https://docs.djangoproject.com/en/stable/intro/tutorial01/#write-your-first-view) and define what URL it should be accessible at. === "urls.py" @@ -98,7 +104,7 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e Once you reach that point, we recommend creating an individual `urls.py` within each of your **Django apps**. - Then, within your **Django project's** `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/dev/ref/urls/#include) to link it all together. + Then, within your **Django project's** `urls.py` you will use Django's [`include` function](https://docs.djangoproject.com/en/stable/ref/urls/#include) to link it all together. ## Viewing your component @@ -114,7 +120,7 @@ If you copy-pasted our example component, you will now see your component displa ??? warning "Do not use `manage.py runserver` for production" - 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/). + This command is only intended for development purposes. For production deployments make sure to read [Django's documentation](https://docs.djangoproject.com/en/stable/howto/deployment/). ## Learn more diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 1b7ae9b2..943b76c0 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -124,7 +124,7 @@ Automatically convert a Django view into a component. At this time, this works best with static views with no interactivity. -Compatible with sync or async [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). +Compatible with sync or async [Function Based Views](https://docs.djangoproject.com/en/stable/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/stable/topics/class-based-views/). === "components.py" @@ -254,7 +254,7 @@ Automatically convert a Django view into an [`iframe` element](https://www.techt The contents of this `#!python iframe` is handled entirely by traditional Django view rendering. While this solution is compatible with more views than `#!python view_to_component`, it comes with different limitations. -Compatible with sync or async [Function Based Views](https://docs.djangoproject.com/en/dev/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/). +Compatible with sync or async [Function Based Views](https://docs.djangoproject.com/en/stable/topics/http/views/) and [Class Based Views](https://docs.djangoproject.com/en/stable/topics/class-based-views/). === "components.py" @@ -383,7 +383,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ## Django CSS -Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). +Allows you to defer loading a CSS stylesheet until a component begins rendering. This stylesheet must be stored within [Django's static files](https://docs.djangoproject.com/en/stable/howto/static-files/). === "components.py" @@ -436,11 +436,11 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. ## Django JS -Allows you to defer loading JavaScript until a component begins rendering. This JavaScript must be stored within [Django's static files](https://docs.djangoproject.com/en/dev/howto/static-files/). +Allows you to defer loading JavaScript until a component begins rendering. This JavaScript must be stored within [Django's static files](https://docs.djangoproject.com/en/stable/howto/static-files/). - - You can use the `#!python prerender` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. --- diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 197a3b29..e5c60a79 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -78,7 +78,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c

{% component "example_project.my_app.components.my_title" %}

{% component "example_project.my_app_2.components.goodbye_world" class="bold small-font" %}

- {% component "example_project.my_app_3.components.simple_button" %} + {% component "example_project.my_app_3.components.my_button" %} ``` @@ -157,7 +157,7 @@ This template tag can be used to insert any number of **client-side** ReactPy co -By default, the only dependencies available are the Python standard library, `pyscript`, `pyodide`, `reactpy` core. +By default, the only [available dependencies](./template-tag.md#pyscript-setup) are the Python standard library, `pyscript`, `pyodide`, `reactpy` core. The entire file path provided is loaded directly into the browser, and must have a `#!python def root()` component to act as the entry point. @@ -166,9 +166,9 @@ The entire file path provided is loaded directly into the browser, and must have !!! warning "Pitfall" - Your provided Python file is loaded directly into the client (web browser) **as raw text**, and ran using a PyScript interpreter. Be cautious about what you include in your Python file. + Similar to JavaScript, your provided Python file is loaded directly into the client (web browser) **as raw text** to run using the PyScript interpreter. Be cautious about what you include in your Python file. - As a result of running client-side, Python packages within your local environment (such as those installed via `pip install ...`) are **not accessible** within PyScript components. + As a result being client-sided, Python packages within your local environment (such as those installed via `pip install ...`) are **not accessible** within PyScript components. @@ -198,28 +198,40 @@ The entire file path provided is loaded directly into the browser, and must have ??? question "How do I execute JavaScript within PyScript components?" - PyScript components have the ability to directly execute standard library JavaScript using the [`pyodide` `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) or [`pyscript` foreign function interface](https://docs.pyscript.net/2024.6.1/user-guide/dom/). + PyScript components several options available to execute JavaScript, including... - The `#!python js` module has access to everything within the browser's JavaScript environment. Therefore, any global JavaScript functions loaded within your HTML `#!html ` can be called as well. However, be mindful of JavaScript load order! + - [Pyodide's `js` module](https://pyodide.org/en/stable/usage/type-conversions.html#importing-javascript-objects-into-python) + - [Pyscript's foreign function interface](https://docs.pyscript.net/latest/user-guide/dom/#ffi) + - [Pyscript's JavaScript modules](https://docs.pyscript.net/latest/user-guide/configuration/#javascript-modules). + + **Pyodide JS Module** + + The Pyodide `#!python js` module has access to everything within the browser's JavaScript environment. Therefore, any global JavaScript functions loaded within your HTML `#!html ` can be called as well. However, you will need to be mindful of JavaScript load order if using [`async` or `deferred`](https://javascript.info/script-async-defer) loading! === "root.py" ```python - {% include "../../examples/python/pyscript-js-execution.py" %} + {% include "../../examples/python/pyodide-js-module.py" %} ``` - To import JavaScript modules in a fashion similar to `#!javascript import {moment} from 'static/moment.js'`, you will need to configure your `#!jinja {% pyscript_setup %}` block to make the module available to PyScript. This module will be accessed within `#!python pyscript.js_modules.*`. For more information, see the [PyScript JS modules docs](https://docs.pyscript.net/2024.6.2/user-guide/configuration/#javascript-modules). + **PyScript FFI** + + ... + + **PyScript JS Modules** + + Assuming you have a local bundle stored within your project's static files, you can import JavaScript modules in a fashion similar to `#!javascript import {moment} from 'static/moment.js'`. You will first need to configure your `#!jinja {% pyscript_setup %}` block to make the `moment.js` module available to PyScript. Then, this module can be accessed within `#!python pyscript.js_modules.*`. === "root.py" ```python - {% include "../../examples/python/pyscript-js-module.py" %} + {% include "../../examples/python/pyscript-local-import.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-js-module.html" %} + {% include "../../examples/html/pyscript-local-import.html" %} ``` diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index 5402cc85..917ba959 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -36,9 +36,9 @@ It is mandatory to use this function alongside [`view_to_iframe`](../reference/c `#!python None` -??? warning "Only use this within `#!python MyAppConfig.ready()`" +??? warning "Only use this within `#!python AppConfig.ready()`" - You should always call `#!python register_iframe` within a Django [`MyAppConfig.ready()` method](https://docs.djangoproject.com/en/dev/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. + You should always call `#!python register_iframe` within a Django [`AppConfig.ready()` method](https://docs.djangoproject.com/en/stable/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. --- @@ -68,7 +68,7 @@ Typically, this function is automatically called on all components contained wit ??? warning "Only use this within `#!python MyAppConfig.ready()`" - You should always call `#!python register_component` within a Django [`MyAppConfig.ready()` method](https://docs.djangoproject.com/en/dev/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. + You should always call `#!python register_component` within a Django [`MyAppConfig.ready()` method](https://docs.djangoproject.com/en/stable/ref/applications/#django.apps.AppConfig.ready). This ensures you will retain multiprocessing compatibility, such as with ASGI web server workers. ??? question "Do I need to use this?" @@ -84,7 +84,7 @@ Typically, this function is automatically called on all components contained wit This is the default postprocessor for the `#!python use_query` hook. -Since ReactPy is rendered within an `#!python asyncio` loop, this postprocessor is exists to prevent Django's `#!python SynchronousOnlyException` by recursively prefetching fields within a `#!python Model` or `#!python QuerySet`. This prefetching step works to eliminate Django's [lazy execution](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) behavior. +Since ReactPy is rendered within an `#!python asyncio` loop, this postprocessor is exists to prevent Django's `#!python SynchronousOnlyException` by recursively prefetching fields within Django's ORM. Note that this effectively eliminates Django's [lazy execution](https://docs.djangoproject.com/en/stable/topics/db/queries/#querysets-are-lazy) behavior. === "components.py" diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt index a7d5b1cf..846a7ba3 100644 --- a/requirements/build-docs.txt +++ b/requirements/build-docs.txt @@ -5,5 +5,4 @@ mkdocs-include-markdown-plugin mkdocs-spellcheck[all] mkdocs-git-authors-plugin mkdocs-minify-plugin -mkdocs-section-index mike diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index c6102c18..cec6a9e1 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -1,8 +1,7 @@ channels >=4.0.0 django >=4.2.0 reactpy >=1.0.2, <1.1.0 -reactpy-router >=0.1.1, <1.0.0 -aiofile >=3.0 +reactpy-router >=1.0.0, <2.0.0 dill >=0.3.5 orjson >=3.6.0 nest_asyncio >=1.5.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3c6e79cf..00000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index d4ece375..77b56743 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -3,7 +3,6 @@ import nest_asyncio from reactpy_django import ( - checks, components, decorators, hooks, @@ -12,14 +11,10 @@ types, utils, ) -from reactpy_django.websocket.paths import ( - REACTPY_WEBSOCKET_PATH, - REACTPY_WEBSOCKET_ROUTE, -) +from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_ROUTE __version__ = "4.0.0" __all__ = [ - "REACTPY_WEBSOCKET_PATH", "REACTPY_WEBSOCKET_ROUTE", "html", "hooks", @@ -27,7 +22,6 @@ "decorators", "types", "utils", - "checks", "router", ] diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index d836a9ca..740df974 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -19,7 +19,7 @@ def reactpy_warnings(app_configs, **kwargs): warnings = [] INSTALLED_APPS: list[str] = getattr(settings, "INSTALLED_APPS", []) - # REACTPY_DATABASE is not an in-memory database. + # Check if REACTPY_DATABASE is not an in-memory database. if ( getattr(settings, "DATABASES", {}) .get(getattr(settings, "REACTPY_DATABASE", "default"), {}) @@ -36,7 +36,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # ReactPy URLs exist + # Check if ReactPy URLs are reachable try: reverse("reactpy:web_modules", kwargs={"file": "example"}) reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"}) @@ -102,16 +102,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 REACTPY_WEBSOCKET_URL setting - if getattr(settings, "REACTPY_WEBSOCKET_URL", None): - warnings.append( - Warning( - "REACTPY_WEBSOCKET_URL has been removed.", - hint="Use REACTPY_URL_PREFIX instead.", - id="reactpy_django.W009", - ) - ) + # DELETED W009: Check if deprecated value REACTPY_WEBSOCKET_URL exists # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs with contextlib.suppress(NoReverseMatch): @@ -152,22 +143,16 @@ def reactpy_warnings(app_configs, **kwargs): ): warnings.append( Warning( - "You have not configured runserver to use ASGI.", + "You have not configured the `runserver` command to use ASGI. " + "ReactPy will work properly in this configuration.", hint="Add daphne to settings.py:INSTALLED_APPS.", id="reactpy_django.W012", ) ) - # 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", - ) - ) + # DELETED W013: Check if deprecated value REACTPY_RECONNECT_MAX exists + # Check if REACTPY_RECONNECT_INTERVAL is set to a large value if ( isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL > 30000 @@ -181,20 +166,22 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_RETRIES is set to a large value 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? " + "REACTPY_RECONNECT_MAX_RETRIES is set to a very large value " + f"{config.REACTPY_RECONNECT_MAX_RETRIES}. 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) + # Check if the REACTPY_RECONNECT_BACKOFF_MULTIPLIER is set to a large value if ( isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER > 100 @@ -207,6 +194,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_INTERVAL is reachable if ( isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) @@ -239,6 +227,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if 'reactpy_django' is in the correct position in INSTALLED_APPS position_to_beat = 0 for app in INSTALLED_APPS: if app.startswith("django.contrib."): @@ -255,6 +244,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_SESSION is not a valid property if getattr(settings, "REACTPY_CLEAN_SESSION", None): warnings.append( Warning( @@ -301,7 +291,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) - # All settings in reactpy_django.conf are the correct data type + # Check if REACTPY_URL_PREFIX is a valid data type if not isinstance(getattr(settings, "REACTPY_URL_PREFIX", ""), str): errors.append( Error( @@ -311,6 +301,8 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E003", ) ) + + # Check if REACTPY_SESSION_MAX_AGE is a valid data type if not isinstance(getattr(settings, "REACTPY_SESSION_MAX_AGE", 0), int): errors.append( Error( @@ -320,6 +312,8 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E004", ) ) + + # Check if REACTPY_CACHE is a valid data type if not isinstance(getattr(settings, "REACTPY_CACHE", ""), str): errors.append( Error( @@ -329,6 +323,8 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E005", ) ) + + # Check if REACTPY_DATABASE is a valid data type if not isinstance(getattr(settings, "REACTPY_DATABASE", ""), str): errors.append( Error( @@ -338,6 +334,8 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E006", ) ) + + # Check if REACTPY_DEFAULT_QUERY_POSTPROCESSOR is a valid data type if not isinstance( getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None)) ): @@ -349,6 +347,8 @@ def reactpy_errors(app_configs, **kwargs): id="reactpy_django.E007", ) ) + + # Check if REACTPY_AUTH_BACKEND is a valid data type if not isinstance(getattr(settings, "REACTPY_AUTH_BACKEND", ""), str): errors.append( Error( @@ -361,6 +361,7 @@ def reactpy_errors(app_configs, **kwargs): # DELETED E009: Check if `channels` is in INSTALLED_APPS + # Check if REACTPY_DEFAULT_HOSTS is a valid data type if not isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", []), list): errors.append( Error( @@ -371,7 +372,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) - # Check of all values in the list are strings + # Check of all values in the REACTPY_DEFAULT_HOSTS are strings if isinstance(getattr(settings, "REACTPY_DEFAULT_HOSTS", None), list): for host in settings.REACTPY_DEFAULT_HOSTS: if not isinstance(host, str): @@ -385,6 +386,7 @@ def reactpy_errors(app_configs, **kwargs): ) break + # Check if REACTPY_RECONNECT_INTERVAL is a valid data type if not isinstance(config.REACTPY_RECONNECT_INTERVAL, int): errors.append( Error( @@ -394,6 +396,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_INTERVAL is a positive integer if ( isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL < 0 @@ -406,6 +409,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_INTERVAL is a valid data type if not isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int): errors.append( Error( @@ -415,6 +419,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_INTERVAL is a positive integer if ( isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and config.REACTPY_RECONNECT_MAX_INTERVAL < 0 @@ -427,6 +432,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_INTERVAL is greater than REACTPY_RECONNECT_INTERVAL if ( isinstance(config.REACTPY_RECONNECT_MAX_INTERVAL, int) and isinstance(config.REACTPY_RECONNECT_INTERVAL, int) @@ -440,6 +446,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_RETRIES is a valid data type if not isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int): errors.append( Error( @@ -449,6 +456,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_MAX_RETRIES is a positive integer if ( isinstance(config.REACTPY_RECONNECT_MAX_RETRIES, int) and config.REACTPY_RECONNECT_MAX_RETRIES < 0 @@ -461,6 +469,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_BACKOFF_MULTIPLIER is a valid data type if not isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)): errors.append( Error( @@ -470,6 +479,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_RECONNECT_BACKOFF_MULTIPLIER is greater than or equal to 1 if ( isinstance(config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER, (int, float)) and config.REACTPY_RECONNECT_BACKOFF_MULTIPLIER < 1 @@ -482,6 +492,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_PRERENDER is a valid data type if not isinstance(config.REACTPY_PRERENDER, bool): errors.append( Error( @@ -491,6 +502,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_AUTO_RELOGIN is a valid data type if not isinstance(config.REACTPY_AUTO_RELOGIN, bool): errors.append( Error( @@ -500,6 +512,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_INTERVAL is a valid data type if not isinstance(config.REACTPY_CLEAN_INTERVAL, (int, type(None))): errors.append( Error( @@ -509,6 +522,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_INTERVAL is a positive integer if ( isinstance(config.REACTPY_CLEAN_INTERVAL, int) and config.REACTPY_CLEAN_INTERVAL < 0 @@ -521,6 +535,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_SESSIONS is a valid data type if not isinstance(config.REACTPY_CLEAN_SESSIONS, bool): errors.append( Error( @@ -530,6 +545,7 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_USER_DATA is a valid data type if not isinstance(config.REACTPY_CLEAN_USER_DATA, bool): errors.append( Error( diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 579c73e3..3794ba73 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -2,10 +2,9 @@ import json import os -from typing import Any, Callable, Sequence, Union, cast, overload +from typing import Any, Callable, Sequence, Union, cast from urllib.parse import urlencode from uuid import uuid4 -from warnings import warn from django.contrib.staticfiles.finders import find from django.core.cache import caches @@ -26,40 +25,15 @@ ) -# Type hints for: -# 1. example = view_to_component(my_view, ...) -# 2. @view_to_component -@overload def view_to_component( view: Callable | View | str, - compatibility: bool = False, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: ... - - -# Type hints for: -# 1. @view_to_component(...) -@overload -def view_to_component( - view: None = ..., - compatibility: bool = False, - transforms: Sequence[Callable[[VdomDict], Any]] = (), - strict_parsing: bool = True, -) -> Callable[[Callable], Any]: ... - - -def view_to_component( - view: Callable | View | str | None = None, - compatibility: bool = False, - transforms: Sequence[Callable[[VdomDict], Any]] = (), - strict_parsing: bool = True, -) -> Any | Callable[[Callable], Any]: +) -> Any: """Converts a Django view to a ReactPy component. Keyword Args: view: The view to convert, or the view's dotted path as a string. - compatibility: **DEPRECATED.** Use `view_to_iframe` instead. transforms: A list of functions that transforms the newly generated VDOM. \ The functions will be called on each VDOM node. strict_parsing: If True, an exception will be generated if the HTML does not \ @@ -69,37 +43,23 @@ def view_to_component( A function that takes `request, *args, key, **kwargs` and returns a ReactPy component. """ - def decorator(view: Callable | View | str): - if not view: - raise ValueError("A view must be provided to `view_to_component`") - - def constructor( - request: HttpRequest | None = None, - *args, - key: Key | None = None, - **kwargs, - ): - return _view_to_component( - view=view, - compatibility=compatibility, - transforms=transforms, - strict_parsing=strict_parsing, - request=request, - args=args, - kwargs=kwargs, - key=key, - ) - - return constructor - - if not view: - warn( - "Using `view_to_component` as a decorator is deprecated. " - "This functionality will be removed in a future version.", - DeprecationWarning, + def constructor( + request: HttpRequest | None = None, + *args, + key: Key | None = None, + **kwargs, + ): + return _view_to_component( + view=view, + transforms=transforms, + strict_parsing=strict_parsing, + request=request, + args=args, + kwargs=kwargs, + key=key, ) - return decorator(view) if view else decorator + return constructor def view_to_iframe( @@ -180,7 +140,6 @@ def pyscript_component( @component def _view_to_component( view: Callable | View | str, - compatibility: bool, transforms: Sequence[Callable[[VdomDict], Any]], strict_parsing: bool, request: HttpRequest | None, @@ -209,10 +168,6 @@ def _view_to_component( ) async def async_render(): """Render the view in an async hook to avoid blocking the main thread.""" - # Compatibility mode doesn't require a traditional render - if compatibility: - return - # Render the view response = await render_view(resolved_view, _request, _args, _kwargs) set_converted_view( @@ -224,17 +179,6 @@ async def async_render(): ) ) - # Render in compatibility mode, if needed - if compatibility: - # Warn the user that compatibility mode is deprecated - warn( - "view_to_component(compatibility=True) is deprecated and will be removed in a future version. " - "Please use `view_to_iframe` instead.", - DeprecationWarning, - ) - - return view_to_iframe(resolved_view)(*_args, **_kwargs) - # Return the view if it's been rendered via the `async_render` hook return converted_view diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index 21a30a32..a891cb5d 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -23,19 +23,11 @@ REACTPY_FAILED_COMPONENTS: set[str] = set() REACTPY_REGISTERED_IFRAME_VIEWS: dict[str, Callable | View] = {} - -# Remove in a future release -REACTPY_WEBSOCKET_URL = getattr( - settings, - "REACTPY_WEBSOCKET_URL", - "reactpy/", -) - # Configurable through Django settings.py REACTPY_URL_PREFIX: str = getattr( settings, "REACTPY_URL_PREFIX", - REACTPY_WEBSOCKET_URL, + "reactpy/", ).strip("/") REACTPY_SESSION_MAX_AGE: int = getattr( settings, diff --git a/src/reactpy_django/database.py b/src/reactpy_django/database.py index 2a7f826d..0d0b2065 100644 --- a/src/reactpy_django/database.py +++ b/src/reactpy_django/database.py @@ -21,7 +21,7 @@ def db_for_write(self, model, **hints): def allow_relation(self, obj1, obj2, **hints): """Returning `None` only allow relations within the same database. - https://docs.djangoproject.com/en/dev/topics/db/multi-db/#limitations-of-multiple-databases + https://docs.djangoproject.com/en/stable/topics/db/multi-db/#limitations-of-multiple-databases """ return None diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 59c110b3..39a028a4 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -2,53 +2,17 @@ from functools import wraps from typing import TYPE_CHECKING, Any, Callable -from warnings import warn from reactpy import component -from reactpy.core.types import ComponentConstructor, ComponentType, VdomDict +from reactpy.core.types import ComponentConstructor from reactpy_django.exceptions import DecoratorParamError -from reactpy_django.hooks import use_scope, use_user +from reactpy_django.hooks import use_user if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser -def auth_required( - component: Callable | None = None, - auth_attribute: str = "is_active", - fallback: ComponentType | Callable | VdomDict | None = None, -) -> Callable: - """If the user passes authentication criteria, the decorated component will be rendered. - Otherwise, the fallback component will be rendered. - - This decorator can be used with or without parentheses. - - Args: - auth_attribute: The value to check within the user object. \ - This is checked in the form of `UserModel.`. \ - fallback: The component or VDOM (`reactpy.html` snippet) to render if the user is not authenticated. - """ - - warn( - "auth_required is deprecated and will be removed in the next major version. " - "An equivalent to this decorator's default is @user_passes_test(lambda user: user.is_active).", - DeprecationWarning, - ) - - def decorator(component): - @wraps(component) - def _wrapped_func(*args, **kwargs): - scope = use_scope() - - if getattr(scope["user"], auth_attribute): - return component(*args, **kwargs) - return fallback(*args, **kwargs) if callable(fallback) else fallback - - return _wrapped_func - - # Return for @authenticated(...) and @authenticated respectively - return decorator if component is None else decorator(component) def user_passes_test( diff --git a/src/reactpy_django/http/views.py b/src/reactpy_django/http/views.py index 983c8361..522d3dcf 100644 --- a/src/reactpy_django/http/views.py +++ b/src/reactpy_django/http/views.py @@ -1,7 +1,7 @@ +import asyncio import os from urllib.parse import parse_qs -from aiofile import async_open from django.core.cache import caches from django.core.exceptions import SuspiciousOperation from django.http import HttpRequest, HttpResponse, HttpResponseNotFound @@ -31,8 +31,8 @@ async def web_modules_file(request: HttpRequest, file: str) -> HttpResponse: cache_key, version=int(last_modified_time) ) if file_contents is None: - async with async_open(path, "r") as fp: - file_contents = await fp.read() + with open(path, "r", encoding="utf-8") as fp: + file_contents = await asyncio.to_thread(fp.read) await caches[REACTPY_CACHE].adelete(cache_key) await caches[REACTPY_CACHE].aset( cache_key, file_contents, timeout=604800, version=int(last_modified_time) diff --git a/src/reactpy_django/router/__init__.py b/src/reactpy_django/router/__init__.py index 3c48e1ab..4c9c0efd 100644 --- a/src/reactpy_django/router/__init__.py +++ b/src/reactpy_django/router/__init__.py @@ -1,4 +1,4 @@ -from reactpy_router.core import create_router +from reactpy_router import create_router from reactpy_django.router.resolvers import DjangoResolver diff --git a/src/reactpy_django/router/converters.py b/src/reactpy_django/router/converters.py index 3611f63e..483dbcbb 100644 --- a/src/reactpy_django/router/converters.py +++ b/src/reactpy_django/router/converters.py @@ -1,7 +1,8 @@ from django.urls.converters import get_converters -from reactpy_router.simple import ConversionInfo +from reactpy_router.types import ConversionInfo CONVERTERS: dict[str, ConversionInfo] = { name: {"regex": converter.regex, "func": converter.to_python} for name, converter in get_converters().items() } +CONVERTERS["any"] = {"regex": r".*", "func": str} diff --git a/src/reactpy_django/router/resolvers.py b/src/reactpy_django/router/resolvers.py index 7c095081..4568786c 100644 --- a/src/reactpy_django/router/resolvers.py +++ b/src/reactpy_django/router/resolvers.py @@ -1,58 +1,22 @@ from __future__ import annotations -import re -from typing import Any - -from reactpy_router.simple import ConverterMapping -from reactpy_router.types import Route +from reactpy_router.resolvers import StarletteResolver +from reactpy_router.types import ConversionInfo, Route from reactpy_django.router.converters import CONVERTERS -PARAM_PATTERN = re.compile(r"<(?P\w+:)?(?P\w+)>") - -# TODO: Make reactpy_router's SimpleResolver generic enough to where we don't have to define our own -class DjangoResolver: +class DjangoResolver(StarletteResolver): """A simple route resolver that uses regex to match paths""" - def __init__(self, route: Route) -> None: - self.element = route.element - self.pattern, self.converters = parse_path(route.path) - self.key = self.pattern.pattern - - def resolve(self, path: str) -> tuple[Any, dict[str, Any]] | None: - match = self.pattern.match(path) - if match: - return ( - self.element, - {k: self.converters[k](v) for k, v in match.groupdict().items()}, - ) - return None - - -# TODO: Make reactpy_router's parse_path generic enough to where we don't have to define our own -def parse_path(path: str) -> tuple[re.Pattern[str], ConverterMapping]: - # Convert path to regex pattern, and make sure to interpret the registered converters (ex. ) - pattern = "^" - last_match_end = 0 - converters: ConverterMapping = {} - for match in PARAM_PATTERN.finditer(path): - param_name = match.group("name") - param_type = (match.group("type") or "str").strip(":") - try: - param_conv = CONVERTERS[param_type] - except KeyError as e: - raise ValueError( - f"Unknown conversion type {param_type!r} in {path!r}" - ) from e - pattern += re.escape(path[last_match_end : match.start()]) - pattern += f"(?P<{param_name}>{param_conv['regex']})" - converters[param_name] = param_conv["func"] - last_match_end = match.end() - pattern += f"{re.escape(path[last_match_end:])}$" - - # Replace literal `*` with "match anything" regex pattern, if it's at the end of the path - if pattern.endswith(r"\*$"): - pattern = f"{pattern[:-3]}.*$" - - return re.compile(pattern), converters + def __init__( + self, + route: Route, + param_pattern=r"<(?P\w+:)?(?P\w+)>", + converters: dict[str, ConversionInfo] | None = None, + ) -> None: + super().__init__( + route=route, + param_pattern=param_pattern, + converters=converters or CONVERTERS, + ) diff --git a/src/reactpy_django/websocket/paths.py b/src/reactpy_django/websocket/paths.py index 17a9c48e..435f9b71 100644 --- a/src/reactpy_django/websocket/paths.py +++ b/src/reactpy_django/websocket/paths.py @@ -10,8 +10,6 @@ ) """A URL path for :class:`ReactpyAsyncWebsocketConsumer`. -Required since the `reverse()` function does not exist for Django Channels, but we need -to know the websocket path. +Required since the `reverse()` function does not exist for Django Channels, but ReactPy needs +to know the current websocket path. """ - -REACTPY_WEBSOCKET_PATH = REACTPY_WEBSOCKET_ROUTE diff --git a/tests/test_app/apps.py b/tests/test_app/apps.py index a5bad495..2bef8446 100644 --- a/tests/test_app/apps.py +++ b/tests/test_app/apps.py @@ -2,8 +2,8 @@ import sys from django.apps import AppConfig -from reactpy_django.utils import register_iframe +from reactpy_django.utils import register_iframe from test_app import views @@ -13,11 +13,11 @@ class TestAppConfig(AppConfig): def ready(self): from django.contrib.auth.models import User - register_iframe("test_app.views.view_to_component_sync_func_compatibility") - register_iframe(views.view_to_component_async_func_compatibility) - register_iframe(views.ViewToComponentSyncClassCompatibility) - register_iframe(views.ViewToComponentAsyncClassCompatibility) - register_iframe(views.ViewToComponentTemplateViewClassCompatibility) + register_iframe("test_app.views.view_to_iframe_sync_func") + register_iframe(views.view_to_iframe_async_func) + register_iframe(views.ViewToIframeSyncClass) + register_iframe(views.ViewToIframeAsyncClass) + register_iframe(views.ViewToIframeTemplateViewClass) register_iframe(views.view_to_iframe_args) if "test" in sys.argv: diff --git a/tests/test_app/components.py b/tests/test_app/components.py index 69b1541c..4ae0544e 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -2,15 +2,14 @@ import inspect from pathlib import Path -import reactpy_django from channels.auth import login, logout from channels.db import database_sync_to_async from django.contrib.auth import get_user_model from django.http import HttpRequest -from django.shortcuts import render from reactpy import component, hooks, html, web -from reactpy_django.components import view_to_component, view_to_iframe +import reactpy_django +from reactpy_django.components import view_to_component, view_to_iframe from test_app.models import ( AsyncForiegnChild, AsyncRelationalChild, @@ -72,7 +71,7 @@ def object_in_templatetag(my_object: TestObject): SimpleButtonModule = web.module_from_file( "SimpleButton", - Path(__file__).parent / "tests" / "js" / "simple-button.js", + Path(__file__).parent / "tests" / "js" / "button-from-js-module.js", resolve_exports=False, fallback="...", ) @@ -80,8 +79,10 @@ def object_in_templatetag(my_object: TestObject): @component -def simple_button(): - return html._("simple_button:", SimpleButton({"id": "simple-button"})) +def button_from_js_module(): + return html._( + "button_from_js_module:", SimpleButton({"id": "button-from-js-module"}) + ) @component @@ -146,45 +147,24 @@ def django_js(): ) -@component -@reactpy_django.decorators.auth_required( - fallback=html.div( - {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success" - ) -) -def unauthorized_user(): - return html.div({"id": "unauthorized-user"}, "unauthorized_user: Fail") - - -@component -@reactpy_django.decorators.auth_required( - auth_attribute="is_anonymous", - fallback=html.div({"id": "authorized-user-fallback"}, "authorized_user: Fail"), -) -def authorized_user(): - return html.div({"id": "authorized-user"}, "authorized_user: Success") - - @reactpy_django.decorators.user_passes_test( lambda user: user.is_anonymous, - fallback=html.div( - {"id": "authorized-user-test-fallback"}, "authorized_user_test: Fail" - ), + fallback=html.div({"id": "authorized-user-fallback"}, "authorized_user: Fail"), ) @component -def authorized_user_test(): - return html.div({"id": "authorized-user-test"}, "authorized_user_test: Success") +def authorized_user(): + return html.div({"id": "authorized-user"}, "authorized_user: Success") @reactpy_django.decorators.user_passes_test( lambda user: user.is_active, fallback=html.div( - {"id": "unauthorized-user-test-fallback"}, "unauthorized_user_test: Success" + {"id": "unauthorized-user-fallback"}, "unauthorized_user: Success" ), ) @component -def unauthorized_user_test(): - return html.div({"id": "unauthorized-user-test"}, "unauthorized_user_test: Fail") +def unauthorized_user(): + return html.div({"id": "unauthorized-user"}, "unauthorized_user: Fail") @reactpy_django.decorators.user_passes_test(lambda user: True) @@ -485,20 +465,12 @@ async def on_change(event): view_to_component_template_view_class = view_to_component( views.ViewToComponentTemplateViewClass.as_view() ) -_view_to_component_sync_func_compatibility = view_to_component( - views.view_to_component_sync_func_compatibility, compatibility=True -) -_view_to_component_async_func_compatibility = view_to_component( - views.view_to_component_async_func_compatibility, compatibility=True -) -_view_to_component_sync_class_compatibility = view_to_component( - views.ViewToComponentSyncClassCompatibility.as_view(), compatibility=True -) -_view_to_component_async_class_compatibility = view_to_component( - views.ViewToComponentAsyncClassCompatibility.as_view(), compatibility=True -) -_view_to_component_template_view_class_compatibility = view_to_component( - views.ViewToComponentTemplateViewClassCompatibility.as_view(), compatibility=True +_view_to_iframe_sync_func = view_to_iframe(views.view_to_iframe_sync_func) +_view_to_iframe_async_func = view_to_iframe(views.view_to_iframe_async_func) +_view_to_iframe_sync_class = view_to_iframe(views.ViewToIframeSyncClass.as_view()) +_view_to_iframe_async_class = view_to_iframe(views.ViewToIframeAsyncClass.as_view()) +_view_to_iframe_template_view_class = view_to_iframe( + views.ViewToIframeTemplateViewClass.as_view() ) _view_to_iframe_args = view_to_iframe(views.view_to_iframe_args) _view_to_iframe_not_registered = view_to_iframe("view_does_not_exist") @@ -509,42 +481,42 @@ async def on_change(event): @component -def view_to_component_sync_func_compatibility(): +def view_to_iframe_sync_func(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_component_sync_func_compatibility(key="test"), + _view_to_iframe_sync_func(key="test"), ) @component -def view_to_component_async_func_compatibility(): +def view_to_iframe_async_func(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_component_async_func_compatibility(), + _view_to_iframe_async_func(), ) @component -def view_to_component_sync_class_compatibility(): +def view_to_iframe_sync_class(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_component_sync_class_compatibility(), + _view_to_iframe_sync_class(), ) @component -def view_to_component_async_class_compatibility(): +def view_to_iframe_async_class(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_component_async_class_compatibility(), + _view_to_iframe_async_class(), ) @component -def view_to_component_template_view_class_compatibility(): +def view_to_iframe_template_view_class(): return html.div( {"id": inspect.currentframe().f_code.co_name}, # type: ignore - _view_to_component_template_view_class_compatibility(), + _view_to_iframe_template_view_class(), ) @@ -623,24 +595,6 @@ def on_click(_): ) -@view_to_component -def view_to_component_decorator(request): - return render( - request, - "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore - ) - - -@view_to_component(strict_parsing=False) -def view_to_component_decorator_args(request): - return render( - request, - "view_to_component.html", - {"test_name": inspect.currentframe().f_code.co_name}, # type: ignore - ) - - @component def custom_host(number=0): scope = reactpy_django.hooks.use_scope() diff --git a/tests/test_app/router/components.py b/tests/test_app/router/components.py index 19eee0ba..ea95c5f2 100644 --- a/tests/test_app/router/components.py +++ b/tests/test_app/router/components.py @@ -1,45 +1,42 @@ from reactpy import component, html, use_location +from reactpy_router import route, use_params, use_search_params +from reactpy_router.types import Route + from reactpy_django.router import django_router -from reactpy_router import route, use_params, use_query @component def display_params(string: str): location = use_location() - query = use_query() - params = use_params() + search_params = use_search_params() + url_params = use_params() return html._( html.div({"id": "router-string"}, string), - html.div(f"Params: {params}"), html.div( {"id": "router-path", "data-path": location.pathname}, - f"Path Name: {location.pathname}", + f"path: {location.pathname}", ), - html.div(f"Query String: {location.search}"), - html.div(f"Query: {query}"), + html.div(f"url_params: {url_params}"), + html.div(f"location.search: {location.search}"), + html.div(f"search_params: {search_params}"), ) +def show_route(path: str, *children: Route) -> Route: + return route(path, display_params(path), *children) + + @component def main(): return django_router( - route("/router/", display_params("Path 1")), - route("/router/any//", display_params("Path 2")), - route("/router/integer//", display_params("Path 3")), - route("/router/path//", display_params("Path 4")), - route("/router/slug//", display_params("Path 5")), - route("/router/string//", display_params("Path 6")), - route("/router/uuid//", display_params("Path 7")), - route("/router/", None, route("abc/", display_params("Path 8"))), - route( - "/router/two///", - display_params("Path 9"), - ), - route( - "/router/star/", - None, - route("one/", display_params("Path 11")), - route("*", display_params("Path 12")), - ), + show_route("/router/", show_route("subroute/")), + show_route("/router/unspecified//"), + show_route("/router/integer//"), + show_route("/router/path//"), + show_route("/router/slug//"), + show_route("/router/string//"), + show_route("/router/uuid//"), + show_route("/router/any/"), + show_route("/router/two///"), ) diff --git a/tests/test_app/router/urls.py b/tests/test_app/router/urls.py index b497b951..73b60990 100644 --- a/tests/test_app/router/urls.py +++ b/tests/test_app/router/urls.py @@ -3,5 +3,5 @@ from test_app.router.views import router urlpatterns = [ - re_path(r"^router/(?P.*)/?$", router), + re_path(r"^router/(?P.*)$", router), ] diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index f15094ac..117e867d 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -27,7 +27,7 @@

ReactPy Test Page


{% component "test_app.components.object_in_templatetag" my_object %}
- {% component "test_app.components.simple_button" %} + {% component "test_app.components.button_from_js_module" %}
{% component "test_app.components.use_connection" %}
@@ -45,10 +45,6 @@

ReactPy Test Page


{% component "test_app.components.authorized_user" %}
- {% component "test_app.components.unauthorized_user_test" %} -
- {% component "test_app.components.authorized_user_test" %} -
{% component "test_app.components.relational_query" %}
{% component "test_app.components.async_relational_query" %} @@ -75,19 +71,15 @@

ReactPy Test Page


{% component "test_app.components.view_to_component_kwargs" %}
- {% component "test_app.components.view_to_component_decorator" %} -
- {% component "test_app.components.view_to_component_decorator_args" %} -
- {% component "test_app.components.view_to_component_sync_func_compatibility" %} + {% component "test_app.components.view_to_iframe_sync_func" %}
- {% component "test_app.components.view_to_component_async_func_compatibility" %} + {% component "test_app.components.view_to_iframe_async_func" %}
- {% component "test_app.components.view_to_component_sync_class_compatibility" %} + {% component "test_app.components.view_to_iframe_sync_class" %}
- {% component "test_app.components.view_to_component_async_class_compatibility" %} + {% component "test_app.components.view_to_iframe_async_class" %}
- {% component "test_app.components.view_to_component_template_view_class_compatibility" %} + {% component "test_app.components.view_to_iframe_template_view_class" %}
{% component "test_app.components.view_to_iframe_args" %}
diff --git a/tests/test_app/tests/js/simple-button.js b/tests/test_app/tests/js/button-from-js-module.js similarity index 100% rename from tests/test_app/tests/js/simple-button.js rename to tests/test_app/tests/js/button-from-js-module.js diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 11fdc390..f3726a4c 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -12,6 +12,7 @@ from django.db import connections from django.test.utils import modify_settings from playwright.sync_api import TimeoutError, sync_playwright + from reactpy_django.models import ComponentSession from reactpy_django.utils import strtobool @@ -21,6 +22,7 @@ class ComponentTests(ChannelsLiveServerTestCase): from django.db import DEFAULT_DB_ALIAS + from reactpy_django import config databases = {"default"} @@ -65,6 +67,7 @@ def setUpClass(cls): headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) cls.page = cls.browser.new_page() + cls.page.set_default_timeout(5000) @classmethod def tearDownClass(cls): @@ -74,14 +77,14 @@ def tearDownClass(cls): cls.playwright.stop() # Close the other server processes + cls._server_process.terminate() + cls._server_process.join() cls._server_process2.terminate() cls._server_process2.join() cls._server_process3.terminate() cls._server_process3.join() # Repurposed from ChannelsLiveServerTestCase._post_teardown - cls._server_process.terminate() - cls._server_process.join() cls._live_server_modified_settings.disable() for db_name in {"default", config.REACTPY_DATABASE}: call_command( @@ -94,13 +97,11 @@ def tearDownClass(cls): def _pre_setup(self): """Handled manually in `setUpClass` to speed things up.""" - pass def _post_teardown(self): """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing database flushing. This is needed to prevent a `SynchronousOnlyOperation` from occuring due to a bug within `ChannelsLiveServerTestCase`.""" - pass def setUp(self): if self.page.url == "about:blank": @@ -121,7 +122,7 @@ def test_object_in_templatetag(self): self.page.locator("#object_in_templatetag[data-success=true]").wait_for() def test_component_from_web_module(self): - self.page.wait_for_selector("#simple-button") + self.page.wait_for_selector("#button-from-js-module") def test_use_connection(self): self.page.locator("#use-connection[data-success=true]").wait_for() @@ -164,24 +165,6 @@ def test_authorized_user(self): ) self.page.wait_for_selector("#authorized-user") - def test_unauthorized_user_test(self): - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#unauthorized-user-test", - timeout=1, - ) - self.page.wait_for_selector("#unauthorized-user-test-fallback") - - def test_authorized_user_test(self): - self.assertRaises( - TimeoutError, - self.page.wait_for_selector, - "#authorized-user-test-fallback", - timeout=1, - ) - self.page.wait_for_selector("#authorized-user-test") - def test_relational_query(self): self.page.locator("#relational-query[data-success=true]").wait_for() @@ -260,39 +243,29 @@ def test_view_to_component_args(self): def test_view_to_component_kwargs(self): self._click_btn_and_check_success("view_to_component_kwargs") - def test_view_to_component_sync_func_compatibility(self): - self.page.frame_locator( - "#view_to_component_sync_func_compatibility > iframe" - ).locator( - "#view_to_component_sync_func_compatibility[data-success=true]" + def test_view_to_iframe_sync_func(self): + self.page.frame_locator("#view_to_iframe_sync_func > iframe").locator( + "#view_to_iframe_sync_func[data-success=true]" ).wait_for() - def test_view_to_component_async_func_compatibility(self): - self.page.frame_locator( - "#view_to_component_async_func_compatibility > iframe" - ).locator( - "#view_to_component_async_func_compatibility[data-success=true]" + def test_view_to_iframe_async_func(self): + self.page.frame_locator("#view_to_iframe_async_func > iframe").locator( + "#view_to_iframe_async_func[data-success=true]" ).wait_for() - def test_view_to_component_sync_class_compatibility(self): - self.page.frame_locator( - "#view_to_component_sync_class_compatibility > iframe" - ).locator( - "#ViewToComponentSyncClassCompatibility[data-success=true]" + def test_view_to_iframe_sync_class(self): + self.page.frame_locator("#view_to_iframe_sync_class > iframe").locator( + "#ViewToIframeSyncClass[data-success=true]" ).wait_for() - def test_view_to_component_async_class_compatibility(self): - self.page.frame_locator( - "#view_to_component_async_class_compatibility > iframe" - ).locator( - "#ViewToComponentAsyncClassCompatibility[data-success=true]" + def test_view_to_iframe_async_class(self): + self.page.frame_locator("#view_to_iframe_async_class > iframe").locator( + "#ViewToIframeAsyncClass[data-success=true]" ).wait_for() - def test_view_to_component_template_view_class_compatibility(self): - self.page.frame_locator( - "#view_to_component_template_view_class_compatibility > iframe" - ).locator( - "#ViewToComponentTemplateViewClassCompatibility[data-success=true]" + def test_view_to_iframe_template_view_class(self): + self.page.frame_locator("#view_to_iframe_template_view_class > iframe").locator( + "#ViewToIframeTemplateViewClass[data-success=true]" ).wait_for() def test_view_to_iframe_args(self): @@ -300,14 +273,6 @@ def test_view_to_iframe_args(self): "#view_to_iframe_args[data-success=Success]" ).wait_for() - def test_view_to_component_decorator(self): - self.page.locator("#view_to_component_decorator[data-success=true]").wait_for() - - def test_view_to_component_decorator_args(self): - self.page.locator( - "#view_to_component_decorator_args[data-success=true]" - ).wait_for() - def test_component_session_exists(self): """Session should exist for components with args/kwargs.""" component = self.page.locator("#parametrized-component") @@ -322,7 +287,7 @@ def test_component_session_exists(self): def test_component_session_missing(self): """No session should exist for components that don't have args/kwargs.""" - component = self.page.locator("#simple-button") + component = self.page.locator("#button-from-js-module") component.wait_for() parent = component.locator("..") session_id = parent.get_attribute("id") @@ -577,26 +542,44 @@ def test_url_router(self): new_page.goto(f"{self.live_server_url}/router/") path = new_page.wait_for_selector("#router-path") self.assertIn("/router/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/", string.text_content()) - new_page.goto(f"{self.live_server_url}/router/any/123/") + new_page.goto(f"{self.live_server_url}/router/subroute/") path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/any/123/", path.get_attribute("data-path")) + self.assertIn("/router/subroute/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("subroute/", string.text_content()) + + new_page.goto(f"{self.live_server_url}/router/unspecified/123/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/unspecified//", string.text_content()) new_page.goto(f"{self.live_server_url}/router/integer/123/") path = new_page.wait_for_selector("#router-path") self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/integer//", string.text_content()) new_page.goto(f"{self.live_server_url}/router/path/abc/123/") path = new_page.wait_for_selector("#router-path") self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/path//", string.text_content()) new_page.goto(f"{self.live_server_url}/router/slug/abc-123/") path = new_page.wait_for_selector("#router-path") self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/slug//", string.text_content()) new_page.goto(f"{self.live_server_url}/router/string/abc/") path = new_page.wait_for_selector("#router-path") self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/string//", string.text_content()) new_page.goto( f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" @@ -606,29 +589,27 @@ def test_url_router(self): "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", path.get_attribute("data-path"), ) - - new_page.goto(f"{self.live_server_url}/router/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/abc/", path.get_attribute("data-path")) - - new_page.goto(f"{self.live_server_url}/router/two/123/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) - - new_page.goto(f"{self.live_server_url}/router/star/one/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/star/one/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual("/router/uuid//", string.text_content()) new_page.goto( - f"{self.live_server_url}/router/star/adslkjgklasdjhfah/6789543256/" + f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" ) path = new_page.wait_for_selector("#router-path") self.assertIn( - "/router/star/adslkjgklasdjhfah/6789543256/", + "/router/any/adslkjgklasdjhfah/6789543256/", path.get_attribute("data-path"), ) string = new_page.query_selector("#router-string") - self.assertEqual("Path 12", string.text_content()) + self.assertEqual("/router/any/", string.text_content()) + + new_page.goto(f"{self.live_server_url}/router/two/123/abc/") + path = new_page.wait_for_selector("#router-path") + self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + string = new_page.query_selector("#router-string") + self.assertEqual( + "/router/two///", string.text_content() + ) finally: new_page.close() diff --git a/tests/test_app/views.py b/tests/test_app/views.py index 7af05f61..0c75b357 100644 --- a/tests/test_app/views.py +++ b/tests/test_app/views.py @@ -87,7 +87,7 @@ def get_context_data(self, **kwargs): return {"test_name": self.__class__.__name__} -def view_to_component_sync_func_compatibility(request): +def view_to_iframe_sync_func(request): return render( request, "view_to_component.html", @@ -95,7 +95,7 @@ def view_to_component_sync_func_compatibility(request): ) -async def view_to_component_async_func_compatibility(request): +async def view_to_iframe_async_func(request): return await database_sync_to_async(render)( request, "view_to_component.html", @@ -103,7 +103,7 @@ async def view_to_component_async_func_compatibility(request): ) -class ViewToComponentSyncClassCompatibility(View): +class ViewToIframeSyncClass(View): def get(self, request, *args, **kwargs): return render( request, @@ -112,7 +112,7 @@ def get(self, request, *args, **kwargs): ) -class ViewToComponentAsyncClassCompatibility(View): +class ViewToIframeAsyncClass(View): async def get(self, request, *args, **kwargs): return await database_sync_to_async(render)( request, @@ -121,7 +121,7 @@ async def get(self, request, *args, **kwargs): ) -class ViewToComponentTemplateViewClassCompatibility(TemplateView): +class ViewToIframeTemplateViewClass(TemplateView): template_name = "view_to_component.html" def get_context_data(self, **kwargs):