diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 17080b52..63a412e0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -59,7 +59,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -72,6 +72,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 53c5aa16..43d114af 100644 --- a/.github/workflows/publish-develop-docs.yml +++ b/.github/workflows/publish-develop-docs.yml @@ -5,7 +5,7 @@ on: branches: - main jobs: - deploy: + publish-develop-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +17,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt - - name: Publish Develop Docs + - name: Install dependencies + run: pip install hatch + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - cd docs - mike deploy --push develop + - name: Publish Develop Docs + run: hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-release-docs.yml b/.github/workflows/publish-latest-docs.yml similarity index 73% rename from .github/workflows/publish-release-docs.yml rename to .github/workflows/publish-latest-docs.yml index 93df3e2a..a4945b6f 100644 --- a/.github/workflows/publish-release-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -1,11 +1,11 @@ -name: Publish Release Docs +name: Publish Latest Docs on: release: types: [published] jobs: - deploy: + publish-latest-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +17,13 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt - - name: Publish ${{ github.event.release.name }} Docs + - name: Install dependencies + run: pip install hatch + - name: Configure Git run: | git config user.name github-actions git config user.email github-actions@github.com - cd docs - mike deploy --push --update-aliases ${{ github.event.release.name }} latest + - name: Publish ${{ github.event.release.name }} Docs + run: hatch run docs:deploy_latest ${{ github.ref_name }} concurrency: group: publish-docs diff --git a/.github/workflows/publish-py.yml b/.github/workflows/publish-py.yml deleted file mode 100644 index 6a86db98..00000000 --- a/.github/workflows/publish-py.yml +++ /dev/null @@ -1,32 +0,0 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -name: Publish Python - -on: - release: - types: [published] - -jobs: - release-package: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.x" - - 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-python.yml b/.github/workflows/publish-python.yml new file mode 100644 index 00000000..e20affbb --- /dev/null +++ b/.github/workflows/publish-python.yml @@ -0,0 +1,27 @@ +name: Publish Python + +on: + release: + types: [published] + +jobs: + publish-python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install dependencies + run: pip install hatch + - name: Build Package + run: hatch build --clean + - name: Publish to PyPI + env: + HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} + HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} + run: hatch publish --yes diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 66a5c942..5a2d4fd4 100644 --- a/.github/workflows/test-docs.yml +++ b/.github/workflows/test-docs.yml @@ -7,8 +7,6 @@ on: pull_request: branches: - main - schedule: - - cron: "0 0 * * *" jobs: docs: @@ -23,20 +21,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - # - name: Check docs links - # uses: umbrelladocs/action-linkspector@v1 - # with: - # github_token: ${{ secrets.github_token }} - # reporter: github-pr-review - # fail_on_error: false + - name: Install Python Dependencies + run: pip install hatch + # DISABLED DUE TO DJANGO DOCS CONSTANTLY THROWING 429 ERRORS + # - name: Check documentation links + # run: hatch run docs:linkcheck - name: Check docs build - run: | - pip install -r requirements/build-docs.txt - cd docs - mkdocs build --strict + run: hatch run docs:build - name: Check docs examples - run: | - pip install -r requirements/check-types.txt - pip install -r requirements/check-style.txt - mypy --show-error-codes docs/examples/python/ - ruff check docs/examples/python/ + run: hatch fmt docs --check diff --git a/.github/workflows/test-javascript.yml b/.github/workflows/test-javascript.yml new file mode 100644 index 00000000..8e204dcb --- /dev/null +++ b/.github/workflows/test-javascript.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + javascript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install hatch + - name: Run Tests + run: hatch run javascript:check diff --git a/.github/workflows/test-python.yml b/.github/workflows/test-python.yml new file mode 100644 index 00000000..ac8d77b6 --- /dev/null +++ b/.github/workflows/test-python.yml @@ -0,0 +1,63 @@ +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "0 0 * * *" + +jobs: + python-source: + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + settings-module: ["single_db", "multi_db"] + operating-system: ["ubuntu-latest", "windows-latest"] + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Use Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Python Dependencies + run: pip install hatch + - name: Run Single DB Tests + run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_${{matrix.settings-module}} -v + + python-formatting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install hatch + - name: Check Python formatting + run: hatch fmt src tests --check + + python-types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - name: Install Python Dependencies + run: pip install hatch + - name: Run Python type checker + run: hatch run python:type_check diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml deleted file mode 100644 index 5eb2e67a..00000000 --- a/.github/workflows/test-src.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Test - -on: - push: - branches: - - main - pull_request: - branches: - - main - schedule: - - cron: "0 0 * * *" - -jobs: - source: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] - steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: latest - - name: Use Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install Python Dependencies - run: pip install -r requirements/test-run.txt - - name: Run Tests - run: nox -t test diff --git a/.gitignore b/.gitignore index e3cbc599..e675e09a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # ReactPy-Django Build Artifacts -src/reactpy_django/static/reactpy_django/client.js +src/reactpy_django/static/reactpy_django/index.js +src/reactpy_django/static/reactpy_django/index.js.map src/reactpy_django/static/reactpy_django/pyscript src/reactpy_django/static/reactpy_django/morphdom diff --git a/.linkspector.yml b/.linkspector.yml deleted file mode 100644 index 6c0747e7..00000000 --- a/.linkspector.yml +++ /dev/null @@ -1,7 +0,0 @@ -dirs: - - ./docs -files: - - README.md - - CHANGELOG.md -useGitIgnore: true -modifiedFilesOnly: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 5342e003..f3a0ff04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,33 +10,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), + + + +## [Unreleased] + +### Changed + +- Updated the interface for `reactpy.hooks.use_channel_layer` to be more intuitive. + - Arguments now must be provided as keyworded arguments. + - The `name` argument has been renamed to `channel`. + - The `group_name` argument has been renamed to `group`. + - The `group_add` and `group_discard` arguments have been removed for simplicity. + +### [5.2.1] - 2025-01-10 + +### Changed + +- Use the latest version of `@reactpy/client` which includes a fix for needless client-side component re-creation. + +### [5.2.0] - 2024-12-29 ### Added -- for new features. + +- User login/logout features! + - `reactpy_django.hooks.use_auth` to provide **persistent** `login` and `logout` functionality to your components. + - `settings.py:REACTPY_AUTH_TOKEN_MAX_AGE` to control the maximum seconds before ReactPy's login token expires. + - `settings.py:REACTPY_CLEAN_AUTH_TOKENS` to control whether ReactPy should clean up expired authentication tokens during automatic cleanups. +- Automatically convert Django forms to ReactPy forms via the new `reactpy_django.components.django_form` component! +- The ReactPy component tree can now be forcibly re-rendered via the new `reactpy_django.hooks.use_rerender` hook. ### Changed -- for changes in existing functionality. -### Deprecated -- for soon-to-be removed features. +- Refactoring of internal code to improve maintainability. No changes to publicly documented API. -### Removed -- for removed features. +### Fixed + +- Fixed bug where pre-rendered components could generate a `SynchronousOnlyOperation` exception if they access a freshly logged out Django user object. + +## [5.1.1] - 2024-12-02 ### Fixed -- for bug fixes. -### Security -- for vulnerability fixes. +- Fixed regression from the previous release where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. -Don't forget to remove deprecated code on each major release! ---> +### Changed - +- Set upper limit on ReactPy version to `<2.0.0`. +- ReactPy web modules are now streamed in chunks. +- ReactPy web modules are now streamed using asynchronous file reading to improve performance. +- Performed refactoring to utilize `ruff` as this repository's linter. -## [Unreleased] +## [5.1.0] - 2024-11-24 + +### Added + +- `settings.py:REACTPY_ASYNC_RENDERING` to enable asynchronous rendering of components. + +### Changed -- Nothing (yet)! +- Bumped the minimum ReactPy version to `1.1.0`. ## [5.0.0] - 2024-10-22 @@ -519,7 +556,11 @@ Don't forget to remove deprecated code on each major release! - Support for IDOM within the Django -[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...HEAD +[Unreleased]: https://github.com/reactive-python/reactpy-django/compare/5.2.1...HEAD +[5.2.1]: https://github.com/reactive-python/reactpy-django/compare/5.2.0...5.2.1 +[5.2.0]: https://github.com/reactive-python/reactpy-django/compare/5.1.1...5.2.0 +[5.1.1]: https://github.com/reactive-python/reactpy-django/compare/5.1.0...5.1.1 +[5.1.0]: https://github.com/reactive-python/reactpy-django/compare/5.0.0...5.1.0 [5.0.0]: https://github.com/reactive-python/reactpy-django/compare/4.0.0...5.0.0 [4.0.0]: https://github.com/reactive-python/reactpy-django/compare/3.8.1...4.0.0 [3.8.1]: https://github.com/reactive-python/reactpy-django/compare/3.8.0...3.8.1 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index ddcb7f8d..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include src/reactpy_django/py.typed -recursive-include src/reactpy_django/static * -recursive-include src/reactpy_django/templates *.html diff --git a/README.md b/README.md index d3d2a1a9..3ebd3faf 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ReactPy-Django

