diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1714ae0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +# Defines the coding style for different editors and IDEs. +# http://editorconfig.org + +# top-most EditorConfig file +root = true + +# Rules for source code. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_size = 2 +indent_style = space +trim_trailing_whitespace = true + +# Rules for Python code. +[*.py] +indent_size = 4 + +# Rules for markdown documents. +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +# Rules for makefile +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9d866e3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d9296f6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +### Summary +- Write a quick summary of what this PR is for + + +##### Related Links +- Paste link to ticket or any other related sites here + +##### Ready for QA Checklist +- [ ] Code Review +- [ ] Dev QA +- [ ] Rebase and Squash diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..0a59d99 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,37 @@ +name: Validate + +on: + push: + branches: + - develop + - master + - main + - 'release/**' + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip setuptools poetry + poetry install + - name: Linting + run: | + make lint + - name: Security + run: make bandit + - name: Testing + run: make tests diff --git a/.gitignore b/.gitignore index e72411f..54f234e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,58 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg +*.py[co] +*.swp +*.bak + +# docs +_build + +# Packages *.egg -MANIFEST +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg + +# Installer logs +pip-log.txt # Unit test / coverage reports -htmlcov/ -.tox/ .coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations +.tox + +.idea + +.DS_Store + +#Translations *.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Sphinx documentation -docs/_build/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ + +#Mr Developer +.mr.developer.cfg + +# Configuration +sdelint.cnf + +#generated data +usecases/output.csv + +# Test files +info.log +htmlcov/ + +#ides +.idea +.vscode + +#custom cert bundle +my_root_certs.crt + +# symbolic links +.flake8 + +conf/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5a073f0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +repos: +- repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.1.13 + hooks: + - id: forbid-crlf + - id: remove-crlf + - id: forbid-tabs + exclude_types: [csv] + - id: remove-tabs + exclude_types: [csv] + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.1.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + args: [--unsafe] + +- repo: https://github.com/pre-commit/mirrors-isort + rev: v5.10.1 + hooks: + - id: isort + +- repo: https://github.com/ambv/black + rev: 22.3.0 + hooks: + - id: black + language_version: python3.12 + +- repo: https://github.com/pycqa/flake8 + rev: 3.9.2 + hooks: + - id: flake8 + additional_dependencies: [flake8-typing-imports==1.10.0] + exclude: ^tests + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index cb23ec1..0000000 --- a/.travis.yml +++ /dev/null @@ -1,39 +0,0 @@ -language: python -dist: bionic - -python: - - "3.5" - - "3.6" - - "3.7" - - "3.8" - -install: - - travis_retry pip install -U six setuptools pip wheel - - travis_retry pip install -U tox tox-travis coverage -script: - - tox -after_success: - - coverage report - - bash <(curl -s https://codecov.io/bash) - -matrix: - include: - - python: "3.8" - env: TOXENV="performance" - script: tox -- -v 2 - - python: "3.8" - env: TOXENV="warnings" - - python: "3.8" - env: TOXENV="isort,lint" - - - python: "3.8" - env: TOXENV="dist" - script: - - python setup.py bdist_wheel - - rm -r djangorestframework_filters.egg-info - - tox --installpkg ./dist/djangorestframework_filters-*.whl - - tox # test sdist - - allow_failures: - - env: TOXENV="warnings" - fast_finish: true diff --git a/CHANGELOG.rst b/CHANGELOG.rst deleted file mode 100644 index a32cdbe..0000000 --- a/CHANGELOG.rst +++ /dev/null @@ -1,151 +0,0 @@ -Unreleased: ------------ - -* #242 Deprecate ``AllLookupsFilter`` -* #191 Fix ``name`` => ``field_name`` warnings - - -v0.11.1: --------- - -Fixes a packaging issue in v0.11.0 - - -v0.11.0: --------- - -This is a minor release that upgrades django-filter compatibility from ``v1.0`` -to ``v1.1``. No new functionality has been introduced. - - -v0.10.2.post0: --------------- - -* #253 Set django-filter version at 1.x-compatible releases. - - -v0.10.2: --------- - -This is a maintenance release that fixes compatibility with django-filter. - -* #189 Fix method name collision - - -v0.10.1: --------- - -This is a maintenance release that fixes the following bugs: - -* #172 Prevent deepcopying of filter's parent - - -v0.10.0: --------- - -This release primarily adds compatibility with django-filter 1.0 (more details -in #144), and is an intermediate step to overhauling the behavior of filters -that span relationships. - -As `RelatedFilter` is a subclass of `ModelChoiceFilter`, you may take advantage -of the `callable` behavior for the `queryset` argument. The `queryset` is now a -required argument, which is a forwards-incompatible change. You can provide the -model's default queryset to maintain the current behavior, or a callable, which -will allow you to filter the queryset by the request's properties. - -* #124 Removed deprecation warnings -* #128 Fix all lookups handling for related fields -* #129 Fix template rendering -* #139 Fix metaclass inheritance bug -* #146 Make `RelatedFilter.queryset` a required argument -* #154 Add python 3.6 support -* #161 Fix request-based filtering -* #170 Improve RelatedFilter queryset error message - -v0.9.1: -------- - -* #128 Fix all lookups handling for related fields -* #129 Fix backend template rendering -* #148 Version lock django-filter<1.0 due to API incompatibilities - -v0.9.0: -------- - -This release is tied to the 0.15.0 update of django-filter, and is in preparation of -a (near) simultaneous 1.0 release. All current deprecations will be removed in the -next release. - -* Updates django-filter requirement to 0.15.0 -* #101 Add support for Django 1.10, set DRF support to 3.3, 3.4, and drop support for python 3.2 -* #114 Add ``lookups`` argument to ``RelatedFilter`` -* #113 Deprecated ``MethodFilter`` for new ``Filter.method`` argument -* #123 Fix declared filters being overwritten by ``AllLookupsFilter`` - -v0.8.1: -------- - -* Fix bug where AllLookupsFilter would override a declared filter of the same name -* #84 Fix AllLookupsFilter compatibility with ``ForeignObject`` related fields -* #82 Fix AllLookupsFilter compatibility with mixin FilterSets -* #81 Fix bug where FilterSet modified ``ViewSet.filter_fields`` -* #79 Prevent infinite recursion for chainable transforms, fixing compatiblity - w/ ``django.contrib.postgres`` - -v0.8.0: -------- - -This release is tied to a major update of django-filter (more details in #66), -which fixes how lookup expressions are resolved. 'in', 'range', and 'isnull' -lookups no longer require special handling by django-rest-framework-filters. -This has the following effects: - - * Deprecates ArrayDecimalField/InSetNumberFilter - * Deprecates ArrayCharField/InSetCharFilter - * Deprecates FilterSet.fix_filter_field - * Deprecates ALL_LOOKUPS in favor of '__all__' constant - * AllLookupsFilter now generates only valid lookup expressions - -* #2 'range' lookup types do not work -* #15 Date lookup types do not work (year, day, ...) -* #16 'in' lookup types do not work -* #64 Fix browsable API filter form -* #69 Fix compatibility with base django-filter `FilterSet`s -* #70 Refactor related filter handling, fixing some edge cases -* Deprecated 'cache' argument to FilterSet -* #73 Warn use of `order_by` - -v0.7.0: -------- - -* #61 Change django-filter requirement to 0.12.0 -* Adds support for Django 1.9 -* #47 Changes implementation of MethodFilterss -* Drops support for Django 1.7 -* #49 Fix ALL_LOOKUPS shortcut to obey filter overrides (in, isnull) -* #46 Fix boolean filter behavior (#25) and isnull override (#6) -* #60 Fix filtering on nonexistent related field - -v0.6.0: -------- - -* #43 Adds a filter exclusion/negation syntax. eg, ?some_filter!=some_value -* #44 Sets the minimum django-filter version required - -v0.5.0: -------- - -* #38 Rework of related filtering, improving performance (#8) and some minor correctness issues -* #35 Add ALL_LOOKUPS shortcut for dict-style filter definitions -* #31 Fix timezone-aware datetime handling -* #36 Fix '__in' filter to work with strings -* #33 Fix RelatedFilter handling to not override existing isnull filters -* #35 Fix python 3.5 compatibility issue -* Drops support for Django 1.6 and below - -v0.4.0: -------- - -* Adds support for Django 1.8, DRF 3.2 -* Drops support for Python 2.6, DRF 2.x -* #23 Adds __in filtering for numeric field types. eg, ?id__in=1,2,3 diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 8a26345..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include LICENSE -include README.rst -recursive-include rest_framework_filters/templates *.html -global-exclude __pycache__ -global-exclude *.py[co] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dbf2c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +lint: + poetry run black . + poetry run isort . + poetry run flake8 . --extend-ignore=D,E501,W601 --extend-exclude=docs/,**/migrations/*,**/south_migrations/*,tests/ --statistics --count + +bandit: + poetry run bandit -c pyproject.toml -r . + +test: + poetry run python ./runtests.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..b579dee --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# django-rest-framework-filters + +django-rest-framework-filters implements a very simple database-based key-value store for Django. + +This package is a fork and only maintained on an as needed basis since the upstream package is no longer maintained at - https://github.com/vikingco/django-rest-framework-filters. + +## Install + +```bash +pip install git+https://github.com/sdelements/django-rest-framework-filters@master +``` + +## Usage + +The interface is straightforward:: + +```python + from rest-framework-filters import get_value_for_key, set_key_value + set_key_value('foo', 'bar') + get_value_for_key('foo') +``` + +## Changelog + +Changes made in this fork: + +- Upgraded to use pyproject.toml +- Use github actions over travis-ci +- Python 3.12 compatibilty +- Django 4.2 compatibilty +- Removed crispy-forms integration diff --git a/README.rst b/README.rst deleted file mode 100644 index e88bbdf..0000000 --- a/README.rst +++ /dev/null @@ -1,634 +0,0 @@ -Django Rest Framework Filters -============================= - -.. image:: https://travis-ci.org/philipn/django-rest-framework-filters.svg?branch=master - :target: https://travis-ci.org/philipn/django-rest-framework-filters - -.. image:: https://codecov.io/gh/philipn/django-rest-framework-filters/branch/master/graph/badge.svg - :target: https://codecov.io/gh/philipn/django-rest-framework-filters - -.. image:: https://img.shields.io/pypi/v/djangorestframework-filters.svg - :target: https://pypi.python.org/pypi/djangorestframework-filters - -.. image:: https://img.shields.io/pypi/pyversions/djangorestframework-filters.svg - :target: https://pypi.org/project/djangorestframework-filters/ - -.. image:: https://img.shields.io/pypi/l/tox-factor.svg - :target: https://pypi.org/project/djangorestframework-filters/ - - -``django-rest-framework-filters`` is an extension to `Django REST framework`_ and `Django filter`_ -that makes it easy to filter across relationships. Historically, this extension also provided a -number of additional features and fixes, however the number of features has shrunk as they are -merged back into ``django-filter``. - -.. _`Django REST framework`: https://github.com/tomchristie/django-rest-framework -.. _`Django filter`: https://github.com/carltongibson/django-filter - -Using ``django-rest-framework-filters``, we can easily do stuff like:: - - /api/article?author__first_name__icontains=john - /api/article?is_published!=true - ----- - -**!** These docs pertain to the upcoming 1.0 release. Current docs can be found `here`_. - -.. _`here`: https://github.com/philipn/django-rest-framework-filters/blob/v0.10.2/README.rst - ----- - -**!** The 1.0 pre-release is compatible with django-filter 2.x and can be installed with -``pip install --pre``. - ----- - -.. contents:: - **Table of Contents** - :local: - :depth: 2 - :backlinks: none - -Features --------- - -* Easy filtering across relationships. -* Support for method filtering across relationships. -* Automatic filter negation with a simple ``param!=value`` syntax. -* Backend for complex operations on multiple filtered querysets. eg, ``q1 | q2``. - - -Requirements ------------- - -* **Python**: 3.5, 3.6, 3.7, 3.8 -* **Django**: 1.11, 2.0, 2.1, 2.2, 3.0, 3.1 -* **DRF**: 3.11 -* **django-filter**: 2.1, 2.2 (Django 2.0+) - - -Installation ------------- - -Install with pip, or your preferred package manager: - -.. code-block:: bash - - $ pip install djangorestframework-filters - - -Add to your ``INSTALLED_APPS`` setting: - -.. code-block:: python - - INSTALLED_APPS = [ - 'rest_framework_filters', - ... - ] - - -``FilterSet`` usage -------------------- - -Upgrading from ``django-filter`` to ``django-rest-framework-filters`` is straightforward: - -* Import from ``rest_framework_filters`` instead of from ``django_filters`` -* Use the ``rest_framework_filters`` backend instead of the one provided by ``django_filter``. - -.. code-block:: python - - # django-filter - from django_filters.rest_framework import FilterSet, filters - - class ProductFilter(FilterSet): - manufacturer = filters.ModelChoiceFilter(queryset=Manufacturer.objects.all()) - ... - - - # django-rest-framework-filters - import rest_framework_filters as filters - - class ProductFilter(filters.FilterSet): - manufacturer = filters.ModelChoiceFilter(queryset=Manufacturer.objects.all()) - ... - - -To use the django-rest-framework-filters backend, add the following to your settings: - -.. code-block:: python - - REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_filters.backends.RestFrameworkFilterBackend', ... - ), - ... - - -Once configured, you can continue to use all of the filters found in ``django-filter``. - - -Filtering across relationships -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -You can easily traverse multiple relationships when filtering by using ``RelatedFilter``: - -.. code-block:: python - - from rest_framework import viewsets - import rest_framework_filters as filters - - - class ManagerFilter(filters.FilterSet): - class Meta: - model = Manager - fields = {'name': ['exact', 'in', 'startswith']} - - - class DepartmentFilter(filters.FilterSet): - manager = filters.RelatedFilter(ManagerFilter, field_name='manager', queryset=Manager.objects.all()) - - class Meta: - model = Department - fields = {'name': ['exact', 'in', 'startswith']} - - - class CompanyFilter(filters.FilterSet): - department = filters.RelatedFilter(DepartmentFilter, field_name='department', queryset=Department.objects.all()) - - class Meta: - model = Company - fields = {'name': ['exact', 'in', 'startswith']} - - - # company viewset - class CompanyView(viewsets.ModelViewSet): - filter_class = CompanyFilter - ... - -Example filter calls: - -.. code-block:: - - /api/companies?department__name=Accounting - /api/companies?department__manager__name__startswith=Bob - -``queryset`` callables -"""""""""""""""""""""" - -Since ``RelatedFilter`` is a subclass of ``ModelChoiceFilter``, the ``queryset`` argument supports callable behavior. -In the following example, the set of departments is restricted to those in the user's company. - -.. code-block:: python - - def departments(request): - company = request.user.company - return company.department_set.all() - - class EmployeeFilter(filters.FilterSet): - department = filters.RelatedFilter(filterset=DepartmentFilter, queryset=departments) - ... - -Recursive & Circular relationships -"""""""""""""""""""""""""""""""""" - -Recursive relations are also supported. Provide the module path as a string in place of the filterset class. - -.. code-block:: python - - class PersonFilter(filters.FilterSet): - name = filters.AllLookupsFilter(field_name='name') - best_friend = filters.RelatedFilter('people.views.PersonFilter', field_name='best_friend', queryset=Person.objects.all()) - - class Meta: - model = Person - - -This feature is also useful for circular relationships, where a related filterset may not yet be created. Note that -you can pass the related filterset by name if it's located in the same module as the parent filterset. - -.. code-block:: python - - class BlogFilter(filters.FilterSet): - post = filters.RelatedFilter('PostFilter', queryset=Post.objects.all()) - - class PostFilter(filters.FilterSet): - blog = filters.RelatedFilter('BlogFilter', queryset=Blog.objects.all()) - - -Supporting ``Filter.method`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -``django_filters.MethodFilter`` has been deprecated and reimplemented as the ``method`` argument -to all filter classes. It incorporates some of the implementation details of the old -``rest_framework_filters.MethodFilter``, but requires less boilerplate and is simpler to write. - -* It is no longer necessary to perform empty/null value checking. -* You may use any filter class (``CharFilter``, ``BooleanFilter``, etc...) which will - validate input values for you. -* The argument signature has changed from ``(name, qs, value)`` to ``(qs, name, value)``. - -.. code-block:: python - - class PostFilter(filters.FilterSet): - # Note the use of BooleanFilter, the original model field's name, and the method argument. - is_published = filters.BooleanFilter(field_name='date_published', method='filter_is_published') - - class Meta: - model = Post - fields = ['title', 'content'] - - def filter_is_published(self, qs, name, value): - """ - `is_published` is based on the `date_published` model field. - If the publishing date is null, then the post is not published. - """ - # incoming value is normalized as a boolean by BooleanFilter - isnull = not value - lookup_expr = LOOKUP_SEP.join([name, 'isnull']) - - return qs.filter(**{lookup_expr: isnull}) - - class AuthorFilter(filters.FilterSet): - posts = filters.RelatedFilter('PostFilter', queryset=Post.objects.all()) - - class Meta: - model = Author - fields = ['name'] - -The above would enable the following filter calls: - -.. code-block:: - - /api/posts?is_published=true - /api/authors?posts__is_published=true - - -In the first API call, the filter method receives a queryset of posts. In the second, -it receives a queryset of users. The filter method in the example modifies the lookup -name to work across the relationship, allowing you to find published posts, or authors -who have published posts. - -Automatic Filter Negation/Exclusion -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -FilterSets support automatic exclusion using a simple ``param!=value`` syntax. This syntax -internally sets the ``exclude`` property on the filter. - -.. code-block:: - - /api/page?title!=The%20Park - -This syntax supports regular filtering combined with exclusion filtering. For example, the -following would search for all articles containing "Hello" in the title, while excluding -those containing "World". - -.. code-block:: - - /api/articles?title__contains=Hello&title__contains!=World - -Note that most filters only accept a single query parameter. In the above, ``title__contains`` -and ``title__contains!`` are interpreted as two separate query parameters. The following would -probably be invalid, although it depends on the specifics of the individual filter class: - -.. code-block:: - - /api/articles?title__contains=Hello&title__contains!=World&title_contains!=Friend - - -Allowing any lookup type on a field -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you need to enable several lookups for a field, django-filter provides the dict-syntax for -``Meta.fields``. - -.. code-block:: python - - class ProductFilter(filters.FilterSet): - class Meta: - model = Product - fields = { - 'price': ['exact', 'lt', 'gt', ...], - } - -``django-rest-framework-filters`` also allows you to enable all possible lookups for any field. -This can be achieved through the use of ``AllLookupsFilter`` or using the ``'__all__'`` value in -the ``Meta.fields`` dict-style syntax. Generated filters (``Meta.fields``, ``AllLookupsFilter``) -will never override your declared filters. - -Note that using all lookups comes with the same admonitions as enabling ``'__all__'`` fields in -django forms (`docs`_). Exposing all lookups may allow users to construct queries that -inadvertently leak data. Use this feature responsibly. - -.. _`docs`: https://docs.djangoproject.com/en/1.10/topics/forms/modelforms/#selecting-the-fields-to-use - -.. code-block:: python - - class ProductFilter(filters.FilterSet): - # Not overridden by `__all__` - price__gt = filters.NumberFilter(field_name='price', lookup_expr='gt', label='Minimum price') - - class Meta: - model = Product - fields = { - 'price': '__all__', - } - - # or - - class ProductFilter(filters.FilterSet): - price = filters.AllLookupsFilter() - - # Not overridden by `AllLookupsFilter` - price__gt = filters.NumberFilter(field_name='price', lookup_expr='gt', label='Minimum price') - - class Meta: - model = Product - -You cannot combine ``AllLookupsFilter`` with ``RelatedFilter`` as the filter names would clash. - -.. code-block:: python - - class ProductFilter(filters.FilterSet): - manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all()) - manufacturer = filters.AllLookupsFilter() - -To work around this, you have the following options: - -.. code-block:: python - - class ProductFilter(filters.FilterSet): - manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all()) - - class Meta: - model = Product - fields = { - 'manufacturer': '__all__', - } - - # or - - class ProductFilter(filters.FilterSet): - manufacturer = filters.RelatedFilter('ManufacturerFilter', queryset=Manufacturer.objects.all(), lookups='__all__') # `lookups` also accepts a list - - class Meta: - model = Product - - -Can I mix and match ``django-filter`` and ``django-rest-framework-filters``? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Yes you can. ``django-rest-framework-filters`` is simply an extension of ``django-filter``. Note -that ``RelatedFilter`` and other ``django-rest-framework-filters`` features are designed to work -with ``rest_framework_filters.FilterSet`` and will not function on a ``django_filters.FilterSet``. -However, the target ``RelatedFilter.filterset`` may point to a ``FilterSet`` from either package, -and both ``FilterSet`` implementations are compatible with the other's DRF backend. - -.. code-block:: python - - # valid - class VanillaFilter(django_filters.FilterSet): - ... - - class DRFFilter(rest_framework_filters.FilterSet): - vanilla = rest_framework_filters.RelatedFilter(filterset=VanillaFilter, queryset=...) - - - # invalid - class DRFFilter(rest_framework_filters.FilterSet): - ... - - class VanillaFilter(django_filters.FilterSet): - drf = rest_framework_filters.RelatedFilter(filterset=DRFFilter, queryset=...) - - -Caveats & Limitations -~~~~~~~~~~~~~~~~~~~~~ - -``MultiWidget`` is incompatible -""""""""""""""""""""""""""""""" - -djangorestframework-filters is not compatible with form widgets that parse query names that differ from the filter's -attribute name. Although this only practically applies to ``MultiWidget``, it is a general limitation that affects -custom widgets that also have this behavior. Affected filters include ``RangeFilter``, ``DateTimeFromToRangeFilter``, -``DateFromToRangeFilter``, ``TimeRangeFilter``, and ``NumericRangeFilter``. - -To demonstrate the incompatiblity, take the following filterset: - -.. code-block:: python - - class PostFilter(FilterSet): - publish_date = filters.DateFromToRangeFilter() - -The above filter allows users to perform a ``range`` query on the publication date. The filter class internally uses -``MultiWidget`` to separately parse the upper and lower bound values. The incompatibility lies in that ``MultiWidget`` -appends an index to its inner widget names. Instead of parsing ``publish_date``, it expects ``publish_date_0`` and -``publish_date_1``. It is possible to fix this by including the attribute name in the querystring, although this is -not recommended. - -.. code-block:: - - ?publish_date_0=2016-01-01&publish_date_1=2016-02-01&publish_date= - -``MultiWidget`` is also discouraged since: - -* ``core-api`` field introspection fails for similar reasons -* ``_0`` and ``_1`` are less API-friendly than ``_min`` and ``_max`` - -The recommended solutions are to either: - -* Create separate filters for each of the sub-widgets (such as ``publish_date_min`` and ``publish_date_max``). -* Use a CSV-based filter such as those derived from ``BaseCSVFilter``/``BaseInFilter``/``BaseRangeFilter``. eg, - -.. code-block:: - - ?publish_date__range=2016-01-01,2016-02-01 - - -Complex Operations ------------------- - -The ``ComplexFilterBackend`` defines a custom querystring syntax and encoding process that enables the expression of -`complex queries`_. This syntax extends standard querystrings with the ability to define multiple sets of parameters -and operators for how the queries should be combined. - -.. _`complex queries`: https://docs.djangoproject.com/en/2.0/topics/db/queries/#complex-lookups-with-q-objects - ----- - -**!** Note that this feature is experimental. Bugs may be encountered, and the backend is subject to change. - ----- - -To understand the backend more fully, consider a query to find all articles that contain titles starting with either -"Who" or "What". The underlying query could be represented with the following: - -.. code-block:: python - - q1 = Article.objects.filter(title__startswith='Who') - q2 = Article.objects.filter(title__startswith='What') - return q1 | q2 - -Now consider the query, but modified with upper and lower date bounds: - -.. code-block:: python - - q1 = Article.objects.filter(title__startswith='Who').filter(publish_date__lte='2005-01-01') - q2 = Article.objects.filter(title__startswith='What').filter(publish_date__gte='2010-01-01') - return q1 | q2 - -Using just a ``FilterSet``, it is certainly feasible to represent the former query by writing a custom filter class. -However, it is less feasible with the latter query, where multiple sets of varying data types and lookups need to be -validated. In contrast, the ``ComplexFilterBackend`` can create this complex query through the arbitrary combination -of a simple filter. To support the above, the querystring needs to be created with minimal changes. Unencoded example: - -.. code-block:: - - (title__startswith=Who&publish_date__lte=2005-01-01) | (title__startswith=What&publish_date__gte=2010-01-01) - -By default, the backend combines queries with both ``&`` (AND) and ``|`` (OR), and supports unary negation ``~``. E.g., - -.. code-block:: - - (param1=value1) & (param2=value2) | ~(param3=value3) - -The backend supports both standard and complex queries. To perform complex queries, the query must be encoded and set -as the value of the ``complex_filter_param`` (defaults to ``filters``). To perform standard queries, use the backend -in the same manner as the ``RestFrameworkFilterBackend``. - - -Configuring ``ComplexFilterBackend`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Similar to other backends, ``ComplexFilterBackend`` must be added to a view's ``filter_backends`` atribute. Either add -it to the ``DEFAULT_FILTER_BACKENDS`` setting, or set it as a backend on the view class. - -.. code-block:: python - - REST_FRAMEWORK = { - 'DEFAULT_FILTER_BACKENDS': ( - 'rest_framework_filters.backends.ComplexFilterBackend', - ), - } - - # or - - class MyViewSet(generics.ListAPIView): - filter_backends = (rest_framework_filters.backends.ComplexFilterBackend, ) - ... - -You may customize how queries are combined by subclassing ``ComplexFilterBackend`` and overriding the ``operators`` -attribute. ``operators`` is a map of operator symbols to functions that combine two querysets. For example, the map -can be overridden to use the ``QuerySet.intersection()`` and ``QuerySet.union()`` instead of ``&`` and ``|``. - -.. code-block:: python - - class CustomizedBackend(ComplexFilterBackend): - operators = { - '&': QuerySet.intersection, - '|': QuerySet.union, - '-': QuerySet.difference, - } - -Unary ``negation`` relies on ORM internals and may be buggy in certain circumstances. If there are issues with this -feature, it can be disabled by setting the ``negation`` attribute to ``False`` on the backend class. If you do -experience bugs, please open an issue on the `bug tracker`_. - -.. _`bug tracker`: https://github.com/philipn/django-rest-framework-filters/issues/ - - -Complex querystring encoding -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Below is the procedure for encoding a complex query: - -* Convert the query paramaters into individual querystrings. -* URL-encode the individual querystrings. -* Wrap the encoded strings in parentheses, and join with operators. -* URL-encode the entire querystring. -* Set as the value to the complex filter param (e.g., ``?filters=``). - -Note that ``filters`` is the default parameter name and can be overridden in the backend class. - - -Using the first example, these steps can be visualized as so: - -* ``title__startswith=Who``, ``title__startswith=What`` -* ``title__startswith%3DWho``, ``title__startswith%3DWhat`` -* ``(title__startswith%3DWho) | (title__startswith%3DWhat)`` -* ``%28title__startswith%253DWho%29%20%7C%20%28title__startswith%253DWhat%29`` -* ``filters=%28title__startswith%253DWho%29%20%7C%20%28title__startswith%253DWhat%29`` - - -Error handling -~~~~~~~~~~~~~~ - -``ComplexFilterBackend`` will raise any decoding errors under the complex filtering parameter name. For example, - -.. code-block:: json - - { - "filters": [ - "Invalid querystring operator. Matched: 'foo'." - ] - } - -When filtering the querysets, filterset validation errors will be collected and raised under the complex filtering -parameter name, then under the filterset's decoded querystring. For a complex query like ``(a=1&b=2) | (c=3&d=4)``, -errors would be raised like so: - -.. code-block:: json - - { - "filters": { - "a=1&b=2": { - "a": ["..."] - }, - "c=3&d=4": { - "c": ["..."] - } - } - { - - -Migrating to 1.0 ----------------- - -Backend renamed, provides new templates -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The backend has been renamed from ``DjangoFilterBackend`` to ``RestFrameworkFilterBackend`` and now uses its own -template paths, located under ``rest_framework_filters`` instead of ``django_filters/rest_framework``. - -To load the included templates, it is necessary to add ``rest_framework_filters`` to the ``INSTALLED_APPS`` setting. - -``RelatedFilter.queryset`` now required -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -The related filterset's model is no longer used to provide the default value for ``RelatedFilter.queryset``. This -change reduces the chance of unintentionally exposing data in the rendered filter forms. You must now explicitly -provide the ``queryset`` argument, or override the ``get_queryset()`` method (see `queryset callables`_). - - -``get_filters()`` renamed to ``get_request_filters()`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -django-filter has add a ``get_filters()`` classmethod to it's API, so this method has been renamed. - - -Publishing ----------- - -.. code-block:: bash - - $ pip install -U twine setuptools wheel - $ rm -rf dist/ build/ - $ python setup.py sdist bdist_wheel - $ twine upload dist/* - - -Copyright & License -------------------- - -Copyright (c) 2013-2015 Philip Neustrom & 2016-2019 Ryan P Kilby. See `LICENSE`_ for details. - -.. _`LICENSE`: https://github.com/philipn/django-rest-framework-filters/blob/master/LICENSE diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 664d48e..0000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -comment: - layout: "reach" diff --git a/manage.py b/manage.py deleted file mode 100644 index 1702b3a..0000000 --- a/manage.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python -import os -import sys - -# Remove the package root directory from `sys.path`, ensuring that rest_framework_filters -# is imported from the installed site packages. Used for testing the distribution. -if '--no-pkgroot' in sys.argv: - sys.argv.remove('--no-pkgroot') - package_root = sys.path.pop(0) - - import rest_framework_filters - package_dir = os.path.join(os.getcwd(), 'rest_framework_filters') - assert not rest_framework_filters.__file__.startswith(package_dir) - - sys.path.insert(0, package_root) - - -if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") - - from django.core.management import execute_from_command_line - - execute_from_command_line(sys.argv) diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..1f924f0 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,686 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "asgiref" +version = "3.8.1" +description = "ASGI specs, helper code, and adapters" +optional = false +python-versions = ">=3.8" +files = [ + {file = "asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47"}, + {file = "asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590"}, +] + +[package.extras] +tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] + +[[package]] +name = "attrs" +version = "23.2.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, + {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] +tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] + +[[package]] +name = "bandit" +version = "1.7.8" +description = "Security oriented static analyser for python code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "bandit-1.7.8-py3-none-any.whl", hash = "sha256:509f7af645bc0cd8fd4587abc1a038fc795636671ee8204d502b933aee44f381"}, + {file = "bandit-1.7.8.tar.gz", hash = "sha256:36de50f720856ab24a24dbaa5fee2c66050ed97c1477e0a1159deab1775eab6b"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} +PyYAML = ">=5.3.1" +rich = "*" +stevedore = ">=1.20.0" + +[package.extras] +baseline = ["GitPython (>=3.1.30)"] +sarif = ["jschema-to-python (>=1.2.3)", "sarif-om (>=1.0.4)"] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)"] +toml = ["tomli (>=1.1.0)"] +yaml = ["PyYAML"] + +[[package]] +name = "black" +version = "24.4.2" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-24.4.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dd1b5a14e417189db4c7b64a6540f31730713d173f0b63e55fabd52d61d8fdce"}, + {file = "black-24.4.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e537d281831ad0e71007dcdcbe50a71470b978c453fa41ce77186bbe0ed6021"}, + {file = "black-24.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaea3008c281f1038edb473c1aa8ed8143a5535ff18f978a318f10302b254063"}, + {file = "black-24.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:7768a0dbf16a39aa5e9a3ded568bb545c8c2727396d063bbaf847df05b08cd96"}, + {file = "black-24.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:257d724c2c9b1660f353b36c802ccece186a30accc7742c176d29c146df6e474"}, + {file = "black-24.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bdde6f877a18f24844e381d45e9947a49e97933573ac9d4345399be37621e26c"}, + {file = "black-24.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e151054aa00bad1f4e1f04919542885f89f5f7d086b8a59e5000e6c616896ffb"}, + {file = "black-24.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:7e122b1c4fb252fd85df3ca93578732b4749d9be076593076ef4d07a0233c3e1"}, + {file = "black-24.4.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:accf49e151c8ed2c0cdc528691838afd217c50412534e876a19270fea1e28e2d"}, + {file = "black-24.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:88c57dc656038f1ab9f92b3eb5335ee9b021412feaa46330d5eba4e51fe49b04"}, + {file = "black-24.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be8bef99eb46d5021bf053114442914baeb3649a89dc5f3a555c88737e5e98fc"}, + {file = "black-24.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:415e686e87dbbe6f4cd5ef0fbf764af7b89f9057b97c908742b6008cc554b9c0"}, + {file = "black-24.4.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bf10f7310db693bb62692609b397e8d67257c55f949abde4c67f9cc574492cc7"}, + {file = "black-24.4.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:98e123f1d5cfd42f886624d84464f7756f60ff6eab89ae845210631714f6db94"}, + {file = "black-24.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48a85f2cb5e6799a9ef05347b476cce6c182d6c71ee36925a6c194d074336ef8"}, + {file = "black-24.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:b1530ae42e9d6d5b670a34db49a94115a64596bc77710b1d05e9801e62ca0a7c"}, + {file = "black-24.4.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:37aae07b029fa0174d39daf02748b379399b909652a806e5708199bd93899da1"}, + {file = "black-24.4.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da33a1a5e49c4122ccdfd56cd021ff1ebc4a1ec4e2d01594fef9b6f267a9e741"}, + {file = "black-24.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef703f83fc32e131e9bcc0a5094cfe85599e7109f896fe8bc96cc402f3eb4b6e"}, + {file = "black-24.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:b9176b9832e84308818a99a561e90aa479e73c523b3f77afd07913380ae2eab7"}, + {file = "black-24.4.2-py3-none-any.whl", hash = "sha256:d36ed1124bb81b32f8614555b34cc4259c3fbc7eec17870e8ff8ded335b58d8c"}, + {file = "black-24.4.2.tar.gz", hash = "sha256:c872b53057f000085da66a19c55d68f6f8ddcac2642392ad3a355878406fbd4d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" +optional = false +python-versions = "*" +files = [ + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, +] + +[[package]] +name = "django" +version = "4.2.13" +description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Django-4.2.13-py3-none-any.whl", hash = "sha256:a17fcba2aad3fc7d46fdb23215095dbbd64e6174bf4589171e732b18b07e426a"}, + {file = "Django-4.2.13.tar.gz", hash = "sha256:837e3cf1f6c31347a1396a3f6b65688f2b4bb4a11c580dcb628b5afe527b68a5"}, +] + +[package.dependencies] +asgiref = ">=3.6.0,<4" +sqlparse = ">=0.3.1" +tzdata = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +argon2 = ["argon2-cffi (>=19.1.0)"] +bcrypt = ["bcrypt"] + +[[package]] +name = "django-filter" +version = "24.2" +description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django-filter-24.2.tar.gz", hash = "sha256:48e5fc1da3ccd6ca0d5f9bb550973518ce977a4edde9d2a8a154a7f4f0b9f96e"}, + {file = "django_filter-24.2-py3-none-any.whl", hash = "sha256:df2ee9857e18d38bed203c8745f62a803fa0f31688c9fe6f8e868120b1848e48"}, +] + +[package.dependencies] +Django = ">=4.2" + +[[package]] +name = "django-upgrade" +version = "1.18.0" +description = "Automatically upgrade your Django project code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django_upgrade-1.18.0-py3-none-any.whl", hash = "sha256:bae6a466bb9dd63dd7e23b665499b84499b2661348f06b371b88d2e609aa0df9"}, + {file = "django_upgrade-1.18.0.tar.gz", hash = "sha256:ae2a2de13e7804773201aef6af2245fa5d503b0a7c88b85b12cf1fdb84197065"}, +] + +[package.dependencies] +tokenize-rt = ">=4.1" + +[[package]] +name = "djangorestframework" +version = "3.15.1" +description = "Web APIs for Django, made easy." +optional = false +python-versions = ">=3.6" +files = [ + {file = "djangorestframework-3.15.1-py3-none-any.whl", hash = "sha256:3ccc0475bce968608cf30d07fb17d8e52d1d7fc8bfe779c905463200750cbca6"}, + {file = "djangorestframework-3.15.1.tar.gz", hash = "sha256:f88fad74183dfc7144b2756d0d2ac716ea5b4c7c9840995ac3bfd8ec034333c1"}, +] + +[package.dependencies] +django = ">=3.0" + +[[package]] +name = "filelock" +version = "3.14.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, + {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "flake8" +version = "7.0.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"}, + {file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.11.0,<2.12.0" +pyflakes = ">=3.2.0,<3.3.0" + +[[package]] +name = "flake8-bandit" +version = "4.1.1" +description = "Automated security testing with bandit and flake8." +optional = false +python-versions = ">=3.6" +files = [ + {file = "flake8_bandit-4.1.1-py3-none-any.whl", hash = "sha256:4c8a53eb48f23d4ef1e59293657181a3c989d0077c9952717e98a0eace43e06d"}, + {file = "flake8_bandit-4.1.1.tar.gz", hash = "sha256:068e09287189cbfd7f986e92605adea2067630b75380c6b5733dab7d87f9a84e"}, +] + +[package.dependencies] +bandit = ">=1.7.3" +flake8 = ">=5.0.0" + +[[package]] +name = "flake8-bugbear" +version = "24.4.26" +description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." +optional = false +python-versions = ">=3.8.1" +files = [ + {file = "flake8_bugbear-24.4.26-py3-none-any.whl", hash = "sha256:cb430dd86bc821d79ccc0b030789a9c87a47a369667f12ba06e80f11305e8258"}, + {file = "flake8_bugbear-24.4.26.tar.gz", hash = "sha256:ff8d4ba5719019ebf98e754624c30c05cef0dadcf18a65d91c7567300e52a130"}, +] + +[package.dependencies] +attrs = ">=19.2.0" +flake8 = ">=6.0.0" + +[package.extras] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit", "pytest", "tox"] + +[[package]] +name = "flake8-docstrings" +version = "1.7.0" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +optional = false +python-versions = ">=3.7" +files = [ + {file = "flake8_docstrings-1.7.0-py2.py3-none-any.whl", hash = "sha256:51f2344026da083fc084166a9353f5082b01f72901df422f74b4d953ae88ac75"}, + {file = "flake8_docstrings-1.7.0.tar.gz", hash = "sha256:4c8cc748dc16e6869728699e5d0d685da9a10b0ea718e090b1ba088e67a941af"}, +] + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + +[[package]] +name = "flake8-polyfill" +version = "1.0.2" +description = "Polyfill package for Flake8 plugins" +optional = false +python-versions = "*" +files = [ + {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, + {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, +] + +[package.dependencies] +flake8 = "*" + +[[package]] +name = "identify" +version = "2.5.36" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"}, + {file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "isort" +version = "5.13.2" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, + {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, +] + +[package.extras] +colors = ["colorama (>=0.4.6)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "packaging" +version = "24.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbr" +version = "6.0.0" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-6.0.0-py2.py3-none-any.whl", hash = "sha256:4a7317d5e3b17a3dccb6a8cfe67dab65b20551404c52c8ed41279fa4f0cb4cda"}, + {file = "pbr-6.0.0.tar.gz", hash = "sha256:d1377122a5a00e2f940ee482999518efe16d745d423a670c27773dfbc3c9a7d9"}, +] + +[[package]] +name = "platformdirs" +version = "4.2.2" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] +type = ["mypy (>=1.8)"] + +[[package]] +name = "pre-commit" +version = "3.7.1" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.11.1" +description = "Python style guide checker" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"}, + {file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"}, +] + +[[package]] +name = "pydocstyle" +version = "6.3.0" +description = "Python docstring style checker" +optional = false +python-versions = ">=3.6" +files = [ + {file = "pydocstyle-6.3.0-py3-none-any.whl", hash = "sha256:118762d452a49d6b05e194ef344a55822987a462831ade91ec5c06fd2169d019"}, + {file = "pydocstyle-6.3.0.tar.gz", hash = "sha256:7ce43f0c0ac87b07494eb9c0b462c0b73e6ff276807f204d6b53edc72b7e44e1"}, +] + +[package.dependencies] +snowballstemmer = ">=2.2.0" + +[package.extras] +toml = ["tomli (>=1.2.3)"] + +[[package]] +name = "pyflakes" +version = "3.2.0" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, + {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, +] + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +optional = false +python-versions = "*" +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sqlparse" +version = "0.5.0" +description = "A non-validating SQL parser." +optional = false +python-versions = ">=3.8" +files = [ + {file = "sqlparse-0.5.0-py3-none-any.whl", hash = "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"}, + {file = "sqlparse-0.5.0.tar.gz", hash = "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93"}, +] + +[package.extras] +dev = ["build", "hatch"] +doc = ["sphinx"] + +[[package]] +name = "stevedore" +version = "5.2.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.2.0-py3-none-any.whl", hash = "sha256:1c15d95766ca0569cad14cb6272d4d31dae66b011a929d7c18219c176ea1b5c9"}, + {file = "stevedore-5.2.0.tar.gz", hash = "sha256:46b93ca40e1114cea93d738a6c1e365396981bb6bb78c27045b7587c9473544d"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "tokenize-rt" +version = "5.2.0" +description = "A wrapper around the stdlib `tokenize` which roundtrips." +optional = false +python-versions = ">=3.8" +files = [ + {file = "tokenize_rt-5.2.0-py2.py3-none-any.whl", hash = "sha256:b79d41a65cfec71285433511b50271b05da3584a1da144a0752e9c621a285289"}, + {file = "tokenize_rt-5.2.0.tar.gz", hash = "sha256:9fe80f8a5c1edad2d3ede0f37481cc0cc1538a2f442c9c2f9e4feacd2792d054"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[[package]] +name = "virtualenv" +version = "20.26.2" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.7" +files = [ + {file = "virtualenv-20.26.2-py3-none-any.whl", hash = "sha256:a624db5e94f01ad993d476b9ee5346fdf7b9de43ccaee0e0197012dc838a0e9b"}, + {file = "virtualenv-20.26.2.tar.gz", hash = "sha256:82bf0f4eebbb78d36ddaee0283d43fe5736b53880b8a8cdcd37390a07ac3741c"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] + +[metadata] +lock-version = "2.0" +python-versions = "~3.12" +content-hash = "b28efeabdf6b4dafcd5de6fcc25fb80c7b04141d1b2c415b79cd5f2af2773bdb" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0808803 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,68 @@ +[tool.poetry] +name = "djangorestframework-filters" +version = "1.0.0" +homepage = "https://github.com/sdelements/django-rest-framework-filters" +description = "Makes it easy to filter across relationships" +authors = ["Security Compass "] +license = "MIT" +readme = "README.md" +# See https://pypi.python.org/pypi?%3Aaction=list_classifiers +classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 5 - Production/Stable', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries :: Python Modules', + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: MIT', + + # Supported Languages + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.12', + 'Framework :: Django', +] +packages = [ + { include = "rest_framework_filters" }, + { include = "tests", format = "sdist" }, +] +exclude = [ + "rest_framework_filters/**/tests", + "tests" +] + +[tool.poetry.dependencies] +python = "~3.12" +django = "^4.2" +django-filter = "^24.2" +djangorestframework = "^3.15" + +[tool.poetry.dev-dependencies] +pre-commit = "3.7.1" +# lint +black = "24.4.2" +flake8 = "7.0.0" +flake8-bandit = "4.1.1" +flake8-bugbear = "24.4.26" +flake8-docstrings = "1.7.0" +flake8-polyfill = "1.0.2" +isort = "5.13.2" +# security +bandit = "1.7.8" +# test +django-upgrade = "1.18.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.bandit] +exclude_dirs = [ + './tests/', +] diff --git a/rest_framework_filters/backends.py b/rest_framework_filters/backends.py index a8476a7..85dca7a 100644 --- a/rest_framework_filters/backends.py +++ b/rest_framework_filters/backends.py @@ -1,7 +1,6 @@ from contextlib import contextmanager from django.http import QueryDict -from django_filters import compat from django_filters.rest_framework import backends from rest_framework.exceptions import ValidationError @@ -14,9 +13,7 @@ class RestFrameworkFilterBackend(backends.DjangoFilterBackend): @property def template(self): - if compat.is_crispy(): - return 'rest_framework_filters/crispy_form.html' - return 'rest_framework_filters/form.html' + return "rest_framework_filters/form.html" @contextmanager def patch_for_rendering(self, request): @@ -51,7 +48,7 @@ def to_html(self, request, queryset, view): class ComplexFilterBackend(RestFrameworkFilterBackend): - complex_filter_param = 'filters' + complex_filter_param = "filters" operators = None negation = True @@ -73,7 +70,9 @@ def filter_queryset(self, request, queryset, view): # Collect the individual filtered querysets querystrings = [op.querystring for op in complex_ops] try: - querysets = self.get_filtered_querysets(querystrings, request, queryset, view) + querysets = self.get_filtered_querysets( + querystrings, request, queryset, view + ) except ValidationError as exc: raise ValidationError({self.complex_filter_param: exc.detail}) diff --git a/rest_framework_filters/complex_ops.py b/rest_framework_filters/complex_ops.py index f7e9814..bb19b41 100644 --- a/rest_framework_filters/complex_ops.py +++ b/rest_framework_filters/complex_ops.py @@ -12,14 +12,14 @@ # current iteration: https://regex101.com/r/5rPycz/3 # special thanks to @JohnDoe2 on the #regex IRC channel! # matches groups of "()" -COMPLEX_OP_RE = re.compile(r'()\(([^)]+)\)([^(]*?(?=\())?') -COMPLEX_OP_NEG_RE = re.compile(r'(~?)\(([^)]+)\)([^(]*?(?=~\(|\())?') +COMPLEX_OP_RE = re.compile(r"()\(([^)]+)\)([^(]*?(?=\())?") +COMPLEX_OP_NEG_RE = re.compile(r"(~?)\(([^)]+)\)([^(]*?(?=~\(|\())?") COMPLEX_OPERATORS = { - '&': QuerySet.__and__, - '|': QuerySet.__or__, + "&": QuerySet.__and__, + "|": QuerySet.__or__, } -ComplexOp = namedtuple('ComplexOp', ['querystring', 'negate', 'op']) +ComplexOp = namedtuple("ComplexOp", ["querystring", "negate", "op"]) def decode_complex_ops(encoded_querystring, operators=None, negation=True): @@ -61,25 +61,27 @@ def decode_complex_ops(encoded_querystring, operators=None, negation=True): if not matches: msg = _("Unable to parse querystring. Decoded: '%(decoded)s'.") - raise ValidationError(msg % {'decoded': decoded_querystring}) + raise ValidationError(msg % {"decoded": decoded_querystring}) results, errors = [], [] for match, has_next in lookahead(matches): negate, querystring, op = match.groups() - negate = negate == '~' + negate = negate == "~" querystring = unquote(querystring) op_func = operators.get(op.strip()) if op else None if op_func is None and has_next: msg = _("Invalid querystring operator. Matched: '%(op)s'.") - errors.append(msg % {'op': op}) + errors.append(msg % {"op": op}) results.append(ComplexOp(querystring, negate, op_func)) - msg = _("Ending querystring must not have trailing characters. Matched: '%(chars)s'.") - trailing_chars = decoded_querystring[matches[-1].end():] + msg = _( + "Ending querystring must not have trailing characters. Matched: '%(chars)s'." + ) + trailing_chars = decoded_querystring[matches[-1].end() :] # noqa: E203 if trailing_chars: - errors.append(msg % {'chars': trailing_chars}) + errors.append(msg % {"chars": trailing_chars}) if errors: raise ValidationError(errors) diff --git a/rest_framework_filters/filters.py b/rest_framework_filters/filters.py index 09f4727..6283091 100644 --- a/rest_framework_filters/filters.py +++ b/rest_framework_filters/filters.py @@ -2,11 +2,10 @@ from django.utils.module_loading import import_string from django_filters.rest_framework.filters import * # noqa -from django_filters.rest_framework.filters import ( - ModelChoiceFilter, ModelMultipleChoiceFilter, -) +from django_filters.rest_framework.filters import (ModelChoiceFilter, + ModelMultipleChoiceFilter) -ALL_LOOKUPS = '__all__' +ALL_LOOKUPS = "__all__" class AutoFilter: @@ -76,7 +75,7 @@ class creation time, instead of during initialization. Args: filterset: The filterset to bind """ - if not hasattr(self, 'bound_filterset'): + if not hasattr(self, "bound_filterset"): self.bound_filterset = filterset def filterset(): @@ -87,7 +86,7 @@ def fget(self): self._filterset = import_string(self._filterset) except ImportError: # Fallback to building import path relative to bind class - path = '.'.join([self.bound_filterset.__module__, self._filterset]) + path = ".".join([self.bound_filterset.__module__, self._filterset]) self._filterset = import_string(path) return self._filterset @@ -95,14 +94,16 @@ def fset(self, value): self._filterset = value return locals() + filterset = property(**filterset()) def get_queryset(self, request): queryset = super(BaseRelatedFilter, self).get_queryset(request) - assert queryset is not None, \ - "Expected `.get_queryset()` for related filter '%s.%s' to " \ - "return a `QuerySet`, but got `None`." \ - % (self.parent.__class__.__name__, self.field_name) + if queryset is None: + raise ValueError( + "Expected `.get_queryset()` for related filter '%s.%s' to return a `QuerySet`, but got `None`." + % (self.parent.__class__.__name__, self.field_name) + ) return queryset diff --git a/rest_framework_filters/filterset.py b/rest_framework_filters/filterset.py index b52d25e..70ee609 100644 --- a/rest_framework_filters/filterset.py +++ b/rest_framework_filters/filterset.py @@ -17,13 +17,17 @@ def related(filterset, filter_name): class FilterSetMetaclass(filterset.FilterSetMetaclass): def __new__(cls, name, bases, attrs): - attrs['auto_filters'] = cls.get_auto_filters(bases, attrs) + attrs["auto_filters"] = cls.get_auto_filters(bases, attrs) new_class = super().__new__(cls, name, bases, attrs) - new_class.related_filters = OrderedDict([ - (name, f) for name, f in new_class.declared_filters.items() - if isinstance(f, filters.BaseRelatedFilter)]) + new_class.related_filters = OrderedDict( + [ + (name, f) + for name, f in new_class.declared_filters.items() + if isinstance(f, filters.BaseRelatedFilter) + ] + ) # See: :meth:`rest_framework_filters.filters.RelatedFilter.bind` for f in new_class.related_filters.values(): @@ -54,17 +58,17 @@ def get_auto_filters(cls, bases, attrs): # Default the `filter.field_name` to the attribute name on the filterset for filter_name, f in auto_filters: - if getattr(f, 'field_name', None) is None: + if getattr(f, "field_name", None) is None: f.field_name = filter_name auto_filters.sort(key=lambda x: x[1].creation_counter) # merge auto filters from base classes for base in reversed(bases): - if hasattr(base, 'auto_filters'): + if hasattr(base, "auto_filters"): auto_filters = [ - (name, f) for name, f - in base.auto_filters.items() + (name, f) + for name, f in base.auto_filters.items() if name not in attrs ] + auto_filters @@ -108,8 +112,9 @@ def expand_auto_filter(cls, new_class, filter_name, f): if gen_f.lookup_expr != "exact": # Update field name to also include lookup expr. - gen_f.field_name = "{field_name}__{lookup_expr}".format(field_name=f.field_name, - lookup_expr=gen_f.lookup_expr) + gen_f.field_name = "{field_name}__{lookup_expr}".format( + field_name=f.field_name, lookup_expr=gen_f.lookup_expr + ) # do not overwrite declared filters if gen_name not in orig_declared: @@ -197,8 +202,9 @@ def disable_subset(cls, *, depth=0): This filterset class with subset disabling mixed in. """ if not issubclass(cls, SubsetDisabledMixin): - cls = type('SubsetDisabled%s' % cls.__name__, - (SubsetDisabledMixin, cls), {}) + cls = type( + "SubsetDisabled%s" % cls.__name__, (SubsetDisabledMixin, cls), {} + ) # recursively disable subset for related filtersets if depth > 0: @@ -254,16 +260,16 @@ def get_param_filter_name(cls, param, rel=None): return None # strip the rel prefix from the param name. - prefix = '%s%s' % (rel or '', LOOKUP_SEP) + prefix = "%s%s" % (rel or "", LOOKUP_SEP) if rel and param.startswith(prefix): - param = param[len(prefix):] + param = param[len(prefix) :] # noqa: E203 # Attempt to match against filters with lookups first. (username__endswith) if param in cls.base_filters: return param # Attempt to match against exclusion filters - if param[-1] == '!' and param[:-1] in cls.base_filters: + if param[-1] == "!" and param[:-1] in cls.base_filters: return param[:-1] # Match against relationships. (author__username__endswith). @@ -288,7 +294,7 @@ def get_request_filters(self): requested_filters[filter_name] = f # exclusion params - exclude_name = '%s!' % filter_name + exclude_name = "%s!" % filter_name if related(self, exclude_name) in self.data: # deepcopy the *base* filter to prevent copying of model & parent f_copy = copy.deepcopy(self.base_filters[filter_name]) @@ -341,15 +347,15 @@ def filter_related_filtersets(self, queryset): """ for related_name, related_filterset in self.related_filtersets.items(): # Related filtersets should only be applied if they had data. - prefix = '%s%s' % (related(self, related_name), LOOKUP_SEP) + prefix = "%s%s" % (related(self, related_name), LOOKUP_SEP) if not any(value.startswith(prefix) for value in self.data): continue field = self.filters[related_name].field - to_field_name = getattr(field, 'to_field_name', 'pk') or 'pk' + to_field_name = getattr(field, "to_field_name", "pk") or "pk" field_name = self.filters[related_name].field_name - lookup_expr = LOOKUP_SEP.join([field_name, 'in']) + lookup_expr = LOOKUP_SEP.join([field_name, "in"]) subquery = related_filterset.qs.values(to_field_name) queryset = queryset.filter(**{lookup_expr: subquery}) @@ -376,19 +382,5 @@ def clean(form): self.form.errors[related(related_filterset, key)] = error return cleaned_data - return Form - - @property - def form(self): - from django_filters import compat - form = super().form - if compat.is_crispy(): - from crispy_forms.helper import FormHelper - - form.helper = FormHelper(form) - form.helper.form_tag = False - form.helper.disable_csrf = True - form.helper.template_pack = 'bootstrap3' - - return form + return Form diff --git a/rest_framework_filters/templates/rest_framework_filters/crispy_form.html b/rest_framework_filters/templates/rest_framework_filters/crispy_form.html deleted file mode 100644 index 2d5a6f8..0000000 --- a/rest_framework_filters/templates/rest_framework_filters/crispy_form.html +++ /dev/null @@ -1,18 +0,0 @@ -{% load rest_framework_filters %} -{% load crispy_forms_tags %} -{% load i18n %} - -

