diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..1e469181 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,37 @@ +# https://editorconfig.org/ + +root = true + +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +charset = utf-8 + +[*.py] +max_line_length = 99 + +[*.yml] +indent_size = 2 + +[*.ini] +indent_size = 2 + +[*.json] +indent_size = 2 +insert_final_newline = unset + +[*.rst] +indent_size = unset +insert_final_newline = unset + +[*.bat] +indent_style = tab + +[LICENSE] +indent_size = unset + +[docs/Makefile] +indent_style = tab diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 00000000..22317f6c --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,16 @@ +{ + "Exclude": [ + "pytest_django/fixtures.py", + ".tox/*", + ".ruff_cache/*", + ".mypy_cache/*", + ".pytest_cache/*", + "pytest_django.egg-info/*", + "__pycache__/*", + "zizmor.sarif", + "docs/_build/*" + ], + "Disable": { + "MaxLineLength": true + } +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9690a3f7..5a591524 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,12 +14,12 @@ jobs: timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - name: Build and Check Package - uses: hynek/build-and-inspect-python-package@b5076c307dc91924a82ad150cdd1533b444d3310 # v2.12.0 + uses: hynek/build-and-inspect-python-package@c52c3a4710070b50470d903818a7b25115dcd076 # v2.13.0 deploy: if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pytest-django' @@ -34,10 +34,10 @@ jobs: steps: - name: Download Package - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v5 with: name: Packages path: dist - name: Publish package - uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc # v1.12.4 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d56f905e..65501027 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -4,12 +4,12 @@ on: push: branches: - main + tags: + - "*" pull_request: - branches: - - main concurrency: - group: ci-main-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} env: @@ -25,12 +25,15 @@ jobs: timeout-minutes: 15 permissions: contents: read + security-events: write + env: + TOXENV: ${{ matrix.name }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: persist-credentials: false - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} @@ -49,11 +52,18 @@ jobs: - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install tox==4.11.1 + python -m pip install uv + uv tool install tox==4.28.4 --with tox-uv - name: Run tox - run: tox -e ${{ matrix.name }} + run: tox + + - name: Upload zizmor SARIF report into the GitHub repo code scanning + if: contains(matrix.name, 'linting') + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: zizmor.sarif + category: zizmor - name: Report coverage if: contains(matrix.name, 'coverage') @@ -71,6 +81,11 @@ jobs: python: '3.13' allow_failure: false + # Explicitly test min pytest. + - name: py313-dj52-sqlite-pytestmin-coverage + python: '3.13' + allow_failure: false + - name: py313-dj52-postgres-xdist-coverage python: '3.13' allow_failure: false @@ -131,15 +146,6 @@ jobs: python: '3.11' allow_failure: false - - name: py38-dj42-sqlite-xdist-coverage - python: '3.8' - allow_failure: false - - # Explicitly test min pytest. - - name: py38-dj42-sqlite-pytestmin-coverage - python: '3.8' - allow_failure: false - # pypy3: not included with coverage reports (much slower then). - name: pypy3-dj42-postgres python: 'pypy3.9' @@ -155,6 +161,6 @@ jobs: steps: - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@223e4bb7a751b91f43eda76992bcfbf23b8b0302 + uses: re-actors/alls-green@2765efec08f0fd63e83ad900f5fd75646be69ff6 with: jobs: ${{ toJSON(needs) }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000..a935769a --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,7 @@ +rules: + unpinned-uses: + config: + policies: + actions/*: ref-pin + codecov/codecov-action: ref-pin + github/*: ref-pin diff --git a/.gitignore b/.gitignore index 35f1856e..27011bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ _build *.egg # autogenerated by setuptools-scm /pytest_django/_version.py +zizmor.sarif diff --git a/.readthedocs.yml b/.readthedocs.yml index ba6a262b..c13e1e00 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -9,11 +9,11 @@ build: python: "3" python: - install: - - method: pip - path: . - extra_requirements: - - docs + install: + - method: pip + path: . + extra_requirements: + - docs formats: - epub diff --git a/Makefile b/Makefile deleted file mode 100644 index af72c983..00000000 --- a/Makefile +++ /dev/null @@ -1,13 +0,0 @@ -.PHONY: docs test clean fix - -test: - tox -e py-dj42-sqlite_file - -docs: - tox -e docs - -fix: - ruff check --fix pytest_django pytest_django_test tests - -clean: - rm -rf bin include/ lib/ man/ pytest_django.egg-info/ build/ diff --git a/README.rst b/README.rst index 90a9fb8d..87291333 100644 --- a/README.rst +++ b/README.rst @@ -32,9 +32,9 @@ pytest-django allows you to test your Django project/applications with the `_ * Version compatibility: - * Django: 4.2, 5.0, 5.1, 5.2 and latest main branch (compatible at the time + * Django: 4.2, 5.1, 5.2 and latest main branch (compatible at the time of each release) - * Python: CPython>=3.8 or PyPy 3 + * Python: CPython>=3.9 or PyPy 3 * pytest: >=7.0 For compatibility with older versions, use previous pytest-django releases. diff --git a/docs/changelog.rst b/docs/changelog.rst index 16d40cb6..d5cd706c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,14 @@ Changelog ========= +v4.12.0 (Not released yet) +-------------------------- + +Improvements +^^^^^^^^^^^^ + +* The :ref:`multiple databases ` support added in v4.3.0 is no longer considered experimental. + v4.11.1 (2025-04-03) -------------------- diff --git a/docs/contributing.rst b/docs/contributing.rst index d5003fc7..897d4ae0 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -140,10 +140,10 @@ writing), running them all will take a long time. All valid configurations can be found in `tox.ini`. To test against a few of them, invoke tox with the `-e` flag:: - $ tox -e py38-dj32-postgres,py310-dj41-mysql + $ tox -e py39-dj42-postgres,py310-dj52-mysql -This will run the tests on Python 3.8/Django 3.2/PostgeSQL and Python -3.10/Django 4.1/MySQL. +This will run the tests on Python 3.9/Django 4.2/PostgeSQL and Python +3.10/Django 5.2/MySQL. Measuring test coverage diff --git a/docs/database.rst b/docs/database.rst index c4410a6a..fcdd219a 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -37,8 +37,8 @@ documentation ` for detail:: assert me.is_superuser -By default ``pytest-django`` will set up the Django databases the -first time a test needs them. Once setup, the database is cached to be +By default ``pytest-django`` will set up Django databases the +first time a test needs them. Once setup, a database is cached to be used for all subsequent tests and rolls back transactions, to isolate tests from each other. This is the same way the standard Django :class:`~django.test.TestCase` uses the database. However @@ -67,22 +67,16 @@ Tests requiring multiple databases .. versionadded:: 4.3 -.. caution:: - - This support is **experimental** and is subject to change without - deprecation. We are still figuring out the best way to expose this - functionality. If you are using this successfully or unsuccessfully, - `let us know `_! - -``pytest-django`` has experimental support for multi-database configurations. -Currently ``pytest-django`` does not specifically support Django's -multi-database support, using the ``databases`` argument to the -:func:`django_db ` mark:: +``pytest-django`` has support for multi-database configurations using the +``databases`` argument to the :func:`django_db ` mark:: @pytest.mark.django_db(databases=['default', 'other']) def test_spam(): assert MyModel.objects.using('other').count() == 0 +If you don't specify ``databases``, only the default database is requested. +To request all databases, you may use the shortcut ``'__all__'``. + For details see :attr:`django.test.TransactionTestCase.databases` and :attr:`django.test.TestCase.databases`. diff --git a/docs/faq.rst b/docs/faq.rst index 8ad588b0..5a4f4d88 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -79,7 +79,7 @@ How can I use ``manage.py test`` with pytest-django? ---------------------------------------------------- pytest-django is designed to work with the ``pytest`` command, but if you -really need integration with ``manage.py test``, you can add this class path +really need integration with ``manage.py test``, you can add this class path in your Django settings: .. code-block:: python diff --git a/docs/helpers.rst b/docs/helpers.rst index c9e189dd..a1a6a59a 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -59,16 +59,10 @@ dynamically in a hook or fixture. :type databases: Iterable[str] | str | None :param databases: - .. caution:: - - This argument is **experimental** and is subject to change without - deprecation. We are still figuring out the best way to expose this - functionality. If you are using this successfully or unsuccessfully, - `let us know `_! The ``databases`` argument defines which databases in a multi-database configuration will be set up and may be used by the test. Defaults to - only the ``default`` database. The special value ``"__all__"`` may be use + only the ``default`` database. The special value ``"__all__"`` may be used to specify all configured databases. For details see :attr:`django.test.TransactionTestCase.databases` and :attr:`django.test.TestCase.databases`. diff --git a/docs/managing_python_path.rst b/docs/managing_python_path.rst index 37488662..561ef822 100644 --- a/docs/managing_python_path.rst +++ b/docs/managing_python_path.rst @@ -87,7 +87,7 @@ You can explicitly add paths to the Python search path using pytest's Example: project with src layout ```````````````````````````````` -For a Django package using the ``src`` layout, with test settings located in a +For a Django package using the ``src`` layout, with test settings located in a ``tests`` package at the top level:: myproj diff --git a/docs/usage.rst b/docs/usage.rst index edfead5e..6e9822c6 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -33,7 +33,7 @@ You can switch it on in `pytest.ini`:: [pytest] FAIL_INVALID_TEMPLATE_VARS = True - + Additional pytest.ini settings ------------------------------ diff --git a/pyproject.toml b/pyproject.toml index 3fb403da..75915cc8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ - "setuptools>=61.0.0", - "setuptools-scm[toml]>=5.0.0", + "setuptools>=61.0.0", + "setuptools-scm[toml]>=5.0.0", ] build-backend = "setuptools.build_meta" @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" name = "pytest-django" description = "A Django plugin for pytest." readme = "README.rst" -requires-python = ">=3.8" +requires-python = ">=3.9" dynamic = ["version"] authors = [ { name = "Andreas Pelme", email = "andreas@pelme.se" }, @@ -22,14 +22,12 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Framework :: Django", "Framework :: Django :: 4.2", - "Framework :: Django :: 5.0", "Framework :: Django :: 5.1", "Framework :: Django :: 5.2", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -42,7 +40,7 @@ classifiers = [ dependencies = [ "pytest>=7.0.0", ] -[project.optional-dependencies] +[dependency-groups] docs = [ "sphinx", "sphinx_rtd_theme", @@ -51,6 +49,25 @@ testing = [ "Django", "django-configurations>=2.0", ] +coverage = [ + "coverage[toml]", + "coverage-enable-subprocess", +] +postgres = [ + "psycopg[binary]", +] +mysql = [ + "mysqlclient==2.2.7", +] +xdist = [ + "pytest-xdist", +] +linting = [ + "editorconfig-checker==3.2.1", + "mypy==1.17.1", + "ruff==0.12.8", + "zizmor==1.11.0", +] [project.urls] Documentation = "https://pytest-django.readthedocs.io/" Repository = "https://github.com/pytest-dev/pytest-django" @@ -112,6 +129,7 @@ exclude_lines = [ ] [tool.ruff] +# preview = true # TODO: Enable this when we have the bandwidth line-length = 99 extend-exclude = [ "pytest_django/_version.py", @@ -119,28 +137,161 @@ extend-exclude = [ [tool.ruff.lint] extend-select = [ - "B", # flake8-bugbear + "AIR", # Airflow + "ERA", # eradicate + "FAST", # FastAPI + "YTT", # flake8-2020 + "ANN", # flake8-annotations + "ASYNC", # flake8-async + "S", # flake8-bandit "BLE", # flake8-blind-except + "FBT", # flake8-boolean-trap + "B", # flake8-bugbear + "A", # flake8-builtins + "COM", # flake8-commas + "C4", # flake8-comprehensions + "CPY", # flake8-copyright "DTZ", # flake8-datetimez + "T10", # flake8-debugger + "DJ", # flake8-django + "EM", # flake8-errmsg + "EXE", # flake8-executable + "FIX", # flake8-fixme "FA", # flake8-future-annotations + "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "ICN", # flake8-import-conventions + "LOG", # flake8-logging "G", # flake8-logging-format - "I", # isort - "PGH", # pygrep-hooks + "INP", # flake8-no-pep420 "PIE", # flake8-pie - "PL", # pylint - "PT", # flake8-pytest-style + "T20", # flake8-print "PYI", # flake8-pyi - "RUF", # Ruff-specific rules + "PT", # flake8-pytest-style + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "SLF", # flake8-self + "SIM", # flake8-simplify "SLOT", # flake8-slots - "T10", # flake8-debugger + "TID", # flake8-tidy-imports + "TD", # flake8-todos + "TC", # flake8-type-checking + "ARG", # flake8-unused-arguments + "PTH", # flake8-use-pathlib + "FLY", # flynt + "I", # isort + "C90", # mccabe + "PD", # pandas-vet + "N", # pep8-naming + "PERF", # Perflint + "E", # pycodestyle Error + "W", # pycodestyle Warning + "DOC", # pydoclint + "D", # pydocstyle + "F", # Pyflakes + "PGH", # pygrep-hooks + "PL", # Pylint "UP", # pyupgrade - "YTT", # flake8-2020 + "FURB", # refurb + "TRY", # tryceratops + "RUF", # Ruff-specific rules ] ignore = [ - "PLR0913", # Too many arguments in function definition - "PLR2004", # Magic value used in comparison, consider replacing 3 with a constant variable - "PT001", # Use `@pytest.fixture()` over `@pytest.fixture` - "PT023", # Use `@pytest.mark.django_db()` over `@pytest.mark.django_db` + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D104", # Missing docstring in public package + "D105", # Missing docstring in magic method + "D107", # Missing docstring in __init__ + "D200", # One-line docstring should fit on one line + "D202", # No blank lines allowed after function docstring + "D203", # Class definitions that are not preceded by a blank line + "D205", # 1 blank line required between summary line and description + "D209", # Multi-line docstring closing quotes should be on a separate line + "D212", # Multi-line docstring summary should start at the first line + "D213", # Multi-line docstring summary should start at the second line + "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood + "D404", # First word of the docstring should not be "This" + "D415", # First line should end with a period, question mark, or exclamation point + "S101", # Use of `assert` detected + + # TODO - need to fix these + "C901", # .. is too complex + "COM812", # Trailing comma missing + "E501", # Line too long + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use an f-string literal, assign to variable first + "FBT001", # Boolean-typed positional argument in function definition + "FBT002", # Boolean default positional argument in function definition + "FBT003", # Boolean positional value in function call + "N802", # Function name `assertRedirects` should be lowercase + "N806", # Variable `UserModel` in function should be lowercase + "PLC0415", # `import` should be at the top-level of a file + "PLR0913", # Too many arguments in function definition + "PLR2004", # Magic value used in comparison, consider replacing .. with a constant variable + "RET504", # Unnecessary assignment to .. before `return` statement + "RET505", # Unnecessary `elif` after `return` statement + "S105", # Possible hardcoded password assigned + "SIM102", # Use a single `if` statement instead of nested `if` statements + "SIM108", # Use ternary operator .. instead of `if`-`else`-block + "SIM114", # Combine `if` branches using logical `or` operator + "SLF001", # Private member accessed + "TC002", # Move third-party import `django.contrib.messages.Message` into a type-checking block + "TC003", # Move standard library import `collections.abc.Sequence` into a type-checking block + "TRY003", # Avoid specifying long messages outside the exception class +] +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ + "ANN", # Disable all annotations + "FIX003", # Line contains XXX, consider resolving the issue + "DJ008", # Model does not define .. method + "N801", # Class name should use CapWords convention + "N802", # Function name should be lowercase + "S", # Disable all security checks + "TD001", # Invalid TODO tag + "TD002", # Missing author in TODO + "TD003", # Missing issue link for this TODO + + # TODO - need to fix these + "ARG005", # Unused lambda argument + "D300", # Use triple double quotes `"""` + "D403", # First word of the docstring should be capitalized + "ERA001", # Found commented-out code + "SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements + "TC001", # Move application import .. into a type-checking block + "TC006", # Add quotes to type expression in `typing.cast()` + "PTH108", # `os.unlink()` should be replaced by `Path.unlink()` + "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` + "RET503", # Missing explicit `return` at the end of function able to return non-`None` value + "RSE102", # Unnecessary parentheses on raised exception +] +"pytest_django_test/*.py" = [ + "ANN", # Disable all annotations + "FIX003", # Line contains XXX, consider resolving the issue + "DJ008", # Model does not define .. method + "N801", # Class name should use CapWords convention + "N802", # Function name should be lowercase + "S", # Disable all security checks + "TD001", # Invalid TODO tag + "TD002", # Missing author in TODO + "TD003", # Missing issue link for this TODO + + # TODO - need to fix these + "ARG005", # Unused lambda argument + "D300", # Use triple double quotes `"""` + "D403", # First word of the docstring should be capitalized + "ERA001", # Found commented-out code + "SIM117", # Use a single `with` statement with multiple contexts instead of nested `with` statements + "TC001", # Move application import .. into a type-checking block + "TC006", # Add quotes to type expression in `typing.cast()` + "PTH108", # `os.unlink()` should be replaced by `Path.unlink()` + "PTH110", # `os.path.exists()` should be replaced by `Path.exists()` + "RET503", # Missing explicit `return` at the end of function able to return non-`None` value + "RSE102", # Unnecessary parentheses on raised exception ] [tool.ruff.lint.isort] diff --git a/pytest_django/asserts.py b/pytest_django/asserts.py index 14741066..76a45809 100644 --- a/pytest_django/asserts.py +++ b/pytest_django/asserts.py @@ -5,7 +5,7 @@ from __future__ import annotations from functools import wraps -from typing import TYPE_CHECKING, Any, Callable, Sequence +from typing import TYPE_CHECKING, Any, Callable from django import VERSION from django.test import LiveServerTestCase, SimpleTestCase, TestCase, TransactionTestCase @@ -25,11 +25,11 @@ class MessagesTestCase(MessagesTestMixin, TestCase): test_case = TestCase("run") -def _wrapper(name: str): +def _wrapper(name: str) -> Callable[..., Any]: func = getattr(test_case, name) @wraps(func) - def assertion_func(*args, **kwargs): + def assertion_func(*args: Any, **kwargs: Any) -> Any: return func(*args, **kwargs) return assertion_func @@ -55,7 +55,12 @@ def assertion_func(*args, **kwargs): if TYPE_CHECKING: + from collections.abc import Collection, Iterator, Sequence + from contextlib import AbstractContextManager + from typing import overload + from django import forms + from django.db.models import Model, QuerySet, RawQuerySet from django.http.response import HttpResponseBase def assertRedirects( @@ -110,34 +115,34 @@ def assertTemplateUsed( template_name: str | None = ..., msg_prefix: str = ..., count: int | None = ..., - ): ... + ) -> None: ... def assertTemplateNotUsed( response: HttpResponseBase | str | None = ..., template_name: str | None = ..., msg_prefix: str = ..., - ): ... + ) -> None: ... def assertRaisesMessage( expected_exception: type[Exception], expected_message: str, - *args, - **kwargs, - ): ... + *args: Any, + **kwargs: Any, + ) -> None: ... def assertWarnsMessage( expected_warning: Warning, expected_message: str, - *args, - **kwargs, - ): ... + *args: Any, + **kwargs: Any, + ) -> None: ... def assertFieldOutput( - fieldclass, - valid, - invalid, - field_args=..., - field_kwargs=..., + fieldclass: type[forms.Field], + valid: Any, + invalid: Any, + field_args: Any = ..., + field_kwargs: Any = ..., empty_value: str = ..., ) -> None: ... @@ -160,6 +165,13 @@ def assertInHTML( msg_prefix: str = ..., ) -> None: ... + # Added in Django 5.1. + def assertNotInHTML( + needle: str, + haystack: str, + msg_prefix: str = ..., + ) -> None: ... + def assertJSONEqual( raw: str, expected_data: Any, @@ -186,34 +198,44 @@ def assertXMLNotEqual( # Removed in Django 5.1: use assertQuerySetEqual. def assertQuerysetEqual( - qs, - values, - transform=..., + qs: Iterator[Any] | list[Model] | QuerySet | RawQuerySet, + values: Collection[Any], + transform: Callable[[Model], Any] | type[str] | None = ..., ordered: bool = ..., msg: str | None = ..., ) -> None: ... def assertQuerySetEqual( - qs, - values, - transform=..., + qs: Iterator[Any] | list[Model] | QuerySet | RawQuerySet, + values: Collection[Any], + transform: Callable[[Model], Any] | type[str] | None = ..., ordered: bool = ..., msg: str | None = ..., ) -> None: ... + @overload + def assertNumQueries( + num: int, func: None = None, *, using: str = ... + ) -> AbstractContextManager[None]: ... + + @overload + def assertNumQueries( + num: int, func: Callable[..., Any], *args: Any, using: str = ..., **kwargs: Any + ) -> None: ... + def assertNumQueries( num: int, func=..., - *args, + *args: Any, using: str = ..., - **kwargs, + **kwargs: Any, ): ... # Added in Django 5.0. def assertMessages( response: HttpResponseBase, expected_messages: Sequence[Message], - *args, + *args: Any, ordered: bool = ..., ) -> None: ... diff --git a/pytest_django/django_compat.py b/pytest_django/django_compat.py index 6c877130..301114a8 100644 --- a/pytest_django/django_compat.py +++ b/pytest_django/django_compat.py @@ -2,9 +2,23 @@ # this is the case before you call them. from __future__ import annotations +from typing import TYPE_CHECKING + import pytest +if TYPE_CHECKING: + from typing import TypeAlias + + from django.contrib.auth.models import AbstractBaseUser + + _User: TypeAlias = AbstractBaseUser + + _UserModel: TypeAlias = type[_User] + + __all__ = ("_User", "_UserModel") + + def is_django_unittest(request_or_item: pytest.FixtureRequest | pytest.Item) -> bool: """Returns whether the request or item is a Django test case.""" from django.test import SimpleTestCase diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 6dc05fdb..6f7929be 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -3,24 +3,10 @@ from __future__ import annotations import os -from contextlib import contextmanager +from collections.abc import Generator, Iterable, Sequence +from contextlib import AbstractContextManager, contextmanager from functools import partial -from typing import ( - TYPE_CHECKING, - AbstractSet, - Any, - Callable, - ContextManager, - Generator, - Iterable, - List, - Literal, - Optional, - Protocol, - Sequence, - Tuple, - Union, -) +from typing import TYPE_CHECKING, Protocol import pytest @@ -30,16 +16,18 @@ if TYPE_CHECKING: + from typing import Any, Callable, Literal, Optional, Union + import django import django.test from . import DjangoDbBlocker + from .django_compat import _User, _UserModel - -_DjangoDbDatabases = Optional[Union[Literal["__all__"], Iterable[str]]] -_DjangoDbAvailableApps = Optional[List[str]] -# transaction, reset_sequences, databases, serialized_rollback, available_apps -_DjangoDb = Tuple[bool, bool, _DjangoDbDatabases, bool, _DjangoDbAvailableApps] + _DjangoDbDatabases = Optional[Union[Literal["__all__"], Iterable[str]]] + _DjangoDbAvailableApps = Optional[list[str]] + # transaction, reset_sequences, databases, serialized_rollback, available_apps + _DjangoDb = tuple[bool, bool, _DjangoDbDatabases, bool, _DjangoDbAvailableApps] __all__ = [ @@ -87,15 +75,15 @@ def django_db_modify_db_settings_xdist_suffix(request: pytest.FixtureRequest) -> @pytest.fixture(scope="session") def django_db_modify_db_settings_parallel_suffix( - django_db_modify_db_settings_tox_suffix: None, - django_db_modify_db_settings_xdist_suffix: None, + django_db_modify_db_settings_tox_suffix: None, # noqa: ARG001 + django_db_modify_db_settings_xdist_suffix: None, # noqa: ARG001 ) -> None: skip_if_no_django() @pytest.fixture(scope="session") def django_db_modify_db_settings( - django_db_modify_db_settings_parallel_suffix: None, + django_db_modify_db_settings_parallel_suffix: None, # noqa: ARG001 ) -> None: """Modify db settings just before the databases are configured.""" skip_if_no_django() @@ -157,7 +145,7 @@ def _get_databases_for_test(test: pytest.Item) -> tuple[Iterable[str], bool]: def _get_databases_for_setup( items: Sequence[pytest.Item], -) -> tuple[AbstractSet[str], AbstractSet[str]]: +) -> tuple[set[str], set[str]]: """Get the database aliases that need to be setup, and the subset that needs to be serialized.""" # Code derived from django.test.utils.DiscoverRunner.get_databases(). @@ -174,12 +162,12 @@ def _get_databases_for_setup( @pytest.fixture(scope="session") def django_db_setup( request: pytest.FixtureRequest, - django_test_environment: None, + django_test_environment: None, # noqa: ARG001 django_db_blocker: DjangoDbBlocker, django_db_use_migrations: bool, django_db_keepdb: bool, django_db_createdb: bool, - django_db_modify_db_settings: None, + django_db_modify_db_settings: None, # noqa: ARG001 ) -> Generator[None, None, None]: """Top level fixture to ensure test databases are available""" from django.test.utils import setup_databases, teardown_databases @@ -215,10 +203,10 @@ def django_db_setup( ) -@pytest.fixture() +@pytest.fixture def _django_db_helper( request: pytest.FixtureRequest, - django_db_setup: None, + django_db_setup: None, # noqa: ARG001 django_db_blocker: DjangoDbBlocker, ) -> Generator[None, None, None]: if is_django_unittest(request): @@ -351,7 +339,7 @@ def __getitem__(self, item: str) -> None: settings.MIGRATION_MODULES = DisableMigrations() class MigrateSilentCommand(migrate.Command): - def handle(self, *args, **kwargs): + def handle(self, *args: Any, **kwargs: Any) -> Any: kwargs["verbosity"] = 0 return super().handle(*args, **kwargs) @@ -379,7 +367,7 @@ def _set_suffix_to_test_databases(suffix: str) -> None: # ############### User visible fixtures ################ -@pytest.fixture() +@pytest.fixture def db(_django_db_helper: None) -> None: """Require a django test database. @@ -396,7 +384,7 @@ def db(_django_db_helper: None) -> None: # The `_django_db_helper` fixture checks if `db` is requested. -@pytest.fixture() +@pytest.fixture def transactional_db(_django_db_helper: None) -> None: """Require a django test database with transaction support. @@ -412,7 +400,7 @@ def transactional_db(_django_db_helper: None) -> None: # The `_django_db_helper` fixture checks if `transactional_db` is requested. -@pytest.fixture() +@pytest.fixture def django_db_reset_sequences( _django_db_helper: None, transactional_db: None, @@ -428,7 +416,7 @@ def django_db_reset_sequences( # is requested. -@pytest.fixture() +@pytest.fixture def django_db_serialized_rollback( _django_db_helper: None, db: None, @@ -449,7 +437,7 @@ def django_db_serialized_rollback( # is requested. -@pytest.fixture() +@pytest.fixture def client() -> django.test.Client: """A Django test client instance.""" skip_if_no_django() @@ -459,7 +447,7 @@ def client() -> django.test.Client: return Client() -@pytest.fixture() +@pytest.fixture def async_client() -> django.test.AsyncClient: """A Django test async client instance.""" skip_if_no_django() @@ -469,27 +457,27 @@ def async_client() -> django.test.AsyncClient: return AsyncClient() -@pytest.fixture() -def django_user_model(db: None): +@pytest.fixture +def django_user_model(db: None) -> _UserModel: # noqa: ARG001 """The class of Django's user model.""" from django.contrib.auth import get_user_model - return get_user_model() + return get_user_model() # type: ignore[no-any-return] -@pytest.fixture() -def django_username_field(django_user_model) -> str: +@pytest.fixture +def django_username_field(django_user_model: _UserModel) -> str: """The fieldname for the username used with Django's user model.""" field: str = django_user_model.USERNAME_FIELD return field -@pytest.fixture() +@pytest.fixture def admin_user( - db: None, - django_user_model, + db: None, # noqa: ARG001 + django_user_model: _User, django_username_field: str, -): +) -> _User: """A Django admin user. This uses an existing user with username "admin", or creates a new one with @@ -515,10 +503,10 @@ def admin_user( return user -@pytest.fixture() +@pytest.fixture def admin_client( - db: None, - admin_user, + db: None, # noqa: ARG001 + admin_user: _User, ) -> django.test.Client: """A Django test client logged in as an admin user.""" from django.test import Client @@ -528,7 +516,7 @@ def admin_client( return client -@pytest.fixture() +@pytest.fixture def rf() -> django.test.RequestFactory: """RequestFactory instance""" skip_if_no_django() @@ -538,7 +526,7 @@ def rf() -> django.test.RequestFactory: return RequestFactory() -@pytest.fixture() +@pytest.fixture def async_rf() -> django.test.AsyncRequestFactory: """AsyncRequestFactory instance""" skip_if_no_django() @@ -564,14 +552,14 @@ def __delattr__(self, attr: str) -> None: self._to_restore.append(override) - def __setattr__(self, attr: str, value) -> None: + def __setattr__(self, attr: str, value: Any) -> None: from django.test import override_settings override = override_settings(**{attr: value}) override.enable() self._to_restore.append(override) - def __getattr__(self, attr: str): + def __getattr__(self, attr: str) -> Any: from django.conf import settings return getattr(settings, attr) @@ -583,8 +571,8 @@ def finalize(self) -> None: del self._to_restore[:] -@pytest.fixture() -def settings(): +@pytest.fixture +def settings() -> Generator[SettingsWrapper, None, None]: """A Django settings object which restores changes after the testrun""" skip_if_no_django() @@ -594,7 +582,9 @@ def settings(): @pytest.fixture(scope="session") -def live_server(request: pytest.FixtureRequest): +def live_server( + request: pytest.FixtureRequest, +) -> Generator[live_server_helper.LiveServer, None, None]: """Run a live Django server in the background during tests The address the server is started from is taken from the @@ -716,13 +706,13 @@ def _assert_num_queries( pytest.fail(msg) -@pytest.fixture() +@pytest.fixture def django_assert_num_queries(pytestconfig: pytest.Config) -> DjangoAssertNumQueries: """Allows to check for an expected number of DB queries.""" return partial(_assert_num_queries, pytestconfig) -@pytest.fixture() +@pytest.fixture def django_assert_max_num_queries(pytestconfig: pytest.Config) -> DjangoAssertNumQueries: """Allows to check for an expected maximum number of DB queries.""" return partial(_assert_num_queries, pytestconfig, exact=False) @@ -736,11 +726,11 @@ def __call__( *, using: str = ..., execute: bool = ..., - ) -> ContextManager[list[Callable[[], Any]]]: + ) -> AbstractContextManager[list[Callable[[], Any]]]: pass # pragma: no cover -@pytest.fixture() +@pytest.fixture def django_capture_on_commit_callbacks() -> DjangoCaptureOnCommitCallbacks: """Captures transaction.on_commit() callbacks for the given database connection.""" from django.test import TestCase diff --git a/pytest_django/live_server_helper.py b/pytest_django/live_server_helper.py index 03b92e1f..e43b7e7b 100644 --- a/pytest_django/live_server_helper.py +++ b/pytest_django/live_server_helper.py @@ -84,7 +84,7 @@ def url(https://github.com/self) -> str: def __str__(self) -> str: return self.url - def __add__(self, other) -> str: + def __add__(self, other: str) -> str: return f"{self}{other}" def __repr__(self) -> str: diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index e8e629f4..314fb856 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -12,8 +12,10 @@ import pathlib import sys import types +from collections.abc import Generator +from contextlib import AbstractContextManager from functools import reduce -from typing import TYPE_CHECKING, ContextManager, Generator, List, NoReturn +from typing import TYPE_CHECKING import pytest @@ -52,6 +54,8 @@ if TYPE_CHECKING: + from typing import Any, NoReturn + import django @@ -184,7 +188,7 @@ def _handle_import_error(extra_message: str) -> Generator[None, None, None]: raise ImportError(msg) from None -def _add_django_project_to_path(args) -> str: +def _add_django_project_to_path(args: list[str]) -> str: def is_django_project(path: pathlib.Path) -> bool: try: return path.is_dir() and (path / "manage.py").exists() @@ -196,7 +200,7 @@ def arg_to_path(arg: str) -> pathlib.Path: arg = arg.split("::", 1)[0] return pathlib.Path(arg) - def find_django_path(args) -> pathlib.Path | None: + def find_django_path(args: list[str]) -> pathlib.Path | None: str_args = (str(arg) for arg in args) path_args = [arg_to_path(x) for x in str_args if not x.startswith("-")] @@ -259,7 +263,7 @@ def _get_boolean_value( ) from None -report_header_key = pytest.StashKey[List[str]]() +report_header_key = pytest.StashKey[list[str]]() @pytest.hookimpl() @@ -569,7 +573,7 @@ def _django_setup_unittest( original_runtest = TestCaseFunction.runtest - def non_debugging_runtest(self) -> None: + def non_debugging_runtest(self) -> None: # noqa: ANN001 self._testcase(result=self) from django.test import SimpleTestCase @@ -603,9 +607,9 @@ def _dj_autoclear_mailbox() -> None: mail.outbox.clear() -@pytest.fixture() +@pytest.fixture def mailoutbox( - django_mail_patch_dns: None, + django_mail_patch_dns: None, # noqa: ARG001 _dj_autoclear_mailbox: None, ) -> list[django.core.mail.EmailMessage] | None: """A clean email outbox to which Django-generated emails are sent.""" @@ -618,7 +622,7 @@ def mailoutbox( return [] -@pytest.fixture() +@pytest.fixture def django_mail_patch_dns( monkeypatch: pytest.MonkeyPatch, django_mail_dnsname: str, @@ -629,7 +633,7 @@ def django_mail_patch_dns( monkeypatch.setattr(mail.message, "DNS_NAME", django_mail_dnsname) -@pytest.fixture() +@pytest.fixture def django_mail_dnsname() -> str: """Return server dns name for using in email messages.""" return "fake-tests.example.com" @@ -829,7 +833,7 @@ def _dj_db_wrapper(self) -> django.db.backends.base.base.BaseDatabaseWrapper: def _save_active_wrapper(self) -> None: self._history.append(self._dj_db_wrapper.ensure_connection) - def _blocking_wrapper(*args, **kwargs) -> NoReturn: + def _blocking_wrapper(*args: Any, **kwargs: Any) -> NoReturn: # noqa: ARG002 __tracebackhide__ = True raise RuntimeError( "Database access not allowed, " @@ -837,13 +841,13 @@ def _blocking_wrapper(*args, **kwargs) -> NoReturn: '"db" or "transactional_db" fixtures to enable it.' ) - def unblock(self) -> ContextManager[None]: + def unblock(self) -> AbstractContextManager[None]: """Enable access to the Django database.""" self._save_active_wrapper() self._dj_db_wrapper.ensure_connection = self._real_ensure_connection return _DatabaseBlockerContextManager(self) - def block(self) -> ContextManager[None]: + def block(self) -> AbstractContextManager[None]: """Disable access to the Django database.""" self._save_active_wrapper() self._dj_db_wrapper.ensure_connection = self._blocking_wrapper diff --git a/pytest_django/runner.py b/pytest_django/runner.py index d9032622..c040b749 100644 --- a/pytest_django/runner.py +++ b/pytest_django/runner.py @@ -1,5 +1,6 @@ from argparse import ArgumentParser -from typing import Any, Iterable +from collections.abc import Iterable +from typing import Any class TestRunner: @@ -11,7 +12,7 @@ def __init__( verbosity: int = 1, failfast: bool = False, keepdb: bool = False, - **kwargs: Any, + **kwargs: Any, # noqa: ARG002 ) -> None: self.verbosity = verbosity self.failfast = failfast @@ -23,7 +24,11 @@ def add_arguments(cls, parser: ArgumentParser) -> None: "--keepdb", action="https://github.com/store_true", help="Preserves the test DB between runs." ) - def run_tests(self, test_labels: Iterable[str], **kwargs: Any) -> int: + def run_tests( + self, + test_labels: Iterable[str], + **kwargs: Any, # noqa: ARG002 + ) -> int: """Run pytest and return the exitcode. It translates some of Django's test command option to pytest's. diff --git a/pytest_django_test/app/views.py b/pytest_django_test/app/views.py index 053f70a9..6c15babf 100644 --- a/pytest_django_test/app/views.py +++ b/pytest_django_test/app/views.py @@ -10,5 +10,5 @@ def admin_required_view(request: HttpRequest) -> HttpResponse: return HttpResponse(Template("You are an admin").render(Context())) -def item_count(request: HttpRequest) -> HttpResponse: +def item_count(request: HttpRequest) -> HttpResponse: # noqa: ARG001 return HttpResponse(f"Item count: {Item.objects.count()}") diff --git a/pytest_django_test/db_helpers.py b/pytest_django_test/db_helpers.py index 712af0d3..b9efe86d 100644 --- a/pytest_django_test/db_helpers.py +++ b/pytest_django_test/db_helpers.py @@ -3,7 +3,7 @@ import os import sqlite3 import subprocess -from typing import Mapping +from collections.abc import Mapping import pytest from django.conf import settings diff --git a/pytest_django_test/db_router.py b/pytest_django_test/db_router.py index e18ae853..8383a7b5 100644 --- a/pytest_django_test/db_router.py +++ b/pytest_django_test/db_router.py @@ -1,14 +1,14 @@ class DbRouter: - def db_for_read(self, model, **hints): + def db_for_read(self, model, **hints): # noqa: ARG002 if model._meta.app_label == "app" and model._meta.model_name == "seconditem": return "second" return None - def db_for_write(self, model, **hints): + def db_for_write(self, model, **hints): # noqa: ARG002 if model._meta.app_label == "app" and model._meta.model_name == "seconditem": return "second" return None - def allow_migrate(self, db, app_label, model_name=None, **hints): + def allow_migrate(self, db, app_label, model_name=None, **hints): # noqa: ARG002 if app_label == "app" and model_name == "seconditem": return db == "second" diff --git a/tests/conftest.py b/tests/conftest.py index e3bfa1f4..7bef403f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -46,7 +46,7 @@ def pytester(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> pyte return pytester -@pytest.fixture() +@pytest.fixture def django_pytester( request: pytest.FixtureRequest, pytester: pytest.Pytester, diff --git a/tests/test_database.py b/tests/test_database.py index c6389756..cb5b54a0 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Generator +from collections.abc import Generator import pytest from django.db import connection, transaction @@ -39,7 +39,7 @@ def test_noaccess_fixture(noaccess: None) -> None: @pytest.fixture -def non_zero_sequences_counter(db: None) -> None: +def non_zero_sequences_counter(db: None) -> None: # noqa: ARG001 """Ensure that the db's internal sequence counter is > 1. This is used to test the `reset_sequences` feature. @@ -73,20 +73,20 @@ def all_dbs(self, request: pytest.FixtureRequest) -> None: else: raise AssertionError() # pragma: no cover - def test_access(self, all_dbs: None) -> None: + def test_access(self, all_dbs: None) -> None: # noqa: ARG002 Item.objects.create(name="spam") - def test_clean_db(self, all_dbs: None) -> None: + def test_clean_db(self, all_dbs: None) -> None: # noqa: ARG002 # Relies on the order: test_access created an object assert Item.objects.count() == 0 - def test_transactions_disabled(self, db: None) -> None: + def test_transactions_disabled(self, db: None) -> None: # noqa: ARG002 if not connection.features.supports_transactions: pytest.skip("transactions required for this test") assert connection.in_atomic_block - def test_transactions_enabled(self, transactional_db: None) -> None: + def test_transactions_enabled(self, transactional_db: None) -> None: # noqa: ARG002 if not connection.features.supports_transactions: pytest.skip("transactions required for this test") @@ -94,7 +94,7 @@ def test_transactions_enabled(self, transactional_db: None) -> None: def test_transactions_enabled_via_reset_seq( self, - django_db_reset_sequences: None, + django_db_reset_sequences: None, # noqa: ARG002 ) -> None: if not connection.features.supports_transactions: pytest.skip("transactions required for this test") @@ -103,9 +103,9 @@ def test_transactions_enabled_via_reset_seq( def test_django_db_reset_sequences_fixture( self, - db: None, + db: None, # noqa: ARG002 django_pytester: DjangoPytester, - non_zero_sequences_counter: None, + non_zero_sequences_counter: None, # noqa: ARG002 ) -> None: if not db_supports_reset_sequences(): pytest.skip( @@ -130,7 +130,11 @@ def test_django_db_reset_sequences_requested( result = django_pytester.runpytest_subprocess("-v", "--reuse-db") result.stdout.fnmatch_lines(["*test_django_db_reset_sequences_requested PASSED*"]) - def test_serialized_rollback(self, db: None, django_pytester: DjangoPytester) -> None: + def test_serialized_rollback( + self, + db: None, # noqa: ARG002 + django_pytester: DjangoPytester, + ) -> None: django_pytester.create_app_file( """ from django.db import migrations @@ -176,11 +180,11 @@ def test_serialized_rollback_3(): assert result.ret == 0 @pytest.fixture - def mydb(self, all_dbs: None) -> None: + def mydb(self, all_dbs: None) -> None: # noqa: ARG002 # This fixture must be able to access the database Item.objects.create(name="spam") - def test_mydb(self, mydb: None) -> None: + def test_mydb(self, mydb: None) -> None: # noqa: ARG002 if not connection.features.supports_transactions: pytest.skip("transactions required for this test") @@ -188,13 +192,13 @@ def test_mydb(self, mydb: None) -> None: item = Item.objects.get(name="spam") assert item - def test_fixture_clean(self, all_dbs: None) -> None: + def test_fixture_clean(self, all_dbs: None) -> None: # noqa: ARG002 # Relies on the order: test_mydb created an object # See https://github.com/pytest-dev/pytest-django/issues/17 assert Item.objects.count() == 0 @pytest.fixture - def fin(self, request: pytest.FixtureRequest, all_dbs: None) -> Generator[None, None, None]: + def fin(self, all_dbs: None) -> Generator[None, None, None]: # noqa: ARG002 # This finalizer must be able to access the database yield Item.objects.create(name="spam") @@ -203,7 +207,7 @@ def test_fin(self, fin: None) -> None: # Check finalizer has db access (teardown will fail if not) pass - def test_durable_transactions(self, all_dbs: None) -> None: + def test_durable_transactions(self, all_dbs: None) -> None: # noqa: ARG002 with transaction.atomic(durable=True): item = Item.objects.create(name="foo") assert Item.objects.get() == item @@ -211,19 +215,19 @@ def test_durable_transactions(self, all_dbs: None) -> None: class TestDatabaseFixturesAllOrder: @pytest.fixture - def fixture_with_db(self, db: None) -> None: + def fixture_with_db(self, db: None) -> None: # noqa: ARG002 Item.objects.create(name="spam") @pytest.fixture - def fixture_with_transdb(self, transactional_db: None) -> None: + def fixture_with_transdb(self, transactional_db: None) -> None: # noqa: ARG002 Item.objects.create(name="spam") @pytest.fixture - def fixture_with_reset_sequences(self, django_db_reset_sequences: None) -> None: + def fixture_with_reset_sequences(self, django_db_reset_sequences: None) -> None: # noqa: ARG002 Item.objects.create(name="spam") @pytest.fixture - def fixture_with_serialized_rollback(self, django_db_serialized_rollback: None) -> None: + def fixture_with_serialized_rollback(self, django_db_serialized_rollback: None) -> None: # noqa: ARG002 Item.objects.create(name="ham") def test_trans(self, fixture_with_transdb: None) -> None: @@ -311,27 +315,27 @@ def test_databases(self, request: pytest.FixtureRequest) -> None: assert marker.kwargs["databases"] == ["default", "replica", "second"] @pytest.mark.django_db(databases=["second"]) - def test_second_database(self, request: pytest.FixtureRequest) -> None: + def test_second_database(self) -> None: SecondItem.objects.create(name="spam") @pytest.mark.django_db(databases=["default"]) - def test_not_allowed_database(self, request: pytest.FixtureRequest) -> None: + def test_not_allowed_database(self) -> None: with pytest.raises(AssertionError, match="not allowed"): SecondItem.objects.count() with pytest.raises(AssertionError, match="not allowed"): SecondItem.objects.create(name="spam") @pytest.mark.django_db(databases=["replica"]) - def test_replica_database(self, request: pytest.FixtureRequest) -> None: + def test_replica_database(self) -> None: Item.objects.using("replica").count() @pytest.mark.django_db(databases=["replica"]) - def test_replica_database_not_allowed(self, request: pytest.FixtureRequest) -> None: + def test_replica_database_not_allowed(self) -> None: with pytest.raises(AssertionError, match="not allowed"): Item.objects.count() @pytest.mark.django_db(transaction=True, databases=["default", "replica"]) - def test_replica_mirrors_default_database(self, request: pytest.FixtureRequest) -> None: + def test_replica_mirrors_default_database(self) -> None: Item.objects.create(name="spam") Item.objects.using("replica").create(name="spam") @@ -339,7 +343,7 @@ def test_replica_mirrors_default_database(self, request: pytest.FixtureRequest) assert Item.objects.using("replica").count() == 2 @pytest.mark.django_db(databases="__all__") - def test_all_databases(self, request: pytest.FixtureRequest) -> None: + def test_all_databases(self) -> None: Item.objects.count() Item.objects.create(name="spam") SecondItem.objects.count() @@ -369,7 +373,7 @@ def test_available_apps_enabled(self, request: pytest.FixtureRequest) -> None: assert marker.kwargs["available_apps"] == ["pytest_django_test.app"] @pytest.mark.django_db - def test_available_apps_default(self, request: pytest.FixtureRequest) -> None: + def test_available_apps_default(self) -> None: from django.apps import apps from django.conf import settings @@ -377,7 +381,7 @@ def test_available_apps_default(self, request: pytest.FixtureRequest) -> None: assert apps.is_installed(app) @pytest.mark.django_db(available_apps=["pytest_django_test.app"]) - def test_available_apps_limited(self, request: pytest.FixtureRequest) -> None: + def test_available_apps_limited(self) -> None: from django.apps import apps from django.conf import settings diff --git a/tests/test_django_configurations.py b/tests/test_django_configurations.py index 88d89cf6..6e1a2b6d 100644 --- a/tests/test_django_configurations.py +++ b/tests/test_django_configurations.py @@ -58,9 +58,9 @@ def test_dc_env_overrides_ini(pytester: pytest.Pytester, monkeypatch: pytest.Mon pytester.makeini( """ - [pytest] - DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini - DJANGO_CONFIGURATION = DO_NOT_USE_ini + [pytest] + DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini + DJANGO_CONFIGURATION = DO_NOT_USE_ini """ ) pkg = pytester.mkpydir("tpkg") @@ -91,9 +91,9 @@ def test_dc_ini(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> N pytester.makeini( """ - [pytest] - DJANGO_SETTINGS_MODULE = tpkg.settings_ini - DJANGO_CONFIGURATION = MySettings + [pytest] + DJANGO_SETTINGS_MODULE = tpkg.settings_ini + DJANGO_CONFIGURATION = MySettings """ ) pkg = pytester.mkpydir("tpkg") @@ -125,9 +125,9 @@ def test_dc_option(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) - pytester.makeini( """ - [pytest] - DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini - DJANGO_CONFIGURATION = DO_NOT_USE_ini + [pytest] + DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini + DJANGO_CONFIGURATION = DO_NOT_USE_ini """ ) pkg = pytester.mkpydir("tpkg") diff --git a/tests/test_django_settings_module.py b/tests/test_django_settings_module.py index fa4db778..68d587e9 100644 --- a/tests/test_django_settings_module.py +++ b/tests/test_django_settings_module.py @@ -22,8 +22,8 @@ def test_ds_ini(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.delenv("DJANGO_SETTINGS_MODULE") pytester.makeini( """ - [pytest] - DJANGO_SETTINGS_MODULE = tpkg.settings_ini + [pytest] + DJANGO_SETTINGS_MODULE = tpkg.settings_ini """ ) pkg = pytester.mkpydir("tpkg") @@ -72,8 +72,8 @@ def test_ds_option(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) - monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "DO_NOT_USE_env") pytester.makeini( """ - [pytest] - DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini + [pytest] + DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini """ ) pkg = pytester.mkpydir("tpkg") @@ -101,8 +101,8 @@ def test_ds_env_override_ini(pytester: pytest.Pytester, monkeypatch: pytest.Monk monkeypatch.setenv("DJANGO_SETTINGS_MODULE", "tpkg.settings_env") pytester.makeini( """\ - [pytest] - DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini + [pytest] + DJANGO_SETTINGS_MODULE = DO_NOT_USE_ini """ ) pkg = pytester.mkpydir("tpkg") @@ -166,8 +166,10 @@ def test_ds_in_pytest_configure( def pytest_configure(): if not settings.configured: - os.environ.setdefault('DJANGO_SETTINGS_MODULE', - 'tpkg.settings_ds') + os.environ.setdefault( + 'DJANGO_SETTINGS_MODULE', + 'tpkg.settings_ds', + ) """ ) @@ -196,18 +198,24 @@ def test_django_settings_configure( p = pytester.makepyfile( run=""" - from django.conf import settings - settings.configure(SECRET_KEY='set from settings.configure()', - DATABASES={'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:' - }}, - INSTALLED_APPS=['django.contrib.auth', - 'django.contrib.contenttypes',]) - - import pytest - - pytest.main() + from django.conf import settings + settings.configure( + SECRET_KEY='set from settings.configure()', + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[ + 'django.contrib.auth', + 'django.contrib.contenttypes', + ] + ) + + import pytest + + pytest.main() """ ) @@ -249,12 +257,19 @@ def test_settings_in_hook(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyP from django.conf import settings def pytest_configure(): - settings.configure(SECRET_KEY='set from pytest_configure', - DATABASES={'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:'}}, - INSTALLED_APPS=['django.contrib.auth', - 'django.contrib.contenttypes',]) + settings.configure( + SECRET_KEY='set from pytest_configure', + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[ + 'django.contrib.auth', + 'django.contrib.contenttypes', + ] + ) """ ) pytester.makepyfile( @@ -305,13 +320,20 @@ def test_debug_false_by_default( from django.conf import settings def pytest_configure(): - settings.configure(SECRET_KEY='set from pytest_configure', - DEBUG=True, - DATABASES={'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:'}}, - INSTALLED_APPS=['django.contrib.auth', - 'django.contrib.contenttypes',]) + settings.configure( + SECRET_KEY='set from pytest_configure', + DEBUG=True, + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[ + 'django.contrib.auth', + 'django.contrib.contenttypes', + ] + ) """ ) @@ -336,8 +358,8 @@ def test_django_debug_mode_true_false( monkeypatch.delenv("DJANGO_SETTINGS_MODULE") pytester.makeini( f""" - [pytest] - django_debug_mode = {django_debug_mode} + [pytest] + django_debug_mode = {django_debug_mode} """ ) pytester.makeconftest( @@ -345,13 +367,20 @@ def test_django_debug_mode_true_false( from django.conf import settings def pytest_configure(): - settings.configure(SECRET_KEY='set from pytest_configure', - DEBUG=%s, - DATABASES={'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:'}}, - INSTALLED_APPS=['django.contrib.auth', - 'django.contrib.contenttypes',]) + settings.configure( + SECRET_KEY='set from pytest_configure', + DEBUG=%s, + DATABASES={ + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } + }, + INSTALLED_APPS=[ + 'django.contrib.auth', + 'django.contrib.contenttypes', + ] + ) """ % (not django_debug_mode) ) diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py index f88ed802..16a548d4 100644 --- a/tests/test_fixtures.py +++ b/tests/test_fixtures.py @@ -4,9 +4,13 @@ fixtures are tested in test_database. """ +from __future__ import annotations + +import os import socket +from collections.abc import Generator from contextlib import contextmanager -from typing import Generator +from typing import TYPE_CHECKING from urllib.error import HTTPError from urllib.request import urlopen @@ -24,6 +28,12 @@ from pytest_django_test.app.models import Item +if TYPE_CHECKING: + from pytest_django.django_compat import _User, _UserModel + from pytest_django.fixtures import SettingsWrapper + from pytest_django.live_server_helper import LiveServer + + @contextmanager def nonverbose_config(config: pytest.Config) -> Generator[None, None, None]: """Ensure that pytest's config.option.verbose is <= 0.""" @@ -51,7 +61,10 @@ def test_admin_client(admin_client: Client) -> None: assert force_str(resp.content) == "You are an admin" -def test_admin_client_no_db_marker(admin_client: Client) -> None: +def test_admin_client_no_db_marker( + db: None, # noqa: ARG001 + admin_client: Client, +) -> None: assert isinstance(admin_client, Client) resp = admin_client.get("/admin-required/") assert force_str(resp.content) == "You are an admin" @@ -59,14 +72,13 @@ def test_admin_client_no_db_marker(admin_client: Client) -> None: # For test below. @pytest.fixture -def existing_admin_user(django_user_model): +def existing_admin_user(django_user_model: _UserModel) -> _User: return django_user_model._default_manager.create_superuser("admin", None, None) +@pytest.mark.django_db +@pytest.mark.usefixtures("existing_admin_user", "admin_user") def test_admin_client_existing_user( - db: None, - existing_admin_user, - admin_user, admin_client: Client, ) -> None: resp = admin_client.get("/admin-required/") @@ -137,7 +149,7 @@ def test_django_assert_max_num_queries_db( @pytest.mark.django_db(transaction=True) def test_django_assert_num_queries_transactional_db( request: pytest.FixtureRequest, - transactional_db: None, + transactional_db: None, # noqa: ARG001 django_assert_num_queries: DjangoAssertNumQueries, ) -> None: with nonverbose_config(request.config): @@ -366,7 +378,13 @@ def test_deleted_again(self, settings) -> None: def test_signals(self, settings) -> None: result = [] - def assert_signal(signal, sender, setting, value, enter) -> None: + def assert_signal( + signal, # noqa: ARG001 + sender, # noqa: ARG001 + setting, + value, + enter, + ) -> None: result.append((setting, value, enter)) from django.test.signals import setting_changed @@ -401,10 +419,12 @@ def receiver(sender, **kwargs): '<>')} fmt_dict.update(kwargs) - print('Setting changed: ' - 'enter=%(enter)s,setting=%(setting)s,' - 'value=%(value)s,actual_value=%(actual_value)s' - % fmt_dict) + print( + 'Setting changed: ' + 'enter=%(enter)s,setting=%(setting)s,' + 'value=%(value)s,actual_value=%(actual_value)s' + % fmt_dict + ) setting_changed.connect(receiver, weak=False) @@ -416,7 +436,7 @@ def test_set(settings): def test_set_non_existent(settings): settings.FOOBAR = 'abc123' - """ + """ ) result = django_pytester.runpytest_subprocess("--tb=short", "-v", "-s") @@ -443,6 +463,7 @@ def test_set_non_existent(settings): class TestLiveServer: + @pytest.mark.skipif("PYTEST_XDIST_WORKER" in os.environ, reason="xdist in use") def test_settings_before(self) -> None: from django.conf import settings @@ -452,12 +473,17 @@ def test_settings_before(self) -> None: ) TestLiveServer._test_settings_before_run = True # type: ignore[attr-defined] - def test_url(https://github.com/self,%20live_server) -> None: + def test_url(https://github.com/self,%20live_server:%20LiveServer) -> None: assert live_server.url == force_str(live_server) - def test_change_settings(self, live_server, settings) -> None: + def test_change_settings( + self, + live_server: LiveServer, + settings: SettingsWrapper, # noqa: ARG002 + ) -> None: assert live_server.url == force_str(live_server) + @pytest.mark.skipif("PYTEST_XDIST_WORKER" in os.environ, reason="xdist in use") def test_settings_restored(self) -> None: """Ensure that settings are restored after test_settings_before.""" from django.conf import settings @@ -469,7 +495,7 @@ def test_settings_restored(self) -> None: ) assert settings.ALLOWED_HOSTS == ["testserver"] - def test_transactions(self, live_server) -> None: + def test_transactions(self, live_server: LiveServer) -> None: # noqa: ARG002 if not connection.features.supports_transactions: pytest.skip("transactions required for this test") @@ -482,12 +508,20 @@ def test_db_changes_visibility(self, live_server) -> None: response_data = urlopen(live_server + "/item_count/").read() assert force_str(response_data) == "Item count: 1" - def test_fixture_db(self, db: None, live_server) -> None: + def test_fixture_db( + self, + db: None, # noqa: ARG002 + live_server: LiveServer, + ) -> None: Item.objects.create(name="foo") response_data = urlopen(live_server + "/item_count/").read() assert force_str(response_data) == "Item count: 1" - def test_fixture_transactional_db(self, transactional_db: None, live_server) -> None: + def test_fixture_transactional_db( + self, + transactional_db: None, # noqa: ARG002 + live_server: LiveServer, + ) -> None: Item.objects.create(name="foo") response_data = urlopen(live_server + "/item_count/").read() assert force_str(response_data) == "Item count: 1" @@ -499,24 +533,32 @@ def item(self) -> Item: item: Item = Item.objects.create(name="foo") return item - def test_item(self, item: Item, live_server) -> None: + def test_item(self, item: Item, live_server: LiveServer) -> None: pass @pytest.fixture - def item_db(self, db: None) -> Item: + def item_db(self, db: None) -> Item: # noqa: ARG002 item: Item = Item.objects.create(name="foo") return item - def test_item_db(self, item_db: Item, live_server) -> None: + def test_item_db( + self, + item_db: Item, # noqa: ARG002 + live_server, + ) -> None: response_data = urlopen(live_server + "/item_count/").read() assert force_str(response_data) == "Item count: 1" @pytest.fixture - def item_transactional_db(self, transactional_db: None) -> Item: + def item_transactional_db(self, transactional_db: None) -> Item: # noqa: ARG002 item: Item = Item.objects.create(name="foo") return item - def test_item_transactional_db(self, item_transactional_db: Item, live_server) -> None: + def test_item_transactional_db( + self, + item_transactional_db: Item, # noqa: ARG002 + live_server: LiveServer, + ) -> None: response_data = urlopen(live_server + "/item_count/").read() assert force_str(response_data) == "Item count: 1" @@ -537,7 +579,6 @@ def test_item_transactional_db(self, item_transactional_db: Item, live_server) - def test_serve_static_with_staticfiles_app( self, django_pytester: DjangoPytester, - settings, ) -> None: """ LiveServer always serves statics with ``django.contrib.staticfiles`` @@ -562,7 +603,7 @@ def test_a(self, live_server, settings): result.stdout.fnmatch_lines(["*test_a*PASSED*"]) assert result.ret == 0 - def test_serve_static_dj17_without_staticfiles_app(self, live_server, settings) -> None: + def test_serve_static_dj17_without_staticfiles_app(self, live_server) -> None: """ Because ``django.contrib.staticfiles`` is not installed LiveServer can not serve statics with django >= 1.7 . @@ -725,7 +766,7 @@ class Test_django_db_blocker: def test_block_manually(self, django_db_blocker: DjangoDbBlocker) -> None: try: django_db_blocker.block() - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="^Database access not allowed,"): Item.objects.exists() finally: django_db_blocker.restore() @@ -733,7 +774,7 @@ def test_block_manually(self, django_db_blocker: DjangoDbBlocker) -> None: @pytest.mark.django_db def test_block_with_block(self, django_db_blocker: DjangoDbBlocker) -> None: with django_db_blocker.block(): - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="^Database access not allowed,"): Item.objects.exists() def test_unblock_manually(self, django_db_blocker: DjangoDbBlocker) -> None: @@ -782,8 +823,7 @@ def django_mail_dnsname(): return 'from.django_mail_dnsname' def test_mailbox_inner(mailoutbox): - mail.send_mail('subject', 'body', 'from@example.com', - ['to@example.com']) + mail.send_mail('subject', 'body', 'from@example.com', ['to@example.com']) m = mailoutbox[0] message = m.message() assert message['Message-ID'].endswith('@from.django_mail_dnsname>') @@ -814,8 +854,9 @@ def mocked_make_msgid(*args, **kwargs): mocked_make_msgid.called = [] monkeypatch.setattr(mail.message, 'make_msgid', mocked_make_msgid) - mail.send_mail('subject', 'body', 'from@example.com', - ['to@example.com']) + mail.send_mail( + 'subject', 'body', 'from@example.com', ['to@example.com'] + ) m = mailoutbox[0] assert len(mocked_make_msgid.called) == 1 diff --git a/tests/test_initialization.py b/tests/test_initialization.py index a15b9f9a..631a41ed 100644 --- a/tests/test_initialization.py +++ b/tests/test_initialization.py @@ -1,14 +1,9 @@ from textwrap import dedent -import pytest - from .helpers import DjangoPytester -def test_django_setup_order_and_uniqueness( - django_pytester: DjangoPytester, - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_django_setup_order_and_uniqueness(django_pytester: DjangoPytester) -> None: """ The django.setup() function shall not be called multiple times by pytest-django, since it resets logging conf each time. diff --git a/tests/test_runner.py b/tests/test_runner.py index 71fd7160..a0bee059 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -6,7 +6,7 @@ @pytest.mark.parametrize( - "kwargs, expected", + ("kwargs", "expected"), [ ({}, call(["tests"])), ({"verbosity": 0}, call(["--quiet", "tests"])), diff --git a/tox.ini b/tox.ini index aed0b8a3..5ffeeead 100644 --- a/tox.ini +++ b/tox.ini @@ -1,30 +1,26 @@ [tox] envlist = py313-dj{main,52,51}-postgres - py312-dj{main,52,51,50,42}-postgres - py311-dj{main,52,51,50,42}-postgres - py310-dj{main,52,51,50,42}-postgres + py312-dj{main,52,51,42}-postgres + py311-dj{main,52,51,42}-postgres + py310-dj{main,52,51,42}-postgres py39-dj42-postgres - py38-dj42-postgres linting [testenv] -extras = testing +dependency_groups = + testing + coverage: coverage + mysql: mysql + postgres: postgres + xdist: xdist deps = djmain: https://github.com/django/django/archive/main.tar.gz dj52: Django>=5.2a1,<6.0 dj51: Django>=5.1,<5.2 dj50: Django>=5.0,<5.1 dj42: Django>=4.2,<4.3 - - mysql: mysqlclient==2.1.0 - - postgres: psycopg[binary] - coverage: coverage[toml] - coverage: coverage-enable-subprocess - pytestmin: pytest>=7.0,<7.1 - xdist: pytest-xdist>=1.15 setenv = mysql: DJANGO_SETTINGS_MODULE=pytest_django_test.settings_mysql @@ -47,26 +43,23 @@ commands = coverage: coverage xml [testenv:linting] -extras = -deps = - ruff==0.9.5 - mypy==1.15.0 +dependency_groups = linting commands = - ruff check --diff {posargs:pytest_django pytest_django_test tests} + ruff check {posargs:pytest_django pytest_django_test tests} ruff format --quiet --diff {posargs:pytest_django pytest_django_test tests} mypy {posargs:pytest_django pytest_django_test tests} + ec . + python -c "import subprocess, sys; sys.exit(subprocess.call('zizmor --persona=pedantic --format sarif .github/workflows/deploy.yml .github/workflows/main.yml > zizmor.sarif', shell=True))" [testenv:doc8] -extras = basepython = python3 skip_install = true +dependency_groups = docs deps = - sphinx doc8 commands = doc8 docs/ [testenv:docs] -deps = -extras = docs +dependency_groups = docs commands = sphinx-build -n -W -b html -d docs/_build/doctrees docs docs/_build/html