- - + + @@ -28,8 +28,9 @@ - [Customizable reconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/settings/#stability-settings) - [Customizable disconnection behavior](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag) - [Multiple root components](https://reactive-python.github.io/reactpy-django/latest/reference/template-tag/) -- [Cross-process communication/signaling (Channel Layers)](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) +- [Cross-process communication/signaling](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-channel-layer) - [Django view to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#view-to-component) +- [Django form to ReactPy component conversion](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-form) - [Django static file access](https://reactive-python.github.io/reactpy-django/latest/reference/components/#django-css) - [Django database access](https://reactive-python.github.io/reactpy-django/latest/reference/hooks/#use-query) diff --git a/docs/examples/html/django_form_bootstrap.html b/docs/examples/html/django_form_bootstrap.html new file mode 100644 index 00000000..6aba84ca --- /dev/null +++ b/docs/examples/html/django_form_bootstrap.html @@ -0,0 +1,11 @@ +{% load django_bootstrap5 %} + + +{% bootstrap_css %} +{% bootstrap_javascript %} + + +{% bootstrap_form form %} +{% bootstrap_button button_type="submit" content="OK" %} +{% bootstrap_button button_type="reset" content="Reset" %} diff --git a/docs/examples/html/pyscript-component.html b/docs/examples/html/pyscript_component.html similarity index 100% rename from docs/examples/html/pyscript-component.html rename to docs/examples/html/pyscript_component.html diff --git a/docs/examples/html/pyscript-initial-object.html b/docs/examples/html/pyscript_initial_object.html similarity index 100% rename from docs/examples/html/pyscript-initial-object.html rename to docs/examples/html/pyscript_initial_object.html diff --git a/docs/examples/html/pyscript-initial-string.html b/docs/examples/html/pyscript_initial_string.html similarity index 100% rename from docs/examples/html/pyscript-initial-string.html rename to docs/examples/html/pyscript_initial_string.html diff --git a/docs/examples/html/pyscript-local-import.html b/docs/examples/html/pyscript_local_import.html similarity index 100% rename from docs/examples/html/pyscript-local-import.html rename to docs/examples/html/pyscript_local_import.html diff --git a/docs/examples/html/pyscript-multiple-files.html b/docs/examples/html/pyscript_multiple_files.html similarity index 100% rename from docs/examples/html/pyscript-multiple-files.html rename to docs/examples/html/pyscript_multiple_files.html diff --git a/docs/examples/html/pyscript-root.html b/docs/examples/html/pyscript_root.html similarity index 100% rename from docs/examples/html/pyscript-root.html rename to docs/examples/html/pyscript_root.html diff --git a/docs/examples/html/pyscript-setup.html b/docs/examples/html/pyscript_setup.html similarity index 100% rename from docs/examples/html/pyscript-setup.html rename to docs/examples/html/pyscript_setup.html diff --git a/docs/examples/html/pyscript-setup-config-object.html b/docs/examples/html/pyscript_setup_config_object.html similarity index 100% rename from docs/examples/html/pyscript-setup-config-object.html rename to docs/examples/html/pyscript_setup_config_object.html diff --git a/docs/examples/html/pyscript-setup-config-string.html b/docs/examples/html/pyscript_setup_config_string.html similarity index 100% rename from docs/examples/html/pyscript-setup-config-string.html rename to docs/examples/html/pyscript_setup_config_string.html diff --git a/docs/examples/html/pyscript-setup-dependencies.html b/docs/examples/html/pyscript_setup_dependencies.html similarity index 100% rename from docs/examples/html/pyscript-setup-dependencies.html rename to docs/examples/html/pyscript_setup_dependencies.html diff --git a/docs/examples/html/pyscript-setup-extra-js-object.html b/docs/examples/html/pyscript_setup_extra_js_object.html similarity index 100% rename from docs/examples/html/pyscript-setup-extra-js-object.html rename to docs/examples/html/pyscript_setup_extra_js_object.html diff --git a/docs/examples/html/pyscript-setup-extra-js-string.html b/docs/examples/html/pyscript_setup_extra_js_string.html similarity index 100% rename from docs/examples/html/pyscript-setup-extra-js-string.html rename to docs/examples/html/pyscript_setup_extra_js_string.html diff --git a/docs/examples/html/pyscript-setup-local-interpreter.html b/docs/examples/html/pyscript_setup_local_interpreter.html similarity index 100% rename from docs/examples/html/pyscript-setup-local-interpreter.html rename to docs/examples/html/pyscript_setup_local_interpreter.html diff --git a/docs/examples/html/pyscript-ssr-parent.html b/docs/examples/html/pyscript_ssr_parent.html similarity index 100% rename from docs/examples/html/pyscript-ssr-parent.html rename to docs/examples/html/pyscript_ssr_parent.html diff --git a/docs/examples/html/pyscript-tag.html b/docs/examples/html/pyscript_tag.html similarity index 100% rename from docs/examples/html/pyscript-tag.html rename to docs/examples/html/pyscript_tag.html diff --git a/docs/examples/python/configure-asgi.py b/docs/examples/python/configure_asgi.py similarity index 77% rename from docs/examples/python/configure-asgi.py rename to docs/examples/python/configure_asgi.py index 8081d747..8feb0ec2 100644 --- a/docs/examples/python/configure-asgi.py +++ b/docs/examples/python/configure_asgi.py @@ -10,11 +10,10 @@ from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402 + from reactpy_django import REACTPY_WEBSOCKET_ROUTE # noqa: E402 -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), - } -) +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": URLRouter([REACTPY_WEBSOCKET_ROUTE]), +}) diff --git a/docs/examples/python/configure-asgi-middleware.py b/docs/examples/python/configure_asgi_middleware.py similarity index 60% rename from docs/examples/python/configure-asgi-middleware.py rename to docs/examples/python/configure_asgi_middleware.py index 6df35a39..0c5a7214 100644 --- a/docs/examples/python/configure-asgi-middleware.py +++ b/docs/examples/python/configure_asgi_middleware.py @@ -1,5 +1,6 @@ # Broken load order, only used for linting from channels.routing import ProtocolTypeRouter, URLRouter + from reactpy_django import REACTPY_WEBSOCKET_ROUTE django_asgi_app = "" @@ -8,9 +9,7 @@ # start from channels.auth import AuthMiddlewareStack # noqa: E402 -application = ProtocolTypeRouter( - { - "http": django_asgi_app, - "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), - } -) +application = ProtocolTypeRouter({ + "http": django_asgi_app, + "websocket": AuthMiddlewareStack(URLRouter([REACTPY_WEBSOCKET_ROUTE])), +}) diff --git a/docs/examples/python/configure-channels-asgi-app.py b/docs/examples/python/configure_channels_asgi_app.py similarity index 100% rename from docs/examples/python/configure-channels-asgi-app.py rename to docs/examples/python/configure_channels_asgi_app.py diff --git a/docs/examples/python/configure-channels-installed-app.py b/docs/examples/python/configure_channels_installed_app.py similarity index 100% rename from docs/examples/python/configure-channels-installed-app.py rename to docs/examples/python/configure_channels_installed_app.py diff --git a/docs/examples/python/configure-installed-apps.py b/docs/examples/python/configure_installed_apps.py similarity index 100% rename from docs/examples/python/configure-installed-apps.py rename to docs/examples/python/configure_installed_apps.py diff --git a/docs/examples/python/configure-urls.py b/docs/examples/python/configure_urls.py similarity index 100% rename from docs/examples/python/configure-urls.py rename to docs/examples/python/configure_urls.py diff --git a/docs/examples/python/django-css.py b/docs/examples/python/django_css.py similarity index 99% rename from docs/examples/python/django-css.py rename to docs/examples/python/django_css.py index aeb4addb..c7f60881 100644 --- a/docs/examples/python/django-css.py +++ b/docs/examples/python/django_css.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import django_css diff --git a/docs/examples/python/django-css-external-link.py b/docs/examples/python/django_css_external_link.py similarity index 53% rename from docs/examples/python/django-css-external-link.py rename to docs/examples/python/django_css_external_link.py index ac1d0fba..28eb3fca 100644 --- a/docs/examples/python/django-css-external-link.py +++ b/docs/examples/python/django_css_external_link.py @@ -4,8 +4,6 @@ @component def my_component(): return html.div( - html.link( - {"rel": "stylesheet", "href": "https://example.com/external-styles.css"} - ), + html.link({"rel": "stylesheet", "href": "https://example.com/external-styles.css"}), html.button("My Button!"), ) diff --git a/docs/examples/python/django-css-local-link.py b/docs/examples/python/django_css_local_link.py similarity index 100% rename from docs/examples/python/django-css-local-link.py rename to docs/examples/python/django_css_local_link.py diff --git a/docs/examples/python/django_form.py b/docs/examples/python/django_form.py new file mode 100644 index 00000000..51960db1 --- /dev/null +++ b/docs/examples/python/django_form.py @@ -0,0 +1,10 @@ +from reactpy import component, html + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + children = [html.input({"type": "submit"})] + return django_form(MyForm, bottom_children=children) diff --git a/docs/examples/python/django_form_bootstrap.py b/docs/examples/python/django_form_bootstrap.py new file mode 100644 index 00000000..449e1cc4 --- /dev/null +++ b/docs/examples/python/django_form_bootstrap.py @@ -0,0 +1,9 @@ +from reactpy import component + +from example.forms import MyForm +from reactpy_django.components import django_form + + +@component +def basic_form(): + return django_form(MyForm, form_template="bootstrap_form.html") diff --git a/docs/examples/python/django_form_class.py b/docs/examples/python/django_form_class.py new file mode 100644 index 00000000..e556295e --- /dev/null +++ b/docs/examples/python/django_form_class.py @@ -0,0 +1,5 @@ +from django import forms + + +class MyForm(forms.Form): + username = forms.CharField(label="Username") diff --git a/docs/examples/python/django_form_on_success.py b/docs/examples/python/django_form_on_success.py new file mode 100644 index 00000000..d8b6927c --- /dev/null +++ b/docs/examples/python/django_form_on_success.py @@ -0,0 +1,21 @@ +from reactpy import component, hooks, html +from reactpy_router import navigate + +from example.forms import MyForm +from reactpy_django.components import django_form +from reactpy_django.types import FormEventData + + +@component +def basic_form(): + submitted, set_submitted = hooks.use_state(False) + + def on_submit(event: FormEventData): + """This function will be called when the form is successfully submitted.""" + set_submitted(True) + + if submitted: + return navigate("/homepage") + + children = [html.input({"type": "submit"})] + return django_form(MyForm, on_success=on_submit, bottom_children=children) diff --git a/docs/examples/python/django-js.py b/docs/examples/python/django_js.py similarity index 99% rename from docs/examples/python/django-js.py rename to docs/examples/python/django_js.py index b4af014c..37868184 100644 --- a/docs/examples/python/django-js.py +++ b/docs/examples/python/django_js.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import django_js diff --git a/docs/examples/python/django-js-local-script.py b/docs/examples/python/django_js_local_script.py similarity index 100% rename from docs/examples/python/django-js-local-script.py rename to docs/examples/python/django_js_local_script.py diff --git a/docs/examples/python/django-js-remote-script.py b/docs/examples/python/django_js_remote_script.py similarity index 100% rename from docs/examples/python/django-js-remote-script.py rename to docs/examples/python/django_js_remote_script.py diff --git a/docs/examples/python/django-query-postprocessor.py b/docs/examples/python/django_query_postprocessor.py similarity index 99% rename from docs/examples/python/django-query-postprocessor.py rename to docs/examples/python/django_query_postprocessor.py index da33c362..7bdc870c 100644 --- a/docs/examples/python/django-query-postprocessor.py +++ b/docs/examples/python/django_query_postprocessor.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component + +from example.models import TodoItem from reactpy_django.hooks import use_query from reactpy_django.utils import django_query_postprocessor diff --git a/docs/examples/python/django-router.py b/docs/examples/python/django_router.py similarity index 92% rename from docs/examples/python/django-router.py rename to docs/examples/python/django_router.py index 5c845967..721eb939 100644 --- a/docs/examples/python/django-router.py +++ b/docs/examples/python/django_router.py @@ -1,7 +1,8 @@ from reactpy import component, html -from reactpy_django.router import django_router from reactpy_router import route +from reactpy_django.router import django_router + @component def my_component(): @@ -14,5 +15,5 @@ def my_component(): route("/router/string//", html.div("Example 6")), route("/router/uuid//", html.div("Example 7")), route("/router/two_values///", html.div("Example 8")), - route("/router/*", html.div("Fallback")), + route("/router/", html.div("Fallback")), ) diff --git a/docs/examples/python/example/__init__.py b/docs/examples/python/example/__init__.py index e69de29b..c32d6329 100644 --- a/docs/examples/python/example/__init__.py +++ b/docs/examples/python/example/__init__.py @@ -0,0 +1,3 @@ +"""This module exists only to satisfy type checkers. + +Do not use the files in this module as examples within the docs.""" diff --git a/docs/examples/python/example/components.py b/docs/examples/python/example/components.py new file mode 100644 index 00000000..ec301524 --- /dev/null +++ b/docs/examples/python/example/components.py @@ -0,0 +1,6 @@ +"""This module exists only to satisfy type checkers. + +Do not use the files in this module as examples within the docs.""" + + +def child_component(): ... diff --git a/docs/examples/python/example/forms.py b/docs/examples/python/example/forms.py new file mode 100644 index 00000000..8d3eefc0 --- /dev/null +++ b/docs/examples/python/example/forms.py @@ -0,0 +1,4 @@ +from django import forms + + +class MyForm(forms.Form): ... diff --git a/docs/examples/python/example/models.py b/docs/examples/python/example/models.py index ec98be92..2bf062b9 100644 --- a/docs/examples/python/example/models.py +++ b/docs/examples/python/example/models.py @@ -1,5 +1,4 @@ -from django.db.models import CharField, Model +from django.db import models -class TodoItem(Model): - text: CharField = CharField(max_length=255) +class TodoItem(models.Model): ... diff --git a/docs/examples/python/example/views.py b/docs/examples/python/example/views.py index 23e21130..49bfeb8e 100644 --- a/docs/examples/python/example/views.py +++ b/docs/examples/python/example/views.py @@ -1,5 +1,8 @@ -from django.shortcuts import render +"""This module exists only to satisfy type checkers. +Do not use the files in this module as examples within the docs.""" -def index(request): - return render(request, "my_template.html") +from python.hello_world_cbv import HelloWorld +from python.hello_world_fbv import hello_world + +__all__ = ["HelloWorld", "hello_world"] diff --git a/docs/examples/python/example/urls.py b/docs/examples/python/first_urls.py similarity index 99% rename from docs/examples/python/example/urls.py rename to docs/examples/python/first_urls.py index 74f72806..a0f1d72f 100644 --- a/docs/examples/python/example/urls.py +++ b/docs/examples/python/first_urls.py @@ -1,4 +1,5 @@ from django.urls import path + from example import views urlpatterns = [ diff --git a/docs/examples/python/first_view.py b/docs/examples/python/first_view.py new file mode 100644 index 00000000..23e21130 --- /dev/null +++ b/docs/examples/python/first_view.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "my_template.html") diff --git a/docs/examples/python/hello_world_app_config_cbv.py b/docs/examples/python/hello_world_app_config_cbv.py index ec448117..c0852da8 100644 --- a/docs/examples/python/hello_world_app_config_cbv.py +++ b/docs/examples/python/hello_world_app_config_cbv.py @@ -1,7 +1,7 @@ from django.apps import AppConfig -from reactpy_django.utils import register_iframe -from . import views +from example import views +from reactpy_django.utils import register_iframe class ExampleAppConfig(AppConfig): diff --git a/docs/examples/python/hello_world_app_config_fbv.py b/docs/examples/python/hello_world_app_config_fbv.py index c23c6919..47a71cde 100644 --- a/docs/examples/python/hello_world_app_config_fbv.py +++ b/docs/examples/python/hello_world_app_config_fbv.py @@ -1,7 +1,7 @@ from django.apps import AppConfig -from reactpy_django.utils import register_iframe -from . import views +from example import views +from reactpy_django.utils import register_iframe class ExampleAppConfig(AppConfig): diff --git a/docs/examples/python/pyodide-js-module.py b/docs/examples/python/pyodide_js_module.py similarity index 58% rename from docs/examples/python/pyodide-js-module.py rename to docs/examples/python/pyodide_js_module.py index a96ef65b..864936dc 100644 --- a/docs/examples/python/pyodide-js-module.py +++ b/docs/examples/python/pyodide_js_module.py @@ -4,8 +4,7 @@ @component def root(): - - def onClick(event): + def on_click(event): js.document.title = "New window title" - return html.button({"onClick": onClick}, "Click Me!") + return html.button({"onClick": on_click}, "Click Me!") diff --git a/docs/examples/python/pyscript-component-initial-object.py b/docs/examples/python/pyscript_component_initial_object.py similarity index 99% rename from docs/examples/python/pyscript-component-initial-object.py rename to docs/examples/python/pyscript_component_initial_object.py index 222a568b..d84328a4 100644 --- a/docs/examples/python/pyscript-component-initial-object.py +++ b/docs/examples/python/pyscript_component_initial_object.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-initial-string.py b/docs/examples/python/pyscript_component_initial_string.py similarity index 99% rename from docs/examples/python/pyscript-component-initial-string.py rename to docs/examples/python/pyscript_component_initial_string.py index 664b9f9b..bb8f9d17 100644 --- a/docs/examples/python/pyscript-component-initial-string.py +++ b/docs/examples/python/pyscript_component_initial_string.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-multiple-files-root.py b/docs/examples/python/pyscript_component_multiple_files_root.py similarity index 99% rename from docs/examples/python/pyscript-component-multiple-files-root.py rename to docs/examples/python/pyscript_component_multiple_files_root.py index 776b26b2..fd826137 100644 --- a/docs/examples/python/pyscript-component-multiple-files-root.py +++ b/docs/examples/python/pyscript_component_multiple_files_root.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-component-root.py b/docs/examples/python/pyscript_component_root.py similarity index 99% rename from docs/examples/python/pyscript-component-root.py rename to docs/examples/python/pyscript_component_root.py index 9880b740..3d795247 100644 --- a/docs/examples/python/pyscript-component-root.py +++ b/docs/examples/python/pyscript_component_root.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript_ffi.py b/docs/examples/python/pyscript_ffi.py new file mode 100644 index 00000000..d744dd88 --- /dev/null +++ b/docs/examples/python/pyscript_ffi.py @@ -0,0 +1,14 @@ +from pyscript import document, window +from reactpy import component, html + + +@component +def root(): + def on_click(event): + my_element = document.querySelector("#example") + my_element.innerText = window.location.hostname + + return html.div( + {"id": "example"}, + html.button({"onClick": on_click}, "Click Me!"), + ) diff --git a/docs/examples/python/pyscript-hello-world.py b/docs/examples/python/pyscript_hello_world.py similarity index 100% rename from docs/examples/python/pyscript-hello-world.py rename to docs/examples/python/pyscript_hello_world.py diff --git a/docs/examples/python/pyscript-initial-object.py b/docs/examples/python/pyscript_initial_object.py similarity index 100% rename from docs/examples/python/pyscript-initial-object.py rename to docs/examples/python/pyscript_initial_object.py diff --git a/docs/examples/python/pyscript-local-import.py b/docs/examples/python/pyscript_local_import.py similarity index 100% rename from docs/examples/python/pyscript-local-import.py rename to docs/examples/python/pyscript_local_import.py diff --git a/docs/examples/python/pyscript-multiple-files-child.py b/docs/examples/python/pyscript_multiple_files_child.py similarity index 100% rename from docs/examples/python/pyscript-multiple-files-child.py rename to docs/examples/python/pyscript_multiple_files_child.py diff --git a/docs/examples/python/pyscript-multiple-files-root.py b/docs/examples/python/pyscript_multiple_files_root.py similarity index 60% rename from docs/examples/python/pyscript-multiple-files-root.py rename to docs/examples/python/pyscript_multiple_files_root.py index dc17e7ad..9ae8e549 100644 --- a/docs/examples/python/pyscript-multiple-files-root.py +++ b/docs/examples/python/pyscript_multiple_files_root.py @@ -1,9 +1,6 @@ -from typing import TYPE_CHECKING - from reactpy import component, html -if TYPE_CHECKING: - from .child import child_component +from example.components import child_component @component diff --git a/docs/examples/python/pyscript-root.py b/docs/examples/python/pyscript_root.py similarity index 100% rename from docs/examples/python/pyscript-root.py rename to docs/examples/python/pyscript_root.py diff --git a/docs/examples/python/pyscript-setup-config-object.py b/docs/examples/python/pyscript_setup_config_object.py similarity index 100% rename from docs/examples/python/pyscript-setup-config-object.py rename to docs/examples/python/pyscript_setup_config_object.py diff --git a/docs/examples/python/pyscript-setup-extra-js-object.py b/docs/examples/python/pyscript_setup_extra_js_object.py similarity index 100% rename from docs/examples/python/pyscript-setup-extra-js-object.py rename to docs/examples/python/pyscript_setup_extra_js_object.py diff --git a/docs/examples/python/pyscript-ssr-child.py b/docs/examples/python/pyscript_ssr_child.py similarity index 100% rename from docs/examples/python/pyscript-ssr-child.py rename to docs/examples/python/pyscript_ssr_child.py diff --git a/docs/examples/python/pyscript-ssr-parent.py b/docs/examples/python/pyscript_ssr_parent.py similarity index 99% rename from docs/examples/python/pyscript-ssr-parent.py rename to docs/examples/python/pyscript_ssr_parent.py index b51aa110..524cdc52 100644 --- a/docs/examples/python/pyscript-ssr-parent.py +++ b/docs/examples/python/pyscript_ssr_parent.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.components import pyscript_component diff --git a/docs/examples/python/pyscript-tag.py b/docs/examples/python/pyscript_tag.py similarity index 99% rename from docs/examples/python/pyscript-tag.py rename to docs/examples/python/pyscript_tag.py index 6455e9da..a038b267 100644 --- a/docs/examples/python/pyscript-tag.py +++ b/docs/examples/python/pyscript_tag.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.html import pyscript example_source_code = """ diff --git a/docs/examples/python/register-component.py b/docs/examples/python/register_component.py similarity index 99% rename from docs/examples/python/register-component.py rename to docs/examples/python/register_component.py index cbdbf789..6d7d3831 100644 --- a/docs/examples/python/register-component.py +++ b/docs/examples/python/register_component.py @@ -1,4 +1,5 @@ from django.apps import AppConfig + from reactpy_django.utils import register_component diff --git a/docs/examples/python/template-tag-args-kwargs.py b/docs/examples/python/template_tag_args_kwargs.py similarity index 100% rename from docs/examples/python/template-tag-args-kwargs.py rename to docs/examples/python/template_tag_args_kwargs.py diff --git a/docs/examples/python/template-tag-bad-view.py b/docs/examples/python/template_tag_bad_view.py similarity index 100% rename from docs/examples/python/template-tag-bad-view.py rename to docs/examples/python/template_tag_bad_view.py diff --git a/docs/examples/python/todo_item_model.py b/docs/examples/python/todo_item_model.py new file mode 100644 index 00000000..ec98be92 --- /dev/null +++ b/docs/examples/python/todo_item_model.py @@ -0,0 +1,5 @@ +from django.db.models import CharField, Model + + +class TodoItem(Model): + text: CharField = CharField(max_length=255) diff --git a/docs/examples/python/use_auth.py b/docs/examples/python/use_auth.py new file mode 100644 index 00000000..2bb1bcbb --- /dev/null +++ b/docs/examples/python/use_auth.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from reactpy import component, html + +from reactpy_django.hooks import use_auth, use_user + + +@component +def my_component(): + auth = use_auth() + user = use_user() + + async def login_user(event): + new_user, _created = await get_user_model().objects.aget_or_create(username="ExampleUser") + await auth.login(new_user) + + async def logout_user(event): + await auth.logout() + + return html.div( + f"Current User: {user}", + html.button({"onClick": login_user}, "Login"), + html.button({"onClick": logout_user}, "Logout"), + ) diff --git a/docs/examples/python/use-channel-layer.py b/docs/examples/python/use_channel_layer.py similarity index 66% rename from docs/examples/python/use-channel-layer.py rename to docs/examples/python/use_channel_layer.py index 83a66f19..ff9330a4 100644 --- a/docs/examples/python/use-channel-layer.py +++ b/docs/examples/python/use_channel_layer.py @@ -1,21 +1,22 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer @component def my_component(): async def receive_message(message): - set_message(message["text"]) + set_message_data(message["text"]) async def send_message(event): if event["key"] == "Enter": await sender({"text": event["target"]["value"]}) - message, set_message = hooks.use_state("") - sender = use_channel_layer("my-channel-name", receiver=receive_message) + message_data, set_message_data = hooks.use_state("") + sender = use_channel_layer(group="my-group-name", receiver=receive_message) return html.div( - f"Received: {message}", + f"Received: {message_data}", html.br(), "Send: ", html.input({"type": "text", "onKeyDown": send_message}), diff --git a/docs/examples/python/use-channel-layer-group.py b/docs/examples/python/use_channel_layer_group.py similarity index 72% rename from docs/examples/python/use-channel-layer-group.py rename to docs/examples/python/use_channel_layer_group.py index bcbabee6..4189b47c 100644 --- a/docs/examples/python/use-channel-layer-group.py +++ b/docs/examples/python/use_channel_layer_group.py @@ -1,10 +1,11 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer @component def my_sender_component(): - sender = use_channel_layer(group_name="my-group-name") + sender = use_channel_layer(group="my-group-name") async def submit_event(event): if event["key"] == "Enter": @@ -20,10 +21,10 @@ async def submit_event(event): def my_receiver_component_1(): message, set_message = hooks.use_state("") - async def receive_event(message): + async def receive_message(message): set_message(message["text"]) - use_channel_layer(group_name="my-group-name", receiver=receive_event) + use_channel_layer(group="my-group-name", receiver=receive_message) return html.div(f"Message Receiver 1: {message}") @@ -32,9 +33,9 @@ async def receive_event(message): def my_receiver_component_2(): message, set_message = hooks.use_state("") - async def receive_event(message): + async def receive_message(message): set_message(message["text"]) - use_channel_layer(group_name="my-group-name", receiver=receive_event) + use_channel_layer(group="my-group-name", receiver=receive_message) return html.div(f"Message Receiver 2: {message}") diff --git a/docs/examples/python/use-channel-layer-signal-receiver.py b/docs/examples/python/use_channel_layer_signal_receiver.py similarity index 54% rename from docs/examples/python/use-channel-layer-signal-receiver.py rename to docs/examples/python/use_channel_layer_signal_receiver.py index 57a92321..d858df3f 100644 --- a/docs/examples/python/use-channel-layer-signal-receiver.py +++ b/docs/examples/python/use_channel_layer_signal_receiver.py @@ -1,4 +1,5 @@ from reactpy import component, hooks, html + from reactpy_django.hooks import use_channel_layer @@ -6,9 +7,10 @@ def my_receiver_component(): message, set_message = hooks.use_state("") - async def receive_event(message): + async def receive_message(message): set_message(message["text"]) - use_channel_layer("my-channel-name", receiver=receive_event) + # This is defined to receive any messages from both "my-channel-name" and "my-group-name". + use_channel_layer(channel="my-channel-name", group="my-group-name", receiver=receive_message) return html.div(f"Message Receiver: {message}") diff --git a/docs/examples/python/use-channel-layer-signal-sender.py b/docs/examples/python/use_channel_layer_signal_sender.py similarity index 64% rename from docs/examples/python/use-channel-layer-signal-sender.py rename to docs/examples/python/use_channel_layer_signal_sender.py index a35a6c88..b900bd2c 100644 --- a/docs/examples/python/use-channel-layer-signal-sender.py +++ b/docs/examples/python/use_channel_layer_signal_sender.py @@ -12,8 +12,10 @@ class ExampleModel(Model): ... def my_sender_signal(sender, instance, **kwargs): layer = get_channel_layer() - # Example of sending a message to a channel - async_to_sync(layer.send)("my-channel-name", {"text": "Hello World!"}) - - # Example of sending a message to a group channel + # EXAMPLE 1: Sending a message to a group. + # Note that `group_send` requires using the `group` argument in `use_channel_layer`. async_to_sync(layer.group_send)("my-group-name", {"text": "Hello World!"}) + + # EXAMPLE 2: Sending a message to a single channel. + # Note that this is typically only used for channels that use point-to-point communication + async_to_sync(layer.send)("my-channel-name", {"text": "Hello World!"}) diff --git a/docs/examples/python/use_channel_layer_single.py b/docs/examples/python/use_channel_layer_single.py new file mode 100644 index 00000000..394cbc48 --- /dev/null +++ b/docs/examples/python/use_channel_layer_single.py @@ -0,0 +1,29 @@ +from reactpy import component, hooks, html + +from reactpy_django.hooks import use_channel_layer + + +@component +def my_sender_component(): + sender = use_channel_layer(channel="my-channel-name") + + async def submit_event(event): + if event["key"] == "Enter": + await sender({"text": event["target"]["value"]}) + + return html.div( + "Message Sender: ", + html.input({"type": "text", "onKeyDown": submit_event}), + ) + + +@component +def my_receiver_component(): + message, set_message = hooks.use_state("") + + async def receive_message(message): + set_message(message["text"]) + + use_channel_layer(channel="my-channel-name", receiver=receive_message) + + return html.div(f"Message Receiver 1: {message}") diff --git a/docs/examples/python/use-connection.py b/docs/examples/python/use_connection.py similarity index 99% rename from docs/examples/python/use-connection.py rename to docs/examples/python/use_connection.py index 1ea0fdb6..a15cd39b 100644 --- a/docs/examples/python/use-connection.py +++ b/docs/examples/python/use_connection.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_connection diff --git a/docs/examples/python/use-location.py b/docs/examples/python/use_location.py similarity index 99% rename from docs/examples/python/use-location.py rename to docs/examples/python/use_location.py index d7afcbac..454da7f6 100644 --- a/docs/examples/python/use-location.py +++ b/docs/examples/python/use_location.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_location diff --git a/docs/examples/python/use-mutation.py b/docs/examples/python/use_mutation.py similarity index 91% rename from docs/examples/python/use-mutation.py rename to docs/examples/python/use_mutation.py index 1bc69312..471a4a4c 100644 --- a/docs/examples/python/use-mutation.py +++ b/docs/examples/python/use_mutation.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation @@ -24,6 +25,6 @@ def submit_event(event): return html.div( html.label("Add an item:"), - html.input({"type": "text", "on_key_down": submit_event}), + html.input({"type": "text", "onKeyDown": submit_event}), mutation_status, ) diff --git a/docs/examples/python/use-mutation-args-kwargs.py b/docs/examples/python/use_mutation_args_kwargs.py similarity index 71% rename from docs/examples/python/use-mutation-args-kwargs.py rename to docs/examples/python/use_mutation_args_kwargs.py index f9889777..9a4b1e0a 100644 --- a/docs/examples/python/use-mutation-args-kwargs.py +++ b/docs/examples/python/use_mutation_args_kwargs.py @@ -1,9 +1,9 @@ from reactpy import component + from reactpy_django.hooks import use_mutation -def example_mutation(value: int, other_value: bool = False): - ... +def example_mutation(value: int, other_value: bool = False): ... @component @@ -11,5 +11,3 @@ def my_component(): mutation = use_mutation(example_mutation) mutation(123, other_value=True) - - ... diff --git a/docs/examples/python/use-mutation-query-refetch.py b/docs/examples/python/use_mutation_query_refetch.py similarity index 85% rename from docs/examples/python/use-mutation-query-refetch.py rename to docs/examples/python/use_mutation_query_refetch.py index 227ab1a7..f2e014e9 100644 --- a/docs/examples/python/use-mutation-query-refetch.py +++ b/docs/examples/python/use_mutation_query_refetch.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation, use_query @@ -26,9 +27,7 @@ def submit_event(event): elif item_query.error or not item_query.data: rendered_items = html.h2("Error when loading!") else: - rendered_items = html.ul( - html.li(item.text, key=item.pk) for item in item_query.data - ) + rendered_items = html.ul(html.li(item.text, key=item.pk) for item in item_query.data) # Handle all possible mutation states if item_mutation.loading: @@ -40,7 +39,7 @@ def submit_event(event): return html.div( html.label("Add an item:"), - html.input({"type": "text", "on_key_down": submit_event}), + html.input({"type": "text", "onKeyDown": submit_event}), mutation_status, rendered_items, ) diff --git a/docs/examples/python/use-mutation-reset.py b/docs/examples/python/use_mutation_reset.py similarity index 81% rename from docs/examples/python/use-mutation-reset.py rename to docs/examples/python/use_mutation_reset.py index 8eb1e042..486f0464 100644 --- a/docs/examples/python/use-mutation-reset.py +++ b/docs/examples/python/use_mutation_reset.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_mutation @@ -21,12 +22,12 @@ def submit_event(event): if item_mutation.loading: mutation_status = html.h2("Adding...") elif item_mutation.error: - mutation_status = html.button({"on_click": reset_event}, "Error: Try again!") + mutation_status = html.button({"onClick": reset_event}, "Error: Try again!") else: mutation_status = html.h2("Mutation done.") return html.div( html.label("Add an item:"), - html.input({"type": "text", "on_key_down": submit_event}), + html.input({"type": "text", "onKeyDown": submit_event}), mutation_status, ) diff --git a/docs/examples/python/use-mutation-thread-sensitive.py b/docs/examples/python/use_mutation_thread_sensitive.py similarity index 90% rename from docs/examples/python/use-mutation-thread-sensitive.py rename to docs/examples/python/use_mutation_thread_sensitive.py index 85046dc0..2d047696 100644 --- a/docs/examples/python/use-mutation-thread-sensitive.py +++ b/docs/examples/python/use_mutation_thread_sensitive.py @@ -1,10 +1,10 @@ from reactpy import component, html + from reactpy_django.hooks import use_mutation def execute_thread_safe_mutation(text): """This is an example mutation function that does some thread-safe operation.""" - pass @component @@ -26,6 +26,6 @@ def submit_event(event): mutation_status = html.h2("Done.") return html.div( - html.input({"type": "text", "on_key_down": submit_event}), + html.input({"type": "text", "onKeyDown": submit_event}), mutation_status, ) diff --git a/docs/examples/python/use-origin.py b/docs/examples/python/use_origin.py similarity index 99% rename from docs/examples/python/use-origin.py rename to docs/examples/python/use_origin.py index e8763bbf..f0713db9 100644 --- a/docs/examples/python/use-origin.py +++ b/docs/examples/python/use_origin.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_origin diff --git a/docs/examples/python/use-query.py b/docs/examples/python/use_query.py similarity index 82% rename from docs/examples/python/use-query.py rename to docs/examples/python/use_query.py index 5688765b..9cadbd25 100644 --- a/docs/examples/python/use-query.py +++ b/docs/examples/python/use_query.py @@ -1,6 +1,7 @@ from channels.db import database_sync_to_async -from example.models import TodoItem from reactpy import component, html + +from example.models import TodoItem from reactpy_django.hooks import use_query @@ -17,8 +18,6 @@ def todo_list(): elif item_query.error or not item_query.data: rendered_items = html.h2("Error when loading!") else: - rendered_items = html.ul( - [html.li(item.text, key=item.pk) for item in item_query.data] - ) + rendered_items = html.ul([html.li(item.text, key=item.pk) for item in item_query.data]) return html.div("Rendered items: ", rendered_items) diff --git a/docs/examples/python/use-query-args.py b/docs/examples/python/use_query_args.py similarity index 99% rename from docs/examples/python/use-query-args.py rename to docs/examples/python/use_query_args.py index 8deb549a..a37f7277 100644 --- a/docs/examples/python/use-query-args.py +++ b/docs/examples/python/use_query_args.py @@ -1,4 +1,5 @@ from reactpy import component + from reactpy_django.hooks import use_query diff --git a/docs/examples/python/use-query-postprocessor-change.py b/docs/examples/python/use_query_postprocessor_change.py similarity index 98% rename from docs/examples/python/use-query-postprocessor-change.py rename to docs/examples/python/use_query_postprocessor_change.py index 5685956d..2faba050 100644 --- a/docs/examples/python/use-query-postprocessor-change.py +++ b/docs/examples/python/use_query_postprocessor_change.py @@ -1,4 +1,5 @@ from reactpy import component + from reactpy_django.hooks import use_query @@ -11,7 +12,6 @@ def my_postprocessor(data, example_kwarg=True): def execute_io_intensive_operation(): """This is an example query function that does something IO intensive.""" - pass @component diff --git a/docs/examples/python/use-query-postprocessor-disable.py b/docs/examples/python/use_query_postprocessor_disable.py similarity index 97% rename from docs/examples/python/use-query-postprocessor-disable.py rename to docs/examples/python/use_query_postprocessor_disable.py index e9541924..a22f7a96 100644 --- a/docs/examples/python/use-query-postprocessor-disable.py +++ b/docs/examples/python/use_query_postprocessor_disable.py @@ -1,10 +1,10 @@ from reactpy import component + from reactpy_django.hooks import use_query def execute_io_intensive_operation(): """This is an example query function that does something IO intensive.""" - pass @component diff --git a/docs/examples/python/use-query-postprocessor-kwargs.py b/docs/examples/python/use_query_postprocessor_kwargs.py similarity index 99% rename from docs/examples/python/use-query-postprocessor-kwargs.py rename to docs/examples/python/use_query_postprocessor_kwargs.py index 4ed108af..18ba2999 100644 --- a/docs/examples/python/use-query-postprocessor-kwargs.py +++ b/docs/examples/python/use_query_postprocessor_kwargs.py @@ -1,5 +1,6 @@ -from example.models import TodoItem from reactpy import component + +from example.models import TodoItem from reactpy_django.hooks import use_query diff --git a/docs/examples/python/use_query_refetch.py b/docs/examples/python/use_query_refetch.py new file mode 100644 index 00000000..c31c12c7 --- /dev/null +++ b/docs/examples/python/use_query_refetch.py @@ -0,0 +1,26 @@ +from channels.db import database_sync_to_async +from reactpy import component, html + +from example.models import TodoItem +from reactpy_django.hooks import use_query + + +async def get_items(): + return await database_sync_to_async(TodoItem.objects.all)() + + +@component +def todo_list(): + item_query = use_query(get_items) + + if item_query.loading: + rendered_items = html.h2("Loading...") + elif item_query.error or not item_query.data: + rendered_items = html.h2("Error when loading!") + else: + rendered_items = html.ul([html.li(item.text, key=item.pk) for item in item_query.data]) + + def on_click(event): + item_query.refetch() + + return html.div("Rendered items: ", rendered_items, html.button({"onClick": on_click}, "Refresh")) diff --git a/docs/examples/python/use-query-thread-sensitive.py b/docs/examples/python/use_query_thread_sensitive.py similarity index 97% rename from docs/examples/python/use-query-thread-sensitive.py rename to docs/examples/python/use_query_thread_sensitive.py index d657be6b..9b929e3a 100644 --- a/docs/examples/python/use-query-thread-sensitive.py +++ b/docs/examples/python/use_query_thread_sensitive.py @@ -1,10 +1,10 @@ from reactpy import component + from reactpy_django.hooks import use_query def execute_thread_safe_operation(): """This is an example query function that does some thread-safe operation.""" - pass @component diff --git a/docs/examples/python/use_rerender.py b/docs/examples/python/use_rerender.py new file mode 100644 index 00000000..cd160e17 --- /dev/null +++ b/docs/examples/python/use_rerender.py @@ -0,0 +1,15 @@ +from uuid import uuid4 + +from reactpy import component, html + +from reactpy_django.hooks import use_rerender + + +@component +def my_component(): + rerender = use_rerender() + + def on_click(): + rerender() + + return html.div(f"UUID: {uuid4()}", html.button({"onClick": on_click}, "Rerender")) diff --git a/docs/examples/python/use-root-id.py b/docs/examples/python/use_root_id.py similarity index 99% rename from docs/examples/python/use-root-id.py rename to docs/examples/python/use_root_id.py index f2088cc4..246a8da1 100644 --- a/docs/examples/python/use-root-id.py +++ b/docs/examples/python/use_root_id.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_root_id diff --git a/docs/examples/python/use-scope.py b/docs/examples/python/use_scope.py similarity index 99% rename from docs/examples/python/use-scope.py rename to docs/examples/python/use_scope.py index 2e6f5961..2bd8f483 100644 --- a/docs/examples/python/use-scope.py +++ b/docs/examples/python/use_scope.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_scope diff --git a/docs/examples/python/use-user.py b/docs/examples/python/use_user.py similarity index 99% rename from docs/examples/python/use-user.py rename to docs/examples/python/use_user.py index 641bbeee..597e9f67 100644 --- a/docs/examples/python/use-user.py +++ b/docs/examples/python/use_user.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user diff --git a/docs/examples/python/use-user-data.py b/docs/examples/python/use_user_data.py similarity index 91% rename from docs/examples/python/use-user-data.py rename to docs/examples/python/use_user_data.py index bc0ffaff..1526d6a8 100644 --- a/docs/examples/python/use-user-data.py +++ b/docs/examples/python/use_user_data.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user_data @@ -15,5 +16,5 @@ def on_submit(event): html.div(f"Data: {query.data}"), html.div(f"Loading: {query.loading | mutation.loading}"), html.div(f"Error(s): {query.error} {mutation.error}"), - html.input({"on_key_press": on_submit}), + html.input({"onKeyPress": on_submit}), ) diff --git a/docs/examples/python/use-user-data-defaults.py b/docs/examples/python/use_user_data_defaults.py similarity index 99% rename from docs/examples/python/use-user-data-defaults.py rename to docs/examples/python/use_user_data_defaults.py index 7a1380bc..2c066ad7 100644 --- a/docs/examples/python/use-user-data-defaults.py +++ b/docs/examples/python/use_user_data_defaults.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.hooks import use_user_data diff --git a/docs/examples/python/user-passes-test.py b/docs/examples/python/user_passes_test.py similarity index 99% rename from docs/examples/python/user-passes-test.py rename to docs/examples/python/user_passes_test.py index 201ad831..37160c1b 100644 --- a/docs/examples/python/user-passes-test.py +++ b/docs/examples/python/user_passes_test.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/user-passes-test-component-fallback.py b/docs/examples/python/user_passes_test_component_fallback.py similarity index 99% rename from docs/examples/python/user-passes-test-component-fallback.py rename to docs/examples/python/user_passes_test_component_fallback.py index 9fb71ea7..b18330d1 100644 --- a/docs/examples/python/user-passes-test-component-fallback.py +++ b/docs/examples/python/user_passes_test_component_fallback.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/user-passes-test-vdom-fallback.py b/docs/examples/python/user_passes_test_vdom_fallback.py similarity index 99% rename from docs/examples/python/user-passes-test-vdom-fallback.py rename to docs/examples/python/user_passes_test_vdom_fallback.py index 5d5c54f4..9dd1ad65 100644 --- a/docs/examples/python/user-passes-test-vdom-fallback.py +++ b/docs/examples/python/user_passes_test_vdom_fallback.py @@ -1,4 +1,5 @@ from reactpy import component, html + from reactpy_django.decorators import user_passes_test diff --git a/docs/examples/python/views.py b/docs/examples/python/views.py deleted file mode 100644 index 60ebc945..00000000 --- a/docs/examples/python/views.py +++ /dev/null @@ -1,7 +0,0 @@ -from .hello_world_cbv import HelloWorld -from .hello_world_fbv import hello_world - -__all__ = [ - "HelloWorld", - "hello_world", -] diff --git a/docs/examples/python/vtc.py b/docs/examples/python/vtc.py index 194d35cc..84c7aeb2 100644 --- a/docs/examples/python/vtc.py +++ b/docs/examples/python/vtc.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-args.py b/docs/examples/python/vtc_args.py similarity index 95% rename from docs/examples/python/vtc-args.py rename to docs/examples/python/vtc_args.py index edc0fbb2..9ce081b5 100644 --- a/docs/examples/python/vtc-args.py +++ b/docs/examples/python/vtc_args.py @@ -1,8 +1,8 @@ from django.http import HttpRequest from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-cbv.py b/docs/examples/python/vtc_cbv.py similarity index 90% rename from docs/examples/python/vtc-cbv.py rename to docs/examples/python/vtc_cbv.py index 47509b75..38e40efe 100644 --- a/docs/examples/python/vtc-cbv.py +++ b/docs/examples/python/vtc_cbv.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.HelloWorld.as_view()) diff --git a/docs/examples/python/vtc-strict-parsing.py b/docs/examples/python/vtc_strict_parsing.py similarity index 90% rename from docs/examples/python/vtc-strict-parsing.py rename to docs/examples/python/vtc_strict_parsing.py index 194d35cc..84c7aeb2 100644 --- a/docs/examples/python/vtc-strict-parsing.py +++ b/docs/examples/python/vtc_strict_parsing.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component hello_world_component = view_to_component(views.hello_world) diff --git a/docs/examples/python/vtc-transforms.py b/docs/examples/python/vtc_transforms.py similarity index 75% rename from docs/examples/python/vtc-transforms.py rename to docs/examples/python/vtc_transforms.py index adbf9ea1..b8402481 100644 --- a/docs/examples/python/vtc-transforms.py +++ b/docs/examples/python/vtc_transforms.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_component -from . import views +from example import views +from reactpy_django.components import view_to_component def example_transform(vdom): @@ -10,9 +10,7 @@ def example_transform(vdom): vdom["children"][0] = "Farewell World!" -hello_world_component = view_to_component( - views.hello_world, transforms=[example_transform] -) +hello_world_component = view_to_component(views.hello_world, transforms=[example_transform]) @component diff --git a/docs/examples/python/vti.py b/docs/examples/python/vti.py index c8ff6796..207e5bc5 100644 --- a/docs/examples/python/vti.py +++ b/docs/examples/python/vti.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe(views.hello_world) diff --git a/docs/examples/python/vti-args.py b/docs/examples/python/vti_args.py similarity index 93% rename from docs/examples/python/vti-args.py rename to docs/examples/python/vti_args.py index f5013ecd..a26c3d3a 100644 --- a/docs/examples/python/vti-args.py +++ b/docs/examples/python/vti_args.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe( views.hello_world, diff --git a/docs/examples/python/vti-cbv.py b/docs/examples/python/vti_cbv.py similarity index 90% rename from docs/examples/python/vti-cbv.py rename to docs/examples/python/vti_cbv.py index 4e1f1b44..63f182ae 100644 --- a/docs/examples/python/vti-cbv.py +++ b/docs/examples/python/vti_cbv.py @@ -1,7 +1,7 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe hello_world_iframe = view_to_iframe(views.HelloWorld.as_view()) diff --git a/docs/examples/python/vti-extra-props.py b/docs/examples/python/vti_extra_props.py similarity index 60% rename from docs/examples/python/vti-extra-props.py rename to docs/examples/python/vti_extra_props.py index 655ad541..09846a1c 100644 --- a/docs/examples/python/vti-extra-props.py +++ b/docs/examples/python/vti_extra_props.py @@ -1,11 +1,9 @@ from reactpy import component, html -from reactpy_django.components import view_to_iframe -from . import views +from example import views +from reactpy_django.components import view_to_iframe -hello_world_iframe = view_to_iframe( - views.hello_world, extra_props={"title": "Hello World!"} -) +hello_world_iframe = view_to_iframe(views.hello_world, extra_props={"title": "Hello World!"}) @component diff --git a/docs/includes/auth-middleware-stack.md b/docs/includes/auth-middleware-stack.md new file mode 100644 index 00000000..7cc0c7f8 --- /dev/null +++ b/docs/includes/auth-middleware-stack.md @@ -0,0 +1,3 @@ +```python linenums="0" +{% include "../examples/python/configure_asgi_middleware.py" start="# start" %} +``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index c1b5922f..bde5ac08 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -16,9 +16,7 @@ nav: - Management Commands: reference/management-commands.md - About: - Changelog: about/changelog.md - - Contributor Guide: - - Code: about/code.md - - Docs: about/docs.md + - Contributor Guide: about/contributing.md - Community: - GitHub Discussions: https://github.com/reactive-python/reactpy-django/discussions - Discord: https://discord.gg/uNb5P4hA9X @@ -126,6 +124,6 @@ site_description: It's React, but in Python. Now with Django integration. copyright: '©

Reactive Python and affiliates. ' repo_url: https://github.com/reactive-python/reactpy-django site_url: https://reactive-python.github.io/reactpy-django -repo_name: ReactPy Django (GitHub) +repo_name: ReactPy Django edit_uri: edit/main/docs/src docs_dir: src diff --git a/docs/overrides/home.html b/docs/overrides/home.html index 67e31441..93d5ca29 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -73,9 +73,9 @@

Create user interfaces from components

{% with image="create-user-interfaces.png", class="pop-left" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/create-user-interfaces-demo.html" %} + {% include "homepage_examples/create_user_interfaces_demo.html" %}

Whether you work on your own or with thousands of other developers, using React feels the same. It is @@ -94,9 +94,9 @@

Write components with pure Python code

{% with image="write-components-with-python.png", class="pop-left" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/write-components-with-python-demo.html" %} + {% include "homepage_examples/write_components_with_python_demo.html" %}
@@ -110,9 +110,9 @@

Add interactivity wherever you need it

{% with image="add-interactivity.png" %} - {% include "home-code-examples/code-block.html" %} + {% include "homepage_examples/code_block.html" %} {% endwith %} - {% include "home-code-examples/add-interactivity-demo.html" %} + {% include "homepage_examples/add_interactivity_demo.html" %}

You don't have to build your whole page in ReactPy. Add React to your existing HTML page, and render diff --git a/docs/overrides/home-code-examples/add-interactivity.py b/docs/overrides/homepage_examples/add_interactivity.py similarity index 74% rename from docs/overrides/home-code-examples/add-interactivity.py rename to docs/overrides/homepage_examples/add_interactivity.py index f29ba3c8..9a7bf76f 100644 --- a/docs/overrides/home-code-examples/add-interactivity.py +++ b/docs/overrides/homepage_examples/add_interactivity.py @@ -1,8 +1,9 @@ -# pylint: disable=assignment-from-no-return, unnecessary-lambda +# ruff: noqa: INP001 from reactpy import component, html, use_state -def filter_videos(*_, **__): ... +def filter_videos(*_, **__): + return [] def search_input(*_, **__): ... @@ -18,7 +19,7 @@ def searchable_video_list(videos): return html._( search_input( - {"onChange": lambda new_text: set_search_text(new_text)}, + {"onChange": lambda event: set_search_text(event["target"]["value"])}, value=search_text, ), video_list( diff --git a/docs/overrides/home-code-examples/add-interactivity-demo.html b/docs/overrides/homepage_examples/add_interactivity_demo.html similarity index 100% rename from docs/overrides/home-code-examples/add-interactivity-demo.html rename to docs/overrides/homepage_examples/add_interactivity_demo.html diff --git a/docs/overrides/home-code-examples/code-block.html b/docs/overrides/homepage_examples/code_block.html similarity index 100% rename from docs/overrides/home-code-examples/code-block.html rename to docs/overrides/homepage_examples/code_block.html diff --git a/docs/overrides/home-code-examples/create-user-interfaces.py b/docs/overrides/homepage_examples/create_user_interfaces.py similarity index 94% rename from docs/overrides/home-code-examples/create-user-interfaces.py rename to docs/overrides/homepage_examples/create_user_interfaces.py index 873b9d88..7878aa6b 100644 --- a/docs/overrides/home-code-examples/create-user-interfaces.py +++ b/docs/overrides/homepage_examples/create_user_interfaces.py @@ -1,3 +1,4 @@ +# ruff: noqa: INP001 from reactpy import component, html diff --git a/docs/overrides/home-code-examples/create-user-interfaces-demo.html b/docs/overrides/homepage_examples/create_user_interfaces_demo.html similarity index 100% rename from docs/overrides/home-code-examples/create-user-interfaces-demo.html rename to docs/overrides/homepage_examples/create_user_interfaces_demo.html diff --git a/docs/overrides/home-code-examples/write-components-with-python.py b/docs/overrides/homepage_examples/write_components_with_python.py similarity index 94% rename from docs/overrides/home-code-examples/write-components-with-python.py rename to docs/overrides/homepage_examples/write_components_with_python.py index 47e28b68..5993046c 100644 --- a/docs/overrides/home-code-examples/write-components-with-python.py +++ b/docs/overrides/homepage_examples/write_components_with_python.py @@ -1,3 +1,4 @@ +# ruff: noqa: INP001 from reactpy import component, html diff --git a/docs/overrides/home-code-examples/write-components-with-python-demo.html b/docs/overrides/homepage_examples/write_components_with_python_demo.html similarity index 100% rename from docs/overrides/home-code-examples/write-components-with-python-demo.html rename to docs/overrides/homepage_examples/write_components_with_python_demo.html diff --git a/docs/src/about/code.md b/docs/src/about/code.md deleted file mode 100644 index 81e49c51..00000000 --- a/docs/src/about/code.md +++ /dev/null @@ -1,85 +0,0 @@ -## Overview - -

- - You will need to set up a Python environment to develop ReactPy-Django. - -

- -!!! abstract "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. - ---- - -## Creating an environment - -If you plan to make code changes to this repository, you will need to install the following dependencies first: - -- [Python 3.9+](https://www.python.org/downloads/) -- [Bun](https://bun.sh/) -- [Git](https://git-scm.com/downloads) - -Once done, you should clone this repository: - -```bash linenums="0" -git clone https://github.com/reactive-python/reactpy-django.git -cd reactpy-django -``` - -Then, by running the command below you can install the dependencies needed to run the ReactPy-Django development environment. - -```bash linenums="0" -pip install -r requirements.txt --upgrade --verbose -``` - -!!! warning "Pitfall" - - Some of our development dependencies require a C++ compiler, which is not installed by default on Windows. If you receive errors related to this during installation, follow the instructions in your console errors. - - Additionally, be aware that ReactPy-Django's JavaScript bundle is built within the following scenarios: - - 1. When `pip install` is run on the `reactpy-django` package. - 2. Every time `python manage.py ...` or `nox ...` is run - -## Running the full test suite - -!!! abstract "Note" - - This repository uses [Nox](https://nox.thea.codes/en/stable/) to run tests. For a full test of available scripts run `nox -l`. - -By running the command below you can run the full test suite: - -```bash linenums="0" -nox -t test -``` - -Or, if you want to run the tests in the background: - -```bash linenums="0" -nox -t test -- --headless -``` - -## Running Django tests - -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 -``` - -## Running Django test web server - -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 -``` - -## Creating a pull request - -{% include-markdown "../../includes/pr.md" %} diff --git a/docs/src/about/contributing.md b/docs/src/about/contributing.md new file mode 100644 index 00000000..06499c3e --- /dev/null +++ b/docs/src/about/contributing.md @@ -0,0 +1,94 @@ +## Overview + +

+ + You will need to set up a Python environment to develop ReactPy-Django. + +

+ +!!! abstract "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. + +--- + +## Creating a development environment + +If you plan to make code changes to this repository, you will need to install the following dependencies first: + +- [Git](https://git-scm.com/downloads) +- [Python 3.9+](https://www.python.org/downloads/) +- [Hatch](https://hatch.pypa.io/latest/) +- [Bun](https://bun.sh/) + +Once you finish installing these dependencies, you can clone this repository: + +```bash linenums="0" +git clone https://github.com/reactive-python/reactpy-django.git +cd reactpy-django +``` + +## Executing test environment commands + +By utilizing `hatch`, the following commands are available to manage the development environment. + +### Tests + +| Command | Description | +| --- | --- | +| `hatch test` | Run Python tests using the current environment's Python version | +| `hatch test --all` | Run tests using all compatible Python versions | +| `hatch test --python 3.9` | Run tests using a specific Python version | +| `hatch test --include "django=5.1"` | Run tests using a specific Django version | +| `hatch test -k test_object_in_templatetag` | Run only a specific test | +| `hatch test --ds test_app.settings_multi_db` | Run tests with a specific Django settings file | +| `hatch run django:runserver` | Manually run the Django development server without running tests | + +??? question "What other arguments are available to me?" + + The `hatch test` command is a wrapper for `pytest`. Hatch "intercepts" a handful of arguments, which can be previewed by typing `hatch test --help`. + + Any additional arguments in the `test` command are provided directly to pytest. See the [pytest documentation](https://docs.pytest.org/en/stable/reference/reference.html#command-line-flags) for what additional arguments are available. + +### Linting and Formatting + +| Command | Description | +| --- | --- | +| `hatch fmt` | Run all linters and formatters | +| `hatch fmt --check` | Run all linters and formatters, but do not save fixes to the disk | +| `hatch fmt --linter` | Run only linters | +| `hatch fmt --formatter` | Run only formatters | +| `hatch run javascript:check` | Run the JavaScript linter/formatter | +| `hatch run javascript:fix` | Run the JavaScript linter/formatter and write fixes to disk | +| `hatch run python:type_check` | Run the Python type checker | + +??? tip "Configure your IDE for linting" + + This repository uses `hatch fmt` for linting and formatting, which is a [modestly customized](https://hatch.pypa.io/latest/config/internal/static-analysis/#default-settings) version of [`ruff`](https://github.com/astral-sh/ruff). + + You can install `ruff` as a plugin to your preferred code editor to create a similar environment. + +### Documentation + +| Command | Description | +| --- | --- | +| `hatch run docs:serve` | Start the [`mkdocs`](https://www.mkdocs.org/) server to view documentation locally | +| `hatch run docs:build` | Build the documentation | +| `hatch run docs:linkcheck` | Check for broken links in the documentation | +| `hatch fmt docs --check` | Run linter on code examples in the documentation | + +### Environment Management + +| Command | Description | +| --- | --- | +| `hatch build --clean` | Build the package from source | +| `hatch env prune` | Delete all virtual environments created by `hatch` | +| `hatch python install 3.12` | Install a specific Python version to your system | + +??? tip "Check out Hatch for all available commands!" + + This documentation only covers commonly used commands. + + You can type `hatch --help` to see all available commands. diff --git a/docs/src/about/docs.md b/docs/src/about/docs.md deleted file mode 100644 index 712570ec..00000000 --- a/docs/src/about/docs.md +++ /dev/null @@ -1,45 +0,0 @@ -## Overview - -

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

- ---- - -## Modifying Docs - -If you plan to make changes to this documentation, you will need to install the following dependencies first: - -- [Python 3.9+](https://www.python.org/downloads/) -- [Git](https://git-scm.com/downloads) - -Once done, you should clone this repository: - -```bash linenums="0" -git clone https://github.com/reactive-python/reactpy-django.git -cd reactpy-django -``` - -Then, by running the command below you can: - -- Install an editable version of the documentation -- Self-host a test server for the documentation - -```bash linenums="0" -pip install -r requirements.txt --upgrade -``` - -Finally, to verify that everything is working properly, you can manually run the docs preview web server. - -```bash linenums="0" -cd docs -mkdocs serve -``` - -Navigate to [`http://127.0.0.1:8000`](http://127.0.0.1:8000) to view a preview of the documentation. - -## GitHub Pull Request - -{% include-markdown "../../includes/pr.md" %} diff --git a/docs/src/assets/img/add-interactivity.png b/docs/src/assets/img/add-interactivity.png index 009b52ac..c3243190 100644 Binary files a/docs/src/assets/img/add-interactivity.png and b/docs/src/assets/img/add-interactivity.png differ diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt index 14aa7a61..d2ff722d 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -43,3 +43,9 @@ unstyled WebSocket WebSockets whitespace +pytest +linter +linters +linting +formatters +bootstrap_form 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 0bf919e2..371893e1 100644 --- a/docs/src/learn/add-reactpy-to-a-django-project.md +++ b/docs/src/learn/add-reactpy-to-a-django-project.md @@ -29,7 +29,7 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject === "settings.py" ```python - {% include "../../examples/python/configure-installed-apps.py" %} + {% include "../../examples/python/configure_installed_apps.py" %} ``` ??? warning "Enable ASGI and Django Channels (Required)" @@ -42,13 +42,13 @@ Add `#!python "reactpy_django"` to [`INSTALLED_APPS`](https://docs.djangoproject 2. Add `#!python "daphne"` to `#!python INSTALLED_APPS`. ```python linenums="0" - {% include "../../examples/python/configure-channels-installed-app.py" %} + {% include "../../examples/python/configure_channels_installed_app.py" %} ``` 3. Set your `#!python ASGI_APPLICATION` variable. ```python linenums="0" - {% include "../../examples/python/configure-channels-asgi-app.py" %} + {% include "../../examples/python/configure_channels_asgi_app.py" %} ``` ??? info "Configure ReactPy settings (Optional)" @@ -64,7 +64,7 @@ Add ReactPy HTTP paths to your `#!python urlpatterns` in your [`urls.py`](https: === "urls.py" ```python - {% include "../../examples/python/configure-urls.py" %} + {% include "../../examples/python/configure_urls.py" %} ``` ## Step 4: Configure `asgi.py` @@ -74,7 +74,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` === "asgi.py" ```python - {% include "../../examples/python/configure-asgi.py" %} + {% include "../../examples/python/configure_asgi.py" %} ``` ??? info "Add `#!python AuthMiddlewareStack` (Optional)" @@ -87,9 +87,7 @@ Register ReactPy's WebSocket using `#!python REACTPY_WEBSOCKET_ROUTE` in your [` In these situations will need to ensure you are using `#!python AuthMiddlewareStack`. - ```python linenums="0" - {% include "../../examples/python/configure-asgi-middleware.py" start="# start" %} - ``` + {% include "../../includes/auth-middleware-stack.md" %} ??? question "Where is my `asgi.py`?" diff --git a/docs/src/learn/your-first-component.md b/docs/src/learn/your-first-component.md index 85af4109..e3be5da4 100644 --- a/docs/src/learn/your-first-component.md +++ b/docs/src/learn/your-first-component.md @@ -8,7 +8,7 @@ Components are one of the core concepts of ReactPy. They are the foundation upon !!! abstract "Note" - If you have reached this point, you should have already [installed ReactPy-Django](../learn/add-reactpy-to-a-django-project.md) through the previous steps. + If you have reached this point, you should have already [installed ReactPy-Django](./add-reactpy-to-a-django-project.md) through the previous steps. --- @@ -68,7 +68,7 @@ Additionally, you can pass in `#!python args` and `#!python kwargs` into your co ???+ 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**. + ReactPy-Django will automatically register any component that is referenced in a Django HTML template. This means you typically do not need to [manually register](../reference/utils.md#register-component) 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. @@ -87,7 +87,7 @@ Within your **Django app**'s `views.py` file, you will need to [create a view fu === "views.py" ```python - {% include "../../examples/python/example/views.py" %} + {% include "../../examples/python/first_view.py" %} ``` 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. @@ -95,7 +95,7 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e === "urls.py" ```python - {% include "../../examples/python/example/urls.py" %} + {% include "../../examples/python/first_urls.py" %} ``` ??? question "Which urls.py do I add my views to?" diff --git a/docs/src/reference/components.md b/docs/src/reference/components.md index 943b76c0..448af463 100644 --- a/docs/src/reference/components.md +++ b/docs/src/reference/components.md @@ -12,26 +12,26 @@ We supply some pre-designed that components can be used to help simplify develop This allows you to embedded any number of client-side PyScript components within traditional ReactPy components. -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} === "components.py" ```python - {% include "../../examples/python/pyscript-ssr-parent.py" %} + {% include "../../examples/python/pyscript_ssr_parent.py" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-ssr-child.py" %} + {% include "../../examples/python/pyscript_ssr_child.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-ssr-parent.html" %} + {% include "../../examples/html/pyscript_ssr_parent.html" %} ``` ??? example "See Interface" @@ -53,31 +53,33 @@ This allows you to embedded any number of client-side PyScript components within === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup.html" %} + {% include "../../examples/html/pyscript_setup.html" %} ``` -{% include-markdown "../reference/template-tag.md" start="" end="" %} +{% include-markdown "./template-tag.md" start="" end="" %} -{% include-markdown "../reference/template-tag.md" start="" end="" trailing-newlines=false preserve-includer-indent=false %} +{% include-markdown "./template-tag.md" start="" end="" %} + +{% include-markdown "./template-tag.md" start="" end="" trailing-newlines=false preserve-includer-indent=false %} === "components.py" ```python - {% include "../../examples/python/pyscript-component-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_component_multiple_files_root.py" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_multiple_files_root.py" %} ``` === "child.py" ```python - {% include "../../examples/python/pyscript-multiple-files-child.py" %} + {% include "../../examples/python/pyscript_multiple_files_child.py" %} ``` ??? question "How do I display something while the component is loading?" @@ -89,7 +91,7 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-initial-object.py" %} + {% include "../../examples/python/pyscript_component_initial_object.py" %} ``` However, you can also use a string containing raw HTML. @@ -97,7 +99,7 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-initial-string.py" %} + {% include "../../examples/python/pyscript_component_initial_string.py" %} ``` ??? question "Can I use a different name for my root component?" @@ -107,13 +109,13 @@ This allows you to embedded any number of client-side PyScript components within === "components.py" ```python - {% include "../../examples/python/pyscript-component-root.py" %} + {% include "../../examples/python/pyscript_component_root.py" %} ``` === "main.py" ```python - {% include "../../examples/python/pyscript-root.py" %} + {% include "../../examples/python/pyscript_root.py" %} ``` --- @@ -156,11 +158,11 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_component` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_component` that will be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/269). - Requires manual intervention to change HTTP methods to anything other than `GET`. - ReactPy events cannot conveniently be attached to converted view HTML. - - Has no option to automatically intercept local anchor link (such as `#!html
`) click events. + - Has no option to automatically intercept click events from hyperlinks (such as `#!html `). ??? question "How do I use this for Class Based Views?" @@ -171,7 +173,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-cbv.py" %} + {% include "../../examples/python/vtc_cbv.py" %} ``` === "views.py" @@ -187,7 +189,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-args.py" %} + {% include "../../examples/python/vtc_args.py" %} ``` === "views.py" @@ -215,7 +217,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-strict-parsing.py" %} + {% include "../../examples/python/vtc_strict_parsing.py" %} ``` === "views.py" @@ -237,7 +239,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vtc-transforms.py" %} + {% include "../../examples/python/vtc_transforms.py" %} ``` === "views.py" @@ -292,12 +294,12 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. ??? info "Existing limitations" - There are currently several limitations of using `#!python view_to_iframe` that may be resolved in a future version. + There are currently several limitations of using `#!python view_to_iframe` which may be [resolved in a future version](https://github.com/reactive-python/reactpy-django/issues/268). - No built-in method of signalling events back to the parent component. - - All provided `#!python *args` and `#!python *kwargs` must be serializable values, since they are encoded into the URL. + - All provided `#!python args` and `#!python kwargs` must be serializable values, since they are encoded into the URL. - The `#!python iframe` will always load **after** the parent component. - - CSS styling for `#!python iframe` elements tends to be awkward/difficult. + - CSS styling for `#!python iframe` elements tends to be awkward. ??? question "How do I use this for Class Based Views?" @@ -308,7 +310,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-cbv.py" %} + {% include "../../examples/python/vti_cbv.py" %} ``` === "views.py" @@ -332,7 +334,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-args.py" %} + {% include "../../examples/python/vti_args.py" %} ``` === "views.py" @@ -364,7 +366,7 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. === "components.py" ```python - {% include "../../examples/python/vti-extra-props.py" %} + {% include "../../examples/python/vti_extra_props.py" %} ``` === "views.py" @@ -381,6 +383,104 @@ Compatible with sync or async [Function Based Views](https://docs.djangoproject. --- +## Django Form + +Automatically convert a Django form into a ReactPy component. + +Compatible with both [standard Django forms](https://docs.djangoproject.com/en/stable/topics/forms/#building-a-form) and [ModelForms](https://docs.djangoproject.com/en/stable/topics/forms/modelforms/). + +=== "components.py" + + ```python + {% include "../../examples/python/django_form.py" %} + ``` + +=== "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + | Name | Type | Description | Default | + | --- | --- | --- | --- | + | `#!python form` | `#!python type[Form | ModelForm]` | The form to convert. | N/A | + | `#!python on_success` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form is successfully submitted. | `#!python None` | + | `#!python on_error` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when the form submission fails. | `#!python None` | + | `#!python on_receive_data` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called before newly submitted form data is rendered. | `#!python None` | + | `#!python on_change` | `#!python AsyncFormEvent | SyncFormEvent | None` | A callback function that is called when a form field is modified by the user. | `#!python None` | + | `#!python auto_save` | `#!python bool` | If `#!python True`, the form will automatically call `#!python save` on successful submission of a `#!python ModelForm`. This has no effect on regular `#!python Form` instances. | `#!python True` | + | `#!python extra_props` | `#!python dict[str, Any] | None` | Additional properties to add to the `#!html
` element. | `#!python None` | + | `#!python extra_transforms` | `#!python Sequence[Callable[[VdomDict], Any]] | None` | A list of functions that transforms the newly generated VDOM. The functions will be repeatedly called on each VDOM node. | `#!python None` | + | `#!python form_template` | `#!python str | None` | The template to use for the form. If `#!python None`, Django's default template is used. | `#!python None` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run event callback functions in thread sensitive mode. This mode only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python top_children` | `#!python Sequence[Any]` | Additional elements to add to the top of the form. | `#!python tuple` | + | `#!python bottom_children` | `#!python Sequence[Any]` | Additional elements to add to the bottom of the form. | `#!python tuple` | + | `#!python key` | `#!python Key | None` | A key to uniquely identify this component which is unique amongst a component's immediate siblings. | `#!python None` | + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python Component` | A ReactPy component. | + +??? info "Existing limitations" + + The following fields are currently incompatible with `#!python django_form`: `#!python FileField`, `#!python ImageField`, `#!python SplitDateTimeField`, and `#!python MultiValueField`. + + Compatibility for these fields will be [added in a future version](https://github.com/reactive-python/reactpy-django/issues/270). + +??? question "How do I style these forms with Bootstrap?" + + You can style these forms by using a form styling library. In the example below, it is assumed that you have already installed [`django-bootstrap5`](https://pypi.org/project/django-bootstrap5/). + + After installing a form styling library, you can then provide ReactPy a custom `#!python form_template` parameter. This parameter allows you to specify a custom HTML template to use to render this the form. + + Note that you can also set a global default for `form_template` by using [`settings.py:REACTPY_DEFAULT_FORM_TEMPLATE`](./settings.md#reactpy_default_form_template). + + === "components.py" + + ```python + {% include "../../examples/python/django_form_bootstrap.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + + === "bootstrap_form.html" + + ```jinja + {% include "../../examples/html/django_form_bootstrap.html" %} + ``` + +??? question "How do I handle form success/errors?" + + You can react to form state by providing a callback function to any of the following parameters: `#!python on_success`, `#!python on_error`, `#!python on_receive_data`, and `#!python on_change`. + + These functions will be called when the form is submitted. + + In the example below, we will use the `#!python on_success` parameter to change the URL upon successful submission. + + === "components.py" + + ```python + {% include "../../examples/python/django_form_on_success.py" %} + ``` + + === "forms.py" + + ```python + {% include "../../examples/python/django_form_class.py" %} + ``` + +--- + ## 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/stable/howto/static-files/). @@ -388,7 +488,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. === "components.py" ```python - {% include "../../examples/python/django-css.py" %} + {% include "../../examples/python/django_css.py" %} ``` ??? example "See Interface" @@ -413,7 +513,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. Here's an example on what you should avoid doing for Django static files: ```python - {% include "../../examples/python/django-css-local-link.py" %} + {% include "../../examples/python/django_css_local_link.py" %} ``` ??? question "How do I load external CSS?" @@ -423,7 +523,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering. For external CSS, you should use `#!python html.link`. ```python - {% include "../../examples/python/django-css-external-link.py" %} + {% include "../../examples/python/django_css_external_link.py" %} ``` ??? question "Why not load my CSS in `#!html `?" @@ -450,7 +550,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on === "components.py" ```python - {% include "../../examples/python/django-js.py" %} + {% include "../../examples/python/django_js.py" %} ``` ??? example "See Interface" @@ -475,7 +575,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on Here's an example on what you should avoid doing for Django static files: ```python - {% include "../../examples/python/django-js-local-script.py" %} + {% include "../../examples/python/django_js_local_script.py" %} ``` ??? question "How do I load external JS?" @@ -485,7 +585,7 @@ Be mindful of load order! If your JavaScript relies on the component existing on For external JavaScript, you should use `#!python html.script`. ```python - {% include "../../examples/python/django-js-remote-script.py" %} + {% include "../../examples/python/django_js_remote_script.py" %} ``` ??? question "Why not load my JS in `#!html `?" diff --git a/docs/src/reference/decorators.md b/docs/src/reference/decorators.md index bc84c75e..1763cf25 100644 --- a/docs/src/reference/decorators.md +++ b/docs/src/reference/decorators.md @@ -17,7 +17,7 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test.py" %} + {% include "../../examples/python/user_passes_test.py" %} ``` ??? example "See Interface" @@ -42,7 +42,7 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test-component-fallback.py" %} + {% include "../../examples/python/user_passes_test_component_fallback.py" %} ``` ??? question "How do I render a simple `#!python reactpy.html` snippet if the test fails?" @@ -52,5 +52,5 @@ This only works with ReactPy components, and is inspired by Django's [`user_pass === "components.py" ```python - {% include "../../examples/python/user-passes-test-vdom-fallback.py" %} + {% include "../../examples/python/user_passes_test_vdom_fallback.py" %} ``` diff --git a/docs/src/reference/hooks.md b/docs/src/reference/hooks.md index 3c07639f..b29990d5 100644 --- a/docs/src/reference/hooks.md +++ b/docs/src/reference/hooks.md @@ -22,20 +22,20 @@ Prefabricated hooks can be used within your `components.py` to help simplify dev Execute functions in the background and return the result, typically to [read](https://www.sumologic.com/glossary/crud/) data from the Django ORM. -The [default postprocessor](../reference/utils.md#django-query-postprocessor) expects your query function to `#!python return` a Django `#!python Model` or `#!python QuerySet`. This needs to be changed or disabled to execute other types of queries. +The [default postprocessor](./utils.md#django-query-postprocessor) expects your query function to `#!python return` a Django `#!python Model` or `#!python QuerySet`. This needs [to be changed](./settings.md#reactpy_default_query_postprocessor) or disabled to execute other types of queries. Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query.py" %} + {% include "../../examples/python/use_query.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" @@ -46,7 +46,7 @@ Query functions can be sync or async. | --- | --- | --- | --- | | `#!python query` | `#!python Callable[FuncParams, Awaitable[Inferred]] | Callable[FuncParams, Inferred]` | A function that executes a query and returns some data. | N/A | | `#!python kwargs` | `#!python dict[str, Any] | None` | Keyword arguments to passed into the `#!python query` function. | `#!python None` | - | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This mode only applies to sync query functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run your query function in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python postprocessor` | `#!python AsyncPostprocessor | SyncPostprocessor | None` | A callable that processes the query `#!python data` before it is returned. The first argument of postprocessor function must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs`. This postprocessor function must return the modified `#!python data`. | `#!python None` | | `#!python postprocessor_kwargs` | `#!python dict[str, Any] | None` | Keyworded arguments passed into the `#!python postprocessor` function. | `#!python None` | @@ -63,7 +63,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-args.py" %} + {% include "../../examples/python/use_query_args.py" %} ``` ??? question "How can I customize this hook's behavior?" @@ -83,7 +83,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-thread-sensitive.py" %} + {% include "../../examples/python/use_query_thread_sensitive.py" %} ``` --- @@ -102,7 +102,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-disable.py" %} + {% include "../../examples/python/use_query_postprocessor_disable.py" %} ``` If you wish to create a custom `#!python postprocessor`, you will need to create a function where the first must be the query `#!python data`. All proceeding arguments are optional `#!python postprocessor_kwargs` (see below). This `#!python postprocessor` function must return the modified `#!python data`. @@ -110,7 +110,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-change.py" %} + {% include "../../examples/python/use_query_postprocessor_change.py" %} ``` --- @@ -126,7 +126,7 @@ Query functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-query-postprocessor-kwargs.py" %} + {% include "../../examples/python/use_query_postprocessor_kwargs.py" %} ``` _Note: In Django's ORM design, the field name to access foreign keys is [postfixed with `_set`](https://docs.djangoproject.com/en/stable/topics/db/examples/many_to_one/) by default._ @@ -135,29 +135,23 @@ Query functions can be sync or async. {% include-markdown "../../includes/orm.md" start="" end="" %} -??? question "Can I make a failed query try again?" +??? question "Can I force a query to run again?" - Yes, `#!python use_mutation` can be re-executed by calling `#!python reset()` on your `#!python use_mutation` instance. + `#!python use_query` can be re-executed by calling `#!python refetch()` on your `#!python use_query` result. - For example, take a look at `#!python reset_event` below. + The example below uses an `#!python onClick` event to determine when to reset the query. === "components.py" ```python - {% include "../../examples/python/use-mutation-reset.py" %} - ``` - - === "models.py" - - ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/use_query_refetch.py" %} ``` ??? question "Why does the example query function return `#!python TodoItem.objects.all()`?" This design decision was based on [Apollo's `#!javascript useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `#!python SynchronousOnlyOperation` exceptions. - With the `#!python Model` or `#!python QuerySet` your function returns, this hook uses the [default postprocessor](../reference/utils.md#django-query-postprocessor) to ensure that all [deferred](https://docs.djangoproject.com/en/stable/ref/models/instances/#django.db.models.Model.get_deferred_fields) or [lazy](https://docs.djangoproject.com/en/stable/topics/db/queries/#querysets-are-lazy) fields are executed. + With the `#!python Model` or `#!python QuerySet` your function returns, this hook uses the [default postprocessor](./utils.md#django-query-postprocessor) to ensure that all [deferred](https://docs.djangoproject.com/en/stable/ref/models/instances/#django.db.models.Model.get_deferred_fields) or [lazy](https://docs.djangoproject.com/en/stable/topics/db/queries/#querysets-are-lazy) fields are executed. --- @@ -172,13 +166,13 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation.py" %} + {% include "../../examples/python/use_mutation.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" @@ -188,7 +182,7 @@ Mutation functions can be sync or async. | Name | Type | Description | Default | | --- | --- | --- | --- | | `#!python mutation` | `#!python Callable[FuncParams, bool | None] | Callable[FuncParams, Awaitable[bool | None]]` | A callable that performs Django ORM create, update, or delete functionality. If this function returns `#!python False`, then your `#!python refetch` function will not be used. | N/A | - | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This mode only applies to sync mutation functions, and is turned on by default due to Django ORM limitations. | `#!python True` | + | `#!python thread_sensitive` | `#!python bool` | Whether to run the mutation in thread sensitive mode. This setting only applies to sync functions, and is turned on by default due to Django ORM limitations. | `#!python True` | | `#!python refetch` | `#!python Callable[..., Any] | Sequence[Callable[..., Any]] | None` | A query function (the function you provide to your `#!python use_query` hook) or a sequence of query functions that need a `#!python refetch` if the mutation succeeds. This is useful for refreshing data after a mutation has been performed. | `#!python None` | **Returns** @@ -204,7 +198,7 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-args-kwargs.py" %} + {% include "../../examples/python/use_mutation_args_kwargs.py" %} ``` ??? question "How can I customize this hook's behavior?" @@ -224,34 +218,34 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-thread-sensitive.py" %} + {% include "../../examples/python/use_mutation_thread_sensitive.py" %} ``` ??? question "Can I make ORM calls without hooks?" {% include-markdown "../../includes/orm.md" start="" end="" %} -??? question "Can I make a failed mutation try again?" +??? question "Can I force a mutation run again?" - Yes, `#!python use_mutation` can be re-executed by calling `#!python reset()` on your `#!python use_mutation` instance. + `#!python use_mutation` can be re-executed by calling `#!python reset()` on your `#!python use_mutation` result. For example, take a look at `#!python reset_event` below. === "components.py" ```python - {% include "../../examples/python/use-mutation-reset.py" %} + {% include "../../examples/python/use_mutation_reset.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? question "Can `#!python use_mutation` trigger a refetch of `#!python use_query`?" - Yes, `#!python use_mutation` can queue a refetch of a `#!python use_query` via the `#!python refetch=...` argument. + `#!python use_mutation` can queue a refetch of a `#!python use_query` via the `#!python refetch=...` argument. The example below is a merge of the `#!python use_query` and `#!python use_mutation` examples above with the addition of a `#!python use_mutation(refetch=...)` argument. @@ -260,29 +254,106 @@ Mutation functions can be sync or async. === "components.py" ```python - {% include "../../examples/python/use-mutation-query-refetch.py" %} + {% include "../../examples/python/use_mutation_query_refetch.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` --- +## User Hooks + +--- + +### Use Auth + +Provides a `#!python NamedTuple` containing `#!python async login` and `#!python async logout` functions. + +This hook utilizes the Django's authentication framework in a way that provides **persistent** login. + +=== "components.py" + + ```python + {% include "../../examples/python/use_auth.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python UseAuthTuple` | A named tuple containing `#!python login` and `#!python logout` async functions. | + +??? warning "Extra Django configuration required" + + Your ReactPy WebSocket must utilize `#!python AuthMiddlewareStack` in order to use this hook. + + {% include "../../includes/auth-middleware-stack.md" %} + +??? question "Why use this instead of `#!python channels.auth.login`?" + + The `#!python channels.auth.*` functions cannot trigger re-renders of your ReactPy components. Additionally, they do not provide persistent authentication when used within ReactPy. + + This is a result of Django's authentication design, which requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies. + + To work around this limitation, when `#!python use_auth().login()` is called within your application, ReactPy performs the following process... + + 1. The server authenticates the user into the WebSocket + 2. The server generates a temporary login token + 3. The server commands the browser to use the login token (via HTTP) + 4. The client performs the HTTP request + 5. The server returns the HTTP response, which contains all necessary cookies + 6. The client stores these cookies in the browser + + This ultimately results in persistent authentication which will be retained even if the browser tab is refreshed. + +--- + +### Use User + +Shortcut that returns the WebSocket or HTTP connection's `#!python User`. + +=== "components.py" + + ```python + {% include "../../examples/python/use_user.py" %} + ``` + +??? example "See Interface" + + **Parameters** + + `#!python None` + + **Returns** + + | Type | Description | + | --- | --- | + | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. | + +--- + ### Use User Data -Store or retrieve a `#!python dict` containing user data specific to the connection's `#!python User`. +Store or retrieve a `#!python dict` containing arbitrary data specific to the connection's `#!python User`. -This hook is useful for storing user-specific data, such as preferences, settings, or any generic key-value pairs. +This hook is useful for storing user-specific data, such as preferences or settings. User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. === "components.py" ```python - {% include "../../examples/python/use-user-data.py" %} + {% include "../../examples/python/use_user_data.py" %} ``` ??? example "See Interface" @@ -309,7 +380,7 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. === "components.py" ```python - {% include "../../examples/python/use-user-data-defaults.py" %} + {% include "../../examples/python/use_user_data_defaults.py" %} ``` --- @@ -320,7 +391,7 @@ User data saved with this hook is stored within the `#!python REACTPY_DATABASE`. ### Use Channel Layer -Subscribe to a [Django Channels layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to send/receive messages. +Subscribe to a [Django Channels Layer](https://channels.readthedocs.io/en/latest/topics/channel_layers.html) to communicate messages. Layers are a multiprocessing-safe communication system that allows you to send/receive messages between different parts of your application. @@ -329,7 +400,7 @@ This is often used to create chat systems, synchronize data between components, === "components.py" ```python - {% include "../../examples/python/use-channel-layer.py" %} + {% include "../../examples/python/use_channel_layer.py" %} ``` ??? example "See Interface" @@ -338,18 +409,16 @@ This is often used to create chat systems, synchronize data between components, | Name | Type | Description | Default | | --- | --- | --- | --- | - | `#!python name` | `#!python str | None` | The name of the channel to subscribe to. If you define a `#!python group_name`, you can keep `#!python name` undefined to auto-generate a unique name. | `#!python None` | - | `#!python group_name` | `#!python str | None` | If configured, any messages sent within this hook will be broadcasted to all channels in this group. | `#!python None` | - | `#!python group_add` | `#!python bool` | If `#!python True`, the channel will automatically be added to the group when the component mounts. | `#!python True` | - | `#!python group_discard` | `#!python bool` | If `#!python True`, the channel will automatically be removed from the group when the component dismounts. | `#!python True` | - | `#!python receiver` | `#!python AsyncMessageReceiver | None` | An async function that receives a `#!python message: dict` from a channel. If more than one receiver waits on the same channel name, a random receiver will get the result. | `#!python None` | - | `#!python layer` | `#!python str` | The channel layer to use. This layer must be defined in `#!python settings.py:CHANNEL_LAYERS`. | `#!python 'default'` | + | `#!python channel` | `#!python str | None` | The name of the channel this hook will send/receive messages on. If `#!python group` is defined and `#!python channel` is `#!python None`, ReactPy will automatically generate a unique channel name. | `#!python None` | + | `#!python group` | `#!python str | None` | If configured, the `#!python channel` is added to a `#!python group` and any messages sent by `#!python AsyncMessageSender` is broadcasted to all channels within the `#!python group`. | `#!python None` | + | `#!python receiver` | `#!python AsyncMessageReceiver | None` | An async function that receives a `#!python message: dict` from a channel. | `#!python None` | + | `#!python layer` | `#!python str` | The Django Channels layer to use. This layer must be defined in `settings.py:CHANNEL_LAYERS`. | `#!python 'default'` | **Returns** | Type | Description | | --- | --- | - | `#!python AsyncMessageSender` | An async callable that can send a `#!python message: dict`. | + | `#!python AsyncMessageSender` | An async callable that can send messages to the channel(s). This callable accepts a single argument, `#!python message: dict`, which is the data sent to the channel or group of channels. | ??? warning "Extra Django configuration required" @@ -357,11 +426,11 @@ This is often used to create chat systems, synchronize data between components, The [Django Channels documentation](https://channels.readthedocs.io/en/latest/topics/channel_layers.html#configuration) has information on what steps you need to take. - In summary, you will need to: + Here is a short summary of the most common installation steps: 1. Install [`redis`](https://redis.io/download/) on your machine. - 2. Run the following command to install `channels-redis` in your Python environment. + 2. Install `channels-redis` in your Python environment. ```bash linenums="0" pip install channels-redis @@ -380,23 +449,57 @@ This is often used to create chat systems, synchronize data between components, } ``` +??? tip "Learn about the quirks of Django Channel Layers" + + ReactPy tries to simplify the process of using Django Channels Layers, but it is important to understand how they work. + + There are a few quirks of Django Channels Layers to be aware of: + + - Any given `#!python channel` should only have one `#!python receiver` registered to it, under normal circumstances. + - This is why ReactPy automatically generates a unique channel name when using `#!python group`. + - When using `#!python group` within this hook, it is suggested to leave `#!python channel` undefined to let ReactPy automatically create a unique channel name (unless you know what you are doing). + - If you have multiple receivers for the same `#!python channel`, only one receiver will get the result. + - This quirk extends to groups as well. For example, If you have two component instances that use the same `#!python channel` within a `#!python group`, the message will only reach one receiver (for that channel). + - Channels exist independently of their `#!python group`. + - Groups are just a loose collection of channel names where a copy of each message can be sent. + - As a result, Django allows you to send messages directly to a `#!python channel` even if it is within a `#!python group`. + - By default, `#!python RedisChannelLayer` will close groups once they have existed for more than 24 hours. + - You need to create your own subclass of `#!python RedisChannelLayer` to change this behavior. + - By default, `#!python RedisChannelLayer` only allows 100 messages backlogged within a `#!python channel` receive queue. + - Rapidly sending messages can overwhelm this queue, resulting in new messages being dropped. + - If you expect to exceed this limit, you need to create your own subclass of `#!python RedisChannelLayer` to change this behavior. + ??? question "How do I broadcast a message to multiple components?" - If more than one receiver waits on the same channel, a random one will get the result. + Groups allow you to broadcast messages to all channels within that group. If you do not define a `#!python channel` while using groups, ReactPy will automatically generate a unique channel name for you. - To get around this, you can define a `#!python group_name` to broadcast messages to all channels within a specific group. If you do not define a channel `#!python name` while using groups, ReactPy will automatically generate a unique channel name for you. + In the example below, since all components use the same channel `#!python group`, messages sent by `#!python my_sender_component` will reach all existing instances of `#!python my_receiver_component_1` and `#!python my_receiver_component_2`. - In the example below, all messages sent by the `#!python sender` component will be received by all `#!python receiver` components that exist (across every active client browser). + === "components.py" + + ```python + {% include "../../examples/python/use_channel_layer_group.py" %} + ``` + +??? question "How do I send a message to a single component (point-to-point communication)?" + + The most common way of using `#!python use_channel_layer` is to broadcast messages to multiple components via a `#!python group`. + + However, you can also use this hook to establish unidirectional, point-to-point communication towards a single `#!python receiver` function. This is slightly more efficient since it avoids the overhead of `#!python group` broadcasting. + + In the example below, `#!python my_sender_component` will communicate directly to a single instance of `#!python my_receiver_component`. This is achieved by defining a `#!python channel` while omitting the `#!python group` parameter. === "components.py" ```python - {% include "../../examples/python/use-channel-layer-group.py" %} + {% include "../../examples/python/use_channel_layer_single.py" %} ``` + Note that if you have multiple instances of `#!python my_receiver_component` using the same `#!python channel`, only one will receive the message. + ??? question "How do I signal a re-render from something that isn't a component?" - There are occasions where you may want to signal a re-render from something that isn't a component, such as a Django model signal. + There are occasions where you may want to signal to the `#!python use_channel_layer` hook from something that isn't a component, such as a Django [model signal](https://docs.djangoproject.com/en/stable/topics/signals/). In these cases, you can use the `#!python use_channel_layer` hook to receive a signal within your component, and then use the `#!python get_channel_layer().send(...)` to send the signal. @@ -405,13 +508,13 @@ This is often used to create chat systems, synchronize data between components, === "signals.py" ```python - {% include "../../examples/python/use-channel-layer-signal-sender.py" %} + {% include "../../examples/python/use_channel_layer_signal_sender.py" %} ``` === "components.py" ```python - {% include "../../examples/python/use-channel-layer-signal-receiver.py" %} + {% include "../../examples/python/use_channel_layer_signal_receiver.py" %} ``` --- @@ -422,12 +525,12 @@ This is often used to create chat systems, synchronize data between components, ### Use Connection -Returns the active connection, which is either a Django [WebSocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) or a [HTTP Request](https://docs.djangoproject.com/en/4.2/ref/request-response/#django.http.HttpRequest). +Returns the active connection, which is either a Django [WebSocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) or a [HTTP Request](https://docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest). === "components.py" ```python - {% include "../../examples/python/use-connection.py" %} + {% include "../../examples/python/use_connection.py" %} ``` ??? example "See Interface" @@ -451,7 +554,7 @@ Shortcut that returns the WebSocket or HTTP connection's [scope](https://channel === "components.py" ```python - {% include "../../examples/python/use-scope.py" %} + {% include "../../examples/python/use_scope.py" %} ``` ??? example "See Interface" @@ -475,7 +578,7 @@ Shortcut that returns the browser's current `#!python Location`. === "components.py" ```python - {% include "../../examples/python/use-location.py" %} + {% include "../../examples/python/use_location.py" %} ``` ??? example "See Interface" @@ -501,7 +604,7 @@ You can expect this hook to provide strings such as `http://example.com`. === "components.py" ```python - {% include "../../examples/python/use-origin.py" %} + {% include "../../examples/python/use_origin.py" %} ``` ??? example "See Interface" @@ -522,14 +625,14 @@ You can expect this hook to provide strings such as `http://example.com`. Shortcut that returns the root component's `#!python id` from the WebSocket or HTTP connection. -The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset when the page is refreshed. +The root ID is a randomly generated `#!python uuid4`. It is notable to mention that it is persistent across the current connection. The `uuid` is reset only when the page is refreshed. -This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance, and/or retain a backlog of messages in case that component is disconnected via `#!python use_channel_layer( ... , group_discard=False)`. +This is useful when used in combination with [`#!python use_channel_layer`](#use-channel-layer) to send messages to a specific component instance. === "components.py" ```python - {% include "../../examples/python/use-root-id.py" %} + {% include "../../examples/python/use_root_id.py" %} ``` ??? example "See Interface" @@ -546,14 +649,14 @@ This is useful when used in combination with [`#!python use_channel_layer`](#use --- -### Use User +### Use Re-render -Shortcut that returns the WebSocket or HTTP connection's `#!python User`. +Returns a function that can be used to trigger a re-render of the entire component tree. === "components.py" ```python - {% include "../../examples/python/use-user.py" %} + {% include "../../examples/python/use_rerender.py" %} ``` ??? example "See Interface" @@ -566,4 +669,4 @@ Shortcut that returns the WebSocket or HTTP connection's `#!python User`. | Type | Description | | --- | --- | - | `#!python AbstractUser` | A Django `#!python User`, which can also be an `#!python AnonymousUser`. | + | `#!python Callable[[], None]` | A function that triggers a re-render of the entire component tree. | diff --git a/docs/src/reference/html.md b/docs/src/reference/html.md index baef6ebf..c9bb0108 100644 --- a/docs/src/reference/html.md +++ b/docs/src/reference/html.md @@ -19,13 +19,13 @@ The `pyscript` tag functions identically to HTML tags contained within `#!python === "components.py" ```python - {% include "../../examples/python/pyscript-tag.py" %} + {% include "../../examples/python/pyscript_tag.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-tag.html" %} + {% include "../../examples/html/pyscript_tag.html" %} ``` -{% include-markdown "../reference/components.md" start="" end="" %} +{% include-markdown "./components.md" start="" end="" %} diff --git a/docs/src/reference/router.md b/docs/src/reference/router.md index 28b84351..0d23ee99 100644 --- a/docs/src/reference/router.md +++ b/docs/src/reference/router.md @@ -25,15 +25,13 @@ URL router that enables the ability to conditionally render other components bas You can duplicate all your URL patterns within both Django and ReactPy, but it's easiest to use a wildcard `.*` within Django's `#!python urlpatterns` and let ReactPy handle the rest. For example... ```python linenums="0" - urlpatterns = [ - re_path(r"^.*$", my_reactpy_router_view), - ] + urlpatterns = [ re_path(r"^.*$", my_reactpy_router_view) ] ``` === "components.py" ```python - {% include "../../examples/python/django-router.py" %} + {% include "../../examples/python/django_router.py" %} ``` ??? example "See Interface" @@ -50,6 +48,6 @@ URL router that enables the ability to conditionally render other components bas | --- | --- | | `#!python VdomDict | None` | The matched component/path after it has been fully rendered. | -??? question "How is this different from `#!python reactpy_router.simple.router`?" +??? question "How is this different from `#!python reactpy_router.browser_router`?" - This component utilizes `reactpy-router` under the hood, but provides a more Django-like URL routing syntax. + The `django_router` component utilizes the same internals as `browser_router`, but provides a more Django-like URL routing syntax. diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index 3f35ee4d..50d0b7db 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -6,12 +6,6 @@ These are ReactPy-Django's default settings values. You can modify these values

-!!! abstract "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. - --- ## General Settings @@ -34,7 +28,7 @@ The prefix used for all ReactPy WebSocket and HTTP URLs. **Example Value(s):** `#!python "example_project.postprocessor"`, `#!python None` -Dotted path to the default `#!python reactpy_django.hooks.use_query` postprocessor function. +Dotted path to the default postprocessor function used by the [`use_query`](./hooks.md#use-query) hook. Postprocessor functions can be async or sync. Here is an example of a sync postprocessor function: @@ -48,13 +42,29 @@ Set `#!python REACTPY_DEFAULT_QUERY_POSTPROCESSOR` to `#!python None` to disable --- +### `#!python REACTPY_DEFAULT_FORM_TEMPLATE` + +**Default:** `#!python None` + +**Example Value(s):** `#!python "my_templates/bootstrap_form.html"` + +File path to the default form template used by the [`django_form`](./components.md#django-form) component. + +This file path must be valid to Django's [template finder](https://docs.djangoproject.com/en/stable/topics/templates/#support-for-template-engines). + +--- + +## Authentication Settings + +--- + ### `#!python REACTPY_AUTH_BACKEND` **Default:** `#!python "django.contrib.auth.backends.ModelBackend"` **Example Value(s):** `#!python "example_project.auth.MyModelBackend"` -Dotted path to the Django authentication backend to use for ReactPy components. This is only needed if: +Dotted path to the Django authentication backend to use for ReactPy components. This is typically needed if: 1. You are using `#!python settings.py:REACTPY_AUTO_RELOGIN=True` and... 2. You are using `#!python AuthMiddlewareStack` and... @@ -63,6 +73,22 @@ Dotted path to the Django authentication backend to use for ReactPy components. --- +### `#!python REACTPY_AUTH_TOKEN_MAX_AGE` + +**Default:** `#!python 30` + +**Example Value(s):** `#!python 5` + +Maximum seconds before ReactPy's login token expires. + +This setting exists because Django's authentication design requires cookies to retain login status. ReactPy is rendered via WebSockets, and browsers do not allow active WebSocket connections to modify cookies. + +To work around this limitation, this setting provides a maximum validity period of a temporary login token. When `#!python reactpy_django.hooks.use_auth().login()` is called within your application, ReactPy will automatically create this temporary login token and command the browser to fetch it via HTTP. + +This setting should be a reasonably low value, but still be high enough to account for a combination of client lag, slow internet, and server response time. + +--- + ### `#!python REACTPY_AUTO_RELOGIN` **Default:** `#!python False` @@ -87,7 +113,7 @@ This is useful to continuously update `#!python last_login` timestamps and refre Multiprocessing-safe database used by ReactPy for database-backed hooks and features. -If configuring this value, it is mandatory to enable our database router like such: +If configuring this value, it is mandatory to configure Django to use the ReactPy database router: === "settings.py" @@ -117,12 +143,24 @@ We recommend using [`redis`](https://docs.djangoproject.com/en/stable/topics/cac Configures whether ReactPy components are rendered in a dedicated thread. -This allows the web server to process other traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://pgjones.gitlab.io/hypercorn/) and [`uvicorn`](https://www.uvicorn.org/). +This allows the web server to process other traffic during ReactPy rendering. Vastly improves throughput with web servers such as [`hypercorn`](https://github.com/pgjones/hypercorn) and [`uvicorn`](https://www.uvicorn.org/). This setting is incompatible with [`daphne`](https://github.com/django/daphne). --- +### `#!python REACTPY_ASYNC_RENDERING` + +**Default:** `#!python False` + +**Example Value(s):** `#!python True` + +Configures whether to use an async ReactPy rendering queue. When enabled, large renders will no longer block smaller renders from taking place. Additionally, prevents the rendering queue from being blocked on waiting for async effects to startup/shutdown (which is a relatively slow operation). + +This setting is currently in early release, and currently no effort is made to de-duplicate renders. For example, if parent and child components are scheduled to render at the same time, both renders will take place, even though a single render would have been sufficient. + +--- + ### `#!python REACTPY_DEFAULT_HOSTS` **Default:** `#!python None` @@ -133,7 +171,7 @@ 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. This is typically useful for self-hosted applications. -You can use the `#!python host` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. +You can use the `#!python host` argument in your [template tag](./template-tag.md#component) to manually override this default. --- @@ -152,7 +190,7 @@ During pre-rendering, there are some key differences in behavior: 3. The component will be non-interactive until a WebSocket connection is formed. 4. The component is re-rendered once a WebSocket connection is formed. -You can use the `#!python prerender` argument in your [template tag](../reference/template-tag.md#component) to manually override this default. +You can use the `#!python prerender` argument in your [template tag](./template-tag.md#component) to manually override this default. --- @@ -246,6 +284,16 @@ Configures whether ReactPy should clean up expired component sessions during aut --- +### `#!python REACTPY_CLEAN_AUTH_TOKENS` + +**Default:** `#!python True` + +**Example Value(s):** `#!python False` + +Configures whether ReactPy should clean up expired authentication tokens during automatic clean up operations. + +--- + ### `#!python REACTPY_CLEAN_USER_DATA` **Default:** `#!python True` diff --git a/docs/src/reference/template-tag.md b/docs/src/reference/template-tag.md index 091b2ac8..146dae4c 100644 --- a/docs/src/reference/template-tag.md +++ b/docs/src/reference/template-tag.md @@ -46,20 +46,20 @@ Each component loaded via this template tag will receive a dedicated WebSocket c === "my_template.html" ```jinja - - {% component "example_project.my_app.components.hello_world" recipient="World" %} - {% component my_variable recipient="World" %} + + + {% component "example_project.my_app.components.hello_world" recipient="World" %} ``` === "views.py" ```python - {% include "../../examples/python/template-tag-bad-view.py" %} + {% include "../../examples/python/template_tag_bad_view.py" %} ``` - _Note: If you decide to not follow this warning, you will need to use the [`register_component`](../reference/utils.md#register-component) function to manually register your components._ + _Note: If you decide to not follow this warning, you will need to use the [`register_component`](./utils.md#register-component) function to manually register your components._ @@ -102,12 +102,12 @@ Each component loaded via this template tag will receive a dedicated WebSocket c === "components.py" ```python - {% include "../../examples/python/template-tag-args-kwargs.py" %} + {% include "../../examples/python/template_tag_args_kwargs.py" %} ``` ??? question "Can I render components on a different server (distributed computing)?" - Yes! This is most commonly done through [`settings.py:REACTPY_HOSTS`](../reference/settings.md#reactpy_default_hosts). However, you can use the `#!python host` keyword to render components on a specific ASGI server. + Yes! This is most commonly done through [`settings.py:REACTPY_HOSTS`](./settings.md#reactpy_default_hosts). However, you can use the `#!python host` keyword to render components on a specific ASGI server. === "my_template.html" @@ -127,7 +127,7 @@ Each component loaded via this template tag will receive a dedicated WebSocket c ??? question "How do I pre-render components for SEO compatibility?" - This is most commonly done through [`settings.py:REACTPY_PRERENDER`](../reference/settings.md#reactpy_prerender). However, you can use the `#!python prerender` keyword to pre-render a specific component. + This is most commonly done through [`settings.py:REACTPY_PRERENDER`](./settings.md#reactpy_prerender). However, you can use the `#!python prerender` keyword to pre-render a specific component. === "my_template.html" @@ -159,7 +159,7 @@ This template tag can be used to insert any number of **client-side** ReactPy co 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. +Your Python component file will be directly loaded into the browser. It must have a `#!python def root()` component to act as the entry point. @@ -175,13 +175,13 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-component.html" %} + {% include "../../examples/html/pyscript_component.html" %} ``` === "hello_world.py" ```python - {% include "../../examples/python/pyscript-hello-world.py" %} + {% include "../../examples/python/pyscript_hello_world.py" %} ``` ??? example "See Interface" @@ -194,6 +194,19 @@ The entire file path provided is loaded directly into the browser, and must have | `#!python initial` | `#!python str | VdomDict | ComponentType` | The initial HTML that is displayed prior to the PyScript component loads. This can either be a string containing raw HTML, a `#!python reactpy.html` snippet, or a non-interactive component. | `#!python ""` | | `#!python root` | `#!python str` | The name of the root component function. | `#!python "root"` | + + +??? tip "Get PyScript type hints within your editor via `webtypy`!" + + By installing the `webtypy` package, you can get type hints for PyScript components within your editor. + + !!! example "Terminal" + + ```bash linenums="0" + pip install webtypy + ``` + + ??? question "How do I execute JavaScript within PyScript components?" @@ -211,12 +224,20 @@ The entire file path provided is loaded directly into the browser, and must have === "root.py" ```python - {% include "../../examples/python/pyodide-js-module.py" %} + {% include "../../examples/python/pyodide_js_module.py" %} ``` - **PyScript FFI** + **PyScript Foreign Function Interface (FFI)** + + PyScript FFI has similar functionality to Pyodide's `js` module, but utilizes a different API. + + There are two importable modules available that are available within the FFI interface: `window` and `document`. - ... + === "root.py" + + ```python + {% include "../../examples/python/pyscript_ffi.py" %} + ``` **PyScript JS Modules** @@ -225,13 +246,13 @@ The entire file path provided is loaded directly into the browser, and must have === "root.py" ```python - {% include "../../examples/python/pyscript-local-import.py" %} + {% include "../../examples/python/pyscript_local_import.py" %} ``` === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-local-import.html" %} + {% include "../../examples/html/pyscript_local_import.html" %} ``` @@ -253,19 +274,19 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-multiple-files.html" %} + {% include "../../examples/html/pyscript_multiple_files.html" %} ``` === "root.py" ```python - {% include "../../examples/python/pyscript-multiple-files-root.py" %} + {% include "../../examples/python/pyscript_multiple_files_root.py" %} ``` === "child.py" ```python - {% include "../../examples/python/pyscript-multiple-files-child.py" %} + {% include "../../examples/python/pyscript_multiple_files_child.py" %} ``` ??? question "How do I display something while the component is loading?" @@ -277,7 +298,7 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-initial-string.html" %} + {% include "../../examples/html/pyscript_initial_string.html" %} ``` However, you can also insert a `#!python reactpy.html` snippet or a non-interactive `#!python @component` via template context. @@ -285,13 +306,13 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-initial-object.html" %} + {% include "../../examples/html/pyscript_initial_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-initial-object.py" %} + {% include "../../examples/python/pyscript_initial_object.py" %} ``` ??? question "Can I use a different name for my root component?" @@ -301,25 +322,25 @@ The entire file path provided is loaded directly into the browser, and must have === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-root.html" %} + {% include "../../examples/html/pyscript_root.html" %} ``` === "main.py" ```python - {% include "../../examples/python/pyscript-root.py" %} + {% include "../../examples/python/pyscript_root.py" %} ``` ## PyScript Setup This template tag configures the current page to be able to run `pyscript`. -You can optionally use this tag to configure the current PyScript environment. For example, you can include a list of Python packages to automatically install within the PyScript environment. +You can optionally use this tag to configure the current PyScript environment, such as adding dependencies. === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup.html" %} + {% include "../../examples/html/pyscript_setup.html" %} ``` ??? example "See Interface" @@ -341,7 +362,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-dependencies.html" %} + {% include "../../examples/html/pyscript_setup_dependencies.html" %} ``` ??? question "How do I install additional Javascript dependencies?" @@ -351,13 +372,13 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-extra-js-object.html" %} + {% include "../../examples/html/pyscript_setup_extra_js_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-setup-extra-js-object.py" %} + {% include "../../examples/python/pyscript_setup_extra_js_object.py" %} ``` The value for `#!python extra_js` is most commonly a Python dictionary, but JSON strings are also supported. @@ -365,7 +386,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-extra-js-string.html" %} + {% include "../../examples/html/pyscript_setup_extra_js_string.html" %} ``` ??? question "How do I modify the `pyscript` default configuration?" @@ -375,7 +396,7 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-config-string.html" %} + {% include "../../examples/html/pyscript_setup_config_string.html" %} ``` While this value is most commonly a JSON string, Python dictionary objects are also supported. @@ -383,13 +404,13 @@ You can optionally use this tag to configure the current PyScript environment. F === "my_template.html" ```jinja - {% include "../../examples/html/pyscript-setup-config-object.html" %} + {% include "../../examples/html/pyscript_setup_config_object.html" %} ``` === "views.py" ```python - {% include "../../examples/python/pyscript-setup-config-object.py" %} + {% include "../../examples/python/pyscript_setup_config_object.py" %} ``` ??? question "Can I use a local interpreter for PyScript?" @@ -403,5 +424,5 @@ You can optionally use this tag to configure the current PyScript environment. F 3. Configure your `#!jinja {% pyscript_setup %}` template tag to use `pyodide` as an interpreter. ```jinja linenums="0" - {% include "../../examples/html/pyscript-setup-local-interpreter.html" %} + {% include "../../examples/html/pyscript_setup_local_interpreter.html" %} ``` diff --git a/docs/src/reference/utils.md b/docs/src/reference/utils.md index 917ba959..c5887d04 100644 --- a/docs/src/reference/utils.md +++ b/docs/src/reference/utils.md @@ -16,7 +16,7 @@ Utility functions provide various miscellaneous functionality for advanced use c This function is used register a Django view as a ReactPy `#!python iframe`. -It is mandatory to use this function alongside [`view_to_iframe`](../reference/components.md#view-to-iframe). +It is mandatory to use this function alongside [`view_to_iframe`](./components.md#view-to-iframe). === "apps.py" @@ -51,7 +51,7 @@ Typically, this function is automatically called on all components contained wit === "apps.py" ```python - {% include "../../examples/python/register-component.py" %} + {% include "../../examples/python/register_component.py" %} ``` ??? example "See Interface" @@ -76,7 +76,7 @@ Typically, this function is automatically called on all components contained wit For security reasons, ReactPy requires all root components to be registered. However, all components contained within Django templates are automatically registered. - This function is commonly needed when you have configured your [`host`](../reference/template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. + This function is commonly needed when you have configured your [`host`](./template-tag.md#component) to a dedicated Django rendering application that doesn't have templates. --- @@ -89,13 +89,13 @@ Since ReactPy is rendered within an `#!python asyncio` loop, this postprocessor === "components.py" ```python - {% include "../../examples/python/django-query-postprocessor.py" %} + {% include "../../examples/python/django_query_postprocessor.py" %} ``` === "models.py" ```python - {% include "../../examples/python/example/models.py" %} + {% include "../../examples/python/todo_item_model.py" %} ``` ??? example "See Interface" diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index 8776de45..00000000 --- a/noxfile.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -from glob import glob -from pathlib import Path - -from nox import Session, session - -ROOT_DIR = Path(__file__).parent - - -@session(tags=["test"]) -def test_python(session: Session) -> None: - """Run the Python-based test suite""" - install_requirements_file(session, "test-env") - session.install(".[all]") - session.chdir(ROOT_DIR / "tests") - session.env["REACTPY_DEBUG_MODE"] = "1" - - posargs = session.posargs[:] - if "--headless" in posargs: - posargs.remove("--headless") - session.env["PLAYWRIGHT_HEADLESS"] = "1" - - if "--no-debug-mode" not in posargs: - posargs.append("--debug-mode") - - session.run("playwright", "install", "chromium") - - # Run tests for each settings file (tests/test_app/settings_*.py) - settings_glob = "test_app/settings_*.py" - settings_files = glob(settings_glob) - assert settings_files, f"No Django settings files found at '{settings_glob}'!" - for settings_file in settings_files: - settings_module = ( - settings_file.strip(".py").replace("/", ".").replace("\\", ".") - ) - session.run( - "python", - "manage.py", - "test", - *posargs, - "--settings", - settings_module, - ) - - -@session(tags=["test"]) -def test_types(session: Session) -> None: - install_requirements_file(session, "check-types") - install_requirements_file(session, "pkg-deps") - session.run("mypy", "--show-error-codes", "src/reactpy_django", "tests/test_app") - - -@session(tags=["test"]) -def test_style(session: Session) -> None: - """Check that style guidelines are being followed""" - install_requirements_file(session, "check-style") - session.run("ruff", "check", ".") - - -@session(tags=["test"]) -def test_javascript(session: Session) -> None: - install_requirements_file(session, "test-env") - session.chdir(ROOT_DIR / "src" / "js") - session.run("bun", "install", external=True) - session.run("bun", "run", "check", external=True) - - -def install_requirements_file(session: Session, name: str) -> None: - session.install("--upgrade", "pip", "setuptools", "wheel") - file_path = ROOT_DIR / "requirements" / f"{name}.txt" - assert file_path.exists(), f"requirements file {file_path} does not exist" - session.install("-r", str(file_path)) diff --git a/pyproject.toml b/pyproject.toml index 99ff6917..64d530e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,251 @@ [build-system] -requires = ["setuptools>=42", "wheel"] -build-backend = "setuptools.build_meta" +build-backend = "hatchling.build" +requires = ["hatchling", "hatch-build-scripts"] -[tool.mypy] -exclude = ['migrations/.*'] -ignore_missing_imports = true -warn_unused_configs = true -warn_redundant_casts = true -warn_unused_ignores = true -check_untyped_defs = true +############################## +# >>> Hatch Build Config <<< # +############################## -[tool.ruff.lint.isort] -known-first-party = ["src", "tests"] +[project] +name = "reactpy_django" +description = "It's React, but in Python. Now with Django integration." +readme = "README.md" +keywords = [ + "React", + "ReactJS", + "ReactPy", + "components", + "asgi", + "django", + "http", + "server", + "reactive", + "interactive", +] +license = "MIT" +authors = [{ name = "Mark Bakhit", email = "archiethemonger@gmail.com" }] +requires-python = ">=3.9" +classifiers = [ + "Framework :: Django", + "Framework :: Django :: 4.0", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Topic :: Multimedia :: Graphics", + "Topic :: Software Development :: Widget Sets", + "Topic :: Software Development :: User Interfaces", + "Environment :: Web Environment", + "Typing :: Typed", +] +dependencies = [ + "channels>=4.0.0", + "django>=4.2.0", + "reactpy>=1.1.0, <2.0.0", + "reactpy-router>=1.0.3, <2.0.0", + "dill>=0.3.5", + "orjson>=3.6.0", + "nest_asyncio>=1.5.0", + "typing_extensions", +] +dynamic = ["version"] +urls.Changelog = "https://reactive-python.github.io/reactpy-django/latest/about/changelog/" +urls.Documentation = "https://reactive-python.github.io/reactpy-django/" +urls.Source = "https://github.com/reactive-python/reactpy-django" -[tool.ruff.lint] -ignore = ["E501"] +[tool.hatch.version] +path = "src/reactpy_django/__init__.py" + +[tool.hatch.build.targets.sdist] +include = ["/src"] +artifacts = ["/src/reactpy_django/static/"] + +[tool.hatch.build.targets.wheel] +artifacts = ["/src/reactpy_django/static/"] + +[tool.hatch.metadata] +license-files = { paths = ["LICENSE.md"] } + +[tool.hatch.envs.default] +installer = "uv" + +[[tool.hatch.build.hooks.build-scripts.scripts]] +commands = [ + "bun install --cwd src/js", + 'bun build src/js/src/index.ts --outdir="src/reactpy_django/static/reactpy_django/" --minify --sourcemap=linked', + 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/@pyscript/core/dist" "src/reactpy_django/static/reactpy_django/pyscript"', + 'cd src/build_scripts && python copy_dir.py "src/js/node_modules/morphdom/dist" "src/reactpy_django/static/reactpy_django/morphdom"', +] +artifacts = [] + +############################# +# >>> Hatch Test Runner <<< # +############################# + +[tool.hatch.envs.hatch-test] +extra-dependencies = [ + "pytest-sugar", + "pytest-django", + "playwright", + "channels[daphne]>=4.0.0", + "twisted", + "tblib", + "servestatic", + "django-bootstrap5", + "decorator", + +] +matrix-name-format = "{variable}-{value}" + +# Django 4.2 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] +django = ["4.2"] + +# Django 5.0 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.10", "3.11", "3.12"] +django = ["5.0"] + +# Django 5.1 +[[tool.hatch.envs.hatch-test.matrix]] +python = ["3.10", "3.11", "3.12", "3.13"] +django = ["5.1"] + +[tool.hatch.envs.hatch-test.overrides] +matrix.django.dependencies = [ + { if = [ + "4.2", + ], value = "django~=4.2" }, + { if = [ + "5.0", + ], value = "django~=5.0" }, + { if = [ + "5.1", + ], value = "django~=5.1" }, +] + +[tool.pytest.ini_options] +addopts = """\ + --strict-config + --strict-markers + --reuse-db + --maxfail=10 + """ +django_find_project = false +DJANGO_SETTINGS_MODULE = "test_app.settings_single_db" +pythonpath = [".", "tests/"] + +################################ +# >>> Hatch Django Scripts <<< # +################################ + +[tool.hatch.envs.django] +extra-dependencies = [ + "channels[daphne]>=4.0.0", + "twisted", + "servestatic", + "django-bootstrap5", + "decorator", + "playwright", +] + +[tool.hatch.envs.django.scripts] +runserver = [ + "cd tests && python manage.py migrate --noinput", + "cd tests && python manage.py runserver", +] +makemigrations = ["cd tests && python manage.py makemigrations"] +clean = ["cd tests && python manage.py clean_reactpy -v 3"] +clean_sessions = ["cd tests && python manage.py clean_reactpy --sessions -v 3"] +clean_auth_tokens = [ + "cd tests && python manage.py clean_reactpy --auth-tokens -v 3", +] +clean_user_data = [ + "cd tests && python manage.py clean_reactpy --user-data -v 3", +] + +####################################### +# >>> Hatch Documentation Scripts <<< # +####################################### + +[tool.hatch.envs.docs] +template = "docs" +extra-dependencies = [ + "mkdocs", + "mkdocs-git-revision-date-localized-plugin", + "mkdocs-material==9.4.0", + "mkdocs-include-markdown-plugin", + "mkdocs-spellcheck[all]", + "mkdocs-git-authors-plugin", + "mkdocs-minify-plugin", + "mike", + "ruff", + "django-stubs", + "linkcheckmd", +] + +[tool.hatch.envs.docs.scripts] +serve = ["cd docs && mkdocs serve"] +build = ["cd docs && mkdocs build --strict"] +linkcheck = [ + "linkcheckMarkdown docs/ -v -r --method head", + "linkcheckMarkdown README.md -v -r", + "linkcheckMarkdown CHANGELOG.md -v -r", +] +deploy_latest = ["cd docs && mike deploy --push --update-aliases {args} latest"] +deploy_develop = ["cd docs && mike deploy --push develop"] + +################################ +# >>> Hatch Python Scripts <<< # +################################ + +[tool.hatch.envs.python] +extra-dependencies = ["django-stubs", "channels-redis", "pyright"] + +[tool.hatch.envs.python.scripts] +type_check = ["pyright src"] + +############################ +# >>> Hatch JS Scripts <<< # +############################ + +[tool.hatch.envs.javascript] +detached = true + +[tool.hatch.envs.javascript.scripts] +check = ["cd src/js && bun install", "cd src/js && bun run check"] +fix = ["cd src/js && bun install", "cd src/js && bun run format"] + +######################### +# >>> Generic Tools <<< # +######################### [tool.ruff] -extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", ".nox/*", "build/*"] +extend-exclude = ["*/migrations/*", ".venv/*", ".eggs/*", "build/*"] line-length = 120 +format.preview = true +lint.extend-ignore = [ + "ARG001", # Unused function argument + "ARG002", # Unused method argument + "ARG004", # Unused static method argument + "FBT001", # Boolean-typed positional argument in function definition + "FBT002", # Boolean default positional argument in function definition + "PLR2004", # Magic value used in comparison + "SIM115", # Use context handler for opening files + "SLF001", # Private member accessed + "E501", # Line too long + "PLC0415", # `import` should be at the top-level of a file + "BLE001", # Do not catch blind exception: `Exception` + "PLW0603", # Using global statement is discouraged + "PLR6301", # Method could be a function, class method, or static method + "S403", # `dill` module is possibly insecure + "S301", # `dill` deserialization is possibly insecure unless using trusted data + "RUF029", # Function is declared async but doesn't contain await expression +] +lint.preview = true +lint.isort.known-first-party = ["reactpy_django", "test_app", "example"] +lint.isort.known-third-party = ["js"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 63e3d68e..00000000 --- a/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ --r requirements/build-docs.txt --r requirements/build-pkg.txt --r requirements/check-style.txt --r requirements/check-types.txt --r requirements/dev-env.txt --r requirements/pkg-deps.txt --r requirements/test-env.txt --r requirements/test-run.txt diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt deleted file mode 100644 index 846a7ba3..00000000 --- a/requirements/build-docs.txt +++ /dev/null @@ -1,8 +0,0 @@ -mkdocs -mkdocs-git-revision-date-localized-plugin -mkdocs-material==9.4.0 -mkdocs-include-markdown-plugin -mkdocs-spellcheck[all] -mkdocs-git-authors-plugin -mkdocs-minify-plugin -mike diff --git a/requirements/build-pkg.txt b/requirements/build-pkg.txt deleted file mode 100644 index 82f40eaf..00000000 --- a/requirements/build-pkg.txt +++ /dev/null @@ -1,3 +0,0 @@ -twine -wheel -build diff --git a/requirements/check-style.txt b/requirements/check-style.txt deleted file mode 100644 index af3ee576..00000000 --- a/requirements/check-style.txt +++ /dev/null @@ -1 +0,0 @@ -ruff diff --git a/requirements/check-types.txt b/requirements/check-types.txt deleted file mode 100644 index c962b716..00000000 --- a/requirements/check-types.txt +++ /dev/null @@ -1,3 +0,0 @@ -mypy -django-stubs[compatible-mypy] -channels-redis diff --git a/requirements/dev-env.txt b/requirements/dev-env.txt deleted file mode 100644 index 05940702..00000000 --- a/requirements/dev-env.txt +++ /dev/null @@ -1,3 +0,0 @@ -twine -wheel --r ./test-run.txt diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt deleted file mode 100644 index cec6a9e1..00000000 --- a/requirements/pkg-deps.txt +++ /dev/null @@ -1,8 +0,0 @@ -channels >=4.0.0 -django >=4.2.0 -reactpy >=1.0.2, <1.1.0 -reactpy-router >=1.0.0, <2.0.0 -dill >=0.3.5 -orjson >=3.6.0 -nest_asyncio >=1.5.0 -typing_extensions diff --git a/requirements/test-env.txt b/requirements/test-env.txt deleted file mode 100644 index fc1ba2ce..00000000 --- a/requirements/test-env.txt +++ /dev/null @@ -1,5 +0,0 @@ -playwright -twisted -channels[daphne]>=4.0.0 -tblib -whitenoise diff --git a/requirements/test-run.txt b/requirements/test-run.txt deleted file mode 100644 index 816817c6..00000000 --- a/requirements/test-run.txt +++ /dev/null @@ -1 +0,0 @@ -nox diff --git a/setup.py b/setup.py deleted file mode 100644 index a3388b35..00000000 --- a/setup.py +++ /dev/null @@ -1,180 +0,0 @@ -from __future__ import annotations, print_function - -import shutil -import subprocess -import sys -import traceback -from logging import getLogger -from pathlib import Path - -from setuptools import find_namespace_packages, setup -from setuptools.command.develop import develop -from setuptools.command.sdist import sdist - -log = getLogger(__name__) - -# ----------------------------------------------------------------------------- -# Basic Constants -# ----------------------------------------------------------------------------- -name = "reactpy_django" -root_dir = Path(__file__).parent -src_dir = root_dir / "src" -js_dir = src_dir / "js" -package_dir = src_dir / name -static_dir = package_dir / "static" / name - - -# ----------------------------------------------------------------------------- -# Package Definition -# ----------------------------------------------------------------------------- -package = { - "name": name, - "python_requires": ">=3.9", - "packages": find_namespace_packages(src_dir), - "package_dir": {"": "src"}, - "description": "It's React, but in Python. Now with Django integration.", - "author": "Mark Bakhit", - "author_email": "archiethemonger@gmail.com", - "url": "https://github.com/reactive-python/reactpy-django", - "license": "MIT", - "platforms": "Linux, Mac OS X, Windows", - "keywords": [ - "interactive", - "reactive", - "widgets", - "DOM", - "React", - "ReactJS", - "ReactPy", - ], - "include_package_data": True, - "zip_safe": False, - "classifiers": [ - "Framework :: Django", - "Framework :: Django :: 4.0", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Intended Audience :: Developers", - "Intended Audience :: Science/Research", - "Topic :: Multimedia :: Graphics", - "Environment :: Web Environment", - ], -} - - -# ----------------------------------------------------------------------------- -# Library Version -# ----------------------------------------------------------------------------- -for line in (package_dir / "__init__.py").read_text().split("\n"): - if line.startswith("__version__ = "): - package["version"] = eval(line.split("=", 1)[1]) - break -else: - print(f"No version found in {package_dir}/__init__.py") - sys.exit(1) - - -# ----------------------------------------------------------------------------- -# Requirements -# ----------------------------------------------------------------------------- -requirements: list[str] = [] -with (root_dir / "requirements" / "pkg-deps.txt").open() as f: - requirements.extend(line for line in map(str.strip, f) if not line.startswith("#")) -package["install_requires"] = requirements - - -# ----------------------------------------------------------------------------- -# Library Description -# ----------------------------------------------------------------------------- -with (root_dir / "README.md").open() as f: - long_description = f.read() - -package["long_description"] = long_description -package["long_description_content_type"] = "text/markdown" - - -# ---------------------------------------------------------------------------- -# Build Javascript -# ---------------------------------------------------------------------------- -def copy_js_files(source_dir: Path, destination: Path) -> None: - if destination.exists(): - shutil.rmtree(destination) - destination.mkdir() - - for file in source_dir.iterdir(): - if file.is_file(): - shutil.copy(file, destination / file.name) - else: - copy_js_files(file, destination / file.name) - - -def build_javascript_first(build_cls: type): - class Command(build_cls): - def run(self): - - log.info("Installing Javascript...") - result = subprocess.run( - ["bun", "install"], cwd=str(js_dir), check=True - ).returncode - if result != 0: - log.error(traceback.format_exc()) - log.error("Failed to install Javascript") - raise RuntimeError("Failed to install Javascript") - - log.info("Building Javascript...") - result = subprocess.run( - [ - "bun", - "build", - "./src/index.tsx", - "--outfile", - str(static_dir / "client.js"), - "--minify", - ], - cwd=str(js_dir), - check=True, - ).returncode - if result != 0: - log.error(traceback.format_exc()) - log.error("Failed to build Javascript") - raise RuntimeError("Failed to build Javascript") - - log.info("Copying @pyscript/core distribution") - pyscript_dist = js_dir / "node_modules" / "@pyscript" / "core" / "dist" - pyscript_static_dir = static_dir / "pyscript" - copy_js_files(pyscript_dist, pyscript_static_dir) - - log.info("Copying Morphdom distribution") - morphdom_dist = js_dir / "node_modules" / "morphdom" / "dist" - morphdom_static_dir = static_dir / "morphdom" - copy_js_files(morphdom_dist, morphdom_static_dir) - - log.info("Successfully built Javascript") - super().run() - - return Command - - -package["cmdclass"] = { - "sdist": build_javascript_first(sdist), - "develop": build_javascript_first(develop), -} - -if sys.version_info < (3, 10, 6): - from distutils.command.build import build - - package["cmdclass"]["build"] = build_javascript_first(build) -else: - from setuptools.command.build_py import build_py - - package["cmdclass"]["build_py"] = build_javascript_first(build_py) - - -# ----------------------------------------------------------------------------- -# Installation -# ----------------------------------------------------------------------------- -if __name__ == "__main__": - setup(**package) diff --git a/src/build_scripts/copy_dir.py b/src/build_scripts/copy_dir.py new file mode 100644 index 00000000..0a2cafab --- /dev/null +++ b/src/build_scripts/copy_dir.py @@ -0,0 +1,33 @@ +# ruff: noqa: INP001 +import logging +import shutil +import sys +from pathlib import Path + + +def copy_files(source: Path, destination: Path) -> None: + if destination.exists(): + shutil.rmtree(destination) + destination.mkdir() + + for file in source.iterdir(): + if file.is_file(): + shutil.copy(file, destination / file.name) + else: + copy_files(file, destination / file.name) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + logging.error("Script used incorrectly!\nUsage: python copy_dir.py ") + sys.exit(1) + + root_dir = Path(__file__).parent.parent.parent + src = Path(root_dir / sys.argv[1]) + dest = Path(root_dir / sys.argv[2]) + + if not src.exists(): + logging.error("Source directory %s does not exist", src) + sys.exit(1) + + copy_files(src, dest) diff --git a/src/js/bun.lockb b/src/js/bun.lockb index 3807b571..0bb863ce 100644 Binary files a/src/js/bun.lockb and b/src/js/bun.lockb differ diff --git a/src/js/eslint.config.js b/src/js/eslint.config.js deleted file mode 100644 index 27082ef3..00000000 --- a/src/js/eslint.config.js +++ /dev/null @@ -1 +0,0 @@ -export default [{}]; diff --git a/src/js/eslint.config.mjs b/src/js/eslint.config.mjs new file mode 100644 index 00000000..320e9f8b --- /dev/null +++ b/src/js/eslint.config.mjs @@ -0,0 +1,43 @@ +import react from "eslint-plugin-react"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import js from "@eslint/js"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + ...compat.extends("eslint:recommended", "plugin:react/recommended"), + { + plugins: { + react, + }, + + languageOptions: { + globals: { + ...globals.browser, + ...globals.node, + }, + + ecmaVersion: "latest", + sourceType: "module", + }, + + settings: { + react: { + version: "18.2.0", + }, + }, + + rules: { + "react/prop-types": "off", + }, + }, +]; diff --git a/src/js/package.json b/src/js/package.json index 7f6cc019..bfcdb72c 100644 --- a/src/js/package.json +++ b/src/js/package.json @@ -12,8 +12,9 @@ "prettier": "^3.3.3" }, "dependencies": { - "@pyscript/core": "^0.5", - "@reactpy/client": "^0.3.1", + "@pyscript/core": "^0.6", + "@reactpy/client": "^0.3.2", + "event-to-object": "^0.1.2", "morphdom": "^2.7.4" } } diff --git a/src/js/src/client.ts b/src/js/src/client.ts index 93d2f00b..1f506f56 100644 --- a/src/js/src/client.ts +++ b/src/js/src/client.ts @@ -19,33 +19,33 @@ export class ReactPyDjangoClient constructor(props: ReactPyDjangoClientProps) { super(); this.urls = props.urls; + this.mountElement = props.mountElement; + this.prerenderElement = props.prerenderElement; + this.offlineElement = props.offlineElement; this.socket = createReconnectingWebSocket({ - readyPromise: this.ready, url: this.urls.componentUrl, - onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), + readyPromise: this.ready, ...props.reconnectOptions, + onMessage: async ({ data }) => this.handleIncoming(JSON.parse(data)), onClose: () => { // If offlineElement exists, show it and hide the mountElement/prerenderElement if (this.prerenderElement) { this.prerenderElement.remove(); this.prerenderElement = null; } - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.mountElement.hidden = true; this.offlineElement.hidden = false; } }, onOpen: () => { // If offlineElement exists, hide it and show the mountElement - if (this.offlineElement) { + if (this.offlineElement && this.mountElement) { this.offlineElement.hidden = true; this.mountElement.hidden = false; } }, }); - this.mountElement = props.mountElement; - this.prerenderElement = props.prerenderElement; - this.offlineElement = props.offlineElement; } sendMessage(message: any): void { diff --git a/src/js/src/components.ts b/src/js/src/components.ts new file mode 100644 index 00000000..176a1f30 --- /dev/null +++ b/src/js/src/components.ts @@ -0,0 +1,88 @@ +import { DjangoFormProps, HttpRequestProps } from "./types"; +import React from "react"; +import ReactDOM from "react-dom"; +/** + * Interface used to bind a ReactPy node to React. + */ +export function bind(node) { + return { + create: (type, props, children) => + React.createElement(type, props, ...children), + render: (element) => { + ReactDOM.render(element, node); + }, + unmount: () => ReactDOM.unmountComponentAtNode(node), + }; +} + +export function DjangoForm({ + onSubmitCallback, + formId, +}: DjangoFormProps): null { + React.useEffect(() => { + const form = document.getElementById(formId) as HTMLFormElement; + + // Submission event function + const onSubmitEvent = (event) => { + event.preventDefault(); + const formData = new FormData(form); + + // Convert the FormData object to a plain object by iterating through it + // If duplicate keys are present, convert the value into an array of values + const entries = formData.entries(); + const formDataArray = Array.from(entries); + const formDataObject = formDataArray.reduce((acc, [key, value]) => { + if (acc[key]) { + if (Array.isArray(acc[key])) { + acc[key].push(value); + } else { + acc[key] = [acc[key], value]; + } + } else { + acc[key] = value; + } + return acc; + }, {}); + + onSubmitCallback(formDataObject); + }; + + // Bind the event listener + if (form) { + form.addEventListener("submit", onSubmitEvent); + } + + // Unbind the event listener when the component dismounts + return () => { + if (form) { + form.removeEventListener("submit", onSubmitEvent); + } + }; + }, []); + + return null; +} + +export function HttpRequest({ method, url, body, callback }: HttpRequestProps) { + React.useEffect(() => { + fetch(url, { + method: method, + body: body, + }) + .then((response) => { + response + .text() + .then((text) => { + callback(response.status, text); + }) + .catch(() => { + callback(response.status, ""); + }); + }) + .catch(() => { + callback(520, ""); + }); + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts new file mode 100644 index 00000000..01856c7d --- /dev/null +++ b/src/js/src/index.ts @@ -0,0 +1,2 @@ +export { HttpRequest, DjangoForm, bind } from "./components"; +export { mountComponent } from "./mount"; diff --git a/src/js/src/index.tsx b/src/js/src/mount.tsx similarity index 97% rename from src/js/src/index.tsx rename to src/js/src/mount.tsx index 51a387f3..a3a02087 100644 --- a/src/js/src/index.tsx +++ b/src/js/src/mount.tsx @@ -52,7 +52,6 @@ export function mountComponent( const client = new ReactPyDjangoClient({ urls: { componentUrl: componentUrl, - query: document.location.search, jsModules: `${httpOrigin}/${jsModulesPath}`, }, reconnectOptions: { @@ -69,7 +68,7 @@ export function mountComponent( // Replace the prerender element with the real element on the first layout update if (client.prerenderElement) { client.onMessage("layout-update", ({ path, model }) => { - if (client.prerenderElement) { + if (client.prerenderElement && client.mountElement) { client.prerenderElement.replaceWith(client.mountElement); client.prerenderElement = null; } diff --git a/src/js/src/types.ts b/src/js/src/types.ts index eea8a866..e3be73d7 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -7,7 +7,6 @@ export type ReconnectOptions = { export type ReactPyUrls = { componentUrl: URL; - query: string; jsModules: string; }; @@ -18,3 +17,15 @@ export type ReactPyDjangoClientProps = { prerenderElement: HTMLElement | null; offlineElement: HTMLElement | null; }; + +export interface DjangoFormProps { + onSubmitCallback: (data: Object) => void; + formId: string; +} + +export interface HttpRequestProps { + method: string; + url: string; + body: string; + callback: (status: Number, response: string) => void; +} diff --git a/src/reactpy_django/__init__.py b/src/reactpy_django/__init__.py index 806428de..c272c9a8 100644 --- a/src/reactpy_django/__init__.py +++ b/src/reactpy_django/__init__.py @@ -13,16 +13,16 @@ ) from reactpy_django.websocket.paths import REACTPY_WEBSOCKET_ROUTE -__version__ = "5.0.0" +__version__ = "5.2.1" __all__ = [ "REACTPY_WEBSOCKET_ROUTE", - "html", - "hooks", "components", "decorators", + "hooks", + "html", + "router", "types", "utils", - "router", ] # Fixes bugs with REACTPY_BACKHAUL_THREAD + built-in asyncio event loops. diff --git a/src/reactpy_django/auth/__init__.py b/src/reactpy_django/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/auth/components.py b/src/reactpy_django/auth/components.py new file mode 100644 index 00000000..e0a1e065 --- /dev/null +++ b/src/reactpy_django/auth/components.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import asyncio +import contextlib +from logging import getLogger +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from django.urls import reverse +from reactpy import component, hooks + +from reactpy_django.javascript_components import HttpRequest +from reactpy_django.models import AuthToken + +if TYPE_CHECKING: + from django.contrib.sessions.backends.base import SessionBase + +_logger = getLogger(__name__) + + +@component +def root_manager(child: Any): + """This component is serves as the parent component for any ReactPy component tree, + which allows for the management of the entire component tree.""" + scope = hooks.use_connection().scope + _, set_rerender = hooks.use_state(uuid4) + + @hooks.use_effect(dependencies=[]) + def setup_asgi_scope(): + """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command + any relevant actions.""" + scope["reactpy"]["rerender"] = rerender + + def rerender(): + """Event that can force a rerender of the entire component tree.""" + set_rerender(uuid4()) + + return child + + +@component +def auth_manager(): + """This component uses a client-side component alongside an authentication token + to make the client (browser) to switch the HTTP auth session, to make it match the websocket session. + + Used to force persistent authentication between Django's websocket and HTTP stack.""" + from reactpy_django import config + + sync_needed, set_sync_needed = hooks.use_state(False) + token = hooks.use_ref("") + scope = hooks.use_connection().scope + + @hooks.use_effect(dependencies=[]) + def setup_asgi_scope(): + """Store trigger functions in the websocket scope so that ReactPy-Django's hooks can command + any relevant actions.""" + scope["reactpy"]["synchronize_auth"] = synchronize_auth + + @hooks.use_effect(dependencies=[sync_needed]) + async def synchronize_auth_watchdog(): + """Detect if the client has taken too long to request a auth session synchronization. + + This effect will automatically be cancelled if the session is successfully + synchronized (via effect dependencies).""" + if sync_needed: + await asyncio.sleep(config.REACTPY_AUTH_TOKEN_MAX_AGE + 0.1) + await asyncio.to_thread( + _logger.warning, + f"Client did not switch authentication sessions within {config.REACTPY_AUTH_TOKEN_MAX_AGE} (REACTPY_AUTH_TOKEN_MAX_AGE) seconds.", + ) + set_sync_needed(False) + + async def synchronize_auth(): + """Event that can command the client to switch HTTP auth sessions (to match the websocket session).""" + session: SessionBase | None = scope.get("session") + if not session or not session.session_key: + return + + # Delete previous token to resolve race conditions where... + # 1. Login was called multiple times before the first one is completed. + # 2. Login was called, but the server failed to respond to the HTTP request. + if token.current: + with contextlib.suppress(AuthToken.DoesNotExist): + obj = await AuthToken.objects.aget(value=token.current) + await obj.adelete() + + # Create a fresh token + token.set_current(str(uuid4())) + + # Begin the process of synchronizing HTTP and websocket auth sessions + obj = await AuthToken.objects.acreate(value=token.current, session_key=session.session_key) + await obj.asave() + set_sync_needed(True) + + async def synchronize_auth_callback(status_code: int, response: str): + """This callback acts as a communication bridge, allowing the client to notify the server + of the status of auth session switch.""" + set_sync_needed(False) + if status_code >= 300 or status_code < 200: + await asyncio.to_thread( + _logger.error, + f"Client returned unexpected HTTP status code ({status_code}) while trying to synchronize authentication sessions.", + ) + + # If needed, synchronize authenication sessions by configuring all relevant session cookies. + # This is achieved by commanding the client to perform a HTTP request to our API endpoint + # that will set any required cookies. + if sync_needed: + return HttpRequest( + { + "method": "GET", + "url": reverse("reactpy:auth_manager", args=[token.current]), + "body": None, + "callback": synchronize_auth_callback, + }, + ) + + return None diff --git a/src/reactpy_django/checks.py b/src/reactpy_django/checks.py index 740df974..88001c77 100644 --- a/src/reactpy_django/checks.py +++ b/src/reactpy_django/checks.py @@ -1,6 +1,7 @@ import contextlib import math import sys +from uuid import uuid4 from django.contrib.staticfiles.finders import find from django.core.checks import Error, Tags, Warning, register @@ -17,19 +18,16 @@ def reactpy_warnings(app_configs, **kwargs): from reactpy_django.config import REACTPY_FAILED_COMPONENTS warnings = [] - INSTALLED_APPS: list[str] = getattr(settings, "INSTALLED_APPS", []) + installed_apps: list[str] = getattr(settings, "INSTALLED_APPS", []) # Check if REACTPY_DATABASE is not an in-memory database. if ( - getattr(settings, "DATABASES", {}) - .get(getattr(settings, "REACTPY_DATABASE", "default"), {}) - .get("NAME", None) + getattr(settings, "DATABASES", {}).get(getattr(settings, "REACTPY_DATABASE", "default"), {}).get("NAME", None) == ":memory:" ): warnings.append( Warning( - "Using ReactPy with an in-memory database can cause unexpected " - "behaviors.", + "Using ReactPy with an in-memory database can cause unexpected behaviors.", hint="Configure settings.py:DATABASES[REACTPY_DATABASE], to use a " "multiprocessing and thread safe database.", id="reactpy_django.W001", @@ -40,6 +38,7 @@ def reactpy_warnings(app_configs, **kwargs): try: reverse("reactpy:web_modules", kwargs={"file": "example"}) reverse("reactpy:view_to_iframe", kwargs={"dotted_path": "example"}) + reverse("reactpy:session_manager", args=[str(uuid4())]) except Exception: warnings.append( Warning( @@ -52,24 +51,22 @@ def reactpy_warnings(app_configs, **kwargs): ) # Warn if REACTPY_BACKHAUL_THREAD is set to True with Daphne - if ( - sys.argv[0].endswith("daphne") - or ("runserver" in sys.argv and "daphne" in INSTALLED_APPS) - ) and getattr(settings, "REACTPY_BACKHAUL_THREAD", False): + if (sys.argv[0].endswith("daphne") or ("runserver" in sys.argv and "daphne" in installed_apps)) and getattr( + settings, "REACTPY_BACKHAUL_THREAD", False + ): warnings.append( Warning( - "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled " - "and you running with Daphne.", + "Unstable configuration detected. REACTPY_BACKHAUL_THREAD is enabled and you running with Daphne.", hint="Set settings.py:REACTPY_BACKHAUL_THREAD to False or use a different web server.", id="reactpy_django.W003", ) ) - # Check if reactpy_django/client.js is available - if not find("reactpy_django/client.js"): + # Check if reactpy_django/index.js is available + if not find("reactpy_django/index.js"): warnings.append( Warning( - "ReactPy client.js could not be found within Django static files!", + "ReactPy index.js could not be found within Django static files!", hint="Check all static files related Django settings and INSTALLED_APPS.", id="reactpy_django.W004", ) @@ -79,10 +76,8 @@ def reactpy_warnings(app_configs, **kwargs): if REACTPY_FAILED_COMPONENTS: warnings.append( Warning( - "ReactPy failed to register the following components:\n\t+ " - + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS), - hint="Check if these paths are valid, or if an exception is being " - "raised during import.", + "ReactPy failed to register the following components:\n\t+ " + "\n\t+ ".join(REACTPY_FAILED_COMPONENTS), + hint="Check if these paths are valid, or if an exception is being raised during import.", id="reactpy_django.W005", ) ) @@ -106,10 +101,8 @@ def reactpy_warnings(app_configs, **kwargs): # Check if REACTPY_URL_PREFIX is being used properly in our HTTP URLs with contextlib.suppress(NoReverseMatch): - full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip( - "/" - ) - reactpy_http_prefix = f'{full_path[: full_path.find("web_module/")].strip("/")}' + full_path = reverse("reactpy:web_modules", kwargs={"file": "example"}).strip("/") + reactpy_http_prefix = f"{full_path[: full_path.find('web_module/')].strip('/')}" if reactpy_http_prefix != config.REACTPY_URL_PREFIX: warnings.append( Warning( @@ -138,9 +131,7 @@ def reactpy_warnings(app_configs, **kwargs): ) # Check if `daphne` is not in installed apps when using `runserver` - if "runserver" in sys.argv and "daphne" not in getattr( - settings, "INSTALLED_APPS", [] - ): + if "runserver" in sys.argv and "daphne" not in getattr(settings, "INSTALLED_APPS", []): warnings.append( Warning( "You have not configured the `runserver` command to use ASGI. " @@ -153,10 +144,7 @@ def reactpy_warnings(app_configs, **kwargs): # 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 - ): + 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? " @@ -167,10 +155,7 @@ 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 - ): + 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 " @@ -204,18 +189,12 @@ def reactpy_warnings(app_configs, **kwargs): 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 - ) + 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_BACKOFF_MULTIPLIER**config.REACTPY_RECONNECT_MAX_RETRIES) * config.REACTPY_RECONNECT_INTERVAL ) warnings.append( @@ -229,13 +208,10 @@ 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: + for app in installed_apps: if app.startswith("django.contrib."): - position_to_beat = INSTALLED_APPS.index(app) - if ( - "reactpy_django" in INSTALLED_APPS - and INSTALLED_APPS.index("reactpy_django") < position_to_beat - ): + position_to_beat = installed_apps.index(app) + if "reactpy_django" in installed_apps and installed_apps.index("reactpy_django") < position_to_beat: warnings.append( Warning( "The position of 'reactpy_django' in INSTALLED_APPS is suspicious.", @@ -244,7 +220,7 @@ def reactpy_warnings(app_configs, **kwargs): ) ) - # Check if REACTPY_CLEAN_SESSION is not a valid property + # Check if user misspelled REACTPY_CLEAN_SESSIONS if getattr(settings, "REACTPY_CLEAN_SESSION", None): warnings.append( Warning( @@ -254,6 +230,27 @@ def reactpy_warnings(app_configs, **kwargs): ) ) + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a large value + auth_token_timeout = config.REACTPY_AUTH_TOKEN_MAX_AGE + if isinstance(auth_token_timeout, int) and auth_token_timeout > 120: + warnings.append( + Warning( + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very large value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE under 120 seconds to prevent security risks.", + id="reactpy_django.W020", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a small value + if isinstance(auth_token_timeout, int) and auth_token_timeout <= 2: + warnings.append( + Warning( + "REACTPY_AUTH_TOKEN_MAX_AGE is set to a very low value.", + hint="It is suggested to keep REACTPY_AUTH_TOKEN_MAX_AGE above 2 seconds to account for client and server latency.", + id="reactpy_django.W021", + ) + ) + return warnings @@ -276,17 +273,13 @@ def reactpy_errors(app_configs, **kwargs): ) # DATABASE_ROUTERS is properly configured when REACTPY_DATABASE is defined - if getattr( - settings, "REACTPY_DATABASE", None - ) and "reactpy_django.database.Router" not in getattr( + if getattr(settings, "REACTPY_DATABASE", None) and "reactpy_django.database.Router" not in getattr( settings, "DATABASE_ROUTERS", [] ): errors.append( Error( - "ReactPy database has been changed but the database router is " - "not configured.", - hint="Set settings.py:DATABASE_ROUTERS to " - "['reactpy_django.database.Router', ...]", + "ReactPy database has been changed but the database router is not configured.", + hint="Set settings.py:DATABASE_ROUTERS to ['reactpy_django.database.Router', ...]", id="reactpy_django.E002", ) ) @@ -336,9 +329,7 @@ def reactpy_errors(app_configs, **kwargs): ) # Check if REACTPY_DEFAULT_QUERY_POSTPROCESSOR is a valid data type - if not isinstance( - getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None)) - ): + if not isinstance(getattr(settings, "REACTPY_DEFAULT_QUERY_POSTPROCESSOR", ""), (str, type(None))): errors.append( Error( "Invalid type for REACTPY_DEFAULT_QUERY_POSTPROCESSOR.", @@ -397,10 +388,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 - ): + if isinstance(config.REACTPY_RECONNECT_INTERVAL, int) and config.REACTPY_RECONNECT_INTERVAL < 0: errors.append( Error( "Invalid value for REACTPY_RECONNECT_INTERVAL.", @@ -420,10 +408,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 - ): + 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.", @@ -457,10 +442,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 - ): + 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.", @@ -523,10 +505,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 - ): + if isinstance(config.REACTPY_CLEAN_INTERVAL, int) and config.REACTPY_CLEAN_INTERVAL < 0: errors.append( Error( "Invalid value for REACTPY_CLEAN_INTERVAL.", @@ -555,4 +534,34 @@ def reactpy_errors(app_configs, **kwargs): ) ) + # Check if REACTPY_CLEAN_AUTH_TOKENS is a valid data type + if not isinstance(config.REACTPY_CLEAN_AUTH_TOKENS, bool): + errors.append( + Error( + "Invalid type for REACTPY_CLEAN_AUTH_TOKENS.", + hint="REACTPY_CLEAN_AUTH_TOKENS should be a boolean.", + id="reactpy_django.E027", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a valid data type + if not isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int): + errors.append( + Error( + "Invalid type for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE should be an integer.", + id="reactpy_django.E028", + ) + ) + + # Check if REACTPY_AUTH_TOKEN_MAX_AGE is a positive integer + if isinstance(config.REACTPY_AUTH_TOKEN_MAX_AGE, int) and config.REACTPY_AUTH_TOKEN_MAX_AGE < 0: + errors.append( + Error( + "Invalid value for REACTPY_AUTH_TOKEN_MAX_AGE.", + hint="REACTPY_AUTH_TOKEN_MAX_AGE should be a non-negative integer.", + id="reactpy_django.E029", + ) + ) + return errors diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 98981730..9234c42e 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -1,35 +1,41 @@ +"""This file contains Django related components. Most of these components utilize wrappers to fix type hints.""" + from __future__ import annotations import json -import os -from typing import Any, Callable, Sequence, Union, cast +from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode -from uuid import uuid4 -from django.contrib.staticfiles.finders import find -from django.core.cache import caches from django.http import HttpRequest from django.urls import reverse -from django.views import View from reactpy import component, hooks, html, utils from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError -from reactpy_django.html import pyscript +from reactpy_django.forms.components import _django_form +from reactpy_django.pyscript.components import _pyscript_component from reactpy_django.utils import ( + cached_static_file, + del_html_head_body_transform, generate_obj_name, import_module, - render_pyscript_template, render_view, - vdom_or_component_to_string, ) +if TYPE_CHECKING: + from collections.abc import Sequence + + from django.forms import Form, ModelForm + from django.views import View + + from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor + def view_to_component( view: Callable | View | str, transforms: Sequence[Callable[[VdomDict], Any]] = (), strict_parsing: bool = True, -) -> Any: +) -> ViewToComponentConstructor: """Converts a Django view to a ReactPy component. Keyword Args: @@ -48,7 +54,7 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): + ) -> ComponentType: return _view_to_component( view=view, transforms=transforms, @@ -62,9 +68,7 @@ def constructor( return constructor -def view_to_iframe( - view: Callable | View | str, extra_props: dict[str, Any] | None = None -): +def view_to_iframe(view: Callable | View | str, extra_props: dict[str, Any] | None = None) -> ViewToIframeConstructor: """ Args: view: The view function or class to convert, or the dotted path to the view. @@ -80,15 +84,13 @@ def constructor( *args, key: Key | None = None, **kwargs, - ): - return _view_to_iframe( - view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key - ) + ) -> ComponentType: + return _view_to_iframe(view=view, extra_props=extra_props, args=args, kwargs=kwargs, key=key) return constructor -def django_css(static_path: str, key: Key | None = None): +def django_css(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -101,7 +103,7 @@ def django_css(static_path: str, key: Key | None = None): return _django_css(static_path=static_path, key=key) -def django_js(static_path: str, key: Key | None = None): +def django_js(static_path: str, key: Key | None = None) -> ComponentType: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -114,11 +116,69 @@ def django_js(static_path: str, key: Key | None = None): return _django_js(static_path=static_path, key=key) +def django_form( + form: type[Form | ModelForm], + *, + on_success: AsyncFormEvent | SyncFormEvent | None = None, + on_error: AsyncFormEvent | SyncFormEvent | None = None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None = None, + on_change: AsyncFormEvent | SyncFormEvent | None = None, + auto_save: bool = True, + extra_props: dict[str, Any] | None = None, + extra_transforms: Sequence[Callable[[VdomDict], Any]] | None = None, + form_template: str | None = None, + thread_sensitive: bool = True, + top_children: Sequence[Any] = (), + bottom_children: Sequence[Any] = (), + key: Key | None = None, +) -> ComponentType: + """Converts a Django form to a ReactPy component. + + Args: + form: The form to convert. + + Keyword Args: + on_success: A callback function that is called when the form is successfully submitted. + on_error: A callback function that is called when the form submission fails. + on_receive_data: A callback function that is called before newly submitted form data is rendered. + on_change: A callback function that is called when a form field is modified by the user. + auto_save: If `True`, the form will automatically call `save` on successful submission of \ + a `ModelForm`. This has no effect on regular `Form` instances. + extra_props: Additional properties to add to the `html.form` element. + extra_transforms: A list of functions that transforms the newly generated VDOM. \ + The functions will be repeatedly called on each VDOM node. + form_template: The template to use for the form. If `None`, Django's default template is used. + thread_sensitive: Whether to run event callback functions in thread sensitive mode. \ + This mode only applies to sync functions, and is turned on by default due to Django \ + ORM limitations. + top_children: Additional elements to add to the top of the form. + bottom_children: Additional elements to add to the bottom of the form. + key: A key to uniquely identify this component which is unique amongst a component's \ + immediate siblings. + """ + + return _django_form( + form=form, + on_success=on_success, + on_error=on_error, + on_receive_data=on_receive_data, + on_change=on_change, + auto_save=auto_save, + extra_props=extra_props or {}, + extra_transforms=extra_transforms or [], + form_template=form_template, + thread_sensitive=thread_sensitive, + top_children=top_children, + bottom_children=bottom_children, + key=key, + ) + + def pyscript_component( *file_paths: str, initial: str | VdomDict | ComponentType = "", root: str = "root", -): +) -> ComponentType: """ Args: file_paths: File path to your client-side component. If multiple paths are \ @@ -146,10 +206,7 @@ def _view_to_component( args: Sequence | None, kwargs: dict | None, ): - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" - converted_view, set_converted_view = hooks.use_state( - cast(Union[VdomDict, None], None) - ) + converted_view, set_converted_view = hooks.use_state(cast(Union[VdomDict, None], None)) _args: Sequence = args or () _kwargs: dict = kwargs or {} if request: @@ -157,23 +214,23 @@ def _view_to_component( else: _request = HttpRequest() _request.method = "GET" - resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore[assignment] + resolved_view: Callable = import_module(view) if isinstance(view, str) else view # type: ignore # Render the view render within a hook @hooks.use_effect( dependencies=[ - json.dumps(vars(_request), default=lambda x: generate_obj_name(x)), - json.dumps([_args, _kwargs], default=lambda x: generate_obj_name(x)), + json.dumps(vars(_request), default=generate_obj_name), + json.dumps([_args, _kwargs], default=generate_obj_name), ] ) - async def async_render(): + async def _render_view(): """Render the view in an async hook to avoid blocking the main thread.""" # Render the view response = await render_view(resolved_view, _request, _args, _kwargs) set_converted_view( utils.html_to_vdom( response.content.decode("utf-8").strip(), - utils.del_html_head_body_transform, + del_html_head_body_transform, *transforms, strict=strict_parsing, ) @@ -189,20 +246,20 @@ def _view_to_iframe( extra_props: dict[str, Any] | None, args: Sequence, kwargs: dict, -) -> VdomDict: - """The actual component. Used to prevent pollution of acceptable kwargs keys.""" +): from reactpy_django.config import REACTPY_REGISTERED_IFRAME_VIEWS if hasattr(view, "view_class"): - view = view.view_class + view = view.view_class # type: ignore dotted_path = view if isinstance(view, str) else generate_obj_name(view) registered_view = REACTPY_REGISTERED_IFRAME_VIEWS.get(dotted_path) if not registered_view: - raise ViewNotRegisteredError( + msg = ( f"'{dotted_path}' has not been registered as an iframe! " "Are you sure you called `register_iframe` within a Django `AppConfig.ready` method?" ) + raise ViewNotRegisteredError(msg) query = kwargs.copy() if args: @@ -223,62 +280,9 @@ def _view_to_iframe( @component def _django_css(static_path: str): - return html.style(_cached_static_contents(static_path)) + return html.style(cached_static_file(static_path)) @component def _django_js(static_path: str): - return html.script(_cached_static_contents(static_path)) - - -def _cached_static_contents(static_path: str) -> str: - from reactpy_django.config import REACTPY_CACHE - - # Try to find the file within Django's static files - abs_path = find(static_path) - if not abs_path: - raise FileNotFoundError( - f"Could not find static file {static_path} within Django's static files." - ) - - # Fetch the file from cache, if available - last_modified_time = os.stat(abs_path).st_mtime - cache_key = f"reactpy_django:static_contents:{static_path}" - file_contents: str | None = caches[REACTPY_CACHE].get( - cache_key, version=int(last_modified_time) - ) - if file_contents is None: - with open(abs_path, encoding="utf-8") as static_file: - file_contents = static_file.read() - caches[REACTPY_CACHE].delete(cache_key) - caches[REACTPY_CACHE].set( - cache_key, file_contents, timeout=None, version=int(last_modified_time) - ) - - return file_contents - - -@component -def _pyscript_component( - *file_paths: str, - initial: str | VdomDict | ComponentType = "", - root: str = "root", -): - rendered, set_rendered = hooks.use_state(False) - uuid = uuid4().hex.replace("-", "") - initial = vdom_or_component_to_string(initial, uuid=uuid) - executor = render_pyscript_template(file_paths, uuid, root) - - if not rendered: - # FIXME: This is needed to properly re-render PyScript during a WebSocket - # disconnection / reconnection. There may be a better way to do this in the future. - set_rendered(True) - return None - - return html._( - html.div( - {"id": f"pyscript-{uuid}", "className": "pyscript", "data-uuid": uuid}, - initial, - ), - pyscript({"async": ""}, executor), - ) + return html.script(cached_static_file(static_path)) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index a891cb5d..cc3ca2fc 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -1,29 +1,34 @@ from __future__ import annotations from itertools import cycle -from typing import Callable +from typing import TYPE_CHECKING, Callable from django.conf import settings from django.core.cache import DEFAULT_CACHE_ALIAS from django.db import DEFAULT_DB_ALIAS -from django.views import View +from reactpy.config import REACTPY_ASYNC_RENDERING as _REACTPY_ASYNC_RENDERING from reactpy.config import REACTPY_DEBUG_MODE as _REACTPY_DEBUG_MODE -from reactpy.core.types import ComponentConstructor -from reactpy_django.types import ( - AsyncPostprocessor, - SyncPostprocessor, -) from reactpy_django.utils import import_dotted_path +if TYPE_CHECKING: + from django.views import View + from reactpy.core.types import ComponentConstructor + + from reactpy_django.types import ( + AsyncPostprocessor, + SyncPostprocessor, + ) + # Non-configurable values -_REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) -REACTPY_DEBUG_MODE = _REACTPY_DEBUG_MODE.current REACTPY_REGISTERED_COMPONENTS: dict[str, ComponentConstructor] = {} REACTPY_FAILED_COMPONENTS: set[str] = set() REACTPY_REGISTERED_IFRAME_VIEWS: dict[str, Callable | View] = {} # Configurable through Django settings.py +DJANGO_DEBUG = settings.DEBUG # Snapshot of Django's DEBUG setting +_REACTPY_DEBUG_MODE.set_current(settings.DEBUG) +_REACTPY_ASYNC_RENDERING.set_current(getattr(settings, "REACTPY_ASYNC_RENDERING", _REACTPY_ASYNC_RENDERING.current)) REACTPY_URL_PREFIX: str = getattr( settings, "REACTPY_URL_PREFIX", @@ -34,6 +39,11 @@ "REACTPY_SESSION_MAX_AGE", 259200, # Default to 3 days ) +REACTPY_AUTH_TOKEN_MAX_AGE: int = getattr( + settings, + "REACTPY_AUTH_TOKEN_MAX_AGE", + 30, # Default to 30 seconds +) REACTPY_CACHE: str = getattr( settings, "REACTPY_CACHE", @@ -55,10 +65,7 @@ else: REACTPY_DEFAULT_QUERY_POSTPROCESSOR = import_dotted_path( "reactpy_django.utils.django_query_postprocessor" - if ( - _default_query_postprocessor == "UNSET" - or not isinstance(_default_query_postprocessor, str) - ) + if (_default_query_postprocessor == "UNSET" or not isinstance(_default_query_postprocessor, str)) else _default_query_postprocessor ) REACTPY_AUTH_BACKEND: str | None = getattr( @@ -77,9 +84,7 @@ None, ) REACTPY_DEFAULT_HOSTS: cycle[str] | None = ( - cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) - if _default_hosts - else None + cycle([host.strip("/") for host in _default_hosts if isinstance(host, str)]) if _default_hosts else None ) REACTPY_RECONNECT_INTERVAL: int = getattr( settings, @@ -121,8 +126,18 @@ "REACTPY_CLEAN_SESSIONS", True, ) +REACTPY_CLEAN_AUTH_TOKENS: bool = getattr( + settings, + "REACTPY_CLEAN_AUTH_TOKENS", + True, +) REACTPY_CLEAN_USER_DATA: bool = getattr( settings, "REACTPY_CLEAN_USER_DATA", True, ) +REACTPY_DEFAULT_FORM_TEMPLATE: str | None = getattr( + settings, + "REACTPY_DEFAULT_FORM_TEMPLATE", + None, +) diff --git a/src/reactpy_django/database.py b/src/reactpy_django/database.py index 0d0b2065..2acd673e 100644 --- a/src/reactpy_django/database.py +++ b/src/reactpy_django/database.py @@ -1,3 +1,5 @@ +from typing import ClassVar + from reactpy_django.config import REACTPY_DATABASE @@ -7,17 +9,19 @@ class Router: auth and contenttypes applications. """ - route_app_labels = {"reactpy_django"} + route_app_labels: ClassVar[set[str]] = {"reactpy_django"} def db_for_read(self, model, **hints): """Attempts to read go to REACTPY_DATABASE.""" if model._meta.app_label in self.route_app_labels: return REACTPY_DATABASE + return None def db_for_write(self, model, **hints): """Attempts to write go to REACTPY_DATABASE.""" if model._meta.app_label in self.route_app_labels: return REACTPY_DATABASE + return None def allow_relation(self, obj1, obj2, **hints): """Returning `None` only allow relations within the same database. @@ -27,5 +31,4 @@ def allow_relation(self, obj1, obj2, **hints): def allow_migrate(self, db, app_label, model_name=None, **hints): """Make sure ReactPy models only appear in REACTPY_DATABASE.""" - if app_label in self.route_app_labels: - return db == REACTPY_DATABASE + return db == REACTPY_DATABASE if app_label in self.route_app_labels else None diff --git a/src/reactpy_django/decorators.py b/src/reactpy_django/decorators.py index 39a028a4..6b3d220e 100644 --- a/src/reactpy_django/decorators.py +++ b/src/reactpy_django/decorators.py @@ -4,15 +4,13 @@ from typing import TYPE_CHECKING, Any, Callable from reactpy import component -from reactpy.core.types import ComponentConstructor from reactpy_django.exceptions import DecoratorParamError from reactpy_django.hooks import use_user if TYPE_CHECKING: from django.contrib.auth.models import AbstractUser - - + from reactpy.core.types import ComponentConstructor def user_passes_test( @@ -33,16 +31,14 @@ def user_passes_test( def decorator(user_component): @wraps(user_component) def _wrapper(*args, **kwargs): - return _user_passes_test( - user_component, fallback, test_func, *args, **kwargs - ) + return _user_passes_test(user_component, fallback, test_func, *args, **kwargs) return _wrapper - return decorator + return decorator # type: ignore -@component +@component # type: ignore def _user_passes_test(component_constructor, fallback, test_func, *args, **kwargs): """Dedicated component for `user_passes_test` to allow us to always have access to hooks.""" user = use_user() @@ -51,15 +47,14 @@ def _user_passes_test(component_constructor, fallback, test_func, *args, **kwarg # Ensure that the component is a ReactPy component. user_component = component_constructor(*args, **kwargs) if not getattr(user_component, "render", None): - raise DecoratorParamError( + msg = ( "`user_passes_test` is not decorating a ReactPy component. " "Did you forget `@user_passes_test` must be ABOVE the `@component` decorator?" ) + raise DecoratorParamError(msg) # Render the component. return user_component - # Render the fallback component. - # Returns an empty string if fallback is None, since ReactPy currently renders None as a string. - # TODO: Remove this fallback when ReactPy can render None properly. - return fallback(*args, **kwargs) if callable(fallback) else (fallback or "") + # Render the fallback content. + return fallback(*args, **kwargs) if callable(fallback) else (fallback or None) diff --git a/src/reactpy_django/exceptions.py b/src/reactpy_django/exceptions.py index 412d647f..c0d4b32d 100644 --- a/src/reactpy_django/exceptions.py +++ b/src/reactpy_django/exceptions.py @@ -1,34 +1,25 @@ -class ComponentParamError(TypeError): - ... +class ComponentParamError(TypeError): ... -class ComponentDoesNotExistError(AttributeError): - ... +class ComponentDoesNotExistError(AttributeError): ... -class OfflineComponentMissing(ComponentDoesNotExistError): - ... +class OfflineComponentMissingError(ComponentDoesNotExistError): ... -class InvalidHostError(ValueError): - ... +class InvalidHostError(ValueError): ... -class ComponentCarrierError(Exception): - ... +class ComponentCarrierError(Exception): ... -class UserNotFoundError(Exception): - ... +class UserNotFoundError(Exception): ... -class ViewNotRegisteredError(AttributeError): - ... +class ViewNotRegisteredError(AttributeError): ... -class ViewDoesNotExistError(AttributeError): - ... +class ViewDoesNotExistError(AttributeError): ... -class DecoratorParamError(TypeError): - ... +class DecoratorParamError(TypeError): ... diff --git a/src/reactpy_django/forms/__init__.py b/src/reactpy_django/forms/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/reactpy_django/forms/components.py b/src/reactpy_django/forms/components.py new file mode 100644 index 00000000..a10d5c92 --- /dev/null +++ b/src/reactpy_django/forms/components.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Callable, Union, cast +from uuid import uuid4 + +from django.forms import Form, ModelForm +from reactpy import component, hooks, html, utils +from reactpy.core.events import event +from reactpy.web import export, module_from_file + +from reactpy_django.forms.transforms import ( + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + infer_key_from_attributes, + intercept_anchor_links, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, +) +from reactpy_django.forms.utils import convert_form_fields, validate_form_args +from reactpy_django.types import AsyncFormEvent, FormEventData, SyncFormEvent +from reactpy_django.utils import ensure_async + +if TYPE_CHECKING: + from collections.abc import Sequence + + from reactpy.core.types import VdomDict + +DjangoForm = export( + module_from_file("reactpy-django", file=Path(__file__).parent.parent / "static" / "reactpy_django" / "index.js"), + ("DjangoForm"), +) + + +@component +def _django_form( + form: type[Form | ModelForm], + on_success: AsyncFormEvent | SyncFormEvent | None, + on_error: AsyncFormEvent | SyncFormEvent | None, + on_receive_data: AsyncFormEvent | SyncFormEvent | None, + on_change: AsyncFormEvent | SyncFormEvent | None, + auto_save: bool, + extra_props: dict, + extra_transforms: Sequence[Callable[[VdomDict], Any]], + form_template: str | None, + thread_sensitive: bool, + top_children: Sequence, + bottom_children: Sequence, +): + from reactpy_django import config + + uuid = hooks.use_ref(uuid4().hex.replace("-", "")).current + top_children_count = hooks.use_ref(len(top_children)) + bottom_children_count = hooks.use_ref(len(bottom_children)) + submitted_data, set_submitted_data = hooks.use_state({} or None) + rendered_form, set_rendered_form = hooks.use_state(cast(Union[str, None], None)) + + # Initialize the form with the provided data + validate_form_args(top_children, top_children_count, bottom_children, bottom_children_count, form) + initialized_form = form(data=submitted_data) + form_event = FormEventData( + form=initialized_form, submitted_data=submitted_data or {}, set_submitted_data=set_submitted_data + ) + + # Validate and render the form + @hooks.use_effect(dependencies=[str(submitted_data)]) + async def render_form(): + """Forms must be rendered in an async loop to allow database fields to execute.""" + if submitted_data: + await ensure_async(initialized_form.full_clean, thread_sensitive=thread_sensitive)() + success = not initialized_form.errors.as_data() + if success and on_success: + await ensure_async(on_success, thread_sensitive=thread_sensitive)(form_event) + if not success and on_error: + await ensure_async(on_error, thread_sensitive=thread_sensitive)(form_event) + if success and auto_save and isinstance(initialized_form, ModelForm): + await ensure_async(initialized_form.save)() + set_submitted_data(None) + + set_rendered_form( + await ensure_async(initialized_form.render)(form_template or config.REACTPY_DEFAULT_FORM_TEMPLATE) + ) + + async def on_submit_callback(new_data: dict[str, Any]): + """Callback function provided directly to the client side listener. This is responsible for transmitting + the submitted form data to the server for processing.""" + convert_form_fields(new_data, initialized_form) + + if on_receive_data: + new_form_event = FormEventData( + form=initialized_form, submitted_data=new_data, set_submitted_data=set_submitted_data + ) + await ensure_async(on_receive_data, thread_sensitive=thread_sensitive)(new_form_event) + + if submitted_data != new_data: + set_submitted_data(new_data) + + async def _on_change(_event): + """Event that exist solely to allow the user to detect form changes.""" + if on_change: + await ensure_async(on_change, thread_sensitive=thread_sensitive)(form_event) + + if not rendered_form: + return None + + return html.form( + extra_props + | { + "id": f"reactpy-{uuid}", + # Intercept the form submission to prevent the browser from navigating + "onSubmit": event(lambda _: None, prevent_default=True), + "onChange": _on_change, + }, + DjangoForm({"onSubmitCallback": on_submit_callback, "formId": f"reactpy-{uuid}"}), + *top_children, + utils.html_to_vdom( + rendered_form, + convert_html_props_to_reactjs, + convert_textarea_children_to_prop, + set_value_prop_on_select_element, + transform_value_prop_on_input_element, + intercept_anchor_links, + infer_key_from_attributes, + *extra_transforms, + strict=False, + ), + *bottom_children, + ) diff --git a/src/reactpy_django/forms/transforms.py b/src/reactpy_django/forms/transforms.py new file mode 100644 index 00000000..1a757b77 --- /dev/null +++ b/src/reactpy_django/forms/transforms.py @@ -0,0 +1,488 @@ +# type: ignore +# TODO: Almost everything in this module should be moved to `reactpy.utils._mutate_vdom()`. +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from reactpy.core.events import EventHandler, to_event_handler_function + +if TYPE_CHECKING: + from reactpy.core.types import VdomDict + + +def convert_html_props_to_reactjs(vdom_tree: VdomDict) -> VdomDict: + """Transformation that standardizes the prop names to be used in the component.""" + # On each node, replace the 'attributes' key names with the standardized names. + if "attributes" in vdom_tree: + vdom_tree["attributes"] = {_normalize_prop_name(k): v for k, v in vdom_tree["attributes"].items()} + + return vdom_tree + + +def convert_textarea_children_to_prop(vdom_tree: VdomDict) -> VdomDict: + """Transformation that converts the text content of a