{% trans "Field filters" %}

-
- {% crispy filter.form %} - - {% for related_name, filterset in filter.related_filtersets.items %} -
- {{ filter|label:related_name }} - - {% crispy filterset.form %} -
- {% endfor %} - - -
diff --git a/rest_framework_filters/utils.py b/rest_framework_filters/utils.py index e713234..bff4b34 100644 --- a/rest_framework_filters/utils.py +++ b/rest_framework_filters/utils.py @@ -18,8 +18,8 @@ def lookups_for_field(model_field): if issubclass(lookup, Transform): transform = lookup(Expression(model_field)) lookups += [ - LOOKUP_SEP.join([expr, sub_expr]) for sub_expr - in lookups_for_transform(transform) + LOOKUP_SEP.join([expr, sub_expr]) + for sub_expr in lookups_for_transform(transform) ] else: @@ -51,13 +51,13 @@ def lookups_for_transform(transform): if issubclass(lookup, Transform): # type match indicates recursion. - if type(transform) == lookup: + if type(transform) is lookup: continue sub_transform = lookup(transform) lookups += [ - LOOKUP_SEP.join([expr, sub_expr]) for sub_expr - in lookups_for_transform(sub_transform) + LOOKUP_SEP.join([expr, sub_expr]) + for sub_expr in lookups_for_transform(sub_transform) ] else: diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..cdf26b6 --- /dev/null +++ b/runtests.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python +import os +import sys + +from django.core.management import execute_from_command_line + + +def runtests(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") + argv = sys.argv[:1] + ["test"] + sys.argv[1:] + execute_from_command_line(argv) + + +if __name__ == "__main__": + runtests() diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e7d3b2e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,75 +0,0 @@ -[coverage:run] -branch = True -source = . -omit = .venv/*,.tox/* - -[coverage:report] -include = rest_framework_filters/* -show_missing = True - -[isort] -skip = migrations -atomic = true -line_length = 90 -include_trailing_comma = true -multi_line_output = 5 -known_third_party = django,pytz,rest_framework,django_filters -known_first_party = rest_framework_filters - -[flake8] -max_line_length = 90 -max_complexity = 10 -exclude = migrations - -extend_ignore = - A003 ; class attribute shadowing builtin - D100,D101,D102,D103,D104,D105,D106,D107 ; docstring presence - - -[metadata] -name = djangorestframework-filters -version = 1.0.0.dev2 -description = Better filtering for Django REST Framework -long_description = file: README.rst, LICENSE -license_file = LICENSE -license = MIT - -author = Philip Neustrom -maintainer = Ryan P Kilby -author_email=philipn@gmail.com -maintainer_email = kilbyr@gmail.com - -url = https://github.com/philipn/django-rest-framework-filters -project_urls = - Source=https://github.com/philipn/django-rest-framework-filters - Tracker=https://github.com/philipn/django-rest-framework-filters/issues - -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Framework :: Django - Framework :: Django :: 1.11 - Framework :: Django :: 2.0 - Framework :: Django :: 2.1 - Framework :: Django :: 2.2 - Framework :: Django :: 3.0 - Framework :: Django :: 3.1 - Intended Audience :: Developers - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.5 - Programming Language :: Python :: 3.6 - Programming Language :: Python :: 3.7 - Programming Language :: Python :: 3.8 - Topic :: Internet :: WWW/HTTP - -[options] -python_requires = >=3.5 -install_requires = - djangorestframework - django-filter>=2.0 - -zip_safe = False -include_package_data = True diff --git a/setup.py b/setup.py deleted file mode 100644 index 7e87d63..0000000 --- a/setup.py +++ /dev/null @@ -1,4 +0,0 @@ -from setuptools import setup, find_packages - - -setup(packages=find_packages(exclude=['tests*'])) diff --git a/tests/perf/filters.py b/tests/perf/filters.py index bfa7831..6f8a323 100644 --- a/tests/perf/filters.py +++ b/tests/perf/filters.py @@ -1,4 +1,3 @@ - from django_filters import FilterSet as DFFilterSet from rest_framework_filters import filters @@ -12,17 +11,23 @@ class NoteFilterWithExplicitRelated(DFFilterSet): class Meta: model = Note fields = { - 'title': [ - 'exact', 'contains', 'startswith', 'endswith', - 'iexact', 'icontains', 'istartswith', 'iendswith', + "title": [ + "exact", + "contains", + "startswith", + "endswith", + "iexact", + "icontains", + "istartswith", + "iendswith", ], - 'author__username': ['exact'], + "author__username": ["exact"], } # drf-filters class UserFilterWithAll(DRFFilterSet): - username = filters.AutoFilter(lookups='__all__') + username = filters.AutoFilter(lookups="__all__") class Meta: model = User @@ -30,7 +35,7 @@ class Meta: class NoteFilterWithRelatedAll(DRFFilterSet): - title = filters.AutoFilter(lookups='__all__') + title = filters.AutoFilter(lookups="__all__") author = filters.RelatedFilter(UserFilterWithAll, queryset=User.objects.all()) class Meta: diff --git a/tests/perf/serializers.py b/tests/perf/serializers.py index c6fa770..82b48c2 100644 --- a/tests/perf/serializers.py +++ b/tests/perf/serializers.py @@ -1,4 +1,3 @@ - from rest_framework import serializers from ..testapp.models import Note @@ -7,4 +6,4 @@ class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note - fields = ['pk', 'title', 'content', 'author'] + fields = ["pk", "title", "content", "author"] diff --git a/tests/perf/tests.py b/tests/perf/tests.py index c33fcce..8d8cd70 100644 --- a/tests/perf/tests.py +++ b/tests/perf/tests.py @@ -13,14 +13,19 @@ # parse command verbosity level & use for results output parser = argparse.ArgumentParser() parser.add_argument( - '-v', '--verbosity', action='store', dest='verbosity', default=1, - type=int, choices=[0, 1, 2, 3], + "-v", + "--verbosity", + action="store", + dest="verbosity", + default=1, + type=int, + choices=[0, 1, 2, 3], ) args, _ = parser.parse_known_args() verbosity = args.verbosity -@tag('perf') +@tag("perf") class PerfTestMixin(object): # This mixin provides common setup for testing the performance differences between # django-filter and django-rest-framework-filters. A callable for each implementation @@ -32,13 +37,13 @@ class PerfTestMixin(object): @classmethod def setUpTestData(cls): - bob = models.User.objects.create(username='bob') - joe = models.User.objects.create(username='joe') + bob = models.User.objects.create(username="bob") + joe = models.User.objects.create(username="joe") - models.Note.objects.create(author=bob, title='Note 1') - models.Note.objects.create(author=bob, title='Note 2') - models.Note.objects.create(author=joe, title='Note 3') - models.Note.objects.create(author=joe, title='Note 4') + models.Note.objects.create(author=bob, title="Note 1") + models.Note.objects.create(author=bob, title="Note 2") + models.Note.objects.create(author=joe, title="Note 3") + models.Note.objects.create(author=joe, title="Note 4") def get_callable(self, *args): # Returns the callable and callable's *args to be used for each test @@ -73,28 +78,32 @@ def test_sanity(self): def test_performance(self): call, args = self.get_callable(*self.django_filter_args()) - df_time = min(repeat( - lambda: call(*args), - number=self.iterations, - repeat=self.repeat, - )) + df_time = min( + repeat( + lambda: call(*args), + number=self.iterations, + repeat=self.repeat, + ) + ) call, args = self.get_callable(*self.rest_framework_filters_args()) - drf_time = min(repeat( - lambda: call(*args), - number=self.iterations, - repeat=self.repeat, - )) + drf_time = min( + repeat( + lambda: call(*args), + number=self.iterations, + repeat=self.repeat, + ) + ) diff = (drf_time - df_time) / df_time * 100.0 if verbosity >= 2: - print('\n' + '-' * 32) - print('%s performance' % self.label) - print('django-filter time:\t%.4fs' % df_time) - print('drf-filters time:\t%.4fs' % drf_time) - print('performance diff:\t%+.2f%% ' % diff) - print('-' * 32) + print("\n" + "-" * 32) + print("%s performance" % self.label) + print("django-filter time:\t%.4fs" % df_time) + print("drf-filters time:\t%.4fs" % drf_time) + print("performance diff:\t%+.2f%% " % diff) + print("-" * 32) self.assertLess(drf_time, df_time * self.threshold) @@ -102,18 +111,20 @@ def test_performance(self): class FilterBackendTests(PerfTestMixin, TestCase): # How much faster or slower is drf-filters than django-filter? threshold = 1.5 - label = 'Filter Backend' + label = "Filter Backend" def get_callable(self, view_class): - view = view_class(action_map={'get': 'list'}) - data = {'author__username': 'bob', 'title__contains': 'Note'} - request = factory.get('/', data=data) + view = view_class(action_map={"get": "list"}) + data = {"author__username": "bob", "title__contains": "Note"} + request = factory.get("/", data=data) request = view.initialize_request(request) backend = view.filter_backends[0] call = backend().filter_queryset args = [ - request, view.get_queryset(), view, + request, + view.get_queryset(), + view, ] return call, args @@ -128,27 +139,28 @@ def validate_result(self, qs): self.assertEqual(qs.count(), 2) -@override_settings(ROOT_URLCONF='tests.perf.urls') +@override_settings(ROOT_URLCONF="tests.perf.urls") class WSGIResponseTests(PerfTestMixin, TestCase): # How much does drf-filters affect the request/response cycle? This includes # response rendering, which provides a more practical picture of the performance # costs of using drf-filters. threshold = 1.3 - label = 'WSGI Response' + label = "WSGI Response" def get_callable(self, url): call = self.client.get args = [ - url, {'author__username': 'bob', 'title__contains': 'Note'}, + url, + {"author__username": "bob", "title__contains": "Note"}, ] return call, args def django_filter_args(self): - return ['/df-notes/'] + return ["/df-notes/"] def rest_framework_filters_args(self): - return ['/drf-notes/'] + return ["/drf-notes/"] def validate_result(self, response): self.assertEqual(response.status_code, 200, response.content) diff --git a/tests/perf/urls.py b/tests/perf/urls.py index f253106..829ad5b 100644 --- a/tests/perf/urls.py +++ b/tests/perf/urls.py @@ -1,14 +1,13 @@ - -from django.conf.urls import include, url +from django.urls import include, path from rest_framework import routers from . import views router = routers.DefaultRouter() -router.register(r'df-notes', views.DFNoteViewSet, basename='df-notes') -router.register(r'drf-notes', views.DRFFNoteViewSet, basename='drf-notes') +router.register(r"df-notes", views.DFNoteViewSet, basename="df-notes") +router.register(r"drf-notes", views.DRFFNoteViewSet, basename="drf-notes") urlpatterns = [ - url(r'^', include(router.urls)), + path("", include(router.urls)), ] diff --git a/tests/related/data.py b/tests/related/data.py index b29cbb8..056268e 100644 --- a/tests/related/data.py +++ b/tests/related/data.py @@ -24,82 +24,82 @@ class RelationshipData: @classmethod def setUpTestData(cls): - b = Blog.objects.create(pk=1, name='Blog A') + b = Blog.objects.create(pk=1, name="Blog A") cls.postA(b) - b = Blog.objects.create(pk=2, name='Blog B') + b = Blog.objects.create(pk=2, name="Blog B") cls.postB(b) - b = Blog.objects.create(pk=3, name='Blog C') + b = Blog.objects.create(pk=3, name="Blog C") cls.postC(b) - b = Blog.objects.create(pk=4, name='Blog D') + b = Blog.objects.create(pk=4, name="Blog D") cls.postD(b) - b = Blog.objects.create(pk=5, name='Blog AB') + b = Blog.objects.create(pk=5, name="Blog AB") cls.postA(b), cls.postB(b) - b = Blog.objects.create(pk=6, name='Blog AC') + b = Blog.objects.create(pk=6, name="Blog AC") cls.postA(b), cls.postC(b) - b = Blog.objects.create(pk=7, name='Blog AD') + b = Blog.objects.create(pk=7, name="Blog AD") cls.postA(b), cls.postD(b) - b = Blog.objects.create(pk=8, name='Blog BC') + b = Blog.objects.create(pk=8, name="Blog BC") cls.postB(b), cls.postC(b) - b = Blog.objects.create(pk=9, name='Blog BD') + b = Blog.objects.create(pk=9, name="Blog BD") cls.postB(b), cls.postD(b) - b = Blog.objects.create(pk=10, name='Blog CD') + b = Blog.objects.create(pk=10, name="Blog CD") cls.postC(b), cls.postD(b) - b = Blog.objects.create(pk=11, name='Blog ABC') + b = Blog.objects.create(pk=11, name="Blog ABC") cls.postA(b), cls.postB(b), cls.postC(b) - b = Blog.objects.create(pk=12, name='Blog ABD') + b = Blog.objects.create(pk=12, name="Blog ABD") cls.postA(b), cls.postB(b), cls.postD(b) - b = Blog.objects.create(pk=13, name='Blog ACD') + b = Blog.objects.create(pk=13, name="Blog ACD") cls.postA(b), cls.postC(b), cls.postD(b) - b = Blog.objects.create(pk=14, name='Blog BCD') + b = Blog.objects.create(pk=14, name="Blog BCD") cls.postB(b), cls.postC(b), cls.postD(b) - b = Blog.objects.create(pk=15, name='Blog ABCD') + b = Blog.objects.create(pk=15, name="Blog ABCD") cls.postA(b), cls.postB(b), cls.postC(b), cls.postD(b) @classmethod def postA(cls, blog): Post.objects.create( blog=blog, - title='Something about Lennon', - publish_date='2008-01-01', + title="Something about Lennon", + publish_date="2008-01-01", ) @classmethod def postB(cls, blog): Post.objects.create( blog=blog, - title='Something about Lennon', - publish_date='2010-01-01', + title="Something about Lennon", + publish_date="2010-01-01", ) @classmethod def postC(cls, blog): Post.objects.create( blog=blog, - title='Ringo was a Starr', - publish_date='2008-01-01', + title="Ringo was a Starr", + publish_date="2008-01-01", ) @classmethod def postD(cls, blog): Post.objects.create( blog=blog, - title='Ringo was a Starr', - publish_date='2010-01-01', + title="Ringo was a Starr", + publish_date="2010-01-01", ) def verify(self, qs, expected): - self.assertQuerysetEqual(qs, expected, attrgetter('pk'), False) + self.assertQuerySetEqual(qs, expected, attrgetter("pk"), False) diff --git a/tests/related/test_exclude.py b/tests/related/test_exclude.py index 3c22beb..3f261c2 100644 --- a/tests/related/test_exclude.py +++ b/tests/related/test_exclude.py @@ -41,19 +41,23 @@ def test_single_exclude(self): # Verify that exclusion is not equivalent # q1 should be equivalent to q2/q4 and *not* q3/q5 - q1 = Blog.objects.exclude(post__title__contains='Lennon') + q1 = Blog.objects.exclude(post__title__contains="Lennon") # nested join - q2 = Blog.objects.exclude(post__in=Post.objects.filter(title__contains='Lennon')) - q3 = Blog.objects.filter(post__in=Post.objects.exclude(title__contains='Lennon')) + q2 = Blog.objects.exclude( + post__in=Post.objects.filter(title__contains="Lennon") + ) + q3 = Blog.objects.filter( + post__in=Post.objects.exclude(title__contains="Lennon") + ) # nested subquery - q4 = Blog.objects.exclude(pk__in=Post.objects - .filter(title__contains='Lennon') - .values('blog')) - q5 = Blog.objects.filter(pk__in=Post.objects - .exclude(title__contains='Lennon') - .values('blog')) + q4 = Blog.objects.exclude( + pk__in=Post.objects.filter(title__contains="Lennon").values("blog") + ) + q5 = Blog.objects.filter( + pk__in=Post.objects.exclude(title__contains="Lennon").values("blog") + ) # C, D, CD *all* entries do not have a title containing 'Lennon' self.verify(q1, [3, 4, 10]) @@ -65,46 +69,44 @@ def test_single_exclude(self): self.verify(q5.distinct(), [3, 4, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) def test_chained_join_statements(self): - q1 = Blog.objects \ - .exclude(post__title__contains='Lennon') \ - .exclude(post__publish_date__year=2008) + q1 = Blog.objects.exclude(post__title__contains="Lennon").exclude( + post__publish_date__year=2008 + ) self.verify(q1, self.NOT_CORRECT_ANY) def test_nested_join_outer_exclude(self): q2 = Blog.objects.exclude( - post__in=Post.objects - .filter(title__contains='Lennon') - .filter(publish_date__year=2008), + post__in=Post.objects.filter(title__contains="Lennon").filter( + publish_date__year=2008 + ), ) self.verify(q2, self.CORRECT) def test_nested_join_inner_exclude(self): q3 = Blog.objects.filter( - post__in=Post.objects - .exclude(title__contains='Lennon') - .exclude(publish_date__year=2008), + post__in=Post.objects.exclude(title__contains="Lennon").exclude( + publish_date__year=2008 + ), ) self.verify(q3, self.NOT_CORRECT_ONE) def test_nested_subquery_outer_exclude(self): q4 = Blog.objects.exclude( - pk__in=Post.objects - .filter(title__contains='Lennon') - .filter(publish_date__year=2008) - .values('blog'), + pk__in=Post.objects.filter(title__contains="Lennon") + .filter(publish_date__year=2008) + .values("blog"), ) self.verify(q4, self.CORRECT) def test_nested_subquery_inner_exclude(self): q5 = Blog.objects.filter( - pk__in=Post.objects - .exclude(title__contains='Lennon') - .exclude(publish_date__year=2008) - .values('blog'), + pk__in=Post.objects.exclude(title__contains="Lennon") + .exclude(publish_date__year=2008) + .values("blog"), ) self.verify(q5, self.NOT_CORRECT_ONE) @@ -112,7 +114,7 @@ def test_nested_subquery_inner_exclude(self): # Test behavior def test_reverse_fk(self): GET = { - 'post__title__contains!': 'Lennon', - 'post__publish_date__year!': '2008', + "post__title__contains!": "Lennon", + "post__publish_date__year!": "2008", } self.verify(BlogFilter(GET).qs, self.NOT_CORRECT_ONE) diff --git a/tests/related/test_filter.py b/tests/related/test_filter.py index a48e2a0..cf6607d 100644 --- a/tests/related/test_filter.py +++ b/tests/related/test_filter.py @@ -36,12 +36,11 @@ class FilterTests(RelationshipData, TestCase): def test_single_filter(self): # Verify that the following queries are equivalent - q1 = Blog.objects.filter(post__title__contains='Lennon') - q2 = Blog.objects.filter(post__in=Post.objects - .filter(title__contains='Lennon')) - q3 = Blog.objects.filter(pk__in=Post.objects - .filter(title__contains='Lennon') - .values('blog')) + q1 = Blog.objects.filter(post__title__contains="Lennon") + q2 = Blog.objects.filter(post__in=Post.objects.filter(title__contains="Lennon")) + q3 = Blog.objects.filter( + pk__in=Post.objects.filter(title__contains="Lennon").values("blog") + ) expected = [1, 2, 5, 6, 7, 8, 9, 11, 12, 13, 14, 15] @@ -50,27 +49,26 @@ def test_single_filter(self): self.verify(q3, expected) def test_chained_join_statements(self): - q1 = Blog.objects \ - .filter(post__title__contains='Lennon') \ - .filter(post__publish_date__year=2008) + q1 = Blog.objects.filter(post__title__contains="Lennon").filter( + post__publish_date__year=2008 + ) self.verify(q1.distinct(), self.NOT_CORRECT) def test_nested_join(self): q2 = Blog.objects.filter( - post__in=Post.objects - .filter(title__contains='Lennon') - .filter(publish_date__year=2008), + post__in=Post.objects.filter(title__contains="Lennon").filter( + publish_date__year=2008 + ), ) self.verify(q2, self.CORRECT) def test_nested_subquery(self): q3 = Blog.objects.filter( - pk__in=Post.objects - .filter(title__contains='Lennon') - .filter(publish_date__year=2008) - .values('blog'), + pk__in=Post.objects.filter(title__contains="Lennon") + .filter(publish_date__year=2008) + .values("blog"), ) self.verify(q3, self.CORRECT) @@ -78,7 +76,7 @@ def test_nested_subquery(self): # Test behavior def test_reverse_fk(self): GET = { - 'post__title__contains': 'Lennon', - 'post__publish_date__year': '2008', + "post__title__contains": "Lennon", + "post__publish_date__year": "2008", } self.verify(BlogFilter(GET).qs, self.CORRECT) diff --git a/tests/settings.py b/tests/settings.py index 5729fa5..209ccb6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,13 +1,11 @@ - DEBUG = True DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'db.sqlite3', - - 'TEST': { - 'NAME': ':memory:', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "db.sqlite3", + "TEST": { + "NAME": ":memory:", }, }, } @@ -15,35 +13,37 @@ MIDDLEWARE = [] INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.staticfiles', - 'rest_framework_filters', - 'rest_framework', - 'django_filters', - 'tests.testapp', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.staticfiles", + "rest_framework_filters", + "rest_framework", + "django_filters", + "tests.testapp", ) -SECRET_KEY = 'testsecretkey' +SECRET_KEY = "testsecretkey" # noqa TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], - 'debug': True, + "debug": True, }, }, ] -ROOT_URLCONF = 'tests.testapp.urls' +ROOT_URLCONF = "tests.testapp.urls" + +STATIC_URL = "/static/" -STATIC_URL = '/static/' +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tests/test_backends.py b/tests/test_backends.py index 9083df6..8379210 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -17,7 +17,7 @@ class RenderMixin: def render(self, viewset_class, data=None): - url = '/' if not data else '/?' + urlencode(data, True) + url = "/" if not data else "/?" + urlencode(data, True) view = viewset_class(action_map={}) backend = view.filter_backends[0] request = view.initialize_request(factory.get(url)) @@ -32,9 +32,9 @@ def setUpTestData(cls): models.User.objects.create(username="user2", email="user2@example.org") def test_django_filter_compatibility(self): - response = self.client.get('/df-users/', {'username': 'user1'}) + response = self.client.get("/df-users/", {"username": "user1"}) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['username'], 'user1') + self.assertEqual(response.data[0]["username"], "user1") def test_filterset_fields_reusability(self): # Ensure auto-generated FilterSet is reusable w/ filterset_fields. See: @@ -43,23 +43,23 @@ def test_filterset_fields_reusability(self): # Ensure that the filterset_fields aren't altered self.assertDictEqual( views.FilterFieldsUserViewSet.filterset_fields, - {'username': '__all__'}, + {"username": "__all__"}, ) - response = self.client.get('/ff-users/', {'username': 'user1'}) + response = self.client.get("/ff-users/", {"username": "user1"}) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['username'], 'user1') + self.assertEqual(response.data[0]["username"], "user1") self.assertDictEqual( views.FilterFieldsUserViewSet.filterset_fields, - {'username': '__all__'}, + {"username": "__all__"}, ) - response = self.client.get('/ff-users/', {'username': 'user1'}) + response = self.client.get("/ff-users/", {"username": "user1"}) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['username'], 'user1') + self.assertEqual(response.data[0]["username"], "user1") self.assertDictEqual( views.FilterFieldsUserViewSet.filterset_fields, - {'username': '__all__'}, + {"username": "__all__"}, ) def test_request_obj_is_passed(test): @@ -77,14 +77,14 @@ def __init__(self, *args, **kwargs): class Meta: model = models.User - fields = ['username'] + fields = ["username"] class ViewSet(views.FilterFieldsUserViewSet): filterset_class = RequestCheck view = ViewSet(action_map={}) backend = view.filter_backends[0] - request = view.initialize_request(factory.get('/')) + request = view.initialize_request(factory.get("/")) backend().filter_queryset(request, view.get_queryset(), view) test.assertTrue(called) @@ -92,18 +92,18 @@ def test_exclusion(self): class RequestCheck(FilterSet): class Meta: model = models.User - fields = ['username'] + fields = ["username"] class ViewSet(views.FilterFieldsUserViewSet): filterset_class = RequestCheck view = ViewSet(action_map={}) backend = view.filter_backends[0] - request = view.initialize_request(factory.get('/?username=user1')) + request = view.initialize_request(factory.get("/?username=user1")) qs = backend().filter_queryset(request, view.get_queryset(), view) self.assertEqual([u.pk for u in qs], [1]) - request = view.initialize_request(factory.get('/?username!=user1')) + request = view.initialize_request(factory.get("/?username!=user1")) qs = backend().filter_queryset(request, view.get_queryset(), view) self.assertEqual([u.pk for u in qs], [2]) @@ -113,12 +113,12 @@ def test_disabled(self): # see: https://github.com/philipn/django-rest-framework-filters/issues/230 view = views.UnfilteredUserViewSet(action_map={}) backend = view.filter_backends[0] - request = view.initialize_request(factory.get('/')) + request = view.initialize_request(factory.get("/")) # ensure view has backend and is missing attributes self.assertIs(backend, RestFrameworkFilterBackend) - self.assertFalse(hasattr(view, 'filterset_class')) - self.assertFalse(hasattr(view, 'filterset_fields')) + self.assertFalse(hasattr(view, "filterset_class")) + self.assertFalse(hasattr(view, "filterset_fields")) # filterset should be None, method should not error self.assertIsNone(backend().get_filterset(request, view.queryset, view)) @@ -134,9 +134,11 @@ class BackendRenderingTests(RenderMixin, APITestCase): def test_sanity(self): # Sanity check to ensure backend can render without crashing. class SimpleViewSet(views.FilterFieldsUserViewSet): - filterset_fields = ['username'] + filterset_fields = ["username"] - self.assertHTMLEqual(self.render(SimpleViewSet), """ + self.assertHTMLEqual( + self.render(SimpleViewSet), + """

Field filters

@@ -145,18 +147,21 @@ class SimpleViewSet(views.FilterFieldsUserViewSet):

- """) + """, + ) def test_django_filter_filterset_compatibility(self): class SimpleFilterSet(django_filters.FilterSet): class Meta: model = models.User - fields = ['username'] + fields = ["username"] class SimpleViewSet(views.FilterFieldsUserViewSet): filterset_class = SimpleFilterSet - self.assertHTMLEqual(self.render(SimpleViewSet), """ + self.assertHTMLEqual( + self.render(SimpleViewSet), + """

Field filters

@@ -165,7 +170,8 @@ class SimpleViewSet(views.FilterFieldsUserViewSet):

- """) + """, + ) def test_related_filterset(self): class UserFilter(FilterSet): @@ -175,13 +181,15 @@ class NoteFilter(FilterSet): author = filters.RelatedFilter( filterset=UserFilter, queryset=models.User.objects.all(), - label='Writer', + label="Writer", ) class RelatedViewSet(views.NoteViewSet): filterset_class = NoteFilter - self.assertHTMLEqual(self.render(RelatedViewSet), """ + self.assertHTMLEqual( + self.render(RelatedViewSet), + """

Field filters

@@ -202,7 +210,8 @@ class RelatedViewSet(views.NoteViewSet):

- """) + """, + ) def test_related_filterset_validation(self): class UserFilter(FilterSet): @@ -212,14 +221,16 @@ class NoteFilter(FilterSet): author = filters.RelatedFilter( filterset=UserFilter, queryset=models.User.objects.all(), - label='Writer', + label="Writer", ) class RelatedViewSet(views.NoteViewSet): filterset_class = NoteFilter - context = {'author': 'invalid', 'author__last_login': 'invalid'} - self.assertHTMLEqual(self.render(RelatedViewSet, context), """ + context = {"author": "invalid", "author__last_login": "invalid"} + self.assertHTMLEqual( + self.render(RelatedViewSet, context), + """

Field filters

    @@ -253,7 +264,8 @@ class RelatedViewSet(views.NoteViewSet): - """) + """, + ) def test_rendering_doesnt_affect_filterset_classes(self): class NoteFilter(FilterSet): @@ -261,7 +273,7 @@ class NoteFilter(FilterSet): class UserFilter(FilterSet): notes = filters.RelatedFilter( - field_name='note', + field_name="note", filterset=NoteFilter, queryset=models.Note.objects.all(), ) @@ -277,7 +289,7 @@ class SimpleViewSet(views.FilterFieldsUserViewSet): self.assertFalse(issubclass(filterset, SubsetDisabledMixin)) # check that FilterSet.related_filters aren't modified - filterset = UserFilter.base_filters['notes'].filterset + filterset = UserFilter.base_filters["notes"].filterset self.assertTrue(issubclass(filterset, FilterSet)) self.assertFalse(issubclass(filterset, SubsetDisabledMixin)) @@ -287,7 +299,7 @@ class NoteFilter(FilterSet): class UserFilter(FilterSet): notes = filters.RelatedFilter( - field_name='note', + field_name="note", filterset=NoteFilter, queryset=models.Note.objects.all(), ) @@ -296,7 +308,7 @@ class SimpleViewSet(views.FilterClassUserViewSet): filterset_class = UserFilter view = SimpleViewSet(action_map={}) - request = view.initialize_request(factory.get('/')) + request = view.initialize_request(factory.get("/")) backend = view.filter_backends[0] backend = backend() @@ -309,7 +321,7 @@ class SimpleViewSet(views.FilterClassUserViewSet): self.assertIsInstance(filterset, SubsetDisabledMixin) # check related filtersets - filterset = filterset.related_filtersets['notes'] + filterset = filterset.related_filtersets["notes"] self.assertIsInstance(filterset, FilterSet) self.assertIsInstance(filterset, SubsetDisabledMixin) @@ -318,7 +330,7 @@ class SimpleViewSet(views.FilterClassUserViewSet): def test_patch_for_rendering_handles_exception(self): view = views.FilterClassUserViewSet(action_map={}) - request = view.initialize_request(factory.get('/')) + request = view.initialize_request(factory.get("/")) backend = view.filter_backends[0] backend = backend() @@ -331,79 +343,6 @@ def test_patch_for_rendering_handles_exception(self): self.assertEqual(backend.get_filterset_class, original) -@modify_settings(INSTALLED_APPS={'append': ['crispy_forms']}) -class BackendCrispyFormsRenderingTests(RenderMixin, APITestCase): - - def test_crispy_forms_filterset_compatibility(self): - class SimpleCrispyFilterSet(FilterSet): - class Meta: - model = models.User - fields = ['username'] - - class SimpleViewSet(views.FilterFieldsUserViewSet): - filterset_class = SimpleCrispyFilterSet - - self.assertHTMLEqual(self.render(SimpleViewSet), """ -

    Field filters

    -
    -
    - -
    - -
    -
    - -
    - """) - - def test_related_filterset_crispy_forms(self): - class UserFilter(FilterSet): - username = filters.CharFilter() - - class NoteFilter(FilterSet): - author = filters.RelatedFilter( - filterset=UserFilter, - queryset=models.User.objects.all(), - label='Writer', - ) - - class RelatedViewSet(views.NoteViewSet): - filterset_class = NoteFilter - - self.assertHTMLEqual(self.render(RelatedViewSet), """ -

    Field filters

    -
    -
    - -
    - -
    -
    - -
    - Writer - -
    - -
    - -
    -
    -
    - - -
    - """) - - class ComplexFilterBackendTests(APITestCase): @classmethod @@ -414,69 +353,75 @@ def setUpTestData(cls): models.User.objects.create(username="user4", email="user4@example.org") def test_valid(self): - readable = quote('(username%3Duser1)|(email__contains%3Dexample.org)') - response = self.client.get('/ffcomplex-users/?filters=' + readable) + readable = quote("(username%3Duser1)|(email__contains%3Dexample.org)") + response = self.client.get("/ffcomplex-users/?filters=" + readable) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual( - [r['username'] for r in response.data], - ['user1', 'user3', 'user4'], + [r["username"] for r in response.data], + ["user1", "user3", "user4"], ) def test_invalid(self): - readable = quote('(username%3Duser1)asdf(email__contains%3Dexample.org)') - response = self.client.get('/ffcomplex-users/?filters=' + readable) + readable = quote("(username%3Duser1)asdf(email__contains%3Dexample.org)") + response = self.client.get("/ffcomplex-users/?filters=" + readable) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.data, { - 'filters': ["Invalid querystring operator. Matched: 'asdf'."], - }) + self.assertDictEqual( + response.data, + { + "filters": ["Invalid querystring operator. Matched: 'asdf'."], + }, + ) def test_invalid_filterset_errors(self): - readable = quote('(id%3Dfoo) | (id%3Dbar)') - response = self.client.get('/ffcomplex-users/?filters=' + readable) + readable = quote("(id%3Dfoo) | (id%3Dbar)") + response = self.client.get("/ffcomplex-users/?filters=" + readable) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) - self.assertDictEqual(response.data, { - 'filters': { - 'id=foo': { - 'id': ['Enter a number.'], - }, - 'id=bar': { - 'id': ['Enter a number.'], + self.assertDictEqual( + response.data, + { + "filters": { + "id=foo": { + "id": ["Enter a number."], + }, + "id=bar": { + "id": ["Enter a number."], + }, }, }, - }) + ) def test_pagination_compatibility(self): # Ensure that complex-filtering does not affect additional query param processing. - readable = quote('(email__contains%3Dexample.org)') + readable = quote("(email__contains%3Dexample.org)") # sanity check w/o pagination - response = self.client.get('/ffcomplex-users/?filters=' + readable) + response = self.client.get("/ffcomplex-users/?filters=" + readable) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual( - [r['username'] for r in response.data], - ['user3', 'user4'], + [r["username"] for r in response.data], + ["user3", "user4"], ) # sanity check w/o complex-filtering - response = self.client.get('/ffcomplex-users/?page_size=1') + response = self.client.get("/ffcomplex-users/?page_size=1") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('results', response.data) + self.assertIn("results", response.data) self.assertListEqual( - [r['username'] for r in response.data['results']], - ['user1'], + [r["username"] for r in response.data["results"]], + ["user1"], ) # pagination + complex-filtering - response = self.client.get('/ffcomplex-users/?page_size=1&filters=' + readable) + response = self.client.get("/ffcomplex-users/?page_size=1&filters=" + readable) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('results', response.data) + self.assertIn("results", response.data) self.assertListEqual( - [r['username'] for r in response.data['results']], - ['user3'], + [r["username"] for r in response.data["results"]], + ["user3"], ) diff --git a/tests/test_complex_ops.py b/tests/test_complex_ops.py index c88ad44..a5f5013 100644 --- a/tests/test_complex_ops.py +++ b/tests/test_complex_ops.py @@ -6,9 +6,9 @@ from django.test import TestCase from rest_framework.serializers import ValidationError -from rest_framework_filters.complex_ops import ( - ComplexOp, combine_complex_queryset, decode_complex_ops, -) +from rest_framework_filters.complex_ops import (ComplexOp, + combine_complex_queryset, + decode_complex_ops) from tests.testapp import models @@ -18,7 +18,7 @@ def encode(querysting): # Python 3.7 added '~' to the reserved character set. if sys.version_info < (3, 7): - result = result.replace('%7E', '~') + result = result.replace("%7E", "~") return result @@ -26,144 +26,159 @@ def encode(querysting): class DecodeComplexOpsTests(TestCase): def test_docstring(self): - encoded = '%28a%253D1%29%20%26%20%28b%253D2%29%20%7C%20~%28c%253D3%29' - readable = '(a%3D1) & (b%3D2) | ~(c%3D3)' + encoded = "%28a%253D1%29%20%26%20%28b%253D2%29%20%7C%20~%28c%253D3%29" + readable = "(a%3D1) & (b%3D2) | ~(c%3D3)" result = [ - ('a=1', False, QuerySet.__and__), - ('b=2', False, QuerySet.__or__), - ('c=3', True, None), + ("a=1", False, QuerySet.__and__), + ("b=2", False, QuerySet.__or__), + ("c=3", True, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_single_op(self): - encoded = '%28a%253D1%29' - readable = '(a%3D1)' + encoded = "%28a%253D1%29" + readable = "(a%3D1)" result = [ - ('a=1', False, None), + ("a=1", False, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_op_spacing(self): - encoded = '%28a%253D1%29%20%26%20%28b%253D2%29' - readable = '(a%3D1) & (b%3D2)' + encoded = "%28a%253D1%29%20%26%20%28b%253D2%29" + readable = "(a%3D1) & (b%3D2)" result = [ - ('a=1', False, QuerySet.__and__), - ('b=2', False, None), + ("a=1", False, QuerySet.__and__), + ("b=2", False, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_missing_parens(self): - encoded = 'a%253D1' - readable = 'a%3D1' + encoded = "a%253D1" + readable = "a%3D1" self.assertEqual(encode(readable), encoded) with self.assertRaises(ValidationError) as exc: decode_complex_ops(encoded) - self.assertEqual(exc.exception.detail, [ - "Unable to parse querystring. Decoded: 'a%3D1'.", - ]) + self.assertEqual( + exc.exception.detail, + [ + "Unable to parse querystring. Decoded: 'a%3D1'.", + ], + ) def test_missing_closing_paren(self): - encoded = '%28a%253D1' - readable = '(a%3D1' + encoded = "%28a%253D1" + readable = "(a%3D1" self.assertEqual(encode(readable), encoded) with self.assertRaises(ValidationError) as exc: decode_complex_ops(encoded) - self.assertEqual(exc.exception.detail, [ - "Unable to parse querystring. Decoded: '(a%3D1'.", - ]) + self.assertEqual( + exc.exception.detail, + [ + "Unable to parse querystring. Decoded: '(a%3D1'.", + ], + ) def test_missing_op(self): - encoded = '%28a%253D1%29%28b%253D2%29%26%28c%253D3%29' - readable = '(a%3D1)(b%3D2)&(c%3D3)' + encoded = "%28a%253D1%29%28b%253D2%29%26%28c%253D3%29" + readable = "(a%3D1)(b%3D2)&(c%3D3)" self.assertEqual(encode(readable), encoded) with self.assertRaises(ValidationError) as exc: decode_complex_ops(encoded) - self.assertEqual(exc.exception.detail, [ - "Invalid querystring operator. Matched: ''.", - ]) + self.assertEqual( + exc.exception.detail, + [ + "Invalid querystring operator. Matched: ''.", + ], + ) def test_invalid_ops(self): - encoded = '%28a%253D1%29asdf%28b%253D2%29qwerty%28c%253D3%29%26' - readable = '(a%3D1)asdf(b%3D2)qwerty(c%3D3)&' + encoded = "%28a%253D1%29asdf%28b%253D2%29qwerty%28c%253D3%29%26" + readable = "(a%3D1)asdf(b%3D2)qwerty(c%3D3)&" self.assertEqual(encode(readable), encoded) with self.assertRaises(ValidationError) as exc: decode_complex_ops(encoded) - self.assertEqual(exc.exception.detail, [ - "Invalid querystring operator. Matched: 'asdf'.", - "Invalid querystring operator. Matched: 'qwerty'.", - "Ending querystring must not have trailing characters. Matched: '&'.", - ]) + self.assertEqual( + exc.exception.detail, + [ + "Invalid querystring operator. Matched: 'asdf'.", + "Invalid querystring operator. Matched: 'qwerty'.", + "Ending querystring must not have trailing characters. Matched: '&'.", + ], + ) def test_negation(self): - encoded = '%28a%253D1%29%20%26%20~%28b%253D2%29' - readable = '(a%3D1) & ~(b%3D2)' + encoded = "%28a%253D1%29%20%26%20~%28b%253D2%29" + readable = "(a%3D1) & ~(b%3D2)" result = [ - ('a=1', False, QuerySet.__and__), - ('b=2', True, None), + ("a=1", False, QuerySet.__and__), + ("b=2", True, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_leading_negation(self): - encoded = '~%28a%253D1%29%20%26%20%28b%253D2%29' - readable = '~(a%3D1) & (b%3D2)' + encoded = "~%28a%253D1%29%20%26%20%28b%253D2%29" + readable = "~(a%3D1) & (b%3D2)" result = [ - ('a=1', True, QuerySet.__and__), - ('b=2', False, None), + ("a=1", True, QuerySet.__and__), + ("b=2", False, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_only_negation(self): - encoded = '~%28a%253D1%29' - readable = '~(a%3D1)' + encoded = "~%28a%253D1%29" + readable = "~(a%3D1)" result = [ - ('a=1', True, None), + ("a=1", True, None), ] self.assertEqual(encode(readable), encoded) self.assertEqual(decode_complex_ops(encoded), result) def test_duplicate_negation(self): - encoded = '%28a%253D1%29%20%26%20~~%28b%253D2%29' - readable = '(a%3D1) & ~~(b%3D2)' + encoded = "%28a%253D1%29%20%26%20~~%28b%253D2%29" + readable = "(a%3D1) & ~~(b%3D2)" self.assertEqual(encode(readable), encoded) with self.assertRaises(ValidationError) as exc: decode_complex_ops(encoded) - self.assertEqual(exc.exception.detail, [ - "Invalid querystring operator. Matched: ' & ~'.", - ]) + self.assertEqual( + exc.exception.detail, + [ + "Invalid querystring operator. Matched: ' & ~'.", + ], + ) def test_tilde_decoding(self): # Ensure decoding handles both RFC 2396 & 3986 - encoded_rfc3986 = '~%28a%253D1%29' - encoded_rfc2396 = '%7E%28a%253D1%29' - readable = '~(a%3D1)' + encoded_rfc3986 = "~%28a%253D1%29" + encoded_rfc2396 = "%7E%28a%253D1%29" + readable = "~(a%3D1)" result = [ - ('a=1', True, None), + ("a=1", True, None), ] self.assertEqual(encode(readable), encoded_rfc3986) @@ -175,61 +190,69 @@ class CombineComplexQuerysetTests(TestCase): @classmethod def setUpTestData(cls): - models.User.objects.create(username='u1', first_name='Bob', last_name='Jones') - models.User.objects.create(username='u2', first_name='Joe', last_name='Jones') - models.User.objects.create(username='u3', first_name='Bob', last_name='Smith') - models.User.objects.create(username='u4', first_name='Joe', last_name='Smith') + models.User.objects.create(username="u1", first_name="Bob", last_name="Jones") + models.User.objects.create(username="u2", first_name="Joe", last_name="Jones") + models.User.objects.create(username="u3", first_name="Bob", last_name="Smith") + models.User.objects.create(username="u4", first_name="Joe", last_name="Smith") def test_single(self): - querysets = [models.User.objects.filter(first_name='Bob')] + querysets = [models.User.objects.filter(first_name="Bob")] complex_ops = [ComplexOp(None, False, None)] - self.assertQuerysetEqual( + self.assertQuerySetEqual( combine_complex_queryset(querysets, complex_ops), - ['u1', 'u3'], attrgetter('username'), False, + ["u1", "u3"], + attrgetter("username"), + False, ) def test_AND(self): querysets = [ - models.User.objects.filter(first_name='Bob'), - models.User.objects.filter(last_name='Jones'), + models.User.objects.filter(first_name="Bob"), + models.User.objects.filter(last_name="Jones"), ] complex_ops = [ ComplexOp(None, False, QuerySet.__and__), ComplexOp(None, False, None), ] - self.assertQuerysetEqual( + self.assertQuerySetEqual( combine_complex_queryset(querysets, complex_ops), - ['u1'], attrgetter('username'), False, + ["u1"], + attrgetter("username"), + False, ) def test_OR(self): querysets = [ - models.User.objects.filter(first_name='Bob'), - models.User.objects.filter(last_name='Smith'), + models.User.objects.filter(first_name="Bob"), + models.User.objects.filter(last_name="Smith"), ] complex_ops = [ ComplexOp(None, False, QuerySet.__or__), ComplexOp(None, False, None), ] - self.assertQuerysetEqual( + self.assertQuerySetEqual( combine_complex_queryset(querysets, complex_ops), - ['u1', 'u3', 'u4'], attrgetter('username'), False, + ["u1", "u3", "u4"], + attrgetter("username"), + False, ) def test_negation(self): querysets = [ - models.User.objects.filter(first_name='Bob'), - models.User.objects.filter(last_name='Smith'), + models.User.objects.filter(first_name="Bob"), + models.User.objects.filter(last_name="Smith"), ] complex_ops = [ ComplexOp(None, False, QuerySet.__and__), ComplexOp(None, True, None), ] - self.assertQuerysetEqual( + self.assertQuerySetEqual( combine_complex_queryset(querysets, complex_ops), - ['u1'], attrgetter('username'), False, + ["u1"], + attrgetter("username"), + False, ) diff --git a/tests/test_filtering.py b/tests/test_filtering.py index dfa7291..fb399da 100644 --- a/tests/test_filtering.py +++ b/tests/test_filtering.py @@ -3,13 +3,12 @@ from rest_framework_filters import FilterSet, filters -from .testapp.filters import ( - AccountFilter, CFilter, CoverFilter, CustomerFilter, NoteFilter, NoteFilterWithAlias, - NoteFilterWithRelatedAlias, PageFilter, PersonFilter, PostFilter, UserFilter, -) -from .testapp.models import ( - A, Account, B, C, Cover, Customer, Note, Page, Person, Post, Tag, User, -) +from .testapp.filters import (AccountFilter, CFilter, CoverFilter, + CustomerFilter, NoteFilter, NoteFilterWithAlias, + NoteFilterWithRelatedAlias, PageFilter, + PersonFilter, PostFilter, UserFilter) +from .testapp.models import (A, Account, B, C, Cover, Customer, Note, Page, + Person, Post, Tag, User) class LocalTagFilter(FilterSet): @@ -33,23 +32,27 @@ def setUpTestData(cls): ####################### Note.objects.create(title="Test 1", content="Test content 1", author=user1) Note.objects.create(title="Test 2", content="Test content 2", author=user1) - Note.objects.create(title="Hello Test 3", content="Test content 3", author=user1) - Note.objects.create(title="Hello Test 4", content="Test content 4", author=user2) + Note.objects.create( + title="Hello Test 3", content="Test content 3", author=user1 + ) + Note.objects.create( + title="Hello Test 4", content="Test content 4", author=user2 + ) def test_all_lookups(self): # Test __iendswith - GET = {'title__iendswith': '2'} + GET = {"title__iendswith": "2"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Test 2") # Test __contains - GET = {'title__contains': 'Test'} + GET = {"title__contains": "Test"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 4) # Test that the default exact filter works - GET = {'title': 'Hello Test 3'} + GET = {"title": "Hello Test 3"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 3") @@ -58,7 +61,7 @@ def test_autofilter_with_mixin(self): # Mixin FilterSets should not error when no model is provided. See: # https://github.com/philipn/django-rest-framework-filters/issues/82 class Mixin(FilterSet): - title = filters.AutoFilter(lookups='__all__') + title = filters.AutoFilter(lookups="__all__") class Actual(Mixin): class Meta: @@ -70,11 +73,11 @@ class Meta: model = Note fields = [] - GET = {'title__contains': 'Hello'} + GET = {"title__contains": "Hello"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 2) - GET = {'title__contains': 'Hello'} + GET = {"title__contains": "Hello"} f = Subclass(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 2) @@ -82,14 +85,16 @@ def test_autofilter_with_method(self): # Test that method param applies to all auto-generated filters. def filter_iexact(qs, field_name, value): # Test that the field name contains the lookup expression. - self.assertEqual(field_name, 'content__icontains') + self.assertEqual(field_name, "content__icontains") return qs.filter(**{field_name: value}, author__username="user1") class Actual(FilterSet): - title = filters.AutoFilter(lookups='__all__', method='filter_title') - content = filters.AutoFilter(lookups=['icontains'], method=filter_iexact) - author = filters.AutoFilter(lookups='__all__', field_name='author__username', method='filter_author') + title = filters.AutoFilter(lookups="__all__", method="filter_title") + content = filters.AutoFilter(lookups=["icontains"], method=filter_iexact) + author = filters.AutoFilter( + lookups="__all__", field_name="author__username", method="filter_author" + ) class Meta: model = Note @@ -102,36 +107,36 @@ def filter_author(self, qs, field_name, value): return qs.filter(**{field_name: value}) # Test method as a function - GET = {'content__icontains': 'test content'} + GET = {"content__icontains": "test content"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 3) # Test method as a string reference to filterset method - GET = {'title__contains': 'Hello'} + GET = {"title__contains": "Hello"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(f.qs.first().author.username, "user1") - GET = {'title__iendswith': '4'} + GET = {"title__iendswith": "4"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 0) - GET = {'title': 'Hello Test 4'} + GET = {"title": "Hello Test 4"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 0) - GET = {'title': 'Hello Test 3'} + GET = {"title": "Hello Test 3"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(f.qs.first().author.username, "user1") # Test method in Autofilter on related field - GET = {'author__contains': 'user2'} + GET = {"author__contains": "user2"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(f.qs.first().author.username, "user2") - GET = {'author': 'user2'} + GET = {"author": "user2"} f = Actual(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(f.qs.first().author.username, "user2") @@ -148,18 +153,18 @@ def setUpTestData(cls): ######################################################################## # Create notes ######################################################### - note1 = Note.objects.create(title="Test 1", - content="Test content 1", - author=user1) - note2 = Note.objects.create(title="Test 2", - content="Test content 2", - author=user1) - Note.objects.create(title="Hello Test 3", - content="Test content 3", - author=user1) - note4 = Note.objects.create(title="Hello Test 4", - content="Test content 4", - author=user2) + note1 = Note.objects.create( + title="Test 1", content="Test content 1", author=user1 + ) + note2 = Note.objects.create( + title="Test 2", content="Test content 2", author=user1 + ) + Note.objects.create( + title="Hello Test 3", content="Test content 3", author=user1 + ) + note4 = Note.objects.create( + title="Hello Test 4", content="Test content 4", author=user2 + ) ######################################################################## # Create posts ######################################################### @@ -174,17 +179,16 @@ def setUpTestData(cls): ######################################################################## # Create pages ######################################################### - Page.objects.create(title="First page", - content="First first.") - Page.objects.create(title="Second page", - content="Second second.", - previous_page_id=1) - Page.objects.create(title="Third page", - content="Third third.", - previous_page_id=2) - Page.objects.create(title="Fourth page", - content="Fourth fourth.", - previous_page_id=3) + Page.objects.create(title="First page", content="First first.") + Page.objects.create( + title="Second page", content="Second second.", previous_page_id=1 + ) + Page.objects.create( + title="Third page", content="Third third.", previous_page_id=2 + ) + Page.objects.create( + title="Fourth page", content="Fourth fourth.", previous_page_id=3 + ) ######################################################################## # ManyToMany ########################################################### @@ -223,24 +227,28 @@ def setUpTestData(cls): ######################################################################## # to_field relations ################################################### - c1 = Customer.objects.create(name='Bob Jones', ssn='111111111', dob='1990-01-01') - c2 = Customer.objects.create(name='Sue Jones', ssn='222222222', dob='1990-01-01') + c1 = Customer.objects.create( + name="Bob Jones", ssn="111111111", dob="1990-01-01" + ) + c2 = Customer.objects.create( + name="Sue Jones", ssn="222222222", dob="1990-01-01" + ) - Account.objects.create(customer=c1, type='c', name='Bank 1 checking') - Account.objects.create(customer=c1, type='s', name='Bank 1 savings') - Account.objects.create(customer=c2, type='c', name='Bank 1 checking 1') - Account.objects.create(customer=c2, type='c', name='Bank 1 checking 2') - Account.objects.create(customer=c2, type='s', name='Bank 2 savings') + Account.objects.create(customer=c1, type="c", name="Bank 1 checking") + Account.objects.create(customer=c1, type="s", name="Bank 1 savings") + Account.objects.create(customer=c2, type="c", name="Bank 1 checking 1") + Account.objects.create(customer=c2, type="c", name="Bank 1 checking 2") + Account.objects.create(customer=c2, type="s", name="Bank 2 savings") def test_relatedfilter(self): # Test that the default exact filter works - GET = {'author': User.objects.get(username='user2').pk} + GET = {"author": User.objects.get(username="user2").pk} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") # Test the username filter on the related UserFilter set. - GET = {'author__username': 'user2'} + GET = {"author__username": "user2"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") @@ -249,35 +257,35 @@ def test_relatedfilter_for_related_all_lookups(self): # ensure that filters work for AutoFilter across a RelatedFilter. # Test that the default exact filter works - GET = {'author': User.objects.get(username='user2').pk} + GET = {"author": User.objects.get(username="user2").pk} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) note = list(f.qs)[0] self.assertEqual(note.title, "Hello Test 4") # Test the username filter on the related UserFilter set. - GET = {'author__username': 'user2'} + GET = {"author__username": "user2"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") - GET = {'author__username__endswith': '2'} + GET = {"author__username__endswith": "2"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") - GET = {'author__username__endswith': '1'} + GET = {"author__username__endswith": "1"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 3) - GET = {'author__username__contains': 'user'} + GET = {"author__username__contains": "user"} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 4) def test_relatedfilter_for_related_all_lookups_and_different_filter_name(self): # Test that the default exact filter works GET = { - 'writer': User.objects.get(username='user2').pk, + "writer": User.objects.get(username="user2").pk, } f = NoteFilterWithAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -285,41 +293,41 @@ def test_relatedfilter_for_related_all_lookups_and_different_filter_name(self): self.assertEqual(note.title, "Hello Test 4") # Test the username filter on the related UserFilter set. - GET = {'writer__username': 'user2'} + GET = {"writer__username": "user2"} f = NoteFilterWithAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") - GET = {'writer__username__endswith': '2'} + GET = {"writer__username__endswith": "2"} f = NoteFilterWithAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) self.assertEqual(list(f.qs)[0].title, "Hello Test 4") - GET = {'writer__username__endswith': '1'} + GET = {"writer__username__endswith": "1"} f = NoteFilterWithAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 3) - GET = {'writer__username__contains': 'user'} + GET = {"writer__username__contains": "user"} f = NoteFilterWithAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 4) def test_relatedfilter_for_aliased_nested_relationships(self): - qs = Page.objects.order_by('pk') + qs = Page.objects.order_by("pk") - f1 = PageFilter({'two_pages_back': '1'}, queryset=qs) - f2 = PageFilter({'two_pages_back': '2'}, queryset=qs) - f3 = PageFilter({'two_pages_back': '3'}, queryset=qs) - f4 = PageFilter({'two_pages_back': '4'}, queryset=qs) + f1 = PageFilter({"two_pages_back": "1"}, queryset=qs) + f2 = PageFilter({"two_pages_back": "2"}, queryset=qs) + f3 = PageFilter({"two_pages_back": "3"}, queryset=qs) + f4 = PageFilter({"two_pages_back": "4"}, queryset=qs) - self.assertQuerysetEqual(f1.qs, [3], lambda p: p.pk) - self.assertQuerysetEqual(f2.qs, [4], lambda p: p.pk) - self.assertQuerysetEqual(f3.qs, [], lambda p: p.pk) - self.assertQuerysetEqual(f4.qs, [], lambda p: p.pk) + self.assertQuerySetEqual(f1.qs, [3], lambda p: p.pk) + self.assertQuerySetEqual(f2.qs, [4], lambda p: p.pk) + self.assertQuerySetEqual(f3.qs, [], lambda p: p.pk) + self.assertQuerySetEqual(f4.qs, [], lambda p: p.pk) def test_relatedfilter_different_name(self): # Test the name filter on the related UserFilter set. GET = { - 'author__name': 'user2', + "author__name": "user2", } f = NoteFilterWithRelatedAlias(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -328,7 +336,7 @@ def test_relatedfilter_different_name(self): def test_double_relation_filter(self): GET = { - 'note__author__username__endswith': 'user2', + "note__author__username__endswith": "user2", } f = PostFilter(GET, queryset=Post.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -337,7 +345,7 @@ def test_double_relation_filter(self): def test_triple_relation_filter(self): GET = { - 'post__note__author__username__endswith': 'user2', + "post__note__author__username__endswith": "user2", } f = CoverFilter(GET, queryset=Cover.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -346,7 +354,7 @@ def test_triple_relation_filter(self): def test_indirect_recursive_relation(self): GET = { - 'a__b__name__endswith': '1', + "a__b__name__endswith": "1", } f = CFilter(GET, queryset=C.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -356,7 +364,7 @@ def test_indirect_recursive_relation(self): def test_direct_recursive_relation(self): # see: https://github.com/philipn/django-rest-framework-filters/issues/333 GET = { - 'best_friend': 1, + "best_friend": 1, } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -365,7 +373,7 @@ def test_direct_recursive_relation(self): def test_direct_recursive_relation__lookup(self): GET = { - 'best_friend__name__endswith': 'hn', + "best_friend__name__endswith": "hn", } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -374,7 +382,7 @@ def test_direct_recursive_relation__lookup(self): def test_m2m_relation(self): GET = { - 'tags__name__endswith': 'ark', + "tags__name__endswith": "ark", } f = PostFilter(GET, queryset=Post.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -382,47 +390,47 @@ def test_m2m_relation(self): self.assertEqual(p.content, "Test content in post 1") GET = { - 'tags__name__endswith': 'ouse', + "tags__name__endswith": "ouse", } f = PostFilter(GET, queryset=Post.objects.all()) self.assertEqual(len(list(f.qs)), 2) contents = {post.content for post in f.qs} - self.assertEqual(contents, {'Test content in post 1', 'Test content in post 3'}) + self.assertEqual(contents, {"Test content in post 1", "Test content in post 3"}) def test_m2m_distinct(self): GET = { - 'tags__name__startswith': 'test', + "tags__name__startswith": "test", } f = PostFilter(GET, queryset=Post.objects.all()) self.assertEqual(len(list(f.qs)), 1) contents = {post.content for post in f.qs} - self.assertEqual(contents, {'Test content in post 2'}) + self.assertEqual(contents, {"Test content in post 2"}) def test_nonexistent_related_field(self): # Invalid filter keys (including those on related filters) should be ignored. # Related: https://github.com/philipn/django-rest-framework-filters/issues/58 GET = { - 'author__nonexistent': 'foobar', + "author__nonexistent": "foobar", } f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 4) GET = { - 'author__nonexistent__isnull': 'foobar', + "author__nonexistent__isnull": "foobar", } f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 4) def test_related_filters_inheritance(self): class ChildFilter(PostFilter): - foo = filters.RelatedFilter(NoteFilter, field_name='note') + foo = filters.RelatedFilter(NoteFilter, field_name="note") self.assertEqual( - ['author', 'note', 'tags'], + ["author", "note", "tags"], list(PostFilter.related_filters), ) self.assertEqual( - ['author', 'note', 'tags', 'foo'], + ["author", "note", "tags", "foo"], list(ChildFilter.related_filters), ) @@ -432,17 +440,19 @@ def test_relatedfilter_queryset_required(self): # The default behavior should not expose information, which requires users to # explicitly set the `queryset` argument. class NoteFilter(FilterSet): - title = filters.CharFilter(field_name='title') - author = filters.RelatedFilter(UserFilter, name='author') + title = filters.CharFilter(field_name="title") + author = filters.RelatedFilter(UserFilter, name="author") class Meta: model = Note fields = [] - GET = {'author': User.objects.get(username='user2').pk} - msg = "Expected `.get_queryset()` for related filter 'NoteFilter.author' " \ - "to return a `QuerySet`, but got `None`." - with self.assertRaisesMessage(AssertionError, msg): + GET = {"author": User.objects.get(username="user2").pk} + msg = ( + "Expected `.get_queryset()` for related filter 'NoteFilter.author' " + "to return a `QuerySet`, but got `None`." + ) + with self.assertRaisesMessage(ValueError, msg): NoteFilter(GET, queryset=Note.objects.all()) def test_relatedfilter_request_is_passed(self): @@ -458,7 +468,7 @@ def __init__(self, *args, **kwargs): class Meta: model = User - fields = ['username'] + fields = ["username"] class NoteFilter(FilterSet): author = filters.RelatedFilter(RequestCheck, queryset=User.objects.all()) @@ -467,7 +477,7 @@ class Meta: model = Note fields = [] - GET = {'author__username': 'user2'} + GET = {"author__username": "user2"} # should pass NoteFilter(GET, queryset=Note.objects.all(), request=object()).qs @@ -475,11 +485,11 @@ class Meta: def test_validation(self): class F(PostFilter): - pk = filters.NumberFilter(field_name='id') + pk = filters.NumberFilter(field_name="id") GET = { - 'note__author': 'foo', - 'pk': 'bar', + "note__author": "foo", + "pk": "bar", } f = F(GET, queryset=Post.objects.all()) @@ -487,33 +497,33 @@ class F(PostFilter): self.assertFalse(f.is_valid()) self.assertEqual(len(f.form.errors.keys()), 2) - self.assertIn('note__author', f.form.errors) - self.assertIn('pk', f.form.errors) + self.assertIn("note__author", f.form.errors) + self.assertIn("pk", f.form.errors) def test_relative_filterset_path(self): # Test that RelatedFilter can import FilterSets by name from its parent's module class PostFilter(FilterSet): - tags = filters.RelatedFilter('LocalTagFilter', queryset=Tag.objects.all()) + tags = filters.RelatedFilter("LocalTagFilter", queryset=Tag.objects.all()) - f = PostFilter({'tags': ''}, queryset=Post.objects.all()) - f = f.filters['tags'].filterset + f = PostFilter({"tags": ""}, queryset=Post.objects.all()) + f = f.filters["tags"].filterset - self.assertEqual(f.__module__, 'tests.test_filtering') - self.assertEqual(f.__name__, 'LocalTagFilter') + self.assertEqual(f.__module__, "tests.test_filtering") + self.assertEqual(f.__name__, "LocalTagFilter") def test_empty_param_name(self): - GET = {'': 'foo', 'author': User.objects.get(username='user2').pk} + GET = {"": "foo", "author": User.objects.get(username="user2").pk} f = NoteFilter(GET, queryset=Note.objects.all()) self.assertEqual(len(list(f.qs)), 1) def test_to_field_forwards_relation(self): - GET = {'customer__name': 'Bob Jones'} + GET = {"customer__name": "Bob Jones"} f = AccountFilter(GET) self.assertEqual(len(list(f.qs)), 2) def test_to_field_reverse_relation(self): # Note: pending #99, this query should ideally return 2 distinct results - GET = {'accounts__type': 'c'} + GET = {"accounts__type": "c"} f = CustomerFilter(GET) self.assertEqual(len(list(f.qs)), 3) @@ -524,66 +534,66 @@ class AnnotationTests(TestCase): @classmethod def setUpTestData(cls): - author1 = User.objects.create(username='author1', email='author1@example.org') - author2 = User.objects.create(username='author2', email='author2@example.org') - Post.objects.create(author=author1, content='Post 1', publish_date='2018-01-01') - Post.objects.create(author=author2, content='Post 2', publish_date=None) + author1 = User.objects.create(username="author1", email="author1@example.org") + author2 = User.objects.create(username="author2", email="author2@example.org") + Post.objects.create(author=author1, content="Post 1", publish_date="2018-01-01") + Post.objects.create(author=author2, content="Post 2", publish_date=None) def test_annotation(self): f = PostFilter( - {'is_published': 'true'}, + {"is_published": "true"}, queryset=Post.objects.all(), ) - self.assertEqual([p.content for p in f.qs], ['Post 1']) + self.assertEqual([p.content for p in f.qs], ["Post 1"]) def test_related_annotation(self): f = UserFilter( - {'posts__is_published': 'true'}, + {"posts__is_published": "true"}, queryset=User.objects.all(), ) - self.assertEqual([a.username for a in f.qs], ['author1']) + self.assertEqual([a.username for a in f.qs], ["author1"]) class MiscTests(TestCase): def test_multiwidget_incompatibility(self): - Person.objects.create(name='A') + Person.objects.create(name="A") # test django-filter functionality class PersonFilter(DFFilterSet): - date_joined = filters.DateFromToRangeFilter(field_name='date_joined') + date_joined = filters.DateFromToRangeFilter(field_name="date_joined") class Meta: model = Person - fields = ['date_joined'] + fields = ["date_joined"] # Test from ... to 2016-01-01 GET = { - 'date_joined_before': '2016-01-01', + "date_joined_before": "2016-01-01", } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(f.qs.count(), 0) # test drf-filters caveat class PersonFilter(FilterSet): - date_joined = filters.DateFromToRangeFilter(field_name='date_joined') + date_joined = filters.DateFromToRangeFilter(field_name="date_joined") class Meta: model = Person - fields = ['date_joined'] + fields = ["date_joined"] # Test from ... to 2016-01-01, failure case GET = { - 'date_joined_before': '2016-01-01', + "date_joined_before": "2016-01-01", } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(f.qs.count(), 1) # Test from ... to 2016-01-01, "fix" GET = { - 'date_joined_before': '2016-01-01', - 'date_joined': '', + "date_joined_before": "2016-01-01", + "date_joined": "", } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(f.qs.count(), 0) diff --git a/tests/test_filters.py b/tests/test_filters.py index c3f9052..cc3a759 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -4,11 +4,11 @@ class A(FilterSet): - c = filters.RelatedFilter('tests.test_filters.C') + c = filters.RelatedFilter("tests.test_filters.C") class B(FilterSet): - a = filters.RelatedFilter('A') + a = filters.RelatedFilter("A") class C(FilterSet): @@ -21,19 +21,19 @@ class RelatedFilterFiltersetTests(TestCase): # - Relative `.filterset` imports are durable to inheritance def subclass(self, cls): - return type('Subclass%s' % cls.__name__, (cls, ), {}) + return type("Subclass%s" % cls.__name__, (cls,), {}) def test_filterset_absolute_import(self): for cls in [A, self.subclass(A)]: with self.subTest(cls=cls): - self.assertIs(cls.base_filters['c'].filterset, C) + self.assertIs(cls.base_filters["c"].filterset, C) def test_filterset_relative_import(self): for cls in [B, self.subclass(B)]: with self.subTest(cls=cls): - self.assertIs(cls.base_filters['a'].filterset, A) + self.assertIs(cls.base_filters["a"].filterset, A) def test_filterset_class(self): for cls in [C, self.subclass(C)]: with self.subTest(cls=cls): - self.assertIs(cls.base_filters['b'].filterset, B) + self.assertIs(cls.base_filters["b"].filterset, B) diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 6e511a3..797434b 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -9,12 +9,11 @@ from rest_framework.views import APIView from rest_framework_filters import FilterSet, filters -from rest_framework_filters.filterset import FilterSetMetaclass, SubsetDisabledMixin +from rest_framework_filters.filterset import (FilterSetMetaclass, + SubsetDisabledMixin) -from .testapp.filters import ( - AFilter, NoteFilter, NoteFilterWithAlias, PersonFilter, PostFilter, TagFilter, - UserFilter, -) +from .testapp.filters import (AFilter, NoteFilter, NoteFilterWithAlias, + PersonFilter, PostFilter, TagFilter, UserFilter) from .testapp.models import Note, Person, Post, Tag, User factory = APIRequestFactory() @@ -35,8 +34,8 @@ class MetaclassTests(TestCase): def test_metamethods(self): functions = [ - 'get_auto_filters', - 'expand_auto_filter', + "get_auto_filters", + "expand_auto_filter", ] for func in functions: @@ -50,12 +49,12 @@ class AutoFilterTests(TestCase): def test_autofilter_not_declared(self): # AutoFilter is not an actual Filter subclass - f = filters.AutoFilter(lookups=['exact']) + f = filters.AutoFilter(lookups=["exact"]) class F(FilterSet): id = f - self.assertEqual(F.auto_filters, {'id': f}) + self.assertEqual(F.auto_filters, {"id": f}) self.assertEqual(F.declared_filters, {}) def test_autofilter_meta_fields_unmodified(self): @@ -64,7 +63,7 @@ def test_autofilter_meta_fields_unmodified(self): f = [] class F(FilterSet): - id = filters.AutoFilter(lookups='__all__') + id = filters.AutoFilter(lookups="__all__") class Meta: model = Note @@ -75,15 +74,15 @@ class Meta: def test_autofilter_replaced(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/118 class F(FilterSet): - id = filters.AutoFilter(lookups=['exact']) + id = filters.AutoFilter(lookups=["exact"]) class Meta: model = Note fields = [] - self.assertEqual(list(F.base_filters), ['id']) - self.assertIsInstance(F.base_filters['id'], filters.NumberFilter) - self.assertEqual(F.base_filters['id'].lookup_expr, 'exact') + self.assertEqual(list(F.base_filters), ["id"]) + self.assertIsInstance(F.base_filters["id"], filters.NumberFilter) + self.assertEqual(F.base_filters["id"].lookup_expr, "exact") def test_autofilter_noop(self): class F(FilterSet): @@ -97,7 +96,7 @@ class Meta: def test_autofilter_with_mixin(self): class Mixin(FilterSet): - title = filters.AutoFilter(lookups=['exact']) + title = filters.AutoFilter(lookups=["exact"]) class Actual(Mixin): class Meta: @@ -113,15 +112,15 @@ class Meta: self.assertEqual(base_filters, {}) base_filters = {name: type(f) for name, f in Actual.base_filters.items()} - self.assertEqual(base_filters, {'title': filters.CharFilter}) + self.assertEqual(base_filters, {"title": filters.CharFilter}) base_filters = {name: type(f) for name, f in Subclass.base_filters.items()} - self.assertEqual(base_filters, {'title': filters.CharFilter}) + self.assertEqual(base_filters, {"title": filters.CharFilter}) def test_autofilter_doesnt_expand_declared(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/234 class F(FilterSet): - pk = filters.AutoFilter(field_name='id', lookups=['exact']) + pk = filters.AutoFilter(field_name="id", lookups=["exact"]) individual = filters.CharFilter() class Meta: @@ -129,31 +128,36 @@ class Meta: fields = [] base_filters = {name: type(f) for name, f in F.base_filters.items()} - self.assertEqual(base_filters, { - 'individual': filters.CharFilter, - 'pk': filters.NumberFilter, - }) + self.assertEqual( + base_filters, + { + "individual": filters.CharFilter, + "pk": filters.NumberFilter, + }, + ) - @unittest.skipIf(django_filters.VERSION < (2, 2), 'requires django-filter 2.2') + @unittest.skipIf(django_filters.VERSION < (2, 2), "requires django-filter 2.2") def test_autofilter_invalid_field(self): msg = "'Meta.fields' must not contain non-model field names: xyz" with self.assertRaisesMessage(TypeError, msg): + class F(FilterSet): - pk = filters.AutoFilter(field_name='xyz', lookups=['exact']) + pk = filters.AutoFilter(field_name="xyz", lookups=["exact"]) class Meta: model = Note fields = [] - @unittest.skipIf(django_filters.VERSION < (2, 2), 'requires django-filter 2.2') + @unittest.skipIf(django_filters.VERSION < (2, 2), "requires django-filter 2.2") def test_all_lookups_invalid_field(self): msg = "'Meta.fields' must not contain non-model field names: xyz" with self.assertRaisesMessage(TypeError, msg): + class F(FilterSet): class Meta: model = Note fields = { - 'xyz': '__all__', + "xyz": "__all__", } def test_relatedfilter_doesnt_expand_declared(self): @@ -161,8 +165,8 @@ def test_relatedfilter_doesnt_expand_declared(self): class F(FilterSet): posts = filters.RelatedFilter( PostFilter, - field_name='post', - lookups=['exact'], + field_name="post", + lookups=["exact"], ) class Meta: @@ -170,9 +174,12 @@ class Meta: fields = [] base_filters = {name: type(f) for name, f in F.base_filters.items()} - self.assertEqual(base_filters, { - 'posts': filters.RelatedFilter, - }) + self.assertEqual( + base_filters, + { + "posts": filters.RelatedFilter, + }, + ) def test_all_lookups_for_relation(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/84 @@ -180,23 +187,25 @@ class F(FilterSet): class Meta: model = Note fields = { - 'author': '__all__', + "author": "__all__", } - self.assertIsInstance(F.base_filters['author'], filters.ModelChoiceFilter) - self.assertIsInstance(F.base_filters['author__in'], BaseInFilter) + self.assertIsInstance(F.base_filters["author"], filters.ModelChoiceFilter) + self.assertIsInstance(F.base_filters["author__in"], BaseInFilter) def test_autofilter_for_related_field(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/127 class F(FilterSet): - author = filters.AutoFilter(field_name='author__last_name', lookups='__all__') + author = filters.AutoFilter( + field_name="author__last_name", lookups="__all__" + ) class Meta: model = Note fields = [] - self.assertIsInstance(F.base_filters['author'], filters.CharFilter) - self.assertEqual(F.base_filters['author'].field_name, 'author__last_name') + self.assertIsInstance(F.base_filters["author"], filters.CharFilter) + self.assertEqual(F.base_filters["author"].field_name, "author__last_name") def test_relatedfilter_combined_with__all__(self): # ensure that related filter is compatible with __all__ lookups. @@ -206,23 +215,23 @@ class F(FilterSet): class Meta: model = Note fields = { - 'author': '__all__', + "author": "__all__", } - self.assertIsInstance(F.base_filters['author'], filters.RelatedFilter) - self.assertIsInstance(F.base_filters['author__in'], BaseInFilter) + self.assertIsInstance(F.base_filters["author"], filters.RelatedFilter) + self.assertIsInstance(F.base_filters["author__in"], BaseInFilter) def test_relatedfilter_lookups(self): # ensure that related filter is compatible with AutoFilter lookups. class F(FilterSet): - author = filters.RelatedFilter(UserFilter, lookups='__all__') + author = filters.RelatedFilter(UserFilter, lookups="__all__") class Meta: model = Note fields = [] - self.assertIsInstance(F.base_filters['author'], filters.RelatedFilter) - self.assertIsInstance(F.base_filters['author__in'], BaseInFilter) + self.assertIsInstance(F.base_filters["author"], filters.RelatedFilter) + self.assertIsInstance(F.base_filters["author__in"], BaseInFilter) def test_relatedfilter_lookups_default(self): class F(FilterSet): @@ -232,20 +241,20 @@ class Meta: model = Note fields = [] - self.assertEqual(len([f for f in F.base_filters if f.startswith('author')]), 1) - self.assertIsInstance(F.base_filters['author'], filters.RelatedFilter) + self.assertEqual(len([f for f in F.base_filters if f.startswith("author")]), 1) + self.assertIsInstance(F.base_filters["author"], filters.RelatedFilter) def test_relatedfilter_lookups_list(self): class F(FilterSet): - author = filters.RelatedFilter(UserFilter, lookups=['in']) + author = filters.RelatedFilter(UserFilter, lookups=["in"]) class Meta: model = Note fields = [] - self.assertEqual(len([f for f in F.base_filters if f.startswith('author')]), 2) - self.assertIsInstance(F.base_filters['author'], filters.RelatedFilter) - self.assertIsInstance(F.base_filters['author__in'], BaseInFilter) + self.assertEqual(len([f for f in F.base_filters if f.startswith("author")]), 2) + self.assertIsInstance(F.base_filters["author"], filters.RelatedFilter) + self.assertIsInstance(F.base_filters["author__in"], BaseInFilter) def test_declared_filter_persistence_with__all__(self): # ensure that __all__ does not overwrite declared filters. @@ -256,30 +265,32 @@ class F(FilterSet): class Meta: model = Person - fields = {'name': '__all__'} + fields = {"name": "__all__"} - self.assertIs(F.base_filters['name'], f) + self.assertIs(F.base_filters["name"], f) def test_declared_filter_persistence_with_autofilter(self): # ensure that AutoFilter does not overwrite declared filters. f = filters.Filter() class F(FilterSet): - id = filters.AutoFilter(lookups='__all__') + id = filters.AutoFilter(lookups="__all__") id__in = f class Meta: model = Note fields = [] - self.assertIs(F.base_filters['id__in'], f) + self.assertIs(F.base_filters["id__in"], f) def test_alllookupsfilter_deprecation_warning(self): - message = ("`AllLookupsFilter()` has been deprecated in " - "favor of `AutoFilter(lookups='__all__')`.") + message = ( + "`AllLookupsFilter()` has been deprecated in " + "favor of `AutoFilter(lookups='__all__')`." + ) with warnings.catch_warnings(record=True) as w: - warnings.simplefilter('always') + warnings.simplefilter("always") class F(FilterSet): field = filters.AllLookupsFilter() @@ -294,9 +305,13 @@ def external_method(instance, qs, field, value): pass class F(FilterSet): - id = filters.AutoFilter(lookups='__all__', method='filterset_method') - title = filters.AutoFilter(lookups=['exact'], method=external_method) - author = filters.AutoFilter(field_name='author__last_name', lookups='__all__', method='related_method') + id = filters.AutoFilter(lookups="__all__", method="filterset_method") + title = filters.AutoFilter(lookups=["exact"], method=external_method) + author = filters.AutoFilter( + field_name="author__last_name", + lookups="__all__", + method="related_method", + ) class Meta: model = Note @@ -310,8 +325,10 @@ def related_method(self, qs, field, value): for field_name, lookup_filter in F.base_filters.items(): # Ensure field name on filter is overridden to include lookup expression. - if lookup_filter.lookup_expr != 'exact': - self.assertTrue(lookup_filter.field_name.endswith(lookup_filter.lookup_expr)) + if lookup_filter.lookup_expr != "exact": + self.assertTrue( + lookup_filter.field_name.endswith(lookup_filter.lookup_expr) + ) self.assertIsNotNone(lookup_filter.method) self.assertIsNotNone(lookup_filter._method) @@ -325,98 +342,111 @@ def test_not_bound(self): self.assertEqual(len(filtersets), 0) def test_not_related_filter(self): - filtersets = NoteFilter({ - 'title': 'foo', - }).get_related_filtersets() + filtersets = NoteFilter( + { + "title": "foo", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 0) def test_exact(self): - filtersets = NoteFilter({ - 'author': 'bob', - }).get_related_filtersets() + filtersets = NoteFilter( + { + "author": "bob", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 1) - self.assertIsInstance(filtersets['author'], UserFilter) + self.assertIsInstance(filtersets["author"], UserFilter) def test_filterset(self): - filtersets = NoteFilter({ - 'author__username': 'bob', - }).get_related_filtersets() + filtersets = NoteFilter( + { + "author__username": "bob", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 1) - self.assertIsInstance(filtersets['author'], UserFilter) + self.assertIsInstance(filtersets["author"], UserFilter) def test_filterset_alias(self): - filtersets = NoteFilterWithAlias({ - 'writer__username': 'bob', - }).get_related_filtersets() + filtersets = NoteFilterWithAlias( + { + "writer__username": "bob", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 1) - self.assertIsInstance(filtersets['writer'], UserFilter) + self.assertIsInstance(filtersets["writer"], UserFilter) def test_filterset_twice_removed(self): - filtersets = PostFilter({ - 'note__author__username': 'bob', - }).get_related_filtersets() + filtersets = PostFilter( + { + "note__author__username": "bob", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 1) - self.assertIsInstance(filtersets['note'], NoteFilter) + self.assertIsInstance(filtersets["note"], NoteFilter) - filtersets = filtersets['note'].get_related_filtersets() + filtersets = filtersets["note"].get_related_filtersets() self.assertEqual(len(filtersets), 1) - self.assertIsInstance(filtersets['author'], UserFilter) + self.assertIsInstance(filtersets["author"], UserFilter) def test_filterset_multiple_filters(self): - filtersets = PostFilter({ - 'note__foo': 'bob', 'tags__bar': 'joe', - }).get_related_filtersets() + filtersets = PostFilter( + { + "note__foo": "bob", + "tags__bar": "joe", + } + ).get_related_filtersets() self.assertEqual(len(filtersets), 2) - self.assertIsInstance(filtersets['note'], NoteFilter) - self.assertIsInstance(filtersets['tags'], TagFilter) + self.assertIsInstance(filtersets["note"], NoteFilter) + self.assertIsInstance(filtersets["tags"], TagFilter) class GetParamFilterNameTests(TestCase): def test_regular_filter(self): - name = UserFilter.get_param_filter_name('email') - self.assertEqual('email', name) + name = UserFilter.get_param_filter_name("email") + self.assertEqual("email", name) def test_exclusion_filter(self): - name = UserFilter.get_param_filter_name('email!') - self.assertEqual('email', name) + name = UserFilter.get_param_filter_name("email!") + self.assertEqual("email", name) def test_non_filter(self): - name = UserFilter.get_param_filter_name('foobar') + name = UserFilter.get_param_filter_name("foobar") self.assertIsNone(name) def test_related_filter(self): # 'exact' matches - name = NoteFilter.get_param_filter_name('author') - self.assertEqual('author', name) + name = NoteFilter.get_param_filter_name("author") + self.assertEqual("author", name) # related attribute filters - name = NoteFilter.get_param_filter_name('author__email') - self.assertEqual('author', name) + name = NoteFilter.get_param_filter_name("author__email") + self.assertEqual("author", name) # non-existent related filters should match, as it's the responsibility # of the related filterset to handle non-existent filters - name = NoteFilter.get_param_filter_name('author__foobar') - self.assertEqual('author', name) + name = NoteFilter.get_param_filter_name("author__foobar") + self.assertEqual("author", name) def test_relationship_regular_filter(self): - name = UserFilter.get_param_filter_name('author__email', rel='author') - self.assertEqual('email', name) + name = UserFilter.get_param_filter_name("author__email", rel="author") + self.assertEqual("email", name) def test_recursive_self_filter(self): - name = PersonFilter.get_param_filter_name('best_friend') - self.assertEqual('best_friend', name) + name = PersonFilter.get_param_filter_name("best_friend") + self.assertEqual("best_friend", name) def test_related_recursive_self_filter(self): # see: https://github.com/philipn/django-rest-framework-filters/issues/333 - name = PersonFilter.get_param_filter_name('best_friend', rel='best_friend') + name = PersonFilter.get_param_filter_name("best_friend", rel="best_friend") self.assertIsNone(name) def test_twice_removed_related_filter(self): @@ -428,47 +458,47 @@ class Meta: model = Post fields = [] - name = PostFilterWithDirectAuthor.get_param_filter_name('note__title') - self.assertEqual('note', name) + name = PostFilterWithDirectAuthor.get_param_filter_name("note__title") + self.assertEqual("note", name) # 'exact' matches, preference more specific filter name, as less specific # filter may not have related access. - name = PostFilterWithDirectAuthor.get_param_filter_name('note__author') - self.assertEqual('note__author', name) + name = PostFilterWithDirectAuthor.get_param_filter_name("note__author") + self.assertEqual("note__author", name) # related attribute filters - name = PostFilterWithDirectAuthor.get_param_filter_name('note__author__email') - self.assertEqual('note__author', name) + name = PostFilterWithDirectAuthor.get_param_filter_name("note__author__email") + self.assertEqual("note__author", name) # non-existent related filters should match, as it's the responsibility # of the related filterset to handle non-existent filters - name = PostFilterWithDirectAuthor.get_param_filter_name('note__author__foobar') - self.assertEqual('note__author', name) + name = PostFilterWithDirectAuthor.get_param_filter_name("note__author__foobar") + self.assertEqual("note__author", name) def test_name_hiding(self): class PostFilterNameHiding(PostFilter): note__author = filters.RelatedFilter(UserFilter) note = filters.RelatedFilter(NoteFilter) - note2 = filters.RelatedFilter(NoteFilter, field_name='note') + note2 = filters.RelatedFilter(NoteFilter, field_name="note") class Meta: model = Post fields = [] - name = PostFilterNameHiding.get_param_filter_name('note__author') - self.assertEqual('note__author', name) + name = PostFilterNameHiding.get_param_filter_name("note__author") + self.assertEqual("note__author", name) - name = PostFilterNameHiding.get_param_filter_name('note__title') - self.assertEqual('note', name) + name = PostFilterNameHiding.get_param_filter_name("note__title") + self.assertEqual("note", name) - name = PostFilterNameHiding.get_param_filter_name('note') - self.assertEqual('note', name) + name = PostFilterNameHiding.get_param_filter_name("note") + self.assertEqual("note", name) - name = PostFilterNameHiding.get_param_filter_name('note2') - self.assertEqual('note2', name) + name = PostFilterNameHiding.get_param_filter_name("note2") + self.assertEqual("note2", name) - name = PostFilterNameHiding.get_param_filter_name('note2__author') - self.assertEqual('note2', name) + name = PostFilterNameHiding.get_param_filter_name("note2__author") + self.assertEqual("note2", name) class GetFilterSubsetTests(TestCase): @@ -483,41 +513,41 @@ class Meta: fields = [] def test_get_subset(self): - filter_subset = self.NoteFilter.get_filter_subset(['title']) + filter_subset = self.NoteFilter.get_filter_subset(["title"]) # ensure that the FilterSet subset only contains the requested fields - self.assertEqual(list(filter_subset), ['title']) + self.assertEqual(list(filter_subset), ["title"]) def test_related_subset(self): # related filters should only return the local RelatedFilter - filter_subset = ['title', 'author', 'author__email'] + filter_subset = ["title", "author", "author__email"] filter_subset = self.NoteFilter.get_filter_subset(filter_subset) - self.assertEqual(list(filter_subset), ['title', 'author']) + self.assertEqual(list(filter_subset), ["title", "author"]) def test_non_filter_subset(self): # non-filter params should be ignored - filter_subset = self.NoteFilter.get_filter_subset(['foobar']) + filter_subset = self.NoteFilter.get_filter_subset(["foobar"]) self.assertEqual(list(filter_subset), []) def test_subset_ordering(self): # sanity check ordering of base filters - filter_subset = ['title', 'author'] + filter_subset = ["title", "author"] filter_subset = [f for f in self.NoteFilter.base_filters if f in filter_subset] - self.assertEqual(list(filter_subset), ['title', 'author']) + self.assertEqual(list(filter_subset), ["title", "author"]) # ensure that the ordering of the subset is the same as the base filters - filter_subset = self.NoteFilter.get_filter_subset(['title', 'author']) - self.assertEqual(list(filter_subset), ['title', 'author']) + filter_subset = self.NoteFilter.get_filter_subset(["title", "author"]) + self.assertEqual(list(filter_subset), ["title", "author"]) # ensure reverse argument order does not change subset ordering - filter_subset = self.NoteFilter.get_filter_subset(['author', 'title']) - self.assertEqual(list(filter_subset), ['title', 'author']) + filter_subset = self.NoteFilter.get_filter_subset(["author", "title"]) + self.assertEqual(list(filter_subset), ["title", "author"]) # ensure related filters do not change subset ordering - filter_subset = ['author__email', 'author', 'title'] + filter_subset = ["author__email", "author", "title"] filter_subset = self.NoteFilter.get_filter_subset(filter_subset) - self.assertEqual(list(filter_subset), ['title', 'author']) + self.assertEqual(list(filter_subset), ["title", "author"]) def test_metaclass_inheritance(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/132 @@ -532,49 +562,49 @@ class NoteFilter(SubFilterSet): class Meta: model = Note - fields = ['title', 'content'] + fields = ["title", "content"] - filter_subset = NoteFilter.get_filter_subset(['author', 'content']) + filter_subset = NoteFilter.get_filter_subset(["author", "content"]) # ensure that the FilterSet subset only contains the requested fields - self.assertEqual(list(filter_subset), ['content', 'author']) + self.assertEqual(list(filter_subset), ["content", "author"]) class DisableSubsetTests(TestCase): class F(FilterSet): class Meta: model = Note - fields = ['author'] + fields = ["author"] def test_unbound_subset(self): F = self.F.disable_subset() self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(F().filters), ['author']) + self.assertEqual(list(F().filters), ["author"]) def test_bound_subset(self): F = self.F.disable_subset() self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(F({}).filters), ['author']) - self.assertEqual(list(F({'author': ''}).filters), ['author']) + self.assertEqual(list(F({}).filters), ["author"]) + self.assertEqual(list(F({"author": ""}).filters), ["author"]) def test_duplicate_disable(self): F = self.F.disable_subset().disable_subset() self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(F({}).filters), ['author']) + self.assertEqual(list(F({}).filters), ["author"]) def test_subset_form(self): # test that subset-enabled forms only have provided fields F = self.F self.assertFalse(issubclass(F, SubsetDisabledMixin)) self.assertEqual(list(F({}).form.fields), []) - self.assertEqual(list(F({'author': ''}).form.fields), ['author']) + self.assertEqual(list(F({"author": ""}).form.fields), ["author"]) def test_subset_disabled_form(self): # test that subset-disabled forms have all fields F = self.F.disable_subset() self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(F({}).form.fields), ['author']) - self.assertEqual(list(F({'author': ''}).form.fields), ['author']) + self.assertEqual(list(F({}).form.fields), ["author"]) + self.assertEqual(list(F({"author": ""}).form.fields), ["author"]) class DisableSubsetRecursiveTests(TestCase): @@ -585,11 +615,11 @@ def test_depth0(self): # 0-depth disabled self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['title', 'b']) + self.assertEqual(list(f.filters), ["title", "b"]) # 1-depth not disabled - F = f.filters['b'].filterset - f = f.related_filtersets['b'] + F = f.filters["b"].filterset + f = f.related_filtersets["b"] self.assertFalse(issubclass(F, SubsetDisabledMixin)) self.assertEqual(list(f.filters), []) @@ -599,17 +629,17 @@ def test_depth1(self): # 0-depth disabled self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['title', 'b']) + self.assertEqual(list(f.filters), ["title", "b"]) # 1-depth disabled - F = f.filters['b'].filterset - f = f.related_filtersets['b'] + F = f.filters["b"].filterset + f = f.related_filtersets["b"] self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['name', 'c']) + self.assertEqual(list(f.filters), ["name", "c"]) # 2-depth not disabled - F = f.filters['c'].filterset - f = f.related_filtersets['c'] + F = f.filters["c"].filterset + f = f.related_filtersets["c"] self.assertFalse(issubclass(F, SubsetDisabledMixin)) self.assertEqual(list(f.filters), []) @@ -619,23 +649,23 @@ def test_depth2(self): # 0-depth disabled self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['title', 'b']) + self.assertEqual(list(f.filters), ["title", "b"]) # 1-depth disabled - F = f.filters['b'].filterset - f = f.related_filtersets['b'] + F = f.filters["b"].filterset + f = f.related_filtersets["b"] self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['name', 'c']) + self.assertEqual(list(f.filters), ["name", "c"]) # 2-depth disabled - F = f.filters['c'].filterset - f = f.related_filtersets['c'] + F = f.filters["c"].filterset + f = f.related_filtersets["c"] self.assertTrue(issubclass(F, SubsetDisabledMixin)) - self.assertEqual(list(f.filters), ['title', 'a']) + self.assertEqual(list(f.filters), ["title", "a"]) # 3-depth not disabled - F = f.filters['a'].filterset - f = f.related_filtersets['a'] + F = f.filters["a"].filterset + f = f.related_filtersets["a"] self.assertFalse(issubclass(F, SubsetDisabledMixin)) self.assertEqual(list(f.filters), []) @@ -648,12 +678,12 @@ class FilterExclusionTests(TestCase): @classmethod def setUpTestData(cls): - t1 = Tag.objects.create(name='Tag 1') - t2 = Tag.objects.create(name='Tag 2') - t3 = Tag.objects.create(name='Something else entirely') + t1 = Tag.objects.create(name="Tag 1") + t2 = Tag.objects.create(name="Tag 2") + t3 = Tag.objects.create(name="Something else entirely") - p1 = Post.objects.create(title='Post 1', content='content 1') - p2 = Post.objects.create(title='Post 2', content='content 2') + p1 = Post.objects.create(title="Post 1", content="content 1") + p2 = Post.objects.create(title="Post 2", content="content 2") p1.tags.set([t1, t2]) p2.tags.set([t3]) @@ -661,70 +691,72 @@ def setUpTestData(cls): def test_exclude_property(self): # Ensure that the filter is set to exclude GET = { - 'name__contains!': 'Tag', + "name__contains!": "Tag", } filterset = TagFilter(GET, queryset=Tag.objects.all()) - self.assertTrue(filterset.filters['name__contains!'].exclude) + self.assertTrue(filterset.filters["name__contains!"].exclude) def test_filter_and_exclude(self): # Ensure that both the filter and exclusion filter are available GET = { - 'name__contains': 'Tag', - 'name__contains!': 'Tag', + "name__contains": "Tag", + "name__contains!": "Tag", } filterset = TagFilter(GET, queryset=Tag.objects.all()) - self.assertFalse(filterset.filters['name__contains'].exclude) - self.assertTrue(filterset.filters['name__contains!'].exclude) + self.assertFalse(filterset.filters["name__contains"].exclude) + self.assertTrue(filterset.filters["name__contains!"].exclude) def test_related_exclude(self): GET = { - 'tags__name__contains!': 'Tag', + "tags__name__contains!": "Tag", } filterset = PostFilter(GET, queryset=Post.objects.all()) - filterset = filterset.related_filtersets['tags'] + filterset = filterset.related_filtersets["tags"] - self.assertTrue(filterset.filters['name__contains!'].exclude) + self.assertTrue(filterset.filters["name__contains!"].exclude) def test_exclusion_results(self): GET = { - 'name__contains!': 'Tag', + "name__contains!": "Tag", } filterset = TagFilter(GET, queryset=Tag.objects.all()) results = [r.name for r in filterset.qs] self.assertEqual(len(results), 1) - self.assertEqual(results[0], 'Something else entirely') + self.assertEqual(results[0], "Something else entirely") def test_filter_and_exclusion_results(self): GET = { - 'name__contains': 'Tag', - 'name__contains!': '2', + "name__contains": "Tag", + "name__contains!": "2", } filterset = TagFilter(GET, queryset=Tag.objects.all()) results = [r.name for r in filterset.qs] self.assertEqual(len(results), 1) - self.assertEqual(results[0], 'Tag 1') + self.assertEqual(results[0], "Tag 1") def test_related_exclusion_results(self): GET = { - 'tags__name__contains!': 'Tag', + "tags__name__contains!": "Tag", } filterset = PostFilter(GET, queryset=Post.objects.all()) results = [r.title for r in filterset.qs] self.assertEqual(len(results), 1) - self.assertEqual(results[0], 'Post 2') + self.assertEqual(results[0], "Post 2") def test_exclude_and_request_interaction(self): # See: https://github.com/philipn/django-rest-framework-filters/issues/171 - request = APIView().initialize_request(factory.get('/?tags__name__contains!=Tag')) + request = APIView().initialize_request( + factory.get("/?tags__name__contains!=Tag") + ) filterset = PostFilter( request.query_params, request=request, @@ -735,9 +767,9 @@ def test_exclude_and_request_interaction(self): with limit_recursion(): qs = filterset.qs except RuntimeError: - self.fail('Recursion limit reached') + self.fail("Recursion limit reached") results = [r.title for r in qs] self.assertEqual(len(results), 1) - self.assertEqual(results[0], 'Post 2') + self.assertEqual(results[0], "Post 2") diff --git a/tests/test_forms.py b/tests/test_forms.py index dc7e6dd..8879a7b 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -26,17 +26,17 @@ def test_subset_disabled_form_fields(self): class F(FilterSet): class Meta: model = Post - fields = ['title', 'content'] + fields = ["title", "content"] F = F.disable_subset() form = F({}).form - self.assertEqual(list(form.fields), ['title', 'content']) + self.assertEqual(list(form.fields), ["title", "content"]) def test_unbound_form_fields(self): class F(FilterSet): class Meta: model = Post - fields = ['title', 'content'] + fields = ["title", "content"] form = F().form self.assertEqual(list(form.fields), []) @@ -45,40 +45,45 @@ def test_bound_form_fields(self): class F(FilterSet): class Meta: model = Post - fields = ['title', 'content'] + fields = ["title", "content"] form = F({}).form self.assertEqual(list(form.fields), []) - form = F({'title': 'foo'}).form - self.assertEqual(list(form.fields), ['title']) + form = F({"title": "foo"}).form + self.assertEqual(list(form.fields), ["title"]) def test_related_form_fields(self): # FilterSet form should not contain fields from related filtersets class F(FilterSet): author = filters.RelatedFilter( - 'tests.testapp.filters.UserFilter', + "tests.testapp.filters.UserFilter", queryset=User.objects.all(), ) class Meta: model = Post - fields = ['title', 'author'] + fields = ["title", "author"] - f = F({'title': '', 'author': '', 'author__email': ''}) + f = F({"title": "", "author": "", "author__email": ""}) form = f.form - self.assertEqual(list(form.fields), ['title', 'author']) + self.assertEqual(list(form.fields), ["title", "author"]) - form = f.related_filtersets['author'].form - self.assertEqual(list(form.fields), ['email']) + form = f.related_filtersets["author"].form + self.assertEqual(list(form.fields), ["email"]) def test_validation_errors(self): - f = PostFilter({ - 'publish_date__year': 'foo', - 'author__last_login__date': 'bar', - }) - self.assertEqual(f.form.errors, { - 'publish_date__year': ['Enter a number.'], - 'author__last_login__date': ['Enter a valid date.'], - }) + f = PostFilter( + { + "publish_date__year": "foo", + "author__last_login__date": "bar", + } + ) + self.assertEqual( + f.form.errors, + { + "publish_date__year": ["Enter a number."], + "author__last_login__date": ["Enter a valid date."], + }, + ) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 925fb3c..12d79c4 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -30,13 +30,13 @@ def add_timedelta(time, timedelta): class PersonSerializer(serializers.ModelSerializer): class Meta: model = Person - fields = ['date_joined', 'time_joined', 'datetime_joined'] + fields = ["date_joined", "time_joined", "datetime_joined"] class PersonFilter(FilterSet): - date_joined = AutoFilter(field_name='date_joined', lookups='__all__') - time_joined = AutoFilter(field_name='time_joined', lookups='__all__') - datetime_joined = AutoFilter(field_name='datetime_joined', lookups='__all__') + date_joined = AutoFilter(field_name="date_joined", lookups="__all__") + time_joined = AutoFilter(field_name="time_joined", lookups="__all__") + datetime_joined = AutoFilter(field_name="datetime_joined", lookups="__all__") class Meta: model = Person @@ -44,8 +44,8 @@ class Meta: class InLookupPersonFilter(FilterSet): - pk = AutoFilter('id', lookups='__all__') - name = AutoFilter('name', lookups='__all__') + pk = AutoFilter("id", lookups="__all__") + name = AutoFilter("name", lookups="__all__") class Meta: model = Person @@ -60,7 +60,9 @@ def setUpTestData(cls): # Created at least one second apart mark = Person.objects.create(name="Mark", best_friend=john) - mark.time_joined = add_timedelta(mark.time_joined, datetime.timedelta(seconds=1)) + mark.time_joined = add_timedelta( + mark.time_joined, datetime.timedelta(seconds=1) + ) mark.datetime_joined += datetime.timedelta(seconds=1) mark.save() @@ -73,21 +75,23 @@ def test_implicit_date_filters(self): # serializer output. data = PersonSerializer(john).data - date_str = JSONRenderer().render(data['date_joined']).decode('utf-8').strip('"') + date_str = JSONRenderer().render(data["date_joined"]).decode("utf-8").strip('"') # Adjust for imprecise rendering of time - offset = parse_datetime(data['datetime_joined']) + datetime.timedelta(seconds=0.6) + offset = parse_datetime(data["datetime_joined"]) + datetime.timedelta( + seconds=0.6 + ) datetime_str = JSONRenderer().render(offset) - datetime_str = datetime_str.decode('utf-8').strip('"') + datetime_str = datetime_str.decode("utf-8").strip('"') # Adjust for imprecise rendering of time - offset = datetime.datetime.combine(today, parse_time(data['time_joined'])) + offset = datetime.datetime.combine(today, parse_time(data["time_joined"])) offset += datetime.timedelta(seconds=0.6) - time_str = JSONRenderer().render(offset.time()).decode('utf-8').strip('"') + time_str = JSONRenderer().render(offset.time()).decode("utf-8").strip('"') # DateField GET = { - 'date_joined__lte': date_str, + "date_joined__lte": date_str, } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 2) @@ -95,7 +99,7 @@ def test_implicit_date_filters(self): # DateTimeField GET = { - 'datetime_joined__lte': datetime_str, + "datetime_joined__lte": datetime_str, } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -104,30 +108,32 @@ def test_implicit_date_filters(self): # TimeField GET = { - 'time_joined__lte': time_str, + "time_joined__lte": time_str, } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) p = list(f.qs)[0] self.assertEqual(p.name, "John") - @override_settings(USE_TZ=True, TIME_ZONE='UTC') + @override_settings(USE_TZ=True, TIME_ZONE="UTC") def test_datetime_timezone_awareness(self): # Issue #24 - coorectly handle datetime strings terminating in 'Z'. # Figure out what the date strings should look like based on the serializer output john = Person.objects.get(name="John") data = PersonSerializer(john).data - offset = parse_datetime(data['datetime_joined']) + datetime.timedelta(seconds=0.6) + offset = parse_datetime(data["datetime_joined"]) + datetime.timedelta( + seconds=0.6 + ) datetime_str = JSONRenderer().render(offset) - datetime_str = datetime_str.decode('utf-8').strip('"') + datetime_str = datetime_str.decode("utf-8").strip('"') # DRF appends a 'Z' to timezone aware UTC datetimes when rendering: # https://github.com/tomchristie/django-rest-framework/blob/3.2.0/rest_framework/fields.py#L1002-L1006 - self.assertTrue(datetime_str.endswith('Z')) + self.assertTrue(datetime_str.endswith("Z")) GET = { - 'datetime_joined__lte': datetime_str, + "datetime_joined__lte": datetime_str, } f = PersonFilter(GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) @@ -139,56 +145,58 @@ class BooleanFilterTests(TestCase): @classmethod def setUpTestData(cls): - User.objects.create(username="user1", - email="user1@example.org", - is_active=True, - last_login=today) - User.objects.create(username="user2", - email="user2@example.org", - is_active=False) + User.objects.create( + username="user1", + email="user1@example.org", + is_active=True, + last_login=today, + ) + User.objects.create( + username="user2", email="user2@example.org", is_active=False + ) def test_boolean_filter(self): # Capitalized True - GET = {'is_active': 'True'} + GET = {"is_active": "True"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user1') + self.assertEqual(results[0].username, "user1") # Lowercase True - GET = {'is_active': 'true'} + GET = {"is_active": "true"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user1') + self.assertEqual(results[0].username, "user1") # Uppercase True - GET = {'is_active': 'TRUE'} + GET = {"is_active": "TRUE"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user1') + self.assertEqual(results[0].username, "user1") # Capitalized False - GET = {'is_active': 'False'} + GET = {"is_active": "False"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user2') + self.assertEqual(results[0].username, "user2") # Lowercase False - GET = {'is_active': 'false'} + GET = {"is_active": "false"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user2') + self.assertEqual(results[0].username, "user2") # Uppercase False - GET = {'is_active': 'FALSE'} + GET = {"is_active": "FALSE"} filterset = UserFilter(GET, queryset=User.objects.all()) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user2') + self.assertEqual(results[0].username, "user2") class InLookupTests(TestCase): @@ -198,20 +206,22 @@ def setUpTestData(cls): john = Person.objects.create(name="John") Person.objects.create(name="Mark", best_friend=john) - User.objects.create(username="user1", - email="user1@example.org", - is_active=True, - last_login=today) - User.objects.create(username="user2", - email="user2@example.org", - is_active=False) + User.objects.create( + username="user1", + email="user1@example.org", + is_active=True, + last_login=today, + ) + User.objects.create( + username="user2", email="user2@example.org", is_active=False + ) def test_inset_number_filter(self): p1 = Person.objects.get(name="John").pk p2 = Person.objects.get(name="Mark").pk ALL_GET = { - 'pk__in': '{:d},{:d}'.format(p1, p2), + "pk__in": "{:d},{:d}".format(p1, p2), } f = InLookupPersonFilter(ALL_GET, queryset=Person.objects.all()) f = [x.pk for x in f.qs] @@ -220,14 +230,14 @@ def test_inset_number_filter(self): self.assertIn(p2, f) INVALID_GET = { - 'pk__in': '{:d},c{:d}'.format(p1, p2), + "pk__in": "{:d},c{:d}".format(p1, p2), } f = InLookupPersonFilter(INVALID_GET, queryset=Person.objects.all()) self.assertFalse(f.is_valid()) self.assertEqual(f.qs.count(), 2) EXTRA_GET = { - 'pk__in': '{:d},{:d},{:d}'.format(p1, p2, p1 * p2), + "pk__in": "{:d},{:d},{:d}".format(p1, p2, p1 * p2), } f = InLookupPersonFilter(EXTRA_GET, queryset=Person.objects.all()) f = [x.pk for x in f.qs] @@ -236,7 +246,7 @@ def test_inset_number_filter(self): self.assertIn(p2, f) DISORDERED_GET = { - 'pk__in': '{:d},{:d},{:d}'.format(p2, p2 * p1, p1), + "pk__in": "{:d},{:d},{:d}".format(p2, p2 * p1, p1), } f = InLookupPersonFilter(DISORDERED_GET, queryset=Person.objects.all()) f = [x.pk for x in f.qs] @@ -249,7 +259,7 @@ def test_inset_char_filter(self): p2 = Person.objects.get(name="Mark").name ALL_GET = { - 'name__in': '{},{}'.format(p1, p2), + "name__in": "{},{}".format(p1, p2), } f = InLookupPersonFilter(ALL_GET, queryset=Person.objects.all()) f = [x.name for x in f.qs] @@ -258,13 +268,13 @@ def test_inset_char_filter(self): self.assertIn(p2, f) NONEXISTENT_GET = { - 'name__in': '{},Foo{}'.format(p1, p2), + "name__in": "{},Foo{}".format(p1, p2), } f = InLookupPersonFilter(NONEXISTENT_GET, queryset=Person.objects.all()) self.assertEqual(len(list(f.qs)), 1) EXTRA_GET = { - 'name__in': '{},{},{}'.format(p1, p2, p1 + p2), + "name__in": "{},{},{}".format(p1, p2, p1 + p2), } f = InLookupPersonFilter(EXTRA_GET, queryset=Person.objects.all()) f = [x.name for x in f.qs] @@ -273,7 +283,7 @@ def test_inset_char_filter(self): self.assertIn(p2, f) DISORDERED_GET = { - 'name__in': '{},{},{}'.format(p2, p2 + p1, p1), + "name__in": "{},{},{}".format(p2, p2 + p1, p1), } f = InLookupPersonFilter(DISORDERED_GET, queryset=Person.objects.all()) f = [x.name for x in f.qs] @@ -286,37 +296,39 @@ class IsNullLookupTests(TestCase): @classmethod def setUpTestData(cls): - User.objects.create(username="user1", - email="user1@example.org", - is_active=True, - last_login=today) - User.objects.create(username="user2", - email="user2@example.org", - is_active=False) + User.objects.create( + username="user1", + email="user1@example.org", + is_active=True, + last_login=today, + ) + User.objects.create( + username="user2", email="user2@example.org", is_active=False + ) def test_isnull_override(self): import django_filters.filters self.assertIsInstance( - UserFilter.base_filters['last_login__isnull'], + UserFilter.base_filters["last_login__isnull"], django_filters.filters.BooleanFilter, ) - GET = {'last_login__isnull': 'false'} + GET = {"last_login__isnull": "false"} filterset = UserFilter(GET, queryset=User.objects.all()) - filter_ = filterset.filters['last_login__isnull'] + filter_ = filterset.filters["last_login__isnull"] self.assertIsInstance(filter_, django_filters.filters.BooleanFilter) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user1') + self.assertEqual(results[0].username, "user1") - GET = {'last_login__isnull': 'true'} + GET = {"last_login__isnull": "true"} filterset = UserFilter(GET, queryset=User.objects.all()) - filter_ = filterset.filters['last_login__isnull'] + filter_ = filterset.filters["last_login__isnull"] self.assertIsInstance(filter_, django_filters.filters.BooleanFilter) results = list(filterset.qs) self.assertEqual(len(results), 1) - self.assertEqual(results[0].username, 'user2') + self.assertEqual(results[0].username, "user2") class FilterMethodTests(TestCase): @@ -325,20 +337,24 @@ class FilterMethodTests(TestCase): def setUpTestData(cls): user = User.objects.create(username="user1", email="user1@example.org") - note1 = Note.objects.create(title="Test 1", content="Test content 1", author=user) - note2 = Note.objects.create(title="Test 2", content="Test content 2", author=user) + note1 = Note.objects.create( + title="Test 1", content="Test content 1", author=user + ) + note2 = Note.objects.create( + title="Test 2", content="Test content 2", author=user + ) post1 = Post.objects.create(note=note1, content="Test content in post 1") - post2 = Post.objects.create(note=note2, - content="Test content in post 2", - publish_date=today) + post2 = Post.objects.create( + note=note2, content="Test content in post 2", publish_date=today + ) Cover.objects.create(post=post1, comment="Cover 1") Cover.objects.create(post=post2, comment="Cover 2") def test_method_filter(self): GET = { - 'is_published': 'true', + "is_published": "true", } filterset = PostFilter(GET, queryset=Post.objects.all()) results = list(filterset.qs) @@ -348,7 +364,7 @@ def test_method_filter(self): def test_related_method_filter(self): # Missing MethodFilter methods are silently ignored, returning the unfiltered qs. GET = { - 'post__is_published': 'true', + "post__is_published": "true", } filterset = CoverFilter(GET, queryset=Cover.objects.all()) results = list(filterset.qs) diff --git a/tests/test_utils.py b/tests/test_utils.py index 878075f..0877576 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -7,38 +7,38 @@ class LookupsForFieldTests(TestCase): def test_standard_field(self): - model_field = Person._meta.get_field('name') + model_field = Person._meta.get_field("name") lookups = utils.lookups_for_field(model_field) - self.assertIn('exact', lookups) - self.assertNotIn('year', lookups) - self.assertNotIn('date', lookups) + self.assertIn("exact", lookups) + self.assertNotIn("year", lookups) + self.assertNotIn("date", lookups) def test_transformed_field(self): - model_field = Person._meta.get_field('datetime_joined') + model_field = Person._meta.get_field("datetime_joined") lookups = utils.lookups_for_field(model_field) - self.assertIn('exact', lookups) - self.assertIn('year__exact', lookups) - self.assertIn('date__year__exact', lookups) + self.assertIn("exact", lookups) + self.assertIn("year__exact", lookups) + self.assertIn("date__year__exact", lookups) def test_relation_field(self): # ForeignObject relations are special cased currently - model_field = Note._meta.get_field('author') + model_field = Note._meta.get_field("author") lookups = utils.lookups_for_field(model_field) - self.assertIn('exact', lookups) - self.assertIn('in', lookups) - self.assertNotIn('regex', lookups) + self.assertIn("exact", lookups) + self.assertIn("in", lookups) + self.assertNotIn("regex", lookups) class LookupsForTransformTests(TestCase): def test_recursion_prevention(self): - model_field = Person._meta.get_field('name') + model_field = Person._meta.get_field("name") lookups = utils.lookups_for_field(model_field) - self.assertIn('unaccent__exact', lookups) - self.assertNotIn('unaccent__unaccent__exact', lookups) + self.assertIn("unaccent__exact", lookups) + self.assertNotIn("unaccent__unaccent__exact", lookups) class LookaheadTests(TestCase): @@ -52,8 +52,11 @@ def test_single(self): def test_multiple(self): result = list(utils.lookahead([1, 2, 3])) - self.assertListEqual(result, [ - (1, True), - (2, True), - (3, False), - ]) + self.assertListEqual( + result, + [ + (1, True), + (2, True), + (3, False), + ], + ) diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py index d6c0860..8b13789 100644 --- a/tests/testapp/__init__.py +++ b/tests/testapp/__init__.py @@ -1,2 +1 @@ -default_app_config = 'tests.testapp.apps.TestappConfig' diff --git a/tests/testapp/apps.py b/tests/testapp/apps.py index eafad9f..1a0e60b 100644 --- a/tests/testapp/apps.py +++ b/tests/testapp/apps.py @@ -1,4 +1,3 @@ - from django.apps import AppConfig from django.db.models import CharField, TextField @@ -6,7 +5,7 @@ class TestappConfig(AppConfig): - name = 'tests.testapp' + name = "tests.testapp" def ready(self): CharField.register_lookup(Unaccent) diff --git a/tests/testapp/filters.py b/tests/testapp/filters.py index ca05134..7b41df0 100644 --- a/tests/testapp/filters.py +++ b/tests/testapp/filters.py @@ -1,30 +1,28 @@ - import django_filters from rest_framework_filters import filters from rest_framework_filters.filters import AutoFilter, RelatedFilter from rest_framework_filters.filterset import FilterSet -from .models import ( - A, Account, B, Blog, C, Cover, Customer, Note, Page, Person, Post, Tag, User, -) +from .models import (A, Account, B, Blog, C, Cover, Customer, Note, Page, + Person, Post, Tag, User) class DFUserFilter(django_filters.FilterSet): - email = filters.CharFilter(field_name='email') + email = filters.CharFilter(field_name="email") class Meta: model = User - fields = '__all__' + fields = "__all__" class UserFilter(FilterSet): - username = AutoFilter(field_name='username', lookups='__all__') - email = filters.CharFilter(field_name='email') - last_login = AutoFilter(lookups='__all__') - is_active = filters.BooleanFilter(field_name='is_active') + username = AutoFilter(field_name="username", lookups="__all__") + email = filters.CharFilter(field_name="email") + last_login = AutoFilter(lookups="__all__") + is_active = filters.BooleanFilter(field_name="is_active") - posts = RelatedFilter('PostFilter', field_name='post', queryset=Post.objects.all()) + posts = RelatedFilter("PostFilter", field_name="post", queryset=Post.objects.all()) class Meta: model = User @@ -32,7 +30,7 @@ class Meta: class NoteFilter(FilterSet): - title = AutoFilter(field_name='title', lookups='__all__') + title = AutoFilter(field_name="title", lookups="__all__") author = RelatedFilter(UserFilter, queryset=User.objects.all()) class Meta: @@ -41,7 +39,7 @@ class Meta: class TagFilter(FilterSet): - name = AutoFilter(field_name='name', lookups='__all__') + name = AutoFilter(field_name="name", lookups="__all__") class Meta: model = Tag @@ -49,8 +47,8 @@ class Meta: class BlogFilter(FilterSet): - name = AutoFilter(field_name='name', lookups='__all__') - post = RelatedFilter('PostFilter', queryset=Post.objects.all()) + name = AutoFilter(field_name="name", lookups="__all__") + post = RelatedFilter("PostFilter", queryset=Post.objects.all()) class Meta: model = Blog @@ -59,10 +57,10 @@ class Meta: class PostFilter(FilterSet): # Used for Related filter and Filter.method regression tests - title = filters.AutoFilter(field_name='title', lookups='__all__') + title = filters.AutoFilter(field_name="title", lookups="__all__") - publish_date = filters.AutoFilter(lookups='__all__') - is_published = filters.BooleanFilter(method='filter_is_published') + publish_date = filters.AutoFilter(lookups="__all__") + is_published = filters.BooleanFilter(method="filter_is_published") author = RelatedFilter(UserFilter, queryset=User.objects.all()) note = RelatedFilter(NoteFilter, queryset=Note.objects.all()) @@ -77,12 +75,11 @@ def filter_is_published(self, queryset, field_name, value): # then the post is not published. This filter method demonstrates annotations. # Note: don't modify this without updating test_filtering.AnnotationTests - return queryset.annotate_is_published() \ - .filter(**{field_name: value}) + return queryset.annotate_is_published().filter(**{field_name: value}) class CoverFilter(FilterSet): - comment = filters.CharFilter(field_name='comment') + comment = filters.CharFilter(field_name="comment") post = RelatedFilter(PostFilter, queryset=Post.objects.all()) class Meta: @@ -91,15 +88,15 @@ class Meta: class PageFilter(FilterSet): - title = filters.CharFilter(field_name='title') + title = filters.CharFilter(field_name="title") previous_page = RelatedFilter( PostFilter, - field_name='previous_page', + field_name="previous_page", queryset=Post.objects.all(), ) two_pages_back = RelatedFilter( PostFilter, - field_name='previous_page__previous_page', + field_name="previous_page__previous_page", queryset=Page.objects.all(), ) @@ -111,7 +108,7 @@ class Meta: ################################################################################ # Aliased parameter names ###################################################### class UserFilterWithAlias(FilterSet): - name = filters.CharFilter(field_name='username') + name = filters.CharFilter(field_name="username") class Meta: model = User @@ -119,8 +116,8 @@ class Meta: class NoteFilterWithAlias(FilterSet): - title = filters.CharFilter(field_name='title') - writer = RelatedFilter(UserFilter, field_name='author', queryset=User.objects.all()) + title = filters.CharFilter(field_name="title") + writer = RelatedFilter(UserFilter, field_name="author", queryset=User.objects.all()) class Meta: model = Note @@ -138,8 +135,8 @@ class Meta: ################################################################################ # Recursive filtersets ######################################################### class AFilter(FilterSet): - title = filters.CharFilter(field_name='title') - b = RelatedFilter('BFilter', field_name='b', queryset=B.objects.all()) + title = filters.CharFilter(field_name="title") + b = RelatedFilter("BFilter", field_name="b", queryset=B.objects.all()) class Meta: model = A @@ -147,8 +144,8 @@ class Meta: class BFilter(FilterSet): - name = filters.CharFilter(field_name='name') - c = RelatedFilter('CFilter', field_name='c', queryset=C.objects.all()) + name = filters.CharFilter(field_name="name") + c = RelatedFilter("CFilter", field_name="c", queryset=C.objects.all()) class Meta: model = B @@ -156,8 +153,8 @@ class Meta: class CFilter(FilterSet): - title = filters.CharFilter(field_name='title') - a = RelatedFilter('AFilter', field_name='a', queryset=A.objects.all()) + title = filters.CharFilter(field_name="title") + a = RelatedFilter("AFilter", field_name="a", queryset=A.objects.all()) class Meta: model = C @@ -165,10 +162,10 @@ class Meta: class PersonFilter(FilterSet): - name = AutoFilter(field_name='name', lookups='__all__') + name = AutoFilter(field_name="name", lookups="__all__") best_friend = RelatedFilter( - 'tests.testapp.filters.PersonFilter', - field_name='best_friend', + "tests.testapp.filters.PersonFilter", + field_name="best_friend", queryset=Person.objects.all(), ) @@ -181,23 +178,23 @@ class Meta: # `to_field` filtersets ######################################################## class CustomerFilter(FilterSet): accounts = RelatedFilter( - 'AccountFilter', - field_name='account', + "AccountFilter", + field_name="account", queryset=Account.objects.all(), ) class Meta: model = Customer - fields = ['name', 'ssn', 'dob', 'accounts'] + fields = ["name", "ssn", "dob", "accounts"] class AccountFilter(FilterSet): customer = RelatedFilter( - 'CustomerFilter', - to_field_name='ssn', + "CustomerFilter", + to_field_name="ssn", queryset=Customer.objects.all(), ) class Meta: model = Account - fields = ['customer', 'type', 'name'] + fields = ["customer", "type", "name"] diff --git a/tests/testapp/lookups.py b/tests/testapp/lookups.py index 235c17c..f8bb5c7 100644 --- a/tests/testapp/lookups.py +++ b/tests/testapp/lookups.py @@ -5,5 +5,5 @@ # This is necessary as the postgres app requires psycopg2 to be installed. class Unaccent(Transform): bilateral = True - lookup_name = 'unaccent' - function = 'UNACCENT' + lookup_name = "unaccent" + function = "UNACCENT" diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index 570c914..2fab9ed 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -1,8 +1,8 @@ # Generated by Django 2.2.2 on 2019-08-14 20:04 +import django.db.models.deletion from django.conf import settings from django.db import migrations, models -import django.db.models.deletion class Migration(migrations.Migration): @@ -15,113 +15,283 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='A', + name="A", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Blog', + name="Blog", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Customer', + name="Customer", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=80)), - ('ssn', models.CharField(max_length=9, unique=True)), - ('dob', models.DateField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=80)), + ("ssn", models.CharField(max_length=9, unique=True)), + ("dob", models.DateField()), ], ), migrations.CreateModel( - name='Note', + name="Note", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('content', models.TextField()), - ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("content", models.TextField()), + ( + "author", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), ], ), migrations.CreateModel( - name='Tag', + name="Tag", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Post', + name="Post", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('content', models.TextField()), - ('publish_date', models.DateField(null=True)), - ('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('blog', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.Blog')), - ('note', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.Note')), - ('tags', models.ManyToManyField(to='testapp.Tag')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("content", models.TextField()), + ("publish_date", models.DateField(null=True)), + ( + "author", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "blog", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.Blog", + ), + ), + ( + "note", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.Note", + ), + ), + ("tags", models.ManyToManyField(to="testapp.Tag")), ], ), migrations.CreateModel( - name='Person', + name="Person", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('date_joined', models.DateField(auto_now_add=True)), - ('time_joined', models.TimeField(auto_now_add=True)), - ('datetime_joined', models.DateTimeField(auto_now_add=True)), - ('best_friend', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.Person')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("date_joined", models.DateField(auto_now_add=True)), + ("time_joined", models.TimeField(auto_now_add=True)), + ("datetime_joined", models.DateTimeField(auto_now_add=True)), + ( + "best_friend", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.Person", + ), + ), ], ), migrations.CreateModel( - name='Page', + name="Page", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('content', models.TextField()), - ('previous_page', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.Page')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("content", models.TextField()), + ( + "previous_page", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.Page", + ), + ), ], ), migrations.CreateModel( - name='Cover', + name="Cover", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('comment', models.CharField(max_length=100)), - ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Post')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("comment", models.CharField(max_length=100)), + ( + "post", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="testapp.Post" + ), + ), ], ), migrations.CreateModel( - name='C', + name="C", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('a', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.A')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ( + "a", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.A", + ), + ), ], ), migrations.CreateModel( - name='B', + name="B", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('c', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.C')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ( + "c", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="testapp.C", + ), + ), ], ), migrations.CreateModel( - name='Account', + name="Account", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.CharField(choices=[('c', 'Checking'), ('s', 'Savings')], max_length=1)), - ('name', models.CharField(max_length=80)), - ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='testapp.Customer', to_field='ssn')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "type", + models.CharField( + choices=[("c", "Checking"), ("s", "Savings")], max_length=1 + ), + ), + ("name", models.CharField(max_length=80)), + ( + "customer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="testapp.Customer", + to_field="ssn", + ), + ), ], ), migrations.AddField( - model_name='a', - name='b', - field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='testapp.B'), + model_name="a", + name="b", + field=models.ForeignKey( + null=True, on_delete=django.db.models.deletion.CASCADE, to="testapp.B" + ), ), ] diff --git a/tests/testapp/models.py b/tests/testapp/models.py index 02258a8..0865243 100644 --- a/tests/testapp/models.py +++ b/tests/testapp/models.py @@ -6,11 +6,13 @@ class PostQuerySet(models.QuerySet): def annotate_is_published(self): - return self.annotate(is_published=Case( - When(publish_date__isnull=False, then=Value(True)), - default=Value(False), - output_field=models.BooleanField(), - )) + return self.annotate( + is_published=Case( + When(publish_date__isnull=False, then=Value(True)), + default=Value(False), + output_field=models.BooleanField(), + ) + ) class Note(models.Model): @@ -48,27 +50,27 @@ class Cover(models.Model): class Page(models.Model): title = models.CharField(max_length=100) content = models.TextField() - previous_page = models.ForeignKey('self', null=True, on_delete=models.CASCADE) + previous_page = models.ForeignKey("self", null=True, on_delete=models.CASCADE) class A(models.Model): title = models.CharField(max_length=100) - b = models.ForeignKey('B', null=True, on_delete=models.CASCADE) + b = models.ForeignKey("B", null=True, on_delete=models.CASCADE) class B(models.Model): name = models.CharField(max_length=100) - c = models.ForeignKey('C', null=True, on_delete=models.CASCADE) + c = models.ForeignKey("C", null=True, on_delete=models.CASCADE) class C(models.Model): title = models.CharField(max_length=100) - a = models.ForeignKey('A', null=True, on_delete=models.CASCADE) + a = models.ForeignKey("A", null=True, on_delete=models.CASCADE) class Person(models.Model): name = models.CharField(max_length=100) - best_friend = models.ForeignKey('self', null=True, on_delete=models.CASCADE) + best_friend = models.ForeignKey("self", null=True, on_delete=models.CASCADE) date_joined = models.DateField(auto_now_add=True) time_joined = models.TimeField(auto_now_add=True) @@ -84,9 +86,9 @@ class Customer(models.Model): class Account(models.Model): TYPE_CHOICES = [ - ('c', 'Checking'), - ('s', 'Savings'), + ("c", "Checking"), + ("s", "Savings"), ] - customer = models.ForeignKey(Customer, to_field='ssn', on_delete=models.CASCADE) + customer = models.ForeignKey(Customer, to_field="ssn", on_delete=models.CASCADE) type = models.CharField(max_length=1, choices=TYPE_CHOICES) name = models.CharField(max_length=80) diff --git a/tests/testapp/serializers.py b/tests/testapp/serializers.py index 6ad083b..a04dc37 100644 --- a/tests/testapp/serializers.py +++ b/tests/testapp/serializers.py @@ -1,4 +1,3 @@ - from rest_framework import serializers from .models import Note, User @@ -7,10 +6,10 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['pk', 'username', 'email', 'is_staff'] + fields = ["pk", "username", "email", "is_staff"] class NoteSerializer(serializers.ModelSerializer): class Meta: model = Note - fields = ['pk', 'title', 'content', 'author'] + fields = ["pk", "title", "content", "author"] diff --git a/tests/testapp/urls.py b/tests/testapp/urls.py index 3d3429c..4afa534 100644 --- a/tests/testapp/urls.py +++ b/tests/testapp/urls.py @@ -1,19 +1,18 @@ - -from django.conf.urls import include, url +from django.urls import include, path from rest_framework import routers from . import views router = routers.DefaultRouter() -router.register('df-users', views.DFUserViewSet, basename='df-users') -router.register('ff-users', views.FilterFieldsUserViewSet, basename='ff-users') -router.register('ffcomplex-users', - views.ComplexFilterFieldsUserViewSet, - basename='ffcomplex-users') -router.register('users', views.UserViewSet) -router.register('notes', views.NoteViewSet) +router.register("df-users", views.DFUserViewSet, basename="df-users") +router.register("ff-users", views.FilterFieldsUserViewSet, basename="ff-users") +router.register( + "ffcomplex-users", views.ComplexFilterFieldsUserViewSet, basename="ffcomplex-users" +) +router.register("users", views.UserViewSet) +router.register("notes", views.NoteViewSet) urlpatterns = [ - url(r'^', include(router.urls)), + path("", include(router.urls)), ] diff --git a/tests/testapp/views.py b/tests/testapp/views.py index 1d9e0e2..e265868 100644 --- a/tests/testapp/views.py +++ b/tests/testapp/views.py @@ -1,4 +1,3 @@ - from rest_framework import pagination, viewsets from rest_framework_filters import backends @@ -29,7 +28,7 @@ class FilterFieldsUserViewSet(viewsets.ModelViewSet): serializer_class = UserSerializer filter_backends = [backends.RestFrameworkFilterBackend] filterset_fields = { - 'username': '__all__', + "username": "__all__", } @@ -40,16 +39,16 @@ class UnfilteredUserViewSet(viewsets.ModelViewSet): class ComplexFilterFieldsUserViewSet(FilterFieldsUserViewSet): - queryset = User.objects.order_by('pk') - filter_backends = (backends.ComplexFilterBackend, ) + queryset = User.objects.order_by("pk") + filter_backends = (backends.ComplexFilterBackend,) filterset_fields = { - 'id': '__all__', - 'username': '__all__', - 'email': '__all__', + "id": "__all__", + "username": "__all__", + "email": "__all__", } class pagination_class(pagination.PageNumberPagination): - page_size_query_param = 'page_size' + page_size_query_param = "page_size" class UserViewSet(viewsets.ModelViewSet): diff --git a/tox.ini b/tox.ini deleted file mode 100644 index ae467f8..0000000 --- a/tox.ini +++ /dev/null @@ -1,71 +0,0 @@ -[tox] -envlist = - py{35,36}-django111, - py{35,36,37}-django20, - py{35,36,37}-django21, - py{35,36,37}-django22, - py{36,37,38}-django30, - py{36,37,38}-django31, - performance, warnings, isort, lint, dist, - -[travis] -unignore_outcomes = true - -[testenv] -commands = coverage run manage.py test --exclude-tag=perf {posargs} -envdir = {toxworkdir}/venvs/{envname} -setenv = - PYTHONDONTWRITEBYTECODE=1 -deps = - coverage>=5.0 - django-crispy-forms~=1.0 - djangorestframework~=3.11.0 - django111: django-filter~=2.1.0 - django111: django~=1.11.0 - django20: django~=2.0.0 - django21: django~=2.1.0 - django22: django~=2.2.0 - django30: django~=3.0.0 - django31: django~=3.1.0 - - -[testenv:performance] -commands = python manage.py test --tag=perf {posargs} -deps = - django - djangorestframework - -[testenv:warnings] -ignore_outcome = True -commands = python -Werror manage.py test --exclude-tag=perf {posargs} -deps = - https://github.com/django/django/archive/master.tar.gz - https://github.com/tomchristie/django-rest-framework/archive/master.tar.gz - -[testenv:isort] -commands = isort --check-only rest_framework_filters tests {posargs:--diff} -deps = isort - -[testenv:lint] -commands = flake8 rest_framework_filters tests {posargs} -deps = - flake8 - darglint - - flake8-assertive - flake8-bugbear - flake8-builtins - flake8-commas - flake8-comprehensions - flake8-docstrings - -[testenv:dist] -commands = - twine check dist/* - python manage.py test --no-pkgroot --exclude-tag=perf {posargs} -deps = - django - djangorestframework - django-crispy-forms - readme_renderer - twine