diff --git a/django_filters/rest_framework/backends.py b/django_filters/rest_framework/backends.py index aecc34cb1..ff0e00c74 100644 --- a/django_filters/rest_framework/backends.py +++ b/django_filters/rest_framework/backends.py @@ -1,13 +1,24 @@ import warnings from django.template import loader +from django.utils.deprecation import RenameMethodsBase from . import filters, filterset from .. import compat, utils -class DjangoFilterBackend(object): - default_filter_set = filterset.FilterSet +# TODO: remove metaclass in 2.1 +class RenameAttributes(utils.RenameAttributesBase, RenameMethodsBase): + renamed_attributes = ( + ('default_filter_set', 'filterset_base', utils.MigrationNotice), + ) + renamed_methods = ( + ('get_filter_class', 'get_filterset_class', utils.MigrationNotice), + ) + + +class DjangoFilterBackend(metaclass=RenameAttributes): + filterset_base = filterset.FilterSet raise_exception = True @property @@ -16,55 +27,69 @@ def template(self): return 'django_filters/rest_framework/crispy_form.html' return 'django_filters/rest_framework/form.html' - def get_filter_class(self, view, queryset=None): + def get_filterset_class(self, view, queryset=None): """ - Return the django-filters `FilterSet` used to filter the queryset. + Return the `FilterSet` class used to filter the queryset. """ - filter_class = getattr(view, 'filter_class', None) - filter_fields = getattr(view, 'filter_fields', None) - - if filter_class: - filter_model = filter_class._meta.model + filterset_class = getattr(view, 'filterset_class', None) + filterset_fields = getattr(view, 'filterset_fields', None) + + # TODO: remove assertion in 2.1 + if filterset_class is None and hasattr(view, 'filter_class'): + utils.deprecate( + "`%s.filter_class` attribute should be renamed `filterset_class`." + % view.__class__.__name__) + filterset_class = getattr(view, 'filter_class', None) + + # TODO: remove assertion in 2.1 + if filterset_fields is None and hasattr(view, 'filter_fields'): + utils.deprecate( + "`%s.filter_fields` attribute should be renamed `filterset_fields`." + % view.__class__.__name__) + filterset_fields = getattr(view, 'filter_fields', None) + + if filterset_class: + filterset_model = filterset_class._meta.model # FilterSets do not need to specify a Meta class - if filter_model and queryset is not None: - assert issubclass(queryset.model, filter_model), \ + if filterset_model and queryset is not None: + assert issubclass(queryset.model, filterset_model), \ 'FilterSet model %s does not match queryset model %s' % \ - (filter_model, queryset.model) + (filterset_model, queryset.model) - return filter_class + return filterset_class - if filter_fields and queryset is not None: - MetaBase = getattr(self.default_filter_set, 'Meta', object) + if filterset_fields and queryset is not None: + MetaBase = getattr(self.filterset_base, 'Meta', object) - class AutoFilterSet(self.default_filter_set): + class AutoFilterSet(self.filterset_base): class Meta(MetaBase): model = queryset.model - fields = filter_fields + fields = filterset_fields return AutoFilterSet return None def filter_queryset(self, request, queryset, view): - filter_class = self.get_filter_class(view, queryset) + filterset_class = self.get_filterset_class(view, queryset) - if filter_class: - filterset = filter_class(request.query_params, queryset=queryset, request=request) + if filterset_class: + filterset = filterset_class(request.query_params, queryset=queryset, request=request) if not filterset.is_valid() and self.raise_exception: raise utils.translate_validation(filterset.errors) return filterset.qs return queryset def to_html(self, request, queryset, view): - filter_class = self.get_filter_class(view, queryset) - if not filter_class: + filterset_class = self.get_filterset_class(view, queryset) + if not filterset_class: return None - filter_instance = filter_class(request.query_params, queryset=queryset, request=request) + filterset = filterset_class(request.query_params, queryset=queryset, request=request) template = loader.get_template(self.template) context = { - 'filter': filter_instance + 'filter': filterset } return template.render(context, request) @@ -93,13 +118,13 @@ def get_schema_fields(self, view): "{} is not compatible with schema generation".format(view.__class__) ) - filter_class = self.get_filter_class(view, queryset) + filterset_class = self.get_filterset_class(view, queryset) - return [] if not filter_class else [ + return [] if not filterset_class else [ compat.coreapi.Field( name=field_name, required=field.extra['required'], location='query', schema=self.get_coreschema_field(field) - ) for field_name, field in filter_class.base_filters.items() + ) for field_name, field in filterset_class.base_filters.items() ] diff --git a/django_filters/utils.py b/django_filters/utils.py index 96d26e3e8..781d3f436 100644 --- a/django_filters/utils.py +++ b/django_filters/utils.py @@ -18,9 +18,84 @@ def deprecate(msg, level_modifier=0): - warnings.warn( - "%s See: https://django-filter.readthedocs.io/en/master/guide/migration.html" % msg, - DeprecationWarning, stacklevel=3 + level_modifier) + warnings.warn(msg, MigrationNotice, stacklevel=3 + level_modifier) + + +class MigrationNotice(DeprecationWarning): + url = 'https://django-filter.readthedocs.io/en/master/guide/migration.html' + + def __init__(self, message): + super().__init__('%s See: %s' % (message, self.url)) + + +class RenameAttributesBase(type): + """ + Handles the deprecation paths when renaming an attribute. + + It does the following: + - Defines accessors that redirect to the renamed attributes. + - Complain whenever an old attribute is accessed. + + This is conceptually based on `django.utils.deprecation.RenameMethodsBase`. + """ + renamed_attributes = () + + def __new__(metacls, name, bases, attrs): + # remove old attributes before creating class + old_names = [r[0] for r in metacls.renamed_attributes] + old_names = [name for name in old_names if name in attrs] + old_attrs = {name: attrs.pop(name) for name in old_names} + + # get a handle to any accessors defined on the class + cls_getattr = attrs.pop('__getattr__', None) + cls_setattr = attrs.pop('__setattr__', None) + + new_class = super().__new__(metacls, name, bases, attrs) + + def __getattr__(self, name): + name = type(self).get_name(name) + if cls_getattr is not None: + return cls_getattr(self, name) + elif hasattr(super(new_class, self), '__getattr__'): + return super(new_class, self).__getattr__(name) + return self.__getattribute__(name) + + def __setattr__(self, name, value): + name = type(self).get_name(name) + if cls_setattr is not None: + return cls_setattr(self, name, value) + return super(new_class, self).__setattr__(name, value) + + new_class.__getattr__ = __getattr__ + new_class.__setattr__ = __setattr__ + + # set renamed attributes + for name, value in old_attrs.items(): + setattr(new_class, name, value) + + return new_class + + def get_name(metacls, name): + """ + Get the real attribute name. If the attribute has been renamed, + the new name will be returned and a deprecation warning issued. + """ + for renamed_attribute in metacls.renamed_attributes: + old_name, new_name, deprecation_warning = renamed_attribute + + if old_name == name: + warnings.warn("`%s.%s` attribute should be renamed `%s`." + % (metacls.__name__, old_name, new_name), + deprecation_warning, 3) + return new_name + + return name + + def __getattr__(metacls, name): + return super().__getattribute__(metacls.get_name(name)) + + def __setattr__(metacls, name, value): + return super().__setattr__(metacls.get_name(name), value) def try_dbfield(fn, field_class): diff --git a/django_filters/views.py b/django_filters/views.py index 882cb92a2..a4ba68a7e 100644 --- a/django_filters/views.py +++ b/django_filters/views.py @@ -7,14 +7,22 @@ from .constants import ALL_FIELDS from .filterset import filterset_factory +from .utils import MigrationNotice, RenameAttributesBase -class FilterMixin(object): +# TODO: remove metaclass in 2.1 +class FilterMixinRenames(RenameAttributesBase): + renamed_attributes = ( + ('filter_fields', 'filterset_fields', MigrationNotice), + ) + + +class FilterMixin(metaclass=FilterMixinRenames): """ A mixin that provides a way to show and handle a FilterSet in a request. """ filterset_class = None - filter_fields = ALL_FIELDS + filterset_fields = ALL_FIELDS strict = True def get_filterset_class(self): @@ -24,7 +32,7 @@ def get_filterset_class(self): if self.filterset_class: return self.filterset_class elif self.model: - return filterset_factory(model=self.model, fields=self.filter_fields) + return filterset_factory(model=self.model, fields=self.filterset_fields) else: msg = "'%s' must define 'filterset_class' or 'model'" raise ImproperlyConfigured(msg % self.__class__.__name__) diff --git a/docs/guide/migration.txt b/docs/guide/migration.txt index 5eb7641e4..c8cb5e874 100644 --- a/docs/guide/migration.txt +++ b/docs/guide/migration.txt @@ -45,6 +45,20 @@ The ``Filter.lookup_expr`` argument no longer accepts ``None`` or a list of expressions. Use the :ref:`LookupChoiceFilter ` instead. +View attributes renamed (`#867`__) +---------------------------------- +__ https://github.com/carltongibson/django-filter/pull/867 + +Several view-related attributes have been renamed to improve consistency with +other parts of the library. The following classes are affected: + +* DRF ``ViewSet.filter_class`` => ``filterset_class`` +* DRF ``ViewSet.filter_fields`` => ``filterset_fields`` +* ``DjangoFilterBackend.default_filter_set`` => ``filterset_base`` +* ``DjangoFilterBackend.get_filter_class()`` => ``get_filterset_class()`` +* ``FilterMixin.filter_fields`` => ``filterset_fields`` + + FilterSet ``Meta.together`` option removed (`#791`__) ----------------------------------------------------- __ https://github.com/carltongibson/django-filter/pull/791 diff --git a/tests/rest_framework/test_backends.py b/tests/rest_framework/test_backends.py index dcf713dfd..cf5abe841 100644 --- a/tests/rest_framework/test_backends.py +++ b/tests/rest_framework/test_backends.py @@ -45,16 +45,16 @@ class FilterableItemView(generics.ListCreateAPIView): class FilterFieldsRootView(FilterableItemView): - filter_fields = ['decimal', 'date'] + filterset_fields = ['decimal', 'date'] class FilterClassRootView(FilterableItemView): - filter_class = SeveralFieldsFilter + filterset_class = SeveralFieldsFilter class GetFilterClassTests(TestCase): - def test_filter_class(self): + def test_filterset_class(self): class Filter(FilterSet): class Meta: model = FilterableItem @@ -62,25 +62,25 @@ class Meta: backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_class = Filter + view.filterset_class = Filter queryset = FilterableItem.objects.all() - filter_class = backend.get_filter_class(view, queryset) - self.assertIs(filter_class, Filter) + filterset_class = backend.get_filterset_class(view, queryset) + self.assertIs(filterset_class, Filter) - def test_filter_class_no_meta(self): + def test_filterset_class_no_meta(self): class Filter(FilterSet): pass backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_class = Filter + view.filterset_class = Filter queryset = FilterableItem.objects.all() - filter_class = backend.get_filter_class(view, queryset) - self.assertIs(filter_class, Filter) + filterset_class = backend.get_filterset_class(view, queryset) + self.assertIs(filterset_class, Filter) - def test_filter_class_no_queryset(self): + def test_filterset_class_no_queryset(self): class Filter(FilterSet): class Meta: model = FilterableItem @@ -88,54 +88,56 @@ class Meta: backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_class = Filter + view.filterset_class = Filter - filter_class = backend.get_filter_class(view, None) - self.assertIs(filter_class, Filter) + filterset_class = backend.get_filterset_class(view, None) + self.assertIs(filterset_class, Filter) - def test_filter_fields(self): + def test_filterset_fields(self): backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_fields = ['text', 'decimal', 'date'] + view.filterset_fields = ['text', 'decimal', 'date'] queryset = FilterableItem.objects.all() - filter_class = backend.get_filter_class(view, queryset) - self.assertEqual(filter_class._meta.fields, view.filter_fields) + filterset_class = backend.get_filterset_class(view, queryset) + self.assertEqual(filterset_class._meta.fields, view.filterset_fields) - def test_filter_fields_malformed(self): + def test_filterset_fields_malformed(self): backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_fields = ['non_existent'] + view.filterset_fields = ['non_existent'] queryset = FilterableItem.objects.all() msg = "'Meta.fields' contains fields that are not defined on this FilterSet: non_existent" with self.assertRaisesMessage(TypeError, msg): - backend.get_filter_class(view, queryset) + backend.get_filterset_class(view, queryset) - def test_filter_fields_no_queryset(self): + def test_filterset_fields_no_queryset(self): backend = DjangoFilterBackend() view = FilterableItemView() - view.filter_fields = ['text', 'decimal', 'date'] + view.filterset_fields = ['text', 'decimal', 'date'] - filter_class = backend.get_filter_class(view, None) - self.assertIsNone(filter_class) + filterset_class = backend.get_filterset_class(view, None) + self.assertIsNone(filterset_class) @skipIf(compat.coreapi is None, 'coreapi must be installed') class GetSchemaFieldsTests(TestCase): - def test_fields_with_filter_fields_list(self): + def test_fields_with_filterset_fields_list(self): backend = DjangoFilterBackend() fields = backend.get_schema_fields(FilterFieldsRootView()) fields = [f.name for f in fields] self.assertEqual(fields, ['decimal', 'date']) - def test_filter_fields_list_with_bad_get_queryset(self): + def test_filterset_fields_list_with_bad_get_queryset(self): """ See: * https://github.com/carltongibson/django-filter/issues/551 """ class BadGetQuerySetView(FilterFieldsRootView): + filterset_fields = ['decimal', 'date'] + def get_queryset(self): raise AttributeError("I don't have that") @@ -151,10 +153,10 @@ def get_queryset(self): self.assertEqual(len(w), 1) self.assertEqual(str(w[0].message), warning) - def test_malformed_filter_fields(self): + def test_malformed_filterset_fields(self): # Malformed filter fields should raise an exception class View(FilterFieldsRootView): - filter_fields = ['non_existent'] + filterset_fields = ['non_existent'] backend = DjangoFilterBackend() @@ -162,9 +164,9 @@ class View(FilterFieldsRootView): with self.assertRaisesMessage(TypeError, msg): backend.get_schema_fields(View()) - def test_fields_with_filter_fields_dict(self): + def test_fields_with_filterset_fields_dict(self): class DictFilterFieldsRootView(FilterFieldsRootView): - filter_fields = { + filterset_fields = { 'decimal': ['exact', 'lt', 'gt'], } @@ -174,7 +176,7 @@ class DictFilterFieldsRootView(FilterFieldsRootView): self.assertEqual(fields, ['decimal', 'decimal__lt', 'decimal__gt']) - def test_fields_with_filter_class(self): + def test_fields_with_filterset_class(self): backend = DjangoFilterBackend() fields = backend.get_schema_fields(FilterClassRootView()) schemas = [f.schema for f in fields] @@ -193,7 +195,7 @@ class Meta(SeveralFieldsFilter.Meta): fields = SeveralFieldsFilter.Meta.fields + ['required_text'] class FilterClassWithRequiredFieldsView(FilterClassRootView): - filter_class = RequiredFieldsFilter + filterset_class = RequiredFieldsFilter backend = DjangoFilterBackend() fields = backend.get_schema_fields(FilterClassWithRequiredFieldsView()) @@ -216,7 +218,7 @@ class F(SeveralFieldsFilter): f = filters.ModelChoiceFilter(queryset=qs) class View(FilterClassRootView): - filter_class = F + filterset_class = F view = View() view.request = factory.get('/') @@ -284,8 +286,8 @@ def test_multiple_engines(self): self.test_backend_output() -class DefaultFilterSetTests(TestCase): - def test_default_meta_inheritance(self): +class AutoFilterSetTests(TestCase): + def test_autofilter_meta_inheritance(self): # https://github.com/carltongibson/django-filter/issues/663 class F(FilterSet): @@ -293,15 +295,15 @@ class Meta: filter_overrides = {BooleanField: {}} class Backend(DjangoFilterBackend): - default_filter_set = F + filterset_base = F view = FilterFieldsRootView() backend = Backend() - filter_class = backend.get_filter_class(view, view.get_queryset()) - filter_overrides = filter_class._meta.filter_overrides + filterset_class = backend.get_filterset_class(view, view.get_queryset()) + filter_overrides = filterset_class._meta.filter_overrides - # derived filter_class.Meta should inherit from default_filter_set.Meta + # derived filterset_class.Meta should inherit from default_filter_set.Meta self.assertIn(BooleanField, filter_overrides) self.assertDictEqual(filter_overrides[BooleanField], {}) @@ -319,7 +321,7 @@ class Meta: request = factory.get('/?id=foo&author=bar&name=baz') request = view.initialize_request(request) queryset = Article.objects.all() - view.filter_class = F + view.filterset_class = F with self.assertRaises(serializers.ValidationError) as exc: backend.filter_queryset(request, queryset, view) @@ -329,3 +331,70 @@ class Meta: 'id': ['Enter a number.'], 'author': ['Select a valid choice. That choice is not one of the available choices.'], }) + + +class RenamedBackendAttributesTests(TestCase): + def test_get_filter_class(self): + expected = "`Backend.get_filter_class` method should be renamed `get_filterset_class`. " \ + "See: https://django-filter.readthedocs.io/en/master/guide/migration.html" + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Backend(DjangoFilterBackend): + def get_filter_class(self): + pass + + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) + + def test_default_filter_set(self): + expected = "`Backend.default_filter_set` attribute should be renamed `filterset_base`. " \ + "See: https://django-filter.readthedocs.io/en/master/guide/migration.html" + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Backend(DjangoFilterBackend): + default_filter_set = None + + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) + + +class RenamedViewSetAttributesTests(TestCase): + + def test_filter_class(self): + expected = "`View.filter_class` attribute should be renamed `filterset_class`. " \ + "See: https://django-filter.readthedocs.io/en/master/guide/migration.html" + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class View(generics.ListCreateAPIView): + filter_class = None + + view = View() + backend = DjangoFilterBackend() + backend.get_filterset_class(view, None) + + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) + + def test_filter_fields(self): + expected = "`View.filter_fields` attribute should be renamed `filterset_fields`. " \ + "See: https://django-filter.readthedocs.io/en/master/guide/migration.html" + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class View(generics.ListCreateAPIView): + filter_fields = None + + view = View() + backend = DjangoFilterBackend() + # import pdb; pdb.set_trace() + backend.get_filterset_class(view, None) + + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) diff --git a/tests/rest_framework/test_integration.py b/tests/rest_framework/test_integration.py index fba96497d..5987477d6 100644 --- a/tests/rest_framework/test_integration.py +++ b/tests/rest_framework/test_integration.py @@ -32,7 +32,7 @@ class Meta: class FilterFieldsRootView(generics.ListCreateAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_fields = ['decimal', 'date'] + filterset_fields = ['decimal', 'date'] filter_backends = (DjangoFilterBackend,) @@ -50,7 +50,7 @@ class Meta: class FilterClassRootView(generics.ListCreateAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_class = SeveralFieldsFilter + filterset_class = SeveralFieldsFilter filter_backends = (DjangoFilterBackend,) @@ -66,14 +66,14 @@ class Meta: class IncorrectlyConfiguredRootView(generics.ListCreateAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_class = MisconfiguredFilter + filterset_class = MisconfiguredFilter filter_backends = (DjangoFilterBackend,) class FilterClassDetailView(generics.RetrieveAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_class = SeveralFieldsFilter + filterset_class = SeveralFieldsFilter filter_backends = (DjangoFilterBackend,) @@ -89,7 +89,7 @@ class Meta: class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_class = BaseFilterableItemFilter + filterset_class = BaseFilterableItemFilter filter_backends = (DjangoFilterBackend,) @@ -97,13 +97,13 @@ class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): class FilterFieldsQuerysetView(generics.ListCreateAPIView): queryset = FilterableItem.objects.all() serializer_class = FilterableItemSerializer - filter_fields = ['decimal', 'date'] + filterset_fields = ['decimal', 'date'] filter_backends = (DjangoFilterBackend,) class GetQuerysetView(generics.ListCreateAPIView): serializer_class = FilterableItemSerializer - filter_class = SeveralFieldsFilter + filterset_class = SeveralFieldsFilter filter_backends = (DjangoFilterBackend,) def get_queryset(self): @@ -198,7 +198,7 @@ def test_filter_with_get_queryset_only(self): def test_get_filtered_class_root_view(self): """ - GET requests to filtered ListCreateAPIView that have a filter_class set + GET requests to filtered ListCreateAPIView that have a filterset_class set should return filtered results. """ view = FilterClassRootView.as_view() @@ -257,7 +257,7 @@ def test_incorrectly_configured_filter(self): def test_base_model_filter(self): """ - The `get_filter_class` model checks should allow base model filters. + The `get_filterset_class` model checks should allow base model filters. """ view = BaseFilterableItemFilterRootView.as_view() @@ -330,8 +330,8 @@ def _get_url(self, item): def test_get_filtered_detail_view(self): """ - GET requests to filtered RetrieveAPIView that have a filter_class set - should return filtered results. + GET requests to filtered RetrieveAPIView that have a filterset_class + set should return filtered results. """ item = self.objects.all()[0] data = self._serialize_object(item) @@ -400,7 +400,7 @@ class DjangoFilterOrderingView(generics.ListAPIView): serializer_class = DjangoFilterOrderingSerializer queryset = DjangoFilterOrderingModel.objects.all() filter_backends = (DjangoFilterBackend,) - filter_fields = ['text'] + filterset_fields = ['text'] ordering = ('-date',) view = DjangoFilterOrderingView.as_view() diff --git a/tests/test_utils.py b/tests/test_utils.py index d31f5b7b2..2d89a9a54 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import datetime +import warnings from django.db import models from django.db.models.constants import LOOKUP_SEP @@ -10,6 +11,8 @@ from django_filters import FilterSet from django_filters.exceptions import FieldLookupError from django_filters.utils import ( + MigrationNotice, + RenameAttributesBase, get_field_parts, get_model_field, handle_timezone, @@ -31,6 +34,166 @@ ) +class MigrationNoticeTests(TestCase): + + def test_message(self): + self.assertEqual( + str(MigrationNotice('Message.')), + 'Message. See: https://django-filter.readthedocs.io/en/master/guide/migration.html' + ) + + +class RenameAttributes(RenameAttributesBase): + renamed_attributes = ( + ('old', 'new', DeprecationWarning), + ) + + +class SENTINEL: + pass + + +class RenameAttributesBaseTests(TestCase): + + def check(self, recorded, count): + expected = '`Example.old` attribute should be renamed `new`.' + + self.assertEqual(len(recorded), count) + for _ in range(count): + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) + + def test_class_creation_warnings(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Example(metaclass=RenameAttributes): + old = SENTINEL + + # single warning for renamed attr on creation + self.check(recorded, 1) + + def test_renamed_attribute_in_class_dict(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('ignore') + + class Example(metaclass=RenameAttributes): + old = SENTINEL + + warnings.simplefilter('always') + + # Ensure `old` and `new` are not both in class dict. + self.assertNotIn('old', Example.__dict__) + self.assertIn('new', Example.__dict__) + + # Ensure `old` value assigned to `new`. + self.assertEqual(Example.new, SENTINEL) + + self.check(recorded, 0) + + def test_class_accessor_warnings(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('ignore') + + class Example(metaclass=RenameAttributes): + new = None + + warnings.simplefilter('always') + + self.assertIsNone(Example.new) + self.assertIsNone(Example.old) + self.check(recorded, 1) + + Example.old = SENTINEL + self.assertIs(Example.new, SENTINEL) + self.assertIs(Example.old, SENTINEL) + self.check(recorded, 2) + + def test_instance_accessor_warnings(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('ignore') + + class Example(metaclass=RenameAttributes): + new = None + + warnings.simplefilter('always') + + example = Example() + self.check(recorded, 0) + + self.assertIsNone(example.new) + self.assertIsNone(example.old) + self.check(recorded, 1) + + example.old = SENTINEL + self.assertIs(example.new, SENTINEL) + self.assertIs(example.old, SENTINEL) + self.check(recorded, 2) + + def test_class_instance_values(self): + with warnings.catch_warnings(record=True): + warnings.simplefilter('ignore') + + class Example(metaclass=RenameAttributes): + new = None + + example = Example() + + # setting instance should not affect class + example.old = SENTINEL + self.assertIsNone(Example.old) + self.assertIsNone(Example.new) + self.assertIs(example.old, SENTINEL) + self.assertIs(example.new, SENTINEL) + + def test_getter_reachable(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Example(metaclass=RenameAttributes): + def __getattr__(self, name): + if name == 'test': + return SENTINEL + return self.__getattribute__(name) + + example = Example() + self.assertIs(example.test, SENTINEL) + self.check(recorded, 0) + + def test_parent_getter_reachable(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Parent: + def __getattr__(self, name): + if name == 'test': + return SENTINEL + return self.__getattribute__(name) + + class Example(Parent, metaclass=RenameAttributes): + pass + + example = Example() + self.assertIs(example.test, SENTINEL) + self.check(recorded, 0) + + def test_setter_reachable(self): + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class Example(metaclass=RenameAttributes): + def __setattr__(self, name, value): + if name == 'test': + value = SENTINEL + super().__setattr__(name, value) + + example = Example() + example.test = None + self.assertIs(example.test, SENTINEL) + self.check(recorded, 0) + + class GetFieldPartsTests(TestCase): def test_field(self): diff --git a/tests/test_views.py b/tests/test_views.py index dc7282b48..1f9a56449 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,3 +1,5 @@ +import warnings + from django.core.exceptions import ImproperlyConfigured from django.test import TestCase, override_settings from django.test.client import RequestFactory @@ -56,7 +58,7 @@ def test_view_with_model_no_filterset(self): def test_view_with_model_and_fields_no_filterset(self): factory = RequestFactory() request = factory.get(self.base_url + '?price=1.0') - view = FilterView.as_view(model=Book, filter_fields=['price']) + view = FilterView.as_view(model=Book, filterset_fields=['price']) # filtering only by price response = view(request) @@ -108,6 +110,19 @@ class MyFilterSet(FilterSet): with self.assertRaises(ImproperlyConfigured): view(request) + def test_filter_fields_removed(self): + expected = "`View.filter_fields` attribute should be renamed `filterset_fields`. " \ + "See: https://django-filter.readthedocs.io/en/master/guide/migration.html" + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter('always') + + class View(FilterView): + filter_fields = None + + message = str(recorded.pop().message) + self.assertEqual(message, expected) + self.assertEqual(len(recorded), 0) + class GenericFunctionalViewTests(GenericViewTestCase): base_url = '/books-legacy/'