diff --git a/.github/workflows/publish-develop-docs.yml b/.github/workflows/publish-develop-docs.yml index 53c5aa16..11a7fa23 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,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt + - name: Install dependencies + run: pip install --upgrade pip hatch uv - name: Publish Develop Docs run: | git config user.name github-actions git config user.email github-actions@github.com - cd docs - mike deploy --push develop + hatch run docs:deploy_develop concurrency: group: publish-docs diff --git a/.github/workflows/publish-latest-docs.yml b/.github/workflows/publish-latest-docs.yml index bc7409f0..697b10da 100644 --- a/.github/workflows/publish-latest-docs.yml +++ b/.github/workflows/publish-latest-docs.yml @@ -5,7 +5,7 @@ on: types: [published] jobs: - deploy: + publish-latest-docs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -17,12 +17,12 @@ jobs: - uses: actions/setup-python@v5 with: python-version: 3.x - - run: pip install -r requirements/build-docs.txt + - name: Install dependencies + run: pip install --upgrade pip hatch uv - name: Publish ${{ github.event.release.name }} Docs 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 + 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 index 6a86db98..f72cc55d 100644 --- a/.github/workflows/publish-py.yml +++ b/.github/workflows/publish-py.yml @@ -8,7 +8,7 @@ on: types: [published] jobs: - release-package: + publish-python: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -20,13 +20,11 @@ jobs: with: python-version: "3.x" - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements/build-pkg.txt + run: pip install --upgrade pip hatch uv - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python -m build --sdist --wheel --outdir dist . + hatch build --clean twine upload dist/* diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml index 66a5c942..08bfadd7 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 --upgrade pip hatch uv + # 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 run docs:check_examples diff --git a/.github/workflows/test-javascript.yml b/.github/workflows/test-javascript.yml new file mode 100644 index 00000000..d5b9db1d --- /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 --upgrade pip hatch uv + - name: Run Tests + run: hatch run javascript:check diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-python.yml similarity index 66% rename from .github/workflows/test-src.yml rename to .github/workflows/test-python.yml index 5eb2e67a..9fe700b8 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-python.yml @@ -11,7 +11,7 @@ on: - cron: "0 0 * * *" jobs: - source: + python: runs-on: ubuntu-latest strategy: matrix: @@ -26,6 +26,8 @@ jobs: 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 + run: pip install --upgrade pip hatch uv + - name: Run Single DB Tests + run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_single_db -v + - name: Run Multi-DB Tests + run: hatch test --python ${{ matrix.python-version }} --ds=test_app.settings_multi_db -v 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 82209551..ec8d38ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,13 @@ Don't forget to remove deprecated code on each major release! ## [Unreleased] -- Nothing (yet)! +### Fixed + +- Fixed regression in v5.1.0 where components would sometimes not output debug messages when `settings.py:DEBUG` is enabled. + +### Changed + +- Set upper limit on ReactPy version to `<2.0.0`. ## [5.1.0] - 2024-11-24 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/docs/mkdocs.yml b/docs/mkdocs.yml index c1b5922f..100b669b 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 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..ecb0131b --- /dev/null +++ b/docs/src/about/contributing.md @@ -0,0 +1,93 @@ +## 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 directly passed on 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 | + +??? 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 run docs:check_examples` | 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/dictionary.txt b/docs/src/dictionary.txt index 14aa7a61..1b4ce080 100644 --- a/docs/src/dictionary.txt +++ b/docs/src/dictionary.txt @@ -43,3 +43,8 @@ unstyled WebSocket WebSockets whitespace +pytest +linter +linters +linting +formatters diff --git a/docs/src/reference/settings.md b/docs/src/reference/settings.md index e65dd203..23760919 100644 --- a/docs/src/reference/settings.md +++ b/docs/src/reference/settings.md @@ -117,7 +117,7 @@ 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). 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..44f920a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,21 +1,215 @@ [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.tsx --outfile src/reactpy_django/static/reactpy_django/client.js --minify", + '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", +] +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 + """ +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"] + +[tool.hatch.envs.django.scripts] +runserver = [ + "cd tests && python manage.py migrate --noinput", + "cd tests && python manage.py runserver", +] + +####################################### +# >>> 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"] +check_examples = ["ruff check docs/examples/python"] + +############################ +# >>> 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/*"] 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 +] +lint.preview = true +lint.isort.known-first-party = ["src", "tests"] 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 61182ef9..00000000 --- a/requirements/pkg-deps.txt +++ /dev/null @@ -1,8 +0,0 @@ -channels >=4.0.0 -django >=4.2.0 -reactpy >=1.1.0, <1.2.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 f0c2f22d..00000000 --- a/setup.py +++ /dev/null @@ -1,182 +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 - -# ----------------------------------------------------------------------------- -# 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 -log = getLogger(__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", - "Topic :: Software Development :: Widget Sets", - "Topic :: Software Development :: User Interfaces", - "Environment :: Web Environment", - "Typing :: Typed", - ], -} - - -# ----------------------------------------------------------------------------- -# 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..1f446f83 --- /dev/null +++ b/src/build_scripts/copy_dir.py @@ -0,0 +1,31 @@ +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: + print("Usage: 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(): + print(f"Source directory {src} does not exist") + sys.exit(1) + + copy_files(src, dest) 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/reactpy_django/clean.py b/src/reactpy_django/clean.py index 93df7be6..1ec327ee 100644 --- a/src/reactpy_django/clean.py +++ b/src/reactpy_django/clean.py @@ -49,7 +49,8 @@ def clean_sessions(verbosity: int = 1): """Deletes expired component sessions from the database. As a performance optimization, this is only run once every REACTPY_SESSION_MAX_AGE seconds. """ - from reactpy_django.config import REACTPY_DEBUG_MODE, REACTPY_SESSION_MAX_AGE + + from reactpy_django.config import DJANGO_DEBUG, REACTPY_SESSION_MAX_AGE from reactpy_django.models import ComponentSession if verbosity >= 2: @@ -66,7 +67,7 @@ def clean_sessions(verbosity: int = 1): session_objects.delete() - if REACTPY_DEBUG_MODE or verbosity >= 2: + if DJANGO_DEBUG or verbosity >= 2: inspect_clean_duration(start_time, "component sessions", verbosity) @@ -78,7 +79,7 @@ def clean_user_data(verbosity: int = 1): However, we can't use Django to enforce this relationship since ReactPy can be configured to use any database. """ - from reactpy_django.config import REACTPY_DEBUG_MODE + from reactpy_django.config import DJANGO_DEBUG from reactpy_django.models import UserDataModel if verbosity >= 2: @@ -102,7 +103,7 @@ def clean_user_data(verbosity: int = 1): user_data_objects.delete() - if REACTPY_DEBUG_MODE or verbosity >= 2: + if DJANGO_DEBUG or verbosity >= 2: inspect_clean_duration(start_time, "user data", verbosity) diff --git a/src/reactpy_django/config.py b/src/reactpy_django/config.py index e74299e3..090980a5 100644 --- a/src/reactpy_django/config.py +++ b/src/reactpy_django/config.py @@ -18,13 +18,13 @@ from reactpy_django.utils import import_dotted_path # Non-configurable values -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 -_REACTPY_DEBUG_MODE.set_current(getattr(settings, "DEBUG")) +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) ) diff --git a/src/reactpy_django/templates/reactpy/component.html b/src/reactpy_django/templates/reactpy/component.html index 6b4ecc16..7e3746f5 100644 --- a/src/reactpy_django/templates/reactpy/component.html +++ b/src/reactpy_django/templates/reactpy/component.html @@ -1,6 +1,6 @@ {% load static %} -{% if reactpy_failure and reactpy_debug_mode %} +{% if reactpy_failure and django_debug %}
{% firstof reactpy_error "UnknownError" %}: "{% firstof reactpy_dotted_path "UnknownPath" %}"
{% endif %} @@ -10,18 +10,18 @@ {% if reactpy_prerender_html %}
{{reactpy_prerender_html|safe}}
{% endif %} {% if reactpy_offline_html %}{% endif %} {% endif %} diff --git a/src/reactpy_django/templates/reactpy/pyscript_setup.html b/src/reactpy_django/templates/reactpy/pyscript_setup.html index e258cf08..547a672a 100644 --- a/src/reactpy_django/templates/reactpy/pyscript_setup.html +++ b/src/reactpy_django/templates/reactpy/pyscript_setup.html @@ -1,7 +1,7 @@ {% load static %} -{% if not reactpy_debug_mode %} +{% if not django_debug %} {% endif %} diff --git a/src/reactpy_django/templatetags/reactpy.py b/src/reactpy_django/templatetags/reactpy.py index 2f34651a..1f419049 100644 --- a/src/reactpy_django/templatetags/reactpy.py +++ b/src/reactpy_django/templatetags/reactpy.py @@ -74,6 +74,8 @@ def component( """ + from reactpy_django.config import DJANGO_DEBUG + request: HttpRequest | None = context.get("request") perceived_host = (request.get_host() if request else "").strip("/") host = ( @@ -93,7 +95,7 @@ def component( _offline_html = "" # Validate the host - if host and reactpy_config.REACTPY_DEBUG_MODE: + if host and DJANGO_DEBUG: try: validate_host(host) except InvalidHostError as e: @@ -108,7 +110,7 @@ def component( return failure_context(dotted_path, ComponentDoesNotExistError(msg)) # Validate the component args & kwargs - if is_local and reactpy_config.REACTPY_DEBUG_MODE: + if is_local and DJANGO_DEBUG: try: validate_component_args(user_component, *args, **kwargs) except ComponentParamError as e: @@ -234,17 +236,21 @@ def pyscript_setup( config: A JSON string or Python dictionary containing PyScript \ configuration values. """ + from reactpy_django.config import DJANGO_DEBUG + return { "pyscript_config": extend_pyscript_config(extra_py, extra_js, config), "pyscript_layout_handler": PYSCRIPT_LAYOUT_HANDLER, - "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + "django_debug": DJANGO_DEBUG, } def failure_context(dotted_path: str, error: Exception): + from reactpy_django.config import DJANGO_DEBUG + return { "reactpy_failure": True, - "reactpy_debug_mode": reactpy_config.REACTPY_DEBUG_MODE, + "django_debug": DJANGO_DEBUG, "reactpy_dotted_path": dotted_path, "reactpy_error": type(error).__name__, } diff --git a/tests/test_app/apps.py b/tests/test_app/apps.py index 2bef8446..c5ec0d60 100644 --- a/tests/test_app/apps.py +++ b/tests/test_app/apps.py @@ -1,4 +1,3 @@ -import contextlib import sys from django.apps import AppConfig @@ -11,8 +10,6 @@ class TestAppConfig(AppConfig): name = "test_app" def ready(self): - from django.contrib.auth.models import User - register_iframe("test_app.views.view_to_iframe_sync_func") register_iframe(views.view_to_iframe_async_func) register_iframe(views.ViewToIframeSyncClass) @@ -22,8 +19,3 @@ def ready(self): if "test" in sys.argv: return - - with contextlib.suppress(Exception): - User.objects.create_superuser( - username="admin", email="admin@example.com", password="password" - ) diff --git a/tests/test_app/middleware.py b/tests/test_app/middleware.py new file mode 100644 index 00000000..0927a100 --- /dev/null +++ b/tests/test_app/middleware.py @@ -0,0 +1,31 @@ +import contextlib + +from asgiref.sync import iscoroutinefunction, markcoroutinefunction + + +class AutoCreateAdminMiddleware: + async_capable = True + sync_capable = True + + def __init__(self, get_response): + from django.contrib.auth.models import User + + # One-time configuration and initialization. + self.get_response = get_response + with contextlib.suppress(Exception): + User.objects.create_superuser( + username="admin", email="admin@example.com", password="password" + ) + + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + def __call__(self, request): + if iscoroutinefunction(self.get_response): + + async def async_call(): + return await self.get_response(request) + + return async_call() + + return self.get_response(request) diff --git a/tests/test_app/prerender/components.py b/tests/test_app/prerender/components.py index dd312195..7a2b29b4 100644 --- a/tests/test_app/prerender/components.py +++ b/tests/test_app/prerender/components.py @@ -1,14 +1,19 @@ from time import sleep -import reactpy_django from reactpy import component, html +import reactpy_django + +SLEEP_TIME = 0.25 + @component def prerender_string(): scope = reactpy_django.hooks.use_scope() - sleep(0.5) + if scope.get("type") != "http": + sleep(SLEEP_TIME) + return ( "prerender_string: Fully Rendered" if scope.get("type") == "websocket" diff --git a/tests/test_app/settings_multi_db.py b/tests/test_app/settings_multi_db.py index 65e37415..fb390e28 100644 --- a/tests/test_app/settings_multi_db.py +++ b/tests/test_app/settings_multi_db.py @@ -12,9 +12,9 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = all( - not sys.argv[0].endswith(webserver_name) - for webserver_name in {"hypercorn", "uvicorn", "daphne"} +DEBUG = not any( + sys.argv[0].endswith(webserver_name) + for webserver_name in ["hypercorn", "uvicorn", "daphne"] ) ALLOWED_HOSTS = ["*"] @@ -32,7 +32,8 @@ ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -66,37 +67,26 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}.sqlite3" if "test" in sys.argv else f"{DB_NAME}.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, "OPTIONS": {"timeout": 20}, }, -} -if "test" in sys.argv: - DATABASES["reactpy"] = { + "reactpy": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}_2.sqlite3" - if "test" in sys.argv - else f"{DB_NAME}_2.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}_2.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}_2.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}_2.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, "OPTIONS": {"timeout": 20}, - } - REACTPY_DATABASE = "reactpy" + }, +} +REACTPY_DATABASE = "reactpy" DATABASE_ROUTERS = ["reactpy_django.database.Router"] # Cache @@ -121,7 +111,6 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True -USE_L10N = True USE_TZ = True # Default primary key field type @@ -139,9 +128,7 @@ ] # Logging -LOG_LEVEL = "WARNING" -if DEBUG and ("test" not in sys.argv): - LOG_LEVEL = "DEBUG" +LOG_LEVEL = "DEBUG" LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -159,4 +146,6 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv +REACTPY_BACKHAUL_THREAD = any( + sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] +) diff --git a/tests/test_app/settings_single_db.py b/tests/test_app/settings_single_db.py index 2550c8d1..e5f8969a 100644 --- a/tests/test_app/settings_single_db.py +++ b/tests/test_app/settings_single_db.py @@ -12,9 +12,9 @@ SECRET_KEY = "django-insecure-n!bd1#+7ufw5#9ipayu9k(lyu@za$c2ajbro7es(v8_7w1$=&c" # Run in production mode when using a real web server -DEBUG = all( - not sys.argv[0].endswith(webserver_name) - for webserver_name in {"hypercorn", "uvicorn", "daphne"} +DEBUG = not any( + sys.argv[0].endswith(webserver_name) + for webserver_name in ["hypercorn", "uvicorn", "daphne"] ) ALLOWED_HOSTS = ["*"] @@ -32,7 +32,8 @@ ] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", - "whitenoise.middleware.WhiteNoiseMiddleware", + "servestatic.middleware.ServeStaticMiddleware", + "test_app.middleware.AutoCreateAdminMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -66,13 +67,9 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - # Changing NAME is needed due to a bug related to `manage.py test` - "NAME": os.path.join( - BASE_DIR, - f"test_{DB_NAME}.sqlite3" if "test" in sys.argv else f"{DB_NAME}.sqlite3", - ), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "TEST": { - "NAME": os.path.join(BASE_DIR, f"test_{DB_NAME}.sqlite3"), + "NAME": os.path.join(BASE_DIR, f"{DB_NAME}.sqlite3"), "OPTIONS": {"timeout": 20}, "DEPENDENCIES": [], }, @@ -102,7 +99,6 @@ LANGUAGE_CODE = "en-us" TIME_ZONE = "UTC" USE_I18N = True -USE_L10N = True USE_TZ = True # Default primary key field type @@ -120,9 +116,7 @@ ] # Logging -LOG_LEVEL = "WARNING" -if DEBUG and ("test" not in sys.argv): - LOG_LEVEL = "DEBUG" +LOG_LEVEL = "DEBUG" LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -140,4 +134,6 @@ CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}} # ReactPy-Django Settings -REACTPY_BACKHAUL_THREAD = "test" not in sys.argv and "runserver" not in sys.argv +REACTPY_BACKHAUL_THREAD = any( + sys.argv[0].endswith(webserver_name) for webserver_name in ["hypercorn", "uvicorn"] +) diff --git a/tests/test_app/tests/__init__.py b/tests/test_app/tests/__init__.py index fff5a11e..e69de29b 100644 --- a/tests/test_app/tests/__init__.py +++ b/tests/test_app/tests/__init__.py @@ -1 +0,0 @@ -from . import * # noqa: F401, F403 diff --git a/tests/test_app/tests/conftest.py b/tests/test_app/tests/conftest.py new file mode 100644 index 00000000..89c7fad9 --- /dev/null +++ b/tests/test_app/tests/conftest.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +os.chdir(Path(__file__).parent.parent.parent) + + +@pytest.fixture(autouse=True) +def enable_db_access_for_all_tests(db): + pass + + +@pytest.fixture(autouse=True, scope="session") +def install_playwright(): + subprocess.run(["playwright", "install", "chromium"], check=True) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index f3726a4c..c4848ccf 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -1,111 +1,25 @@ -import asyncio import os import socket -import sys -from functools import partial from time import sleep -from channels.testing import ChannelsLiveServerTestCase -from channels.testing.live import make_application -from django.core.exceptions import ImproperlyConfigured -from django.core.management import call_command -from django.db import connections -from django.test.utils import modify_settings -from playwright.sync_api import TimeoutError, sync_playwright +from playwright.sync_api import TimeoutError from reactpy_django.models import ComponentSession from reactpy_django.utils import strtobool -GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") -CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. +from .utils import GITHUB_ACTIONS, PlaywrightTestCase +CLICK_DELAY = 250 if strtobool(GITHUB_ACTIONS) else 25 # Delay in miliseconds. -class ComponentTests(ChannelsLiveServerTestCase): - from django.db import DEFAULT_DB_ALIAS - from reactpy_django import config +class GenericComponentTests(PlaywrightTestCase): databases = {"default"} @classmethod def setUpClass(cls): - # Repurposed from ChannelsLiveServerTestCase._pre_setup - for connection in connections.all(): - if cls._is_in_memory_db(cls, connection): - raise ImproperlyConfigured( - "ChannelLiveServerTestCase can not be used with in memory databases" - ) - cls._live_server_modified_settings = modify_settings( - ALLOWED_HOSTS={"append": cls.host} - ) - cls._live_server_modified_settings.enable() - get_application = partial( - make_application, - static_wrapper=cls.static_wrapper if cls.serve_static else None, - ) - cls._server_process = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process.start() - cls._server_process.ready.wait() - cls._port = cls._server_process.port.value - - # Open the second server process, used for testing custom hosts - cls._server_process2 = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process2.start() - cls._server_process2.ready.wait() - cls._port2 = cls._server_process2.port.value - - # Open the third server process, used for testing offline fallback - cls._server_process3 = cls.ProtocolServerProcess(cls.host, get_application) - cls._server_process3.start() - cls._server_process3.ready.wait() - cls._port3 = cls._server_process3.port.value - - # Open a Playwright browser window - if sys.platform == "win32": - asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) - cls.playwright = sync_playwright().start() - headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) - cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) - cls.page = cls.browser.new_page() - cls.page.set_default_timeout(5000) - - @classmethod - def tearDownClass(cls): - from reactpy_django import config - - # Close the Playwright browser - cls.playwright.stop() - - # Close the other server processes - cls._server_process.terminate() - cls._server_process.join() - cls._server_process2.terminate() - cls._server_process2.join() - cls._server_process3.terminate() - cls._server_process3.join() - - # Repurposed from ChannelsLiveServerTestCase._post_teardown - cls._live_server_modified_settings.disable() - for db_name in {"default", config.REACTPY_DATABASE}: - call_command( - "flush", - verbosity=0, - interactive=False, - database=db_name, - reset_sequences=False, - ) - - def _pre_setup(self): - """Handled manually in `setUpClass` to speed things up.""" - - def _post_teardown(self): - """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing - database flushing. This is needed to prevent a `SynchronousOnlyOperation` from - occuring due to a bug within `ChannelsLiveServerTestCase`.""" - - def setUp(self): - if self.page.url == "about:blank": - self.page.goto(self.live_server_url) + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}") def test_hello_world(self): self.page.wait_for_selector("#hello-world") @@ -297,149 +211,6 @@ def test_component_session_missing(self): os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") self.assertFalse(query_exists) - def test_custom_host(self): - """Make sure that the component is rendered by a separate server.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/port/{self._port2}/") - try: - elem = new_page.locator(".custom_host-0") - elem.wait_for() - self.assertIn( - f"Server Port: {self._port2}", - elem.text_content(), - ) - finally: - new_page.close() - - def test_custom_host_wrong_port(self): - """Make sure that other ports are not rendering components.""" - new_page = self.browser.new_page() - try: - tmp_sock = socket.socket() - tmp_sock.bind((self._server_process.host, 0)) - random_port = tmp_sock.getsockname()[1] - new_page.goto(f"{self.live_server_url}/port/{random_port}/") - with self.assertRaises(TimeoutError): - new_page.locator(".custom_host").wait_for(timeout=1000) - finally: - new_page.close() - - def test_host_roundrobin(self): - """Verify if round-robin host selection is working.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8") - try: - elem0 = new_page.locator(".custom_host-0") - elem1 = new_page.locator(".custom_host-1") - elem2 = new_page.locator(".custom_host-2") - elem3 = new_page.locator(".custom_host-3") - - elem0.wait_for() - elem1.wait_for() - elem2.wait_for() - elem3.wait_for() - - current_ports = { - elem0.get_attribute("data-port"), - elem1.get_attribute("data-port"), - elem2.get_attribute("data-port"), - elem3.get_attribute("data-port"), - } - correct_ports = { - str(self._port), - str(self._port2), - } - - # There should only be two ports in the set - self.assertEqual(current_ports, correct_ports) - self.assertEqual(len(current_ports), 2) - finally: - new_page.close() - - def test_prerender(self): - """Verify if round-robin host selection is working.""" - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/prerender/") - try: - string = new_page.locator("#prerender_string") - vdom = new_page.locator("#prerender_vdom") - component = new_page.locator("#prerender_component") - use_root_id_http = new_page.locator("#use-root-id-http") - use_root_id_ws = new_page.locator("#use-root-id-ws") - use_user_http = new_page.locator("#use-user-http[data-success=True]") - use_user_ws = new_page.locator("#use-user-ws[data-success=true]") - - string.wait_for() - vdom.wait_for() - component.wait_for() - use_root_id_http.wait_for() - use_user_http.wait_for() - - # Check if the prerender occurred - self.assertEqual( - string.all_inner_texts(), ["prerender_string: Prerendered"] - ) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Prerendered"] - ) - root_id_value = use_root_id_http.get_attribute("data-value") - self.assertEqual(len(root_id_value), 36) - - # Check if the full render occurred - sleep(1) - self.assertEqual( - string.all_inner_texts(), ["prerender_string: Fully Rendered"] - ) - self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"]) - self.assertEqual( - component.all_inner_texts(), ["prerender_component: Fully Rendered"] - ) - use_root_id_ws.wait_for() - use_user_ws.wait_for() - self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value) - - finally: - new_page.close() - - def test_component_errors(self): - new_page = self.browser.new_page() - new_page.goto(f"{self.live_server_url}/errors/") - try: - # ComponentDoesNotExistError - broken_component = new_page.locator("#component_does_not_exist_error") - broken_component.wait_for() - self.assertIn( - "ComponentDoesNotExistError:", broken_component.text_content() - ) - - # ComponentParamError - broken_component = new_page.locator("#component_param_error") - broken_component.wait_for() - self.assertIn("ComponentParamError:", broken_component.text_content()) - - # InvalidHostError - broken_component = new_page.locator("#invalid_host_error") - broken_component.wait_for() - self.assertIn("InvalidHostError:", broken_component.text_content()) - - # SynchronousOnlyOperation - broken_component = new_page.locator("#broken_postprocessor_query pre") - broken_component.wait_for() - self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) - - # ViewNotRegisteredError - broken_component = new_page.locator("#view_to_iframe_not_registered pre") - broken_component.wait_for() - self.assertIn("ViewNotRegisteredError:", broken_component.text_content()) - - # DecoratorParamError - broken_component = new_page.locator("#incorrect_user_passes_test_decorator") - broken_component.wait_for() - self.assertIn("DecoratorParamError:", broken_component.text_content()) - finally: - new_page.close() - def test_use_user_data(self): text_input = self.page.wait_for_selector("#use-user-data input") login_1 = self.page.wait_for_selector("#use-user-data .login-1") @@ -536,170 +307,333 @@ def test_use_user_data_with_default(self): user_data_div.text_content(), ) + +class PrerenderTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/prerender/") + + def test_prerender(self): + """Verify if round-robin host selection is working.""" + string = self.page.locator("#prerender_string") + vdom = self.page.locator("#prerender_vdom") + component = self.page.locator("#prerender_component") + use_root_id_http = self.page.locator("#use-root-id-http") + use_root_id_ws = self.page.locator("#use-root-id-ws") + use_user_http = self.page.locator("#use-user-http[data-success=True]") + use_user_ws = self.page.locator("#use-user-ws[data-success=true]") + + # Check if the prerender occurred properly + string.wait_for() + vdom.wait_for() + component.wait_for() + use_root_id_http.wait_for() + use_user_http.wait_for() + self.assertEqual(string.all_inner_texts(), ["prerender_string: Prerendered"]) + self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Prerendered"]) + self.assertEqual( + component.all_inner_texts(), ["prerender_component: Prerendered"] + ) + root_id_value = use_root_id_http.get_attribute("data-value") + self.assertEqual(len(root_id_value), 36) + + # Check if the full render occurred + sleep(2) + self.assertEqual(string.all_inner_texts(), ["prerender_string: Fully Rendered"]) + self.assertEqual(vdom.all_inner_texts(), ["prerender_vdom: Fully Rendered"]) + self.assertEqual( + component.all_inner_texts(), ["prerender_component: Fully Rendered"] + ) + use_root_id_ws.wait_for() + use_user_ws.wait_for() + self.assertEqual(use_root_id_ws.get_attribute("data-value"), root_id_value) + + +class ErrorTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/errors/") + + def test_component_does_not_exist_error(self): + broken_component = self.page.locator("#component_does_not_exist_error") + broken_component.wait_for() + self.assertIn("ComponentDoesNotExistError:", broken_component.text_content()) + + def test_component_param_error(self): + broken_component = self.page.locator("#component_param_error") + broken_component.wait_for() + self.assertIn("ComponentParamError:", broken_component.text_content()) + + def test_invalid_host_error(self): + broken_component = self.page.locator("#invalid_host_error") + broken_component.wait_for() + self.assertIn("InvalidHostError:", broken_component.text_content()) + + def test_synchronous_only_operation_error(self): + broken_component = self.page.locator("#broken_postprocessor_query pre") + broken_component.wait_for() + self.assertIn("SynchronousOnlyOperation:", broken_component.text_content()) + + def test_view_not_registered_error(self): + broken_component = self.page.locator("#view_to_iframe_not_registered pre") + broken_component.wait_for() + self.assertIn("ViewNotRegisteredError:", broken_component.text_content()) + + def test_decorator_param_error(self): + broken_component = self.page.locator("#incorrect_user_passes_test_decorator") + broken_component.wait_for() + self.assertIn("DecoratorParamError:", broken_component.text_content()) + + +class UrlRouterTests(PlaywrightTestCase): + def test_url_router(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/router/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/subroute/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/subroute/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("subroute/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/unspecified/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/unspecified//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/integer/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/integer/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/integer//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/path/abc/123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/path//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/slug/abc-123/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/slug//", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/string/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/string/abc/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/string//", string.text_content()) - - new_page.goto( - f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" - ) - path = new_page.wait_for_selector("#router-path") - self.assertIn( - "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", - path.get_attribute("data-path"), - ) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/uuid//", string.text_content()) + self.page.goto(f"{self.live_server_url}/router/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/", string.text_content()) + + def test_url_router_subroute(self): + self.page.goto(f"{self.live_server_url}/router/subroute/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/subroute/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("subroute/", string.text_content()) + + def test_url_unspecified(self): + self.page.goto(f"{self.live_server_url}/router/unspecified/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/unspecified/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/unspecified//", string.text_content()) + + def test_url_router_integer(self): + self.page.goto(f"{self.live_server_url}/router/integer/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/integer/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/integer//", string.text_content()) + + def test_url_router_path(self): + self.page.goto(f"{self.live_server_url}/router/path/abc/123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/path/abc/123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/path//", string.text_content()) + + def test_url_router_slug(self): + self.page.goto(f"{self.live_server_url}/router/slug/abc-123/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/slug/abc-123/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/slug//", string.text_content()) + + def test_url_router_string(self): + self.page.goto(f"{self.live_server_url}/router/string/abc/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/string/abc/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/string//", string.text_content()) + + def test_url_router_uuid(self): + self.page.goto( + f"{self.live_server_url}/router/uuid/123e4567-e89b-12d3-a456-426614174000/" + ) + path = self.page.wait_for_selector("#router-path") + self.assertIn( + "/router/uuid/123e4567-e89b-12d3-a456-426614174000/", + path.get_attribute("data-path"), + ) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/uuid//", string.text_content()) - new_page.goto( - f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" - ) - path = new_page.wait_for_selector("#router-path") - self.assertIn( - "/router/any/adslkjgklasdjhfah/6789543256/", - path.get_attribute("data-path"), - ) - string = new_page.query_selector("#router-string") - self.assertEqual("/router/any/", string.text_content()) - - new_page.goto(f"{self.live_server_url}/router/two/123/abc/") - path = new_page.wait_for_selector("#router-path") - self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) - string = new_page.query_selector("#router-string") - self.assertEqual( - "/router/two///", string.text_content() - ) + def test_url_router_any(self): + self.page.goto( + f"{self.live_server_url}/router/any/adslkjgklasdjhfah/6789543256/" + ) + path = self.page.wait_for_selector("#router-path") + self.assertIn( + "/router/any/adslkjgklasdjhfah/6789543256/", + path.get_attribute("data-path"), + ) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/any/", string.text_content()) - finally: - new_page.close() + def test_url_router_int_and_string(self): + self.page.goto(f"{self.live_server_url}/router/two/123/abc/") + path = self.page.wait_for_selector("#router-path") + self.assertIn("/router/two/123/abc/", path.get_attribute("data-path")) + string = self.page.query_selector("#router-string") + self.assertEqual("/router/two///", string.text_content()) - def test_offline_components(self): - new_page = self.browser.new_page() - try: - server3_url = self.live_server_url.replace( - str(self._port), str(self._port3) - ) - new_page.goto(f"{server3_url}/offline/") - new_page.wait_for_selector("div:not([hidden]) > #online") - self.assertIsNotNone(new_page.query_selector("div[hidden] > #offline")) - self._server_process3.terminate() - self._server_process3.join() - new_page.wait_for_selector("div:not([hidden]) > #offline") - self.assertIsNotNone(new_page.query_selector("div[hidden] > #online")) - finally: - new_page.close() +class ChannelLayersTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/channel-layers/") def test_channel_layer_components(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/channel-layers/") - sender = new_page.wait_for_selector("#sender") - sender.type("test", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) - receiver = new_page.wait_for_selector("#receiver[data-message='test']") - self.assertIsNotNone(receiver) - - sender = new_page.wait_for_selector("#group-sender") - sender.type("1234", delay=CLICK_DELAY) - sender.press("Enter", delay=CLICK_DELAY) - receiver_1 = new_page.wait_for_selector( - "#group-receiver-1[data-message='1234']" - ) - receiver_2 = new_page.wait_for_selector( - "#group-receiver-2[data-message='1234']" - ) - receiver_3 = new_page.wait_for_selector( - "#group-receiver-3[data-message='1234']" - ) - self.assertIsNotNone(receiver_1) - self.assertIsNotNone(receiver_2) - self.assertIsNotNone(receiver_3) - - finally: - new_page.close() - - def test_pyscript_components(self): - new_page = self.browser.new_page() - try: - new_page.goto(f"{self.live_server_url}/pyscript/") - new_page.wait_for_selector("#hello-world-loading") - new_page.wait_for_selector("#hello-world") - new_page.wait_for_selector("#custom-root") - new_page.wait_for_selector("#multifile-parent") - new_page.wait_for_selector("#multifile-child") - - new_page.wait_for_selector("#counter") - new_page.wait_for_selector("#counter pre[data-value='0']") - new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='1']") - new_page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='2']") - new_page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#counter pre[data-value='1']") - - new_page.wait_for_selector("#parent") - new_page.wait_for_selector("#child") - new_page.wait_for_selector("#child pre[data-value='0']") - new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='1']") - new_page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='2']") - new_page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#child pre[data-value='1']") - - new_page.wait_for_selector("#parent-toggle") - new_page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle") - new_page.wait_for_selector("#parent-toggle pre[data-value='0']") - new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='1']") - new_page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='2']") - new_page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) - new_page.wait_for_selector("#parent-toggle pre[data-value='1']") - - new_page.wait_for_selector("#moment[data-success=true]") - finally: - new_page.close() + sender = self.page.wait_for_selector("#sender") + sender.type("test", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver = self.page.wait_for_selector("#receiver[data-message='test']") + self.assertIsNotNone(receiver) + + sender = self.page.wait_for_selector("#group-sender") + sender.type("1234", delay=CLICK_DELAY) + sender.press("Enter", delay=CLICK_DELAY) + receiver_1 = self.page.wait_for_selector( + "#group-receiver-1[data-message='1234']" + ) + receiver_2 = self.page.wait_for_selector( + "#group-receiver-2[data-message='1234']" + ) + receiver_3 = self.page.wait_for_selector( + "#group-receiver-3[data-message='1234']" + ) + self.assertIsNotNone(receiver_1) + self.assertIsNotNone(receiver_2) + self.assertIsNotNone(receiver_3) + + +class PyscriptTests(PlaywrightTestCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/pyscript/") + + def test_0_hello_world(self): + self.page.wait_for_selector("#hello-world-loading") + self.page.wait_for_selector("#hello-world") + + def test_1_custom_root(self): + self.page.wait_for_selector("#custom-root") + + def test_1_multifile(self): + self.page.wait_for_selector("#multifile-parent") + self.page.wait_for_selector("#multifile-child") + + def test_1_counter(self): + self.page.wait_for_selector("#counter") + self.page.wait_for_selector("#counter pre[data-value='0']") + self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='1']") + self.page.wait_for_selector("#counter .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='2']") + self.page.wait_for_selector("#counter .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#counter pre[data-value='1']") + + def test_1_server_side_parent(self): + self.page.wait_for_selector("#parent") + self.page.wait_for_selector("#child") + self.page.wait_for_selector("#child pre[data-value='0']") + self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='1']") + self.page.wait_for_selector("#child .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='2']") + self.page.wait_for_selector("#child .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#child pre[data-value='1']") + + def test_1_server_side_parent_with_toggle(self): + self.page.wait_for_selector("#parent-toggle") + self.page.wait_for_selector("#parent-toggle button").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle") + self.page.wait_for_selector("#parent-toggle pre[data-value='0']") + self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='1']") + self.page.wait_for_selector("#parent-toggle .plus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='2']") + self.page.wait_for_selector("#parent-toggle .minus").click(delay=CLICK_DELAY) + self.page.wait_for_selector("#parent-toggle pre[data-value='1']") + + def test_1_javascript_module_execution_within_pyscript(self): + self.page.wait_for_selector("#moment[data-success=true]") + + +class DistributedComputingTests(PlaywrightTestCase): + + @classmethod + def setUpServer(cls): + super().setUpServer() + cls._server_process2 = cls.ProtocolServerProcess(cls.host, cls.get_application) + cls._server_process2.start() + cls._server_process2.ready.wait() + cls._port2 = cls._server_process2.port.value + + @classmethod + def tearDownServer(cls): + super().tearDownServer() + cls._server_process2.terminate() + cls._server_process2.join() + + def test_host_roundrobin(self): + """Verify if round-robin host selection is working.""" + self.page.goto( + f"{self.live_server_url}/roundrobin/{self._port}/{self._port2}/8" + ) + elem0 = self.page.locator(".custom_host-0") + elem1 = self.page.locator(".custom_host-1") + elem2 = self.page.locator(".custom_host-2") + elem3 = self.page.locator(".custom_host-3") + + elem0.wait_for() + elem1.wait_for() + elem2.wait_for() + elem3.wait_for() + + current_ports = { + elem0.get_attribute("data-port"), + elem1.get_attribute("data-port"), + elem2.get_attribute("data-port"), + elem3.get_attribute("data-port"), + } + correct_ports = { + str(self._port), + str(self._port2), + } + + # There should only be two ports in the set + self.assertEqual(current_ports, correct_ports) + self.assertEqual(len(current_ports), 2) + + def test_custom_host(self): + """Make sure that the component is rendered by a separate server.""" + self.page.goto(f"{self.live_server_url}/port/{self._port2}/") + elem = self.page.locator(".custom_host-0") + elem.wait_for() + self.assertIn( + f"Server Port: {self._port2}", + elem.text_content(), + ) + + def test_custom_host_wrong_port(self): + """Make sure that other ports are not rendering components.""" + tmp_sock = socket.socket() + tmp_sock.bind((self._server_process.host, 0)) + random_port = tmp_sock.getsockname()[1] + self.page.goto(f"{self.live_server_url}/port/{random_port}/") + with self.assertRaises(TimeoutError): + self.page.locator(".custom_host").wait_for(timeout=1000) + + +class OfflineTests(PlaywrightTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.page.goto(f"http://{cls.host}:{cls._port}/offline/") + + def test_offline_components(self): + self.page.wait_for_selector("div:not([hidden]) > #online") + self.assertIsNotNone(self.page.query_selector("div[hidden] > #offline")) + self._server_process.terminate() + self._server_process.join() + self.page.wait_for_selector("div:not([hidden]) > #offline") + self.assertIsNotNone(self.page.query_selector("div[hidden] > #online")) diff --git a/tests/test_app/tests/test_database.py b/tests/test_app/tests/test_database.py index 6daa516f..83e34ccb 100644 --- a/tests/test_app/tests/test_database.py +++ b/tests/test_app/tests/test_database.py @@ -4,6 +4,7 @@ import dill as pickle from django.test import TransactionTestCase + from reactpy_django import clean from reactpy_django.models import ComponentSession, UserDataModel from reactpy_django.types import ComponentParams diff --git a/tests/test_app/tests/test_regex.py b/tests/test_app/tests/test_regex.py index bf567413..5c3ec95a 100644 --- a/tests/test_app/tests/test_regex.py +++ b/tests/test_app/tests/test_regex.py @@ -1,6 +1,7 @@ import re from django.test import TestCase + from reactpy_django.utils import COMMENT_REGEX, COMPONENT_REGEX @@ -21,10 +22,10 @@ def test_component_regex(self): ) self.assertRegex( r"""{% - component - "my.component" - class="my_thing" - attr="attribute" + component + "my.component" + class="my_thing" + attr="attribute" %}""", # noqa: W291 COMPONENT_REGEX, @@ -84,10 +85,10 @@ def test_comment_regex(self): COMMENT_REGEX, ) self.assertRegex( - r"""""", # noqa: W291 COMMENT_REGEX, ) @@ -138,8 +139,8 @@ def test_comment_regex(self): COMMENT_REGEX.sub( "", r"""""", # noqa: W291 ), "", diff --git a/tests/test_app/tests/utils.py b/tests/test_app/tests/utils.py new file mode 100644 index 00000000..fe32d97d --- /dev/null +++ b/tests/test_app/tests/utils.py @@ -0,0 +1,92 @@ +import asyncio +import os +import sys +from functools import partial + +from channels.testing import ChannelsLiveServerTestCase +from channels.testing.live import make_application +from django.core.exceptions import ImproperlyConfigured +from django.core.management import call_command +from django.db import connections +from django.test.utils import modify_settings +from playwright.sync_api import sync_playwright + +from reactpy_django.utils import strtobool + +GITHUB_ACTIONS = os.getenv("GITHUB_ACTIONS", "False") + + +class PlaywrightTestCase(ChannelsLiveServerTestCase): + + from reactpy_django import config + + databases = {"default"} + + @classmethod + def setUpClass(cls): + # Repurposed from ChannelsLiveServerTestCase._pre_setup + for connection in connections.all(): + if cls._is_in_memory_db(cls, connection): + raise ImproperlyConfigured( + "ChannelLiveServerTestCase can not be used with in memory databases" + ) + cls._live_server_modified_settings = modify_settings( + ALLOWED_HOSTS={"append": cls.host} + ) + cls._live_server_modified_settings.enable() + cls.get_application = partial( + make_application, + static_wrapper=cls.static_wrapper if cls.serve_static else None, + ) + cls.setUpServer() + + # Open a Playwright browser window + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + cls.playwright = sync_playwright().start() + headless = strtobool(os.environ.get("PLAYWRIGHT_HEADLESS", GITHUB_ACTIONS)) + cls.browser = cls.playwright.chromium.launch(headless=bool(headless)) + cls.page = cls.browser.new_page() + cls.page.set_default_timeout(10000) + + @classmethod + def setUpServer(cls): + cls._server_process = cls.ProtocolServerProcess(cls.host, cls.get_application) + cls._server_process.start() + cls._server_process.ready.wait() + cls._port = cls._server_process.port.value + + @classmethod + def tearDownClass(cls): + from reactpy_django import config + + # Close the Playwright browser + cls.playwright.stop() + + # Close the other server processes + cls.tearDownServer() + + # Repurposed from ChannelsLiveServerTestCase._post_teardown + cls._live_server_modified_settings.disable() + # Using set to prevent duplicates + for db_name in {"default", config.REACTPY_DATABASE}: + call_command( + "flush", + verbosity=0, + interactive=False, + database=db_name, + reset_sequences=False, + ) + + @classmethod + def tearDownServer(cls): + cls._server_process.terminate() + cls._server_process.join() + + def _pre_setup(self): + """Handled manually in `setUpClass` to speed things up.""" + + def _post_teardown(self): + """Handled manually in `tearDownClass` to prevent TransactionTestCase from doing + database flushing. This is needed to prevent a `SynchronousOnlyOperation` from + occurring due to a bug within `ChannelsLiveServerTestCase`."""