From 3fcc01273c5efef26d911e50c02a4a43f89b34eb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 27 Jun 2013 20:29:52 +0100 Subject: [PATCH 01/63] Remove deprecated code --- rest_framework/compat.py | 5 +- rest_framework/fields.py | 7 --- rest_framework/permissions.py | 12 +---- rest_framework/relations.py | 69 ++++--------------------- rest_framework/serializers.py | 32 ++---------- rest_framework/tests/test_serializer.py | 4 +- 6 files changed, 21 insertions(+), 108 deletions(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index b748dcc51..161fffa8d 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -494,11 +494,14 @@ try: if provider_version in ('0.2.3', '0.2.4'): # 0.2.3 and 0.2.4 are supported version that do not support # timezone aware datetimes - from datetime.datetime import now as provider_now + import datetime + provider_now = datetime.datetime.now else: # Any other supported version does use timezone aware datetimes from django.utils.timezone import now as provider_now except ImportError: + import traceback + traceback.print_exc() oauth2_provider = None oauth2_provider_models = None oauth2_provider_forms = None diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 35848b4ce..2e23715de 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -224,13 +224,6 @@ class WritableField(Field): validators=[], error_messages=None, widget=None, default=None, blank=None): - # 'blank' is to be deprecated in favor of 'required' - if blank is not None: - warnings.warn('The `blank` keyword argument is deprecated. ' - 'Use the `required` keyword argument instead.', - DeprecationWarning, stacklevel=2) - required = not(blank) - super(WritableField, self).__init__(source=source, label=label, help_text=help_text) self.read_only = read_only diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index 1036663e0..0c7b02ffa 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -2,13 +2,10 @@ Provides a set of pluggable permission policies. """ from __future__ import unicode_literals -import inspect -import warnings +from rest_framework.compat import oauth2_provider_scope, oauth2_constants SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] -from rest_framework.compat import oauth2_provider_scope, oauth2_constants - class BasePermission(object): """ @@ -25,13 +22,6 @@ class BasePermission(object): """ Return `True` if permission is granted, `False` otherwise. """ - if len(inspect.getargspec(self.has_permission).args) == 4: - warnings.warn( - 'The `obj` argument in `has_permission` is deprecated. ' - 'Use `has_object_permission()` instead for object permissions.', - DeprecationWarning, stacklevel=2 - ) - return self.has_permission(request, view, obj) return True diff --git a/rest_framework/relations.py b/rest_framework/relations.py index edaf76d6e..ede694e31 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -40,14 +40,6 @@ class RelatedField(WritableField): many = False def __init__(self, *args, **kwargs): - - # 'null' is to be deprecated in favor of 'required' - if 'null' in kwargs: - warnings.warn('The `null` keyword argument is deprecated. ' - 'Use the `required` keyword argument instead.', - DeprecationWarning, stacklevel=2) - kwargs['required'] = not kwargs.pop('null') - queryset = kwargs.pop('queryset', None) self.many = kwargs.pop('many', self.many) if self.many: @@ -424,14 +416,11 @@ class HyperlinkedRelatedField(RelatedField): request = self.context.get('request', None) format = self.format or self.context.get('format', None) - if request is None: - msg = ( - "Using `HyperlinkedRelatedField` without including the request " - "in the serializer context is deprecated. " - "Add `context={'request': request}` when instantiating " - "the serializer." - ) - warnings.warn(msg, DeprecationWarning, stacklevel=4) + assert request is not None, ( + "`HyperlinkedRelatedField` requires the request in the serializer " + "context. Add `context={'request': request}` when instantiating " + "the serializer." + ) # If the object has not yet been saved then we cannot hyperlink to it. if getattr(obj, 'pk', None) is None: @@ -530,11 +519,11 @@ class HyperlinkedIdentityField(Field): format = self.context.get('format', None) view_name = self.view_name - if request is None: - warnings.warn("Using `HyperlinkedIdentityField` without including the " - "request in the serializer context is deprecated. " - "Add `context={'request': request}` when instantiating the serializer.", - DeprecationWarning, stacklevel=4) + assert request is not None, ( + "`HyperlinkedIdentityField` requires the request in the serializer" + " context. Add `context={'request': request}` when instantiating " + "the serializer." + ) # By default use whatever format is given for the current context # unless the target is a different type to the source. @@ -593,41 +582,3 @@ class HyperlinkedIdentityField(Field): pass raise NoReverseMatch() - - -### Old-style many classes for backwards compat - -class ManyRelatedField(RelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyRelatedField()` is deprecated. ' - 'Use `RelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyRelatedField, self).__init__(*args, **kwargs) - - -class ManyPrimaryKeyRelatedField(PrimaryKeyRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyPrimaryKeyRelatedField()` is deprecated. ' - 'Use `PrimaryKeyRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyPrimaryKeyRelatedField, self).__init__(*args, **kwargs) - - -class ManySlugRelatedField(SlugRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManySlugRelatedField()` is deprecated. ' - 'Use `SlugRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManySlugRelatedField, self).__init__(*args, **kwargs) - - -class ManyHyperlinkedRelatedField(HyperlinkedRelatedField): - def __init__(self, *args, **kwargs): - warnings.warn('`ManyHyperlinkedRelatedField()` is deprecated. ' - 'Use `HyperlinkedRelatedField(many=True)` instead.', - DeprecationWarning, stacklevel=2) - kwargs['many'] = True - super(ManyHyperlinkedRelatedField, self).__init__(*args, **kwargs) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 023f7ccfb..ae39cce88 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -15,7 +15,6 @@ import copy import datetime import types from decimal import Decimal -from django.core.paginator import Page from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict @@ -141,7 +140,7 @@ class BaseSerializer(WritableField): _dict_class = SortedDictWithMetadata def __init__(self, instance=None, data=None, files=None, - context=None, partial=False, many=None, + context=None, partial=False, many=False, allow_add_remove=False, **kwargs): super(BaseSerializer, self).__init__(**kwargs) self.opts = self._options_class(self.Meta) @@ -348,12 +347,7 @@ class BaseSerializer(WritableField): if value is None: return None - if self.many is not None: - many = self.many - else: - many = hasattr(value, '__iter__') and not isinstance(value, (Page, dict, six.text_type)) - - if many: + if self.many: return [self.to_native(item) for item in value] return self.to_native(value) @@ -424,16 +418,7 @@ class BaseSerializer(WritableField): if self._errors is None: data, files = self.init_data, self.init_files - if self.many is not None: - many = self.many - else: - many = hasattr(data, '__iter__') and not isinstance(data, (Page, dict, six.text_type)) - if many: - warnings.warn('Implict list/queryset serialization is deprecated. ' - 'Use the `many=True` flag when instantiating the serializer.', - DeprecationWarning, stacklevel=3) - - if many: + if self.many: ret = [] errors = [] update = self.object is not None @@ -486,16 +471,7 @@ class BaseSerializer(WritableField): if self._data is None: obj = self.object - if self.many is not None: - many = self.many - else: - many = hasattr(obj, '__iter__') and not isinstance(obj, (Page, dict)) - if many: - warnings.warn('Implict list/queryset serialization is deprecated. ' - 'Use the `many=True` flag when instantiating the serializer.', - DeprecationWarning, stacklevel=2) - - if many: + if self.many: self._data = [self.to_native(item) for item in obj] else: self._data = self.to_native(obj) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 8b87a0847..151eb6484 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1268,7 +1268,7 @@ class NestedSerializerContextTests(TestCase): model = Album fields = ("photo_set", "callable") - photo_set = PhotoSerializer(source="photo_set") + photo_set = PhotoSerializer(source="photo_set", many=True) callable = serializers.SerializerMethodField("_callable") def _callable(self, instance): @@ -1280,7 +1280,7 @@ class NestedSerializerContextTests(TestCase): albums = None class AlbumCollectionSerializer(serializers.Serializer): - albums = AlbumSerializer(source="albums") + albums = AlbumSerializer(source="albums", many=True) album1 = Album.objects.create(title="album 1") album2 = Album.objects.create(title="album 2") From 379ad8a82485e61b180ee823ba49799d39446aeb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 27 Jun 2013 20:36:14 +0100 Subject: [PATCH 02/63] pending deprecations -> deprecated --- rest_framework/generics.py | 32 +++++++++++++++---------------- rest_framework/mixins.py | 8 ++++---- rest_framework/relations.py | 36 +++++++++++++++++------------------ rest_framework/serializers.py | 8 ++++---- 4 files changed, 42 insertions(+), 42 deletions(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 99e9782e2..874a142c8 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -108,11 +108,11 @@ class GenericAPIView(views.APIView): deprecated_style = False if page_size is not None: warnings.warn('The `page_size` parameter to `paginate_queryset()` ' - 'is due to be deprecated. ' + 'is deprecated. ' 'Note that the return style of this method is also ' 'changed, and will simply return a page object ' 'when called without a `page_size` argument.', - PendingDeprecationWarning, stacklevel=2) + DeprecationWarning, stacklevel=2) deprecated_style = True else: # Determine the required page size. @@ -123,10 +123,10 @@ class GenericAPIView(views.APIView): if not self.allow_empty: warnings.warn( - 'The `allow_empty` parameter is due to be deprecated. ' + 'The `allow_empty` parameter is deprecated. ' 'To use `allow_empty=False` style behavior, You should override ' '`get_queryset()` and explicitly raise a 404 on empty querysets.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) paginator = self.paginator_class(queryset, page_size, @@ -166,10 +166,10 @@ class GenericAPIView(views.APIView): if not filter_backends and self.filter_backend: warnings.warn( 'The `filter_backend` attribute and `FILTER_BACKEND` setting ' - 'are due to be deprecated in favor of a `filter_backends` ' + 'are deprecated in favor of a `filter_backends` ' 'attribute and `DEFAULT_FILTER_BACKENDS` setting, that take ' 'a *list* of filter backend classes.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) filter_backends = [self.filter_backend] @@ -192,8 +192,8 @@ class GenericAPIView(views.APIView): """ if queryset is not None: warnings.warn('The `queryset` parameter to `get_paginate_by()` ' - 'is due to be deprecated.', - PendingDeprecationWarning, stacklevel=2) + 'is deprecated.', + DeprecationWarning, stacklevel=2) if self.paginate_by_param: query_params = self.request.QUERY_PARAMS @@ -272,16 +272,16 @@ class GenericAPIView(views.APIView): filter_kwargs = {self.lookup_field: lookup} elif pk is not None and self.lookup_field == 'pk': warnings.warn( - 'The `pk_url_kwarg` attribute is due to be deprecated. ' + 'The `pk_url_kwarg` attribute is deprecated. ' 'Use the `lookup_field` attribute instead', - PendingDeprecationWarning + DeprecationWarning ) filter_kwargs = {'pk': pk} elif slug is not None and self.lookup_field == 'pk': warnings.warn( - 'The `slug_url_kwarg` attribute is due to be deprecated. ' + 'The `slug_url_kwarg` attribute is deprecated. ' 'Use the `lookup_field` attribute instead', - PendingDeprecationWarning + DeprecationWarning ) filter_kwargs = {self.slug_field: slug} else: @@ -482,9 +482,9 @@ class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, class MultipleObjectAPIView(GenericAPIView): def __init__(self, *args, **kwargs): warnings.warn( - 'Subclassing `MultipleObjectAPIView` is due to be deprecated. ' + 'Subclassing `MultipleObjectAPIView` is deprecated. ' 'You should simply subclass `GenericAPIView` instead.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) super(MultipleObjectAPIView, self).__init__(*args, **kwargs) @@ -492,8 +492,8 @@ class MultipleObjectAPIView(GenericAPIView): class SingleObjectAPIView(GenericAPIView): def __init__(self, *args, **kwargs): warnings.warn( - 'Subclassing `SingleObjectAPIView` is due to be deprecated. ' + 'Subclassing `SingleObjectAPIView` is deprecated. ' 'You should simply subclass `GenericAPIView` instead.', - PendingDeprecationWarning, stacklevel=2 + DeprecationWarning, stacklevel=2 ) super(SingleObjectAPIView, self).__init__(*args, **kwargs) diff --git a/rest_framework/mixins.py b/rest_framework/mixins.py index f11def6d4..679dfa6c3 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -24,14 +24,14 @@ def _get_validation_exclusions(obj, pk=None, slug_field=None, lookup_field=None) include = [] if pk: - # Pending deprecation + # Deprecated pk_field = obj._meta.pk while pk_field.rel: pk_field = pk_field.rel.to._meta.pk include.append(pk_field.name) if slug_field: - # Pending deprecation + # Deprecated include.append(slug_field) if lookup_field and lookup_field != 'pk': @@ -77,10 +77,10 @@ class ListModelMixin(object): # `.allow_empty = False`, to raise 404 errors on empty querysets. if not self.allow_empty and not self.object_list: warnings.warn( - 'The `allow_empty` parameter is due to be deprecated. ' + 'The `allow_empty` parameter is deprecated. ' 'To use `allow_empty=False` style behavior, You should override ' '`get_queryset()` and explicitly raise a 404 on empty querysets.', - PendingDeprecationWarning + DeprecationWarning ) class_name = self.__class__.__name__ error_msg = self.empty_error % {'class_name': class_name} diff --git a/rest_framework/relations.py b/rest_framework/relations.py index ede694e31..f1f7dea72 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -314,7 +314,7 @@ class HyperlinkedRelatedField(RelatedField): 'incorrect_type': _('Incorrect type. Expected url string, received %s.'), } - # These are all pending deprecation + # These are all deprecated pk_url_kwarg = 'pk' slug_field = 'slug' slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden @@ -328,16 +328,16 @@ class HyperlinkedRelatedField(RelatedField): self.lookup_field = kwargs.pop('lookup_field', self.lookup_field) self.format = kwargs.pop('format', None) - # These are pending deprecation + # These are deprecated if 'pk_url_kwarg' in kwargs: - msg = 'pk_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'pk_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_url_kwarg' in kwargs: - msg = 'slug_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_field' in kwargs: - msg = 'slug_field is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_field is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) self.pk_url_kwarg = kwargs.pop('pk_url_kwarg', self.pk_url_kwarg) self.slug_field = kwargs.pop('slug_field', self.slug_field) @@ -380,9 +380,9 @@ class HyperlinkedRelatedField(RelatedField): # If the lookup succeeds using the default slug params, # then `slug_field` is being used implicitly, and we # we need to warn about the pending deprecation. - msg = 'Implicit slug field hyperlinked fields are pending deprecation.' \ + msg = 'Implicit slug field hyperlinked fields are deprecated.' \ 'You should set `lookup_field=slug` on the HyperlinkedRelatedField.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + warnings.warn(msg, DeprecationWarning, stacklevel=2) return ret except NoReverseMatch: pass @@ -480,7 +480,7 @@ class HyperlinkedIdentityField(Field): lookup_field = 'pk' read_only = True - # These are all pending deprecation + # These are all deprecated pk_url_kwarg = 'pk' slug_field = 'slug' slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden @@ -496,16 +496,16 @@ class HyperlinkedIdentityField(Field): lookup_field = kwargs.pop('lookup_field', None) self.lookup_field = lookup_field or self.lookup_field - # These are pending deprecation + # These are deprecated if 'pk_url_kwarg' in kwargs: - msg = 'pk_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'pk_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_url_kwarg' in kwargs: - msg = 'slug_url_kwarg is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_url_kwarg is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) if 'slug_field' in kwargs: - msg = 'slug_field is pending deprecation. Use lookup_field instead.' - warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + msg = 'slug_field is deprecated. Use lookup_field instead.' + warnings.warn(msg, DeprecationWarning, stacklevel=2) self.slug_field = kwargs.pop('slug_field', self.slug_field) default_slug_kwarg = self.slug_url_kwarg or self.slug_field diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index ae39cce88..dd9e14ad7 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -593,10 +593,10 @@ class ModelSerializer(Serializer): if len(inspect.getargspec(self.get_nested_field).args) == 2: warnings.warn( 'The `get_nested_field(model_field)` call signature ' - 'is due to be deprecated. ' + 'is deprecated. ' 'Use `get_nested_field(model_field, related_model, ' 'to_many) instead', - PendingDeprecationWarning + DeprecationWarning ) field = self.get_nested_field(model_field) else: @@ -605,10 +605,10 @@ class ModelSerializer(Serializer): if len(inspect.getargspec(self.get_nested_field).args) == 3: warnings.warn( 'The `get_related_field(model_field, to_many)` call ' - 'signature is due to be deprecated. ' + 'signature is deprecated. ' 'Use `get_related_field(model_field, related_model, ' 'to_many) instead', - PendingDeprecationWarning + DeprecationWarning ) field = self.get_related_field(model_field, to_many=to_many) else: From d72603bc6a16112008959c5267839f819c2bc43a Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Wed, 5 Jun 2013 17:39:14 -0400 Subject: [PATCH 03/63] Add support for collection routes to SimpleRouter --- rest_framework/decorators.py | 26 +++++++++++++++ rest_framework/routers.py | 33 +++++++++++++++++-- rest_framework/tests/test_routers.py | 48 +++++++++++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index c69756a43..dacd380fb 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -113,6 +113,7 @@ def link(**kwargs): """ def decorator(func): func.bind_to_methods = ['get'] + func.collection = False func.kwargs = kwargs return func return decorator @@ -124,6 +125,31 @@ def action(methods=['post'], **kwargs): """ def decorator(func): func.bind_to_methods = methods + func.collection = False + func.kwargs = kwargs + return func + return decorator + + +def collection_link(**kwargs): + """ + Used to mark a method on a ViewSet that should be routed for GET requests. + """ + def decorator(func): + func.bind_to_methods = ['get'] + func.collection = True + func.kwargs = kwargs + return func + return decorator + + +def collection_action(methods=['post'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for POST requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.collection = True func.kwargs = kwargs return func return decorator diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 930011d39..9b859a7c7 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -88,6 +88,17 @@ class SimpleRouter(BaseRouter): name='{basename}-list', initkwargs={'suffix': 'List'} ), + # Dynamically generated collection routes. + # Generated using @collection_action or @collection_link decorators + # on methods of the viewset. + Route( + url=r'^{prefix}/{methodname}{trailing_slash}$', + mapping={ + '{httpmethod}': '{methodname}', + }, + name='{basename}-collection-{methodnamehyphen}', + initkwargs={} + ), # Detail route. Route( url=r'^{prefix}/{lookup}{trailing_slash}$', @@ -107,7 +118,7 @@ class SimpleRouter(BaseRouter): mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-{methodnamehyphen}', + name='{basename}-dynamic-{methodnamehyphen}', initkwargs={} ), ] @@ -142,20 +153,25 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes]) # Determine any `@action` or `@link` decorated methods on the viewset + collection_routes = [] dynamic_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) + collection = getattr(attr, 'collection', False) if httpmethods: if methodname in known_actions: raise ImproperlyConfigured('Cannot use @action or @link decorator on ' 'method "%s" as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] - dynamic_routes.append((httpmethods, methodname)) + if collection: + collection_routes.append((httpmethods, methodname)) + else: + dynamic_routes.append((httpmethods, methodname)) ret = [] for route in self.routes: - if route.mapping == {'{httpmethod}': '{methodname}'}: + if route.name == '{basename}-dynamic-{methodnamehyphen}': # Dynamic routes (@link or @action decorator) for httpmethods, methodname in dynamic_routes: initkwargs = route.initkwargs.copy() @@ -166,6 +182,17 @@ class SimpleRouter(BaseRouter): name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) + elif route.name == '{basename}-collection-{methodnamehyphen}': + # Dynamic routes (@collection_link or @collection_action decorator) + for httpmethods, methodname in collection_routes: + initkwargs = route.initkwargs.copy() + initkwargs.update(getattr(viewset, methodname).kwargs) + ret.append(Route( + url=replace_methodname(route.url, methodname), + mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), + name=replace_methodname(route.name, methodname), + initkwargs=initkwargs, + )) else: # Standard route ret.append(route) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 5fcccb741..60f150d2c 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action +from rest_framework.decorators import link, action, collection_link, collection_action from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -214,3 +214,49 @@ class TestActionAppliedToExistingRoute(TestCase): with self.assertRaises(ImproperlyConfigured): self.router.urls + + +class StaticAndDynamicViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + return Response({'method': 'list'}) + + @collection_action() + def collection_action(self, request, *args, **kwargs): + return Response({'method': 'action1'}) + + @action() + def dynamic_action(self, request, *args, **kwargs): + return Response({'method': 'action2'}) + + @collection_link() + def collection_link(self, request, *args, **kwargs): + return Response({'method': 'link1'}) + + @link() + def dynamic_link(self, request, *args, **kwargs): + return Response({'method': 'link2'}) + + +class TestStaticAndDynamicRouter(TestCase): + def setUp(self): + self.router = SimpleRouter() + + def test_link_and_action_decorator(self): + routes = self.router.get_routes(StaticAndDynamicViewSet) + decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] + # Make sure all these endpoints exist and none have been clobbered + for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): + route = decorator_routes[i] + # check url listing + if endpoint.startswith('collection_'): + self.assertEqual(route.url, + '^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint)) + else: + self.assertEqual(route.url, + '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint)) + # check method to function mapping + if endpoint.endswith('action'): + method_map = 'post' + else: + method_map = 'get' + self.assertEqual(route.mapping[method_map], endpoint) From 5b11e23f6fb35834057fba35832a597ce443cc77 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Wed, 5 Jun 2013 17:41:29 -0400 Subject: [PATCH 04/63] Add docs for collection routes --- docs/api-guide/viewsets.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 47e59e2b2..9fa6615ba 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,7 +92,9 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link` or `@action` decorators. The `@link` decorator will route `GET` requests, and the `@action` decorator will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@collection_link`, `@collection_action`, `@link`, or `@action` decorators. The `@collection_link` and `@link` decorator will route `GET` requests, and the `@collection_action` and `@action` decorator will route `POST` requests. + +The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. For example: @@ -121,13 +123,20 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -The `@action` and `@link` decorators can additionally take extra arguments that will be set for the routed view only. For example... + @collection_link() + def recent_users(self, request): + recent_users = User.objects.all().order('-last_login') + page = self.paginate_queryset(recent_users) + serializer = self.get_pagination_serializer(page) + return Response(serializer.data) + +The decorators can additionally take extra arguments that will be set for the routed view only. For example... @action(permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... -The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: +The `@collection_action` and `@action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): From 57cf8b5fa4f62f9b58912f10536a7ae5076ce54c Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Thu, 6 Jun 2013 11:51:52 -0400 Subject: [PATCH 05/63] Rework extra routes doc for better readability --- docs/api-guide/viewsets.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 9fa6615ba..e83487fb0 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,7 +92,7 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@collection_link`, `@collection_action`, `@link`, or `@action` decorators. The `@collection_link` and `@link` decorator will route `GET` requests, and the `@collection_action` and `@action` decorator will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@collection_link`, or `@collection_action` decorators. The `@link` and `@collection_link` decorators will route `GET` requests, and the `@action` and `@collection_action` decorators will route `POST` requests. The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. @@ -136,7 +136,7 @@ The decorators can additionally take extra arguments that will be set for the ro def set_password(self, request, pk=None): ... -The `@collection_action` and `@action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `method` argument. For example: +The `@action` and `@collection_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): From 8d521c068a254cef604df1f15690275dca986778 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sun, 16 Jun 2013 12:43:59 -0400 Subject: [PATCH 06/63] Revert route name change and add key to Route object to identify different route types --- rest_framework/routers.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 9b859a7c7..541df4a9d 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -25,7 +25,7 @@ from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns -Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +Route = namedtuple('Route', ['key', 'url', 'mapping', 'name', 'initkwargs']) def replace_methodname(format_string, methodname): @@ -80,6 +80,7 @@ class SimpleRouter(BaseRouter): routes = [ # List route. Route( + key='list', url=r'^{prefix}{trailing_slash}$', mapping={ 'get': 'list', @@ -92,15 +93,17 @@ class SimpleRouter(BaseRouter): # Generated using @collection_action or @collection_link decorators # on methods of the viewset. Route( + key='collection', url=r'^{prefix}/{methodname}{trailing_slash}$', mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-collection-{methodnamehyphen}', + name='{basename}-{methodnamehyphen}', initkwargs={} ), # Detail route. Route( + key='detail', url=r'^{prefix}/{lookup}{trailing_slash}$', mapping={ 'get': 'retrieve', @@ -114,11 +117,12 @@ class SimpleRouter(BaseRouter): # Dynamically generated routes. # Generated using @action or @link decorators on methods of the viewset. Route( + key='dynamic', url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', mapping={ '{httpmethod}': '{methodname}', }, - name='{basename}-dynamic-{methodnamehyphen}', + name='{basename}-{methodnamehyphen}', initkwargs={} ), ] @@ -171,23 +175,25 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: - if route.name == '{basename}-dynamic-{methodnamehyphen}': + if route.key == 'dynamic': # Dynamic routes (@link or @action decorator) for httpmethods, methodname in dynamic_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( + key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) - elif route.name == '{basename}-collection-{methodnamehyphen}': + elif route.key == 'collection': # Dynamic routes (@collection_link or @collection_action decorator) for httpmethods, methodname in collection_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( + key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), From f02274307826ebf98998e502fecca171bb0de696 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sun, 16 Jun 2013 12:51:33 -0400 Subject: [PATCH 07/63] Rename router collection test case --- rest_framework/tests/test_routers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 60f150d2c..e0a7e292e 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -216,7 +216,7 @@ class TestActionAppliedToExistingRoute(TestCase): self.router.urls -class StaticAndDynamicViewSet(viewsets.ViewSet): +class CollectionAndDynamicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) @@ -237,12 +237,12 @@ class StaticAndDynamicViewSet(viewsets.ViewSet): return Response({'method': 'link2'}) -class TestStaticAndDynamicRouter(TestCase): +class TestCollectionAndDynamicRouter(TestCase): def setUp(self): self.router = SimpleRouter() def test_link_and_action_decorator(self): - routes = self.router.get_routes(StaticAndDynamicViewSet) + routes = self.router.get_routes(CollectionAndDynamicViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): From e14cbaf6961ad9c94deaf0417d8e8ce5ec96d0ac Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sat, 13 Jul 2013 11:11:53 -0400 Subject: [PATCH 08/63] Changed collection_* decorators to list_* --- docs/api-guide/viewsets.md | 10 ++++----- rest_framework/decorators.py | 16 +++++++------- rest_framework/routers.py | 31 ++++++++++++++-------------- rest_framework/tests/test_routers.py | 24 ++++++++++----------- 4 files changed, 41 insertions(+), 40 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index e83487fb0..6d6bb1334 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,15 +92,15 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@collection_link`, or `@collection_action` decorators. The `@link` and `@collection_link` decorators will route `GET` requests, and the `@action` and `@collection_action` decorators will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@list_link`, or `@list_action` decorators. The `@link` and `@list_link` decorators will route `GET` requests, and the `@action` and `@list_action` decorators will route `POST` requests. -The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@collection_link` and `@collection_action` decorators are intended for methods which operate on a collection of objects. +The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@list_link` and `@list_action` decorators are intended for methods which operate on a list of objects. For example: from django.contrib.auth.models import User from rest_framework import viewsets - from rest_framework.decorators import action + from rest_framework.decorators import action, list_link from rest_framework.response import Response from myapp.serializers import UserSerializer, PasswordSerializer @@ -123,7 +123,7 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @collection_link() + @list_link() def recent_users(self, request): recent_users = User.objects.all().order('-last_login') page = self.paginate_queryset(recent_users) @@ -136,7 +136,7 @@ The decorators can additionally take extra arguments that will be set for the ro def set_password(self, request, pk=None): ... -The `@action` and `@collection_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: +The `@action` and `@list_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @action(methods=['POST', 'DELETE']) def unset_password(self, request, pk=None): diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index dacd380fb..92f551db7 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -109,11 +109,11 @@ def permission_classes(permission_classes): def link(**kwargs): """ - Used to mark a method on a ViewSet that should be routed for GET requests. + Used to mark a method on a ViewSet that should be routed for detail GET requests. """ def decorator(func): func.bind_to_methods = ['get'] - func.collection = False + func.detail = True func.kwargs = kwargs return func return decorator @@ -121,35 +121,35 @@ def link(**kwargs): def action(methods=['post'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for POST requests. + Used to mark a method on a ViewSet that should be routed for detail POST requests. """ def decorator(func): func.bind_to_methods = methods - func.collection = False + func.detail = True func.kwargs = kwargs return func return decorator -def collection_link(**kwargs): +def list_link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for GET requests. """ def decorator(func): func.bind_to_methods = ['get'] - func.collection = True + func.detail = False func.kwargs = kwargs return func return decorator -def collection_action(methods=['post'], **kwargs): +def list_action(methods=['post'], **kwargs): """ Used to mark a method on a ViewSet that should be routed for POST requests. """ def decorator(func): func.bind_to_methods = methods - func.collection = True + func.detail = False func.kwargs = kwargs return func return decorator diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 541df4a9d..c8f711e91 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -89,8 +89,8 @@ class SimpleRouter(BaseRouter): name='{basename}-list', initkwargs={'suffix': 'List'} ), - # Dynamically generated collection routes. - # Generated using @collection_action or @collection_link decorators + # Dynamically generated list routes. + # Generated using @list_action or @list_link decorators # on methods of the viewset. Route( key='collection', @@ -114,7 +114,7 @@ class SimpleRouter(BaseRouter): name='{basename}-detail', initkwargs={'suffix': 'Instance'} ), - # Dynamically generated routes. + # Dynamically generated detail routes. # Generated using @action or @link decorators on methods of the viewset. Route( key='dynamic', @@ -157,27 +157,28 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes]) # Determine any `@action` or `@link` decorated methods on the viewset - collection_routes = [] - dynamic_routes = [] + detail_routes = [] + list_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) - collection = getattr(attr, 'collection', False) + detail = getattr(attr, 'detail', True) if httpmethods: if methodname in known_actions: - raise ImproperlyConfigured('Cannot use @action or @link decorator on ' - 'method "%s" as it is an existing route' % methodname) + raise ImproperlyConfigured('Cannot use @action, @link, @list_action ' + 'or @list_link decorator on method "%s" ' + 'as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] - if collection: - collection_routes.append((httpmethods, methodname)) + if detail: + detail_routes.append((httpmethods, methodname)) else: - dynamic_routes.append((httpmethods, methodname)) + list_routes.append((httpmethods, methodname)) ret = [] for route in self.routes: if route.key == 'dynamic': - # Dynamic routes (@link or @action decorator) - for httpmethods, methodname in dynamic_routes: + # Dynamic detail routes (@link or @action decorator) + for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( @@ -188,8 +189,8 @@ class SimpleRouter(BaseRouter): initkwargs=initkwargs, )) elif route.key == 'collection': - # Dynamic routes (@collection_link or @collection_action decorator) - for httpmethods, methodname in collection_routes: + # Dynamic list routes (@list_link or @list_action decorator) + for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index e0a7e292e..393101763 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action, collection_link, collection_action +from rest_framework.decorators import link, action, list_link, list_action from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -216,39 +216,39 @@ class TestActionAppliedToExistingRoute(TestCase): self.router.urls -class CollectionAndDynamicViewSet(viewsets.ViewSet): +class DynamicListAndDetailViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @collection_action() - def collection_action(self, request, *args, **kwargs): + @list_action() + def list_action(self, request, *args, **kwargs): return Response({'method': 'action1'}) @action() - def dynamic_action(self, request, *args, **kwargs): + def detail_action(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @collection_link() - def collection_link(self, request, *args, **kwargs): + @list_link() + def list_link(self, request, *args, **kwargs): return Response({'method': 'link1'}) @link() - def dynamic_link(self, request, *args, **kwargs): + def detail_link(self, request, *args, **kwargs): return Response({'method': 'link2'}) -class TestCollectionAndDynamicRouter(TestCase): +class TestDynamicListAndDetailRouter(TestCase): def setUp(self): self.router = SimpleRouter() def test_link_and_action_decorator(self): - routes = self.router.get_routes(CollectionAndDynamicViewSet) + routes = self.router.get_routes(DynamicListAndDetailViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered - for i, endpoint in enumerate(['collection_action', 'collection_link', 'dynamic_action', 'dynamic_link']): + for i, endpoint in enumerate(['list_action', 'list_link', 'detail_action', 'detail_link']): route = decorator_routes[i] # check url listing - if endpoint.startswith('collection_'): + if endpoint.startswith('list_'): self.assertEqual(route.url, '^{{prefix}}/{0}{{trailing_slash}}$'.format(endpoint)) else: From ca7ba07b4e42bd1c7c6bb8088c0c5a2c434b56ee Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Sat, 13 Jul 2013 11:12:59 -0400 Subject: [PATCH 09/63] Introduce DynamicDetailRoute and DynamicListRoute to distinguish between different route types --- rest_framework/routers.py | 26 ++++++++------------------ 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/rest_framework/routers.py b/rest_framework/routers.py index c8f711e91..b8f19b66a 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -25,7 +25,9 @@ from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns -Route = namedtuple('Route', ['key', 'url', 'mapping', 'name', 'initkwargs']) +Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs']) +DynamicDetailRoute = namedtuple('DynamicDetailRoute', ['url', 'name', 'initkwargs']) +DynamicListRoute = namedtuple('DynamicListRoute', ['url', 'name', 'initkwargs']) def replace_methodname(format_string, methodname): @@ -80,7 +82,6 @@ class SimpleRouter(BaseRouter): routes = [ # List route. Route( - key='list', url=r'^{prefix}{trailing_slash}$', mapping={ 'get': 'list', @@ -92,18 +93,13 @@ class SimpleRouter(BaseRouter): # Dynamically generated list routes. # Generated using @list_action or @list_link decorators # on methods of the viewset. - Route( - key='collection', + DynamicListRoute( url=r'^{prefix}/{methodname}{trailing_slash}$', - mapping={ - '{httpmethod}': '{methodname}', - }, name='{basename}-{methodnamehyphen}', initkwargs={} ), # Detail route. Route( - key='detail', url=r'^{prefix}/{lookup}{trailing_slash}$', mapping={ 'get': 'retrieve', @@ -116,12 +112,8 @@ class SimpleRouter(BaseRouter): ), # Dynamically generated detail routes. # Generated using @action or @link decorators on methods of the viewset. - Route( - key='dynamic', + DynamicDetailRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', - mapping={ - '{httpmethod}': '{methodname}', - }, name='{basename}-{methodnamehyphen}', initkwargs={} ), @@ -154,7 +146,7 @@ class SimpleRouter(BaseRouter): Returns a list of the Route namedtuple. """ - known_actions = flatten([route.mapping.values() for route in self.routes]) + known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) # Determine any `@action` or `@link` decorated methods on the viewset detail_routes = [] @@ -176,25 +168,23 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: - if route.key == 'dynamic': + if isinstance(route, DynamicDetailRoute): # Dynamic detail routes (@link or @action decorator) for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( - key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), initkwargs=initkwargs, )) - elif route.key == 'collection': + elif isinstance(route, DynamicListRoute): # Dynamic list routes (@list_link or @list_action decorator) for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( - key=route.key, url=replace_methodname(route.url, methodname), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, methodname), From eaae8fb2d973769a827214e0606a7e41028d5d34 Mon Sep 17 00:00:00 2001 From: Alex Burgel Date: Mon, 15 Jul 2013 18:35:13 -0400 Subject: [PATCH 10/63] Combined link_* and action_* decorators into detail_route and list_route, marked the originals as deprecated. --- docs/api-guide/routers.md | 16 +++++----- docs/api-guide/viewsets.md | 16 +++++----- docs/tutorial/6-viewsets-and-routers.md | 8 ++--- rest_framework/decorators.py | 19 +++++++----- rest_framework/routers.py | 14 ++++----- rest_framework/tests/test_routers.py | 40 ++++++++++++------------- 6 files changed, 59 insertions(+), 54 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 865829057..f196dc3cd 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -35,12 +35,12 @@ The example above would generate the following URL patterns: * URL pattern: `^accounts/$` Name: `'account-list'` * URL pattern: `^accounts/{pk}/$` Name: `'account-detail'` -### Extra link and actions +### Registering additional routes -Any methods on the viewset decorated with `@link` or `@action` will also be routed. +Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed. For example, a given method like this on the `UserViewSet` class: - @action(permission_classes=[IsAdminOrIsSelf]) + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... @@ -52,7 +52,7 @@ The following URL pattern would additionally be generated: ## SimpleRouter -This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@link` or `@action` decorators. +This router includes routes for the standard set of `list`, `create`, `retrieve`, `update`, `partial_update` and `destroy` actions. The viewset can also mark additional methods to be routed, using the `@detail_route` or `@list_route` decorators. @@ -62,8 +62,8 @@ This router includes routes for the standard set of `list`, `create`, `retrieve` - - + +
URL StyleHTTP MethodActionURL Name
PUTupdate
PATCHpartial_update
DELETEdestroy
{prefix}/{lookup}/{methodname}/GET@link decorated method{basename}-{methodname}
POST@action decorated method
{prefix}/{lookup}/{methodname}/GET@detail_route decorated method{basename}-{methodname}
POST@detail_route decorated method
By default the URLs created by `SimpleRouter` are appending with a trailing slash. @@ -86,8 +86,8 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/[.format]GET@link decorated method{basename}-{methodname} - POST@action decorated method + {prefix}/{lookup}/{methodname}/[.format]GET@detail_route decorated method{basename}-{methodname} + POST@detail_route decorated method As with `SimpleRouter` the trailing slashs on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router. diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 6d6bb1334..7a8d5979b 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -92,15 +92,15 @@ The default routers included with REST framework will provide routes for a stand def destroy(self, request, pk=None): pass -If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@link`, `@action`, `@list_link`, or `@list_action` decorators. The `@link` and `@list_link` decorators will route `GET` requests, and the `@action` and `@list_action` decorators will route `POST` requests. +If you have ad-hoc methods that you need to be routed to, you can mark them as requiring routing using the `@detail_route` or `@list_route` decorators. -The `@link` and `@action` decorators contain `pk` in their URL pattern and are intended for methods which require a single instance. The `@list_link` and `@list_action` decorators are intended for methods which operate on a list of objects. +The `@detail_route` decorator contains `pk` in its URL pattern and is intended for methods which require a single instance. The `@list_route` decorator is intended for methods which operate on a list of objects. For example: from django.contrib.auth.models import User from rest_framework import viewsets - from rest_framework.decorators import action, list_link + from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from myapp.serializers import UserSerializer, PasswordSerializer @@ -111,7 +111,7 @@ For example: queryset = User.objects.all() serializer_class = UserSerializer - @action() + @detail_route(methods=['post']) def set_password(self, request, pk=None): user = self.get_object() serializer = PasswordSerializer(data=request.DATA) @@ -123,7 +123,7 @@ For example: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - @list_link() + @list_route() def recent_users(self, request): recent_users = User.objects.all().order('-last_login') page = self.paginate_queryset(recent_users) @@ -132,13 +132,13 @@ For example: The decorators can additionally take extra arguments that will be set for the routed view only. For example... - @action(permission_classes=[IsAdminOrIsSelf]) + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) def set_password(self, request, pk=None): ... -The `@action` and `@list_action` decorators will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: +By default, the decorators will route `GET` requests, but may also accept other HTTP methods, by using the `methods` argument. For example: - @action(methods=['POST', 'DELETE']) + @detail_route(methods=['post', 'delete']) def unset_password(self, request, pk=None): ... --- diff --git a/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index f16add39d..f126ba045 100644 --- a/docs/tutorial/6-viewsets-and-routers.md +++ b/docs/tutorial/6-viewsets-and-routers.md @@ -25,7 +25,7 @@ Here we've used `ReadOnlyModelViewSet` class to automatically provide the defaul Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighlight` view classes. We can remove the three views, and again replace them with a single class. - from rest_framework.decorators import link + from rest_framework.decorators import detail_route class SnippetViewSet(viewsets.ModelViewSet): """ @@ -39,7 +39,7 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl permission_classes = (permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly,) - @link(renderer_classes=[renderers.StaticHTMLRenderer]) + @detail_route(renderer_classes=[renderers.StaticHTMLRenderer]) def highlight(self, request, *args, **kwargs): snippet = self.get_object() return Response(snippet.highlighted) @@ -49,9 +49,9 @@ Next we're going to replace the `SnippetList`, `SnippetDetail` and `SnippetHighl This time we've used the `ModelViewSet` class in order to get the complete set of default read and write operations. -Notice that we've also used the `@link` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style. +Notice that we've also used the `@detail_route` decorator to create a custom action, named `highlight`. This decorator can be used to add any custom endpoints that don't fit into the standard `create`/`update`/`delete` style. -Custom actions which use the `@link` decorator will respond to `GET` requests. We could have instead used the `@action` decorator if we wanted an action that responded to `POST` requests. +Custom actions which use the `@detail_route` decorator will respond to `GET` requests. We can use the `methods` argument if we wanted an action that responded to `POST` requests. ## Binding ViewSets to URLs explicitly diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 92f551db7..1ca176f2c 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -3,13 +3,14 @@ The most important decorator in this module is `@api_view`, which is used for writing function-based views with REST framework. There are also various decorators for setting the API policies on function -based views, as well as the `@action` and `@link` decorators, which are +based views, as well as the `@detail_route` and `@list_route` decorators, which are used to annotate methods on viewsets that should be included by routers. """ from __future__ import unicode_literals from rest_framework.compat import six from rest_framework.views import APIView import types +import warnings def api_view(http_method_names): @@ -111,6 +112,8 @@ def link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for detail GET requests. """ + msg = 'link is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) def decorator(func): func.bind_to_methods = ['get'] func.detail = True @@ -123,6 +126,8 @@ def action(methods=['post'], **kwargs): """ Used to mark a method on a ViewSet that should be routed for detail POST requests. """ + msg = 'action is pending deprecation. Use detail_route instead.' + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) def decorator(func): func.bind_to_methods = methods func.detail = True @@ -131,21 +136,21 @@ def action(methods=['post'], **kwargs): return decorator -def list_link(**kwargs): +def detail_route(methods=['get'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for GET requests. + Used to mark a method on a ViewSet that should be routed for detail requests. """ def decorator(func): - func.bind_to_methods = ['get'] - func.detail = False + func.bind_to_methods = methods + func.detail = True func.kwargs = kwargs return func return decorator -def list_action(methods=['post'], **kwargs): +def list_route(methods=['get'], **kwargs): """ - Used to mark a method on a ViewSet that should be routed for POST requests. + Used to mark a method on a ViewSet that should be routed for list requests. """ def decorator(func): func.bind_to_methods = methods diff --git a/rest_framework/routers.py b/rest_framework/routers.py index b8f19b66a..b761ba9ae 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -91,7 +91,7 @@ class SimpleRouter(BaseRouter): initkwargs={'suffix': 'List'} ), # Dynamically generated list routes. - # Generated using @list_action or @list_link decorators + # Generated using @list_route decorator # on methods of the viewset. DynamicListRoute( url=r'^{prefix}/{methodname}{trailing_slash}$', @@ -111,7 +111,7 @@ class SimpleRouter(BaseRouter): initkwargs={'suffix': 'Instance'} ), # Dynamically generated detail routes. - # Generated using @action or @link decorators on methods of the viewset. + # Generated using @detail_route decorator on methods of the viewset. DynamicDetailRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', name='{basename}-{methodnamehyphen}', @@ -148,7 +148,7 @@ class SimpleRouter(BaseRouter): known_actions = flatten([route.mapping.values() for route in self.routes if isinstance(route, Route)]) - # Determine any `@action` or `@link` decorated methods on the viewset + # Determine any `@detail_route` or `@list_route` decorated methods on the viewset detail_routes = [] list_routes = [] for methodname in dir(viewset): @@ -157,8 +157,8 @@ class SimpleRouter(BaseRouter): detail = getattr(attr, 'detail', True) if httpmethods: if methodname in known_actions: - raise ImproperlyConfigured('Cannot use @action, @link, @list_action ' - 'or @list_link decorator on method "%s" ' + raise ImproperlyConfigured('Cannot use @detail_route or @list_route ' + 'decorators on method "%s" ' 'as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] if detail: @@ -169,7 +169,7 @@ class SimpleRouter(BaseRouter): ret = [] for route in self.routes: if isinstance(route, DynamicDetailRoute): - # Dynamic detail routes (@link or @action decorator) + # Dynamic detail routes (@detail_route decorator) for httpmethods, methodname in detail_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) @@ -180,7 +180,7 @@ class SimpleRouter(BaseRouter): initkwargs=initkwargs, )) elif isinstance(route, DynamicListRoute): - # Dynamic list routes (@list_link or @list_action decorator) + # Dynamic list routes (@list_route decorator) for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 393101763..c3597e389 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -4,7 +4,7 @@ from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions from rest_framework.compat import include, patterns, url -from rest_framework.decorators import link, action, list_link, list_action +from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter from rest_framework.test import APIRequestFactory @@ -18,23 +18,23 @@ class BasicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @action() + @detail_route(methods=['post']) def action1(self, request, *args, **kwargs): return Response({'method': 'action1'}) - @action() + @detail_route(methods=['post']) def action2(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @action(methods=['post', 'delete']) + @detail_route(methods=['post', 'delete']) def action3(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @link() + @detail_route() def link1(self, request, *args, **kwargs): return Response({'method': 'link1'}) - @link() + @detail_route() def link2(self, request, *args, **kwargs): return Response({'method': 'link2'}) @@ -175,7 +175,7 @@ class TestActionKeywordArgs(TestCase): class TestViewSet(viewsets.ModelViewSet): permission_classes = [] - @action(permission_classes=[permissions.AllowAny]) + @detail_route(methods=['post'], permission_classes=[permissions.AllowAny]) def custom(self, request, *args, **kwargs): return Response({ 'permission_classes': self.permission_classes @@ -196,14 +196,14 @@ class TestActionKeywordArgs(TestCase): class TestActionAppliedToExistingRoute(TestCase): """ - Ensure `@action` decorator raises an except when applied + Ensure `@detail_route` decorator raises an except when applied to an existing route """ def test_exception_raised_when_action_applied_to_existing_route(self): class TestViewSet(viewsets.ModelViewSet): - @action() + @detail_route(methods=['post']) def retrieve(self, request, *args, **kwargs): return Response({ 'hello': 'world' @@ -220,20 +220,20 @@ class DynamicListAndDetailViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) - @list_action() - def list_action(self, request, *args, **kwargs): + @list_route(methods=['post']) + def list_route_post(self, request, *args, **kwargs): return Response({'method': 'action1'}) - @action() - def detail_action(self, request, *args, **kwargs): + @detail_route(methods=['post']) + def detail_route_post(self, request, *args, **kwargs): return Response({'method': 'action2'}) - @list_link() - def list_link(self, request, *args, **kwargs): + @list_route() + def list_route_get(self, request, *args, **kwargs): return Response({'method': 'link1'}) - @link() - def detail_link(self, request, *args, **kwargs): + @detail_route() + def detail_route_get(self, request, *args, **kwargs): return Response({'method': 'link2'}) @@ -241,11 +241,11 @@ class TestDynamicListAndDetailRouter(TestCase): def setUp(self): self.router = SimpleRouter() - def test_link_and_action_decorator(self): + def test_list_and_detail_route_decorators(self): routes = self.router.get_routes(DynamicListAndDetailViewSet) decorator_routes = [r for r in routes if not (r.name.endswith('-list') or r.name.endswith('-detail'))] # Make sure all these endpoints exist and none have been clobbered - for i, endpoint in enumerate(['list_action', 'list_link', 'detail_action', 'detail_link']): + for i, endpoint in enumerate(['list_route_get', 'list_route_post', 'detail_route_get', 'detail_route_post']): route = decorator_routes[i] # check url listing if endpoint.startswith('list_'): @@ -255,7 +255,7 @@ class TestDynamicListAndDetailRouter(TestCase): self.assertEqual(route.url, '^{{prefix}}/{{lookup}}/{0}{{trailing_slash}}$'.format(endpoint)) # check method to function mapping - if endpoint.endswith('action'): + if endpoint.endswith('_post'): method_map = 'post' else: method_map = 'get' From 4292cc18fa3e4b3f5e67c02c3780cdcbf901a0a1 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 19 Aug 2013 20:53:30 +0100 Subject: [PATCH 11/63] Docs tweaking --- docs/api-guide/routers.md | 11 +++++++---- docs/api-guide/viewsets.md | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 7884c2e94..c84654189 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -48,6 +48,8 @@ The following URL pattern would additionally be generated: * URL pattern: `^users/{pk}/set_password/$` Name: `'user-set-password'` +For more information see the viewset documentation on [marking extra actions for routing][route-decorators]. + # API Guide ## SimpleRouter @@ -58,12 +60,12 @@ This router includes routes for the standard set of `list`, `create`, `retrieve` URL StyleHTTP MethodActionURL Name {prefix}/GETlist{basename}-list POSTcreate + {prefix}/{methodname}/GET, or as specified by `methods` argument`@list_route` decorated method{basename}-{methodname} {prefix}/{lookup}/GETretrieve{basename}-detail PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/GET@detail_route decorated method{basename}-{methodname} - POST@detail_route decorated method + {prefix}/{lookup}/{methodname}/GET, or as specified by `methods` argument`@detail_route` decorated method{basename}-{methodname} By default the URLs created by `SimpleRouter` are appended with a trailing slash. @@ -82,12 +84,12 @@ This router is similar to `SimpleRouter` as above, but additionally includes a d [.format]GETautomatically generated root viewapi-root {prefix}/[.format]GETlist{basename}-list POSTcreate + {prefix}/{methodname}/[.format]GET, or as specified by `methods` argument`@list_route` decorated method{basename}-{methodname} {prefix}/{lookup}/[.format]GETretrieve{basename}-detail PUTupdate PATCHpartial_update DELETEdestroy - {prefix}/{lookup}/{methodname}/[.format]GET@detail_route decorated method{basename}-{methodname} - POST@detail_route decorated method + {prefix}/{lookup}/{methodname}/[.format]GET, or as specified by `methods` argument`@detail_route` decorated method{basename}-{methodname} As with `SimpleRouter` the trailing slashes on the URL routes can be removed by setting the `trailing_slash` argument to `False` when instantiating the router. @@ -144,3 +146,4 @@ If you want to provide totally custom behavior, you can override `BaseRouter` an You may also want to override the `get_default_base_name(self, viewset)` method, or else always explicitly set the `base_name` argument when registering your viewsets with the router. [cite]: http://guides.rubyonrails.org/routing.html +[route-decorators]: viewsets.html#marking-extra-actions-for-routing \ No newline at end of file diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index 95efc229f..9005e7cbc 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -61,7 +61,7 @@ There are two main advantages of using a `ViewSet` class over using a `View` cla Both of these come with a trade-off. Using regular views and URL confs is more explicit and gives you more control. ViewSets are helpful if you want to get up and running quickly, or when you have a large API and you want to enforce a consistent URL configuration throughout. -## Marking extra methods for routing +## Marking extra actions for routing The default routers included with REST framework will provide routes for a standard set of create/retrieve/update/destroy style operations, as shown below: From 8acee2e626746f3096c49b3ebb13aaf7dc882917 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 19 Aug 2013 21:02:22 +0100 Subject: [PATCH 12/63] Commenting link/action decorators as pending deprecation --- rest_framework/decorators.py | 51 ++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 25 deletions(-) diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 1ca176f2c..18e41a18d 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -108,6 +108,31 @@ def permission_classes(permission_classes): return decorator +def detail_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for detail requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = True + func.kwargs = kwargs + return func + return decorator + + +def list_route(methods=['get'], **kwargs): + """ + Used to mark a method on a ViewSet that should be routed for list requests. + """ + def decorator(func): + func.bind_to_methods = methods + func.detail = False + func.kwargs = kwargs + return func + return decorator + +# These are now pending deprecation, in favor of `detail_route` and `list_route`. + def link(**kwargs): """ Used to mark a method on a ViewSet that should be routed for detail GET requests. @@ -133,28 +158,4 @@ def action(methods=['post'], **kwargs): func.detail = True func.kwargs = kwargs return func - return decorator - - -def detail_route(methods=['get'], **kwargs): - """ - Used to mark a method on a ViewSet that should be routed for detail requests. - """ - def decorator(func): - func.bind_to_methods = methods - func.detail = True - func.kwargs = kwargs - return func - return decorator - - -def list_route(methods=['get'], **kwargs): - """ - Used to mark a method on a ViewSet that should be routed for list requests. - """ - def decorator(func): - func.bind_to_methods = methods - func.detail = False - func.kwargs = kwargs - return func - return decorator + return decorator \ No newline at end of file From 815ef50735f50c7aff5255e60f1b484e75178e87 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:18:46 +0100 Subject: [PATCH 13/63] If page size query param <= 0, just use default page size. Closes #1028 --- rest_framework/generics.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 874a142c8..bcd62bf9d 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -14,6 +14,15 @@ from rest_framework.settings import api_settings import warnings +def strict_positive_int(integer_string): + """ + Cast a string to a strictly positive integer. + """ + ret = int(integer_string) + if ret <= 0: + raise ValueError() + return ret + def get_object_or_404(queryset, **filter_kwargs): """ Same as Django's standard shortcut, but make sure to raise 404 @@ -198,7 +207,7 @@ class GenericAPIView(views.APIView): if self.paginate_by_param: query_params = self.request.QUERY_PARAMS try: - return int(query_params[self.paginate_by_param]) + return strict_positive_int(query_params[self.paginate_by_param]) except (KeyError, ValueError): pass From 44ceef841543877a700c3fb4a0f84dfecbad0cbb Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:30:25 +0100 Subject: [PATCH 14/63] Updating 2.4.0 release notes --- .travis.yml | 1 + docs/topics/release-notes.md | 5 +- rest_framework/compat.py | 2 +- rest_framework/six.py | 389 ----------------------------------- tox.ini | 2 + 5 files changed, 8 insertions(+), 391 deletions(-) delete mode 100644 rest_framework/six.py diff --git a/.travis.yml b/.travis.yml index 6a4532411..f8640db2c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ install: - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4 --use-mirrors; fi" - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" + - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install six --use-mirrors; fi" - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi" - export PYTHONPATH=. diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 52abfc08e..f3bb19c67 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,9 +40,12 @@ You can determine your currently installed version using `pip freeze`: ## 2.3.x series -### Master +### 2.4.0 +* `@detail_route` and `@list_route` decorators replace `@action` and `@link`. +* `six` no longer bundled. For Django <= 1.4.1, install `six` package. * Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. +* Bugfix: `?page_size=0` query parameter now falls back to default page size for view, instead of always turning pagination off. ### 2.3.7 diff --git a/rest_framework/compat.py b/rest_framework/compat.py index baee3a9c2..178a697f3 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -14,7 +14,7 @@ from django.conf import settings try: from django.utils import six except ImportError: - from rest_framework import six + import six # location of patterns, url, include changes in 1.4 onwards try: diff --git a/rest_framework/six.py b/rest_framework/six.py deleted file mode 100644 index 9e3823128..000000000 --- a/rest_framework/six.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Utilities for writing code that runs on Python 2 and 3""" - -import operator -import sys -import types - -__author__ = "Benjamin Peterson " -__version__ = "1.2.0" - - -# True if we are running on Python 3. -PY3 = sys.version_info[0] == 3 - -if PY3: - string_types = str, - integer_types = int, - class_types = type, - text_type = str - binary_type = bytes - - MAXSIZE = sys.maxsize -else: - string_types = basestring, - integer_types = (int, long) - class_types = (type, types.ClassType) - text_type = unicode - binary_type = str - - if sys.platform == "java": - # Jython always uses 32 bits. - MAXSIZE = int((1 << 31) - 1) - else: - # It's possible to have sizeof(long) != sizeof(Py_ssize_t). - class X(object): - def __len__(self): - return 1 << 31 - try: - len(X()) - except OverflowError: - # 32-bit - MAXSIZE = int((1 << 31) - 1) - else: - # 64-bit - MAXSIZE = int((1 << 63) - 1) - del X - - -def _add_doc(func, doc): - """Add documentation to a function.""" - func.__doc__ = doc - - -def _import_module(name): - """Import module, returning the module after the last dot.""" - __import__(name) - return sys.modules[name] - - -class _LazyDescr(object): - - def __init__(self, name): - self.name = name - - def __get__(self, obj, tp): - result = self._resolve() - setattr(obj, self.name, result) - # This is a bit ugly, but it avoids running this again. - delattr(tp, self.name) - return result - - -class MovedModule(_LazyDescr): - - def __init__(self, name, old, new=None): - super(MovedModule, self).__init__(name) - if PY3: - if new is None: - new = name - self.mod = new - else: - self.mod = old - - def _resolve(self): - return _import_module(self.mod) - - -class MovedAttribute(_LazyDescr): - - def __init__(self, name, old_mod, new_mod, old_attr=None, new_attr=None): - super(MovedAttribute, self).__init__(name) - if PY3: - if new_mod is None: - new_mod = name - self.mod = new_mod - if new_attr is None: - if old_attr is None: - new_attr = name - else: - new_attr = old_attr - self.attr = new_attr - else: - self.mod = old_mod - if old_attr is None: - old_attr = name - self.attr = old_attr - - def _resolve(self): - module = _import_module(self.mod) - return getattr(module, self.attr) - - - -class _MovedItems(types.ModuleType): - """Lazy loading of moved objects""" - - -_moved_attributes = [ - MovedAttribute("cStringIO", "cStringIO", "io", "StringIO"), - MovedAttribute("filter", "itertools", "builtins", "ifilter", "filter"), - MovedAttribute("input", "__builtin__", "builtins", "raw_input", "input"), - MovedAttribute("map", "itertools", "builtins", "imap", "map"), - MovedAttribute("reload_module", "__builtin__", "imp", "reload"), - MovedAttribute("reduce", "__builtin__", "functools"), - MovedAttribute("StringIO", "StringIO", "io"), - MovedAttribute("xrange", "__builtin__", "builtins", "xrange", "range"), - MovedAttribute("zip", "itertools", "builtins", "izip", "zip"), - - MovedModule("builtins", "__builtin__"), - MovedModule("configparser", "ConfigParser"), - MovedModule("copyreg", "copy_reg"), - MovedModule("http_cookiejar", "cookielib", "http.cookiejar"), - MovedModule("http_cookies", "Cookie", "http.cookies"), - MovedModule("html_entities", "htmlentitydefs", "html.entities"), - MovedModule("html_parser", "HTMLParser", "html.parser"), - MovedModule("http_client", "httplib", "http.client"), - MovedModule("BaseHTTPServer", "BaseHTTPServer", "http.server"), - MovedModule("CGIHTTPServer", "CGIHTTPServer", "http.server"), - MovedModule("SimpleHTTPServer", "SimpleHTTPServer", "http.server"), - MovedModule("cPickle", "cPickle", "pickle"), - MovedModule("queue", "Queue"), - MovedModule("reprlib", "repr"), - MovedModule("socketserver", "SocketServer"), - MovedModule("tkinter", "Tkinter"), - MovedModule("tkinter_dialog", "Dialog", "tkinter.dialog"), - MovedModule("tkinter_filedialog", "FileDialog", "tkinter.filedialog"), - MovedModule("tkinter_scrolledtext", "ScrolledText", "tkinter.scrolledtext"), - MovedModule("tkinter_simpledialog", "SimpleDialog", "tkinter.simpledialog"), - MovedModule("tkinter_tix", "Tix", "tkinter.tix"), - MovedModule("tkinter_constants", "Tkconstants", "tkinter.constants"), - MovedModule("tkinter_dnd", "Tkdnd", "tkinter.dnd"), - MovedModule("tkinter_colorchooser", "tkColorChooser", - "tkinter.colorchooser"), - MovedModule("tkinter_commondialog", "tkCommonDialog", - "tkinter.commondialog"), - MovedModule("tkinter_tkfiledialog", "tkFileDialog", "tkinter.filedialog"), - MovedModule("tkinter_font", "tkFont", "tkinter.font"), - MovedModule("tkinter_messagebox", "tkMessageBox", "tkinter.messagebox"), - MovedModule("tkinter_tksimpledialog", "tkSimpleDialog", - "tkinter.simpledialog"), - MovedModule("urllib_robotparser", "robotparser", "urllib.robotparser"), - MovedModule("winreg", "_winreg"), -] -for attr in _moved_attributes: - setattr(_MovedItems, attr.name, attr) -del attr - -moves = sys.modules["django.utils.six.moves"] = _MovedItems("moves") - - -def add_move(move): - """Add an item to six.moves.""" - setattr(_MovedItems, move.name, move) - - -def remove_move(name): - """Remove item from six.moves.""" - try: - delattr(_MovedItems, name) - except AttributeError: - try: - del moves.__dict__[name] - except KeyError: - raise AttributeError("no such move, %r" % (name,)) - - -if PY3: - _meth_func = "__func__" - _meth_self = "__self__" - - _func_code = "__code__" - _func_defaults = "__defaults__" - - _iterkeys = "keys" - _itervalues = "values" - _iteritems = "items" -else: - _meth_func = "im_func" - _meth_self = "im_self" - - _func_code = "func_code" - _func_defaults = "func_defaults" - - _iterkeys = "iterkeys" - _itervalues = "itervalues" - _iteritems = "iteritems" - - -try: - advance_iterator = next -except NameError: - def advance_iterator(it): - return it.next() -next = advance_iterator - - -if PY3: - def get_unbound_function(unbound): - return unbound - - Iterator = object - - def callable(obj): - return any("__call__" in klass.__dict__ for klass in type(obj).__mro__) -else: - def get_unbound_function(unbound): - return unbound.im_func - - class Iterator(object): - - def next(self): - return type(self).__next__(self) - - callable = callable -_add_doc(get_unbound_function, - """Get the function out of a possibly unbound function""") - - -get_method_function = operator.attrgetter(_meth_func) -get_method_self = operator.attrgetter(_meth_self) -get_function_code = operator.attrgetter(_func_code) -get_function_defaults = operator.attrgetter(_func_defaults) - - -def iterkeys(d): - """Return an iterator over the keys of a dictionary.""" - return iter(getattr(d, _iterkeys)()) - -def itervalues(d): - """Return an iterator over the values of a dictionary.""" - return iter(getattr(d, _itervalues)()) - -def iteritems(d): - """Return an iterator over the (key, value) pairs of a dictionary.""" - return iter(getattr(d, _iteritems)()) - - -if PY3: - def b(s): - return s.encode("latin-1") - def u(s): - return s - if sys.version_info[1] <= 1: - def int2byte(i): - return bytes((i,)) - else: - # This is about 2x faster than the implementation above on 3.2+ - int2byte = operator.methodcaller("to_bytes", 1, "big") - import io - StringIO = io.StringIO - BytesIO = io.BytesIO -else: - def b(s): - return s - def u(s): - return unicode(s, "unicode_escape") - int2byte = chr - import StringIO - StringIO = BytesIO = StringIO.StringIO -_add_doc(b, """Byte literal""") -_add_doc(u, """Text literal""") - - -if PY3: - import builtins - exec_ = getattr(builtins, "exec") - - - def reraise(tp, value, tb=None): - if value.__traceback__ is not tb: - raise value.with_traceback(tb) - raise value - - - print_ = getattr(builtins, "print") - del builtins - -else: - def exec_(code, globs=None, locs=None): - """Execute code in a namespace.""" - if globs is None: - frame = sys._getframe(1) - globs = frame.f_globals - if locs is None: - locs = frame.f_locals - del frame - elif locs is None: - locs = globs - exec("""exec code in globs, locs""") - - - exec_("""def reraise(tp, value, tb=None): - raise tp, value, tb -""") - - - def print_(*args, **kwargs): - """The new-style print function.""" - fp = kwargs.pop("file", sys.stdout) - if fp is None: - return - def write(data): - if not isinstance(data, basestring): - data = str(data) - fp.write(data) - want_unicode = False - sep = kwargs.pop("sep", None) - if sep is not None: - if isinstance(sep, unicode): - want_unicode = True - elif not isinstance(sep, str): - raise TypeError("sep must be None or a string") - end = kwargs.pop("end", None) - if end is not None: - if isinstance(end, unicode): - want_unicode = True - elif not isinstance(end, str): - raise TypeError("end must be None or a string") - if kwargs: - raise TypeError("invalid keyword arguments to print()") - if not want_unicode: - for arg in args: - if isinstance(arg, unicode): - want_unicode = True - break - if want_unicode: - newline = unicode("\n") - space = unicode(" ") - else: - newline = "\n" - space = " " - if sep is None: - sep = space - if end is None: - end = newline - for i, arg in enumerate(args): - if i: - write(sep) - write(arg) - write(end) - -_add_doc(reraise, """Reraise an exception.""") - - -def with_metaclass(meta, base=object): - """Create a base class with a metaclass.""" - return meta("NewBase", (base,), {}) - - -### Additional customizations for Django ### - -if PY3: - _iterlists = "lists" - _assertRaisesRegex = "assertRaisesRegex" -else: - _iterlists = "iterlists" - _assertRaisesRegex = "assertRaisesRegexp" - - -def iterlists(d): - """Return an iterator over the values of a MultiValueDict.""" - return getattr(d, _iterlists)() - - -def assertRaisesRegex(self, *args, **kwargs): - return getattr(self, _assertRaisesRegex)(*args, **kwargs) - - -add_move(MovedModule("_dummy_thread", "dummy_thread")) -add_move(MovedModule("_thread", "thread")) diff --git a/tox.ini b/tox.ini index aa97fd350..6ec400ddf 100644 --- a/tox.ini +++ b/tox.ini @@ -91,6 +91,7 @@ deps = django==1.3.5 django-oauth-plus==2.0 oauth2==1.5.211 django-oauth2-provider==0.2.3 + six [testenv:py2.6-django1.3] basepython = python2.6 @@ -100,3 +101,4 @@ deps = django==1.3.5 django-oauth-plus==2.0 oauth2==1.5.211 django-oauth2-provider==0.2.3 + six From f631f55f8ebdf3d4e478aa5ca435ad36e86bee0f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 21 Aug 2013 21:35:17 +0100 Subject: [PATCH 15/63] Tweak comment --- rest_framework/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 178a697f3..66be96a62 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -10,7 +10,7 @@ import django from django.core.exceptions import ImproperlyConfigured from django.conf import settings -# Try to import six from Django, fallback to included `six`. +# Try to import six from Django, fallback to external `six` package. try: from django.utils import six except ImportError: From bf07b8e616bd92e4ae3c2c09b198181d7075e6bd Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 29 Aug 2013 08:53:19 +0100 Subject: [PATCH 16/63] Better docs for customizing dynamic routes. Refs #908 --- docs/api-guide/routers.md | 81 +++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index f083b3d45..730fa876a 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -123,28 +123,87 @@ The arguments to the `Route` named tuple are: **initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. Note that the `suffix` argument is reserved for identifying the viewset type, used when generating the view name and breadcrumb links. +## Customizing dynamic routes + +You can also customize how the `@list_route` and `@detail_route` decorators are routed. +To route either or both of these decorators, include a `DynamicListRoute` and/or `DynamicDetailRoute` named tuple in the `.routes` list. + +The arguments to `DynamicListRoute` and `DynamicDetailRoute` are: + +**url**: A string representing the URL to be routed. May include the same format strings as `Route`, and additionally accepts the `{methodname}` and `{methodnamehyphen}` format strings. + +**name**: The name of the URL as used in `reverse` calls. May include the following format strings: `{basename}`, `{methodname}` and `{methodnamehyphen}`. + +**initkwargs**: A dictionary of any additional arguments that should be passed when instantiating the view. + ## Example The following example will only route to the `list` and `retrieve` actions, and does not use the trailing slash convention. - from rest_framework.routers import Route, SimpleRouter + from rest_framework.routers import Route, DynamicDetailRoute, SimpleRouter - class ReadOnlyRouter(SimpleRouter): + class CustomReadOnlyRouter(SimpleRouter): """ A router for read-only APIs, which doesn't use trailing slashes. """ routes = [ - Route(url=r'^{prefix}$', - mapping={'get': 'list'}, - name='{basename}-list', - initkwargs={'suffix': 'List'}), - Route(url=r'^{prefix}/{lookup}$', - mapping={'get': 'retrieve'}, - name='{basename}-detail', - initkwargs={'suffix': 'Detail'}) + Route( + url=r'^{prefix}$', + mapping={'get': 'list'}, + name='{basename}-list', + initkwargs={'suffix': 'List'} + ), + Route( + url=r'^{prefix}/{lookup}$', + mapping={'get': 'retrieve'}, + name='{basename}-detail', + initkwargs={'suffix': 'Detail'} + ), + DynamicDetailRoute( + url=r'^{prefix}/{lookup}/{methodnamehyphen}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ) ] -The `SimpleRouter` class provides another example of setting the `.routes` attribute. +Let's take a look at the routes our `CustomReadOnlyRouter` would generate for a simple viewset. + +`views.py`: + + class UserViewSet(viewsets.ReadOnlyModelViewSet): + """ + A viewset that provides the standard actions + """ + queryset = User.objects.all() + serializer_class = UserSerializer + lookup_field = 'username' + + @detail_route() + def group_names(self, request): + """ + Returns a list of all the group names that the given + user belongs to. + """ + user = self.get_object() + groups = user.groups.all() + return Response([group.name for group in groups]) + +`urls.py`: + + router = CustomReadOnlyRouter() + router.register('users', UserViewSet) + urlpatterns = router.urls + +The following mappings would be generated... + + + + + + +
URLHTTP MethodActionURL Name
/usersGETlistuser-list
/users/{username}GETretrieveuser-detail
/users/{username}/group-namesGETgroup_namesuser-group-names
+ +For another example of setting the `.routes` attribute, see the source code for the `SimpleRouter` class. ## Advanced custom routers From e441f85109e64345a12e65062fc0e51c5787e67f Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Sep 2013 10:30:04 +0100 Subject: [PATCH 17/63] Drop 1.3 support --- .travis.yml | 12 +- rest_framework/authentication.py | 2 +- rest_framework/compat.py | 381 +----------------- rest_framework/fields.py | 4 +- rest_framework/routers.py | 2 +- rest_framework/runtests/settings.py | 5 +- rest_framework/runtests/urls.py | 2 +- rest_framework/serializers.py | 6 +- .../templates/rest_framework/base.html | 1 + .../templates/rest_framework/login_base.html | 1 + rest_framework/templatetags/rest_framework.py | 87 +--- rest_framework/tests/test_authentication.py | 2 +- rest_framework/tests/test_breadcrumbs.py | 2 +- rest_framework/tests/test_filters.py | 3 +- rest_framework/tests/test_htmlrenderer.py | 2 +- .../tests/test_hyperlinkedserializers.py | 4 +- .../tests/test_relations_hyperlink.py | 2 +- rest_framework/tests/test_renderers.py | 3 +- rest_framework/tests/test_request.py | 2 +- rest_framework/tests/test_response.py | 2 +- rest_framework/tests/test_reverse.py | 2 +- rest_framework/tests/test_routers.py | 2 +- rest_framework/tests/test_testing.py | 2 +- rest_framework/tests/test_urlpatterns.py | 2 +- rest_framework/urlpatterns.py | 2 +- rest_framework/urls.py | 2 +- rest_framework/utils/encoders.py | 3 +- tox.ini | 22 +- 28 files changed, 56 insertions(+), 506 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7ebe715a0..456f8e9c0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,18 +10,15 @@ env: - DJANGO="https://www.djangoproject.com/download/1.6a1/tarball/" - DJANGO="django==1.5.1 --use-mirrors" - DJANGO="django==1.4.5 --use-mirrors" - - DJANGO="django==1.3.7 --use-mirrors" install: - pip install $DJANGO - - pip install defusedxml==0.3 + - pip install defusedxml==0.3 --use-mirrors + - pip install django-filter==0.6 --use-mirrors - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.0 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4 --use-mirrors; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-guardian==1.1.1 --use-mirrors; fi" - - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install django-filter==0.5.4 --use-mirrors; fi" - - "if [[ ${DJANGO::11} == 'django==1.3' ]]; then pip install six --use-mirrors; fi" - - "if [[ ${DJANGO::11} != 'django==1.3' ]]; then pip install django-filter==0.6 --use-mirrors; fi" - export PYTHONPATH=. script: @@ -31,10 +28,5 @@ matrix: exclude: - python: "3.2" env: DJANGO="django==1.4.5 --use-mirrors" - - python: "3.2" - env: DJANGO="django==1.3.7 --use-mirrors" - python: "3.3" env: DJANGO="django==1.4.5 --use-mirrors" - - python: "3.3" - env: DJANGO="django==1.3.7 --use-mirrors" - diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index cf001a24d..db5cce40d 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -6,8 +6,8 @@ import base64 from django.contrib.auth import authenticate from django.core.exceptions import ImproperlyConfigured +from django.middleware.csrf import CsrfViewMiddleware from rest_framework import exceptions, HTTP_HEADER_ENCODING -from rest_framework.compat import CsrfViewMiddleware from rest_framework.compat import oauth, oauth_provider, oauth_provider_store from rest_framework.compat import oauth2_provider, provider_now from rest_framework.authtoken.models import Token diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 1238f043a..f048b10ad 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -5,25 +5,19 @@ versions of django/python, and compatibility wrappers around optional packages. # flake8: noqa from __future__ import unicode_literals - import django from django.core.exceptions import ImproperlyConfigured from django.conf import settings + # Try to import six from Django, fallback to external `six` package. try: from django.utils import six except ImportError: import six -# location of patterns, url, include changes in 1.4 onwards -try: - from django.conf.urls import patterns, url, include -except ImportError: - from django.conf.urls.defaults import patterns, url, include - -# Handle django.utils.encoding rename: -# smart_unicode -> smart_text +# Handle django.utils.encoding rename in 1.5 onwards. +# smart_unicode -> smart_text # force_unicode -> force_text try: from django.utils.encoding import smart_text @@ -41,13 +35,15 @@ try: except ImportError: from django.http import HttpResponse as HttpResponseBase + # django-filter is optional try: import django_filters except ImportError: django_filters = None -# guardian is optional + +# django-guardian is optional try: import guardian except ImportError: @@ -80,14 +76,6 @@ except ImportError: Image = None -def get_concrete_model(model_cls): - try: - return model_cls._meta.concrete_model - except AttributeError: - # 1.3 does not include concrete model - return model_cls - - # Django 1.5 add support for custom auth user model if django.VERSION >= (1, 5): AUTH_USER_MODEL = settings.AUTH_USER_MODEL @@ -95,46 +83,13 @@ else: AUTH_USER_MODEL = 'auth.User' +# View._allowed_methods only present from 1.5 onwards if django.VERSION >= (1, 5): from django.views.generic import View else: - from django.views.generic import View as _View - from django.utils.decorators import classonlymethod - from django.utils.functional import update_wrapper + from django.views.generic import View as DjangoView - class View(_View): - # 1.3 does not include head method in base View class - # See: https://code.djangoproject.com/ticket/15668 - @classonlymethod - def as_view(cls, **initkwargs): - """ - Main entry point for a request-response process. - """ - # sanitize keyword arguments - for key in initkwargs: - if key in cls.http_method_names: - raise TypeError("You tried to pass in the %s method name as a " - "keyword argument to %s(). Don't do that." - % (key, cls.__name__)) - if not hasattr(cls, key): - raise TypeError("%s() received an invalid keyword %r" % ( - cls.__name__, key)) - - def view(request, *args, **kwargs): - self = cls(**initkwargs) - if hasattr(self, 'get') and not hasattr(self, 'head'): - self.head = self.get - return self.dispatch(request, *args, **kwargs) - - # take name and docstring from class - update_wrapper(view, cls, updated=()) - - # and possible attributes set by decorators - # like csrf_exempt from dispatch - update_wrapper(view, cls.dispatch, assigned=()) - return view - - # _allowed_methods only present from 1.5 onwards + class View(DjangoView): def _allowed_methods(self): return [m.upper() for m in self.http_method_names if hasattr(self, m)] @@ -144,316 +99,16 @@ if 'patch' not in View.http_method_names: View.http_method_names = View.http_method_names + ['patch'] -# PUT, DELETE do not require CSRF until 1.4. They should. Make it better. -if django.VERSION >= (1, 4): - from django.middleware.csrf import CsrfViewMiddleware -else: - import hashlib - import re - import random - import logging - - from django.conf import settings - from django.core.urlresolvers import get_callable - - try: - from logging import NullHandler - except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - - logger = logging.getLogger('django.request') - - if not logger.handlers: - logger.addHandler(NullHandler()) - - def same_origin(url1, url2): - """ - Checks if two URLs are 'same-origin' - """ - p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2) - return p1[0:2] == p2[0:2] - - def constant_time_compare(val1, val2): - """ - Returns True if the two strings are equal, False otherwise. - - The time taken is independent of the number of characters that match. - """ - if len(val1) != len(val2): - return False - result = 0 - for x, y in zip(val1, val2): - result |= ord(x) ^ ord(y) - return result == 0 - - # Use the system (hardware-based) random number generator if it exists. - if hasattr(random, 'SystemRandom'): - randrange = random.SystemRandom().randrange - else: - randrange = random.randrange - - _MAX_CSRF_KEY = 18446744073709551616 # 2 << 63 - - REASON_NO_REFERER = "Referer checking failed - no Referer." - REASON_BAD_REFERER = "Referer checking failed - %s does not match %s." - REASON_NO_CSRF_COOKIE = "CSRF cookie not set." - REASON_BAD_TOKEN = "CSRF token missing or incorrect." - - def _get_failure_view(): - """ - Returns the view to be used for CSRF rejections - """ - return get_callable(settings.CSRF_FAILURE_VIEW) - - def _get_new_csrf_key(): - return hashlib.md5("%s%s" % (randrange(0, _MAX_CSRF_KEY), settings.SECRET_KEY)).hexdigest() - - def get_token(request): - """ - Returns the the CSRF token required for a POST form. The token is an - alphanumeric value. - - A side effect of calling this function is to make the the csrf_protect - decorator and the CsrfViewMiddleware add a CSRF cookie and a 'Vary: Cookie' - header to the outgoing response. For this reason, you may need to use this - function lazily, as is done by the csrf context processor. - """ - request.META["CSRF_COOKIE_USED"] = True - return request.META.get("CSRF_COOKIE", None) - - def _sanitize_token(token): - # Allow only alphanum, and ensure we return a 'str' for the sake of the post - # processing middleware. - token = re.sub('[^a-zA-Z0-9]', '', str(token.decode('ascii', 'ignore'))) - if token == "": - # In case the cookie has been truncated to nothing at some point. - return _get_new_csrf_key() - else: - return token - - class CsrfViewMiddleware(object): - """ - Middleware that requires a present and correct csrfmiddlewaretoken - for POST requests that have a CSRF cookie, and sets an outgoing - CSRF cookie. - - This middleware should be used in conjunction with the csrf_token template - tag. - """ - # The _accept and _reject methods currently only exist for the sake of the - # requires_csrf_token decorator. - def _accept(self, request): - # Avoid checking the request twice by adding a custom attribute to - # request. This will be relevant when both decorator and middleware - # are used. - request.csrf_processing_done = True - return None - - def _reject(self, request, reason): - return _get_failure_view()(request, reason=reason) - - def process_view(self, request, callback, callback_args, callback_kwargs): - - if getattr(request, 'csrf_processing_done', False): - return None - - try: - csrf_token = _sanitize_token(request.COOKIES[settings.CSRF_COOKIE_NAME]) - # Use same token next time - request.META['CSRF_COOKIE'] = csrf_token - except KeyError: - csrf_token = None - # Generate token and store it in the request, so it's available to the view. - request.META["CSRF_COOKIE"] = _get_new_csrf_key() - - # Wait until request.META["CSRF_COOKIE"] has been manipulated before - # bailing out, so that get_token still works - if getattr(callback, 'csrf_exempt', False): - return None - - # Assume that anything not defined as 'safe' by RC2616 needs protection. - if request.method not in ('GET', 'HEAD', 'OPTIONS', 'TRACE'): - if getattr(request, '_dont_enforce_csrf_checks', False): - # Mechanism to turn off CSRF checks for test suite. It comes after - # the creation of CSRF cookies, so that everything else continues to - # work exactly the same (e.g. cookies are sent etc), but before the - # any branches that call reject() - return self._accept(request) - - if request.is_secure(): - # Suppose user visits http://example.com/ - # An active network attacker,(man-in-the-middle, MITM) sends a - # POST form which targets https://example.com/detonate-bomb/ and - # submits it via javascript. - # - # The attacker will need to provide a CSRF cookie and token, but - # that is no problem for a MITM and the session independent - # nonce we are using. So the MITM can circumvent the CSRF - # protection. This is true for any HTTP connection, but anyone - # using HTTPS expects better! For this reason, for - # https://example.com/ we need additional protection that treats - # http://example.com/ as completely untrusted. Under HTTPS, - # Barth et al. found that the Referer header is missing for - # same-domain requests in only about 0.2% of cases or less, so - # we can use strict Referer checking. - referer = request.META.get('HTTP_REFERER') - if referer is None: - logger.warning('Forbidden (%s): %s' % (REASON_NO_REFERER, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_NO_REFERER) - - # Note that request.get_host() includes the port - good_referer = 'https://%s/' % request.get_host() - if not same_origin(referer, good_referer): - reason = REASON_BAD_REFERER % (referer, good_referer) - logger.warning('Forbidden (%s): %s' % (reason, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, reason) - - if csrf_token is None: - # No CSRF cookie. For POST requests, we insist on a CSRF cookie, - # and in this way we can avoid all CSRF attacks, including login - # CSRF. - logger.warning('Forbidden (%s): %s' % (REASON_NO_CSRF_COOKIE, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_NO_CSRF_COOKIE) - - # check non-cookie token for match - request_csrf_token = "" - if request.method == "POST": - request_csrf_token = request.POST.get('csrfmiddlewaretoken', '') - - if request_csrf_token == "": - # Fall back to X-CSRFToken, to make things easier for AJAX, - # and possible for PUT/DELETE - request_csrf_token = request.META.get('HTTP_X_CSRFTOKEN', '') - - if not constant_time_compare(request_csrf_token, csrf_token): - logger.warning('Forbidden (%s): %s' % (REASON_BAD_TOKEN, request.path), - extra={ - 'status_code': 403, - 'request': request, - } - ) - return self._reject(request, REASON_BAD_TOKEN) - - return self._accept(request) - -# timezone support is new in Django 1.4 -try: - from django.utils import timezone -except ImportError: - timezone = None - -# dateparse is ALSO new in Django 1.4 -try: - from django.utils.dateparse import parse_date, parse_datetime, parse_time -except ImportError: - import datetime - import re - - date_re = re.compile( - r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})$' - ) - - datetime_re = re.compile( - r'(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})' - r'[T ](?P\d{1,2}):(?P\d{1,2})' - r'(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?' - r'(?PZ|[+-]\d{1,2}:\d{1,2})?$' - ) - - time_re = re.compile( - r'(?P\d{1,2}):(?P\d{1,2})' - r'(?::(?P\d{1,2})(?:\.(?P\d{1,6})\d{0,6})?)?' - ) - - def parse_date(value): - match = date_re.match(value) - if match: - kw = dict((k, int(v)) for k, v in match.groupdict().iteritems()) - return datetime.date(**kw) - - def parse_time(value): - match = time_re.match(value) - if match: - kw = match.groupdict() - if kw['microsecond']: - kw['microsecond'] = kw['microsecond'].ljust(6, '0') - kw = dict((k, int(v)) for k, v in kw.iteritems() if v is not None) - return datetime.time(**kw) - - def parse_datetime(value): - """Parse datetime, but w/o the timezone awareness in 1.4""" - match = datetime_re.match(value) - if match: - kw = match.groupdict() - if kw['microsecond']: - kw['microsecond'] = kw['microsecond'].ljust(6, '0') - kw = dict((k, int(v)) for k, v in kw.iteritems() if v is not None) - return datetime.datetime(**kw) - - -# smart_urlquote is new on Django 1.4 -try: - from django.utils.html import smart_urlquote -except ImportError: - import re - from django.utils.encoding import smart_str - try: - from urllib.parse import quote, urlsplit, urlunsplit - except ImportError: # Python 2 - from urllib import quote - from urlparse import urlsplit, urlunsplit - - unquoted_percents_re = re.compile(r'%(?![0-9A-Fa-f]{2})') - - def smart_urlquote(url): - "Quotes a URL if it isn't already quoted." - # Handle IDN before quoting. - scheme, netloc, path, query, fragment = urlsplit(url) - try: - netloc = netloc.encode('idna').decode('ascii') # IDN -> ACE - except UnicodeError: # invalid domain part - pass - else: - url = urlunsplit((scheme, netloc, path, query, fragment)) - - # An URL is considered unquoted if it contains no % characters or - # contains a % not followed by two hexadecimal digits. See #9655. - if '%' not in url or unquoted_percents_re.search(url): - # See http://bugs.python.org/issue2637 - url = quote(smart_str(url), safe=b'!*\'();:@&=+$,/?#[]~') - - return force_text(url) - - -# RequestFactory only provide `generic` from 1.5 onwards - +# RequestFactory only provides `generic` from 1.5 onwards from django.test.client import RequestFactory as DjangoRequestFactory from django.test.client import FakePayload try: # In 1.5 the test client uses force_bytes from django.utils.encoding import force_bytes_or_smart_bytes except ImportError: - # In 1.3 and 1.4 the test client just uses smart_str + # In 1.4 the test client just uses smart_str from django.utils.encoding import smart_str as force_bytes_or_smart_bytes - class RequestFactory(DjangoRequestFactory): def generic(self, method, path, data='', content_type='application/octet-stream', **extra): @@ -478,6 +133,7 @@ class RequestFactory(DjangoRequestFactory): r.update(extra) return self.request(**r) + # Markdown is optional try: import markdown @@ -492,7 +148,6 @@ try: safe_mode = False md = markdown.Markdown(extensions=extensions, safe_mode=safe_mode) return md.convert(text) - except ImportError: apply_markdown = None @@ -510,14 +165,16 @@ try: except ImportError: etree = None -# OAuth is optional + +# OAuth2 is optional try: # Note: The `oauth2` package actually provides oauth1.0a support. Urg. import oauth2 as oauth except ImportError: oauth = None -# OAuth is optional + +# OAuthProvider is optional try: import oauth_provider from oauth_provider.store import store as oauth_provider_store @@ -525,6 +182,7 @@ except (ImportError, ImproperlyConfigured): oauth_provider = None oauth_provider_store = None + # OAuth 2 support is optional try: import provider.oauth2 as oauth2_provider @@ -542,8 +200,6 @@ try: # Any other supported version does use timezone aware datetimes from django.utils.timezone import now as provider_now except ImportError: - import traceback - traceback.print_exc() oauth2_provider = None oauth2_provider_models = None oauth2_provider_forms = None @@ -551,7 +207,8 @@ except ImportError: oauth2_constants = None provider_now = None -# Handle lazy strings + +# Handle lazy strings across Py2/Py3 from django.utils.functional import Promise if six.PY3: diff --git a/rest_framework/fields.py b/rest_framework/fields.py index b3a9b0dfe..f340510d3 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -18,12 +18,14 @@ from django.conf import settings from django.db.models.fields import BLANK_CHOICE_DASH from django.http import QueryDict from django.forms import widgets +from django.utils import timezone from django.utils.encoding import is_protected_type from django.utils.translation import ugettext_lazy as _ from django.utils.datastructures import SortedDict +from django.utils.dateparse import parse_date, parse_datetime, parse_time from rest_framework import ISO_8601 from rest_framework.compat import ( - timezone, parse_date, parse_datetime, parse_time, BytesIO, six, smart_text, + BytesIO, six, smart_text, force_text, is_non_str_iterable ) from rest_framework.settings import api_settings diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 1c7a81585..790299cc3 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -17,9 +17,9 @@ from __future__ import unicode_literals import itertools from collections import namedtuple +from django.conf.urls import patterns, url from django.core.exceptions import ImproperlyConfigured from rest_framework import views -from rest_framework.compat import patterns, url from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.urlpatterns import format_suffix_patterns diff --git a/rest_framework/runtests/settings.py b/rest_framework/runtests/settings.py index be7216580..12aa73e7b 100644 --- a/rest_framework/runtests/settings.py +++ b/rest_framework/runtests/settings.py @@ -93,10 +93,7 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', - # Uncomment the next line to enable the admin: - # 'django.contrib.admin', - # Uncomment the next line to enable admin documentation: - # 'django.contrib.admindocs', + 'django.contrib.staticfiles', 'rest_framework', 'rest_framework.authtoken', 'rest_framework.tests', diff --git a/rest_framework/runtests/urls.py b/rest_framework/runtests/urls.py index ed5baeae6..dff710115 100644 --- a/rest_framework/runtests/urls.py +++ b/rest_framework/runtests/urls.py @@ -1,7 +1,7 @@ """ Blank URLConf just to keep runtests.py happy. """ -from rest_framework.compat import patterns +from django.conf.urls import patterns urlpatterns = patterns('', ) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index f17757625..9e3881a2c 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -18,7 +18,7 @@ from decimal import Decimal from django.db import models from django.forms import widgets from django.utils.datastructures import SortedDict -from rest_framework.compat import get_concrete_model, six +from rest_framework.compat import six # Note: We do the following so that users of the framework can use this style: # @@ -575,7 +575,7 @@ class ModelSerializer(Serializer): cls = self.opts.model assert cls is not None, \ "Serializer class '%s' is missing 'model' Meta option" % self.__class__.__name__ - opts = get_concrete_model(cls)._meta + opts = cls._meta.concrete_model._meta ret = SortedDict() nested = bool(self.opts.depth) @@ -784,7 +784,7 @@ class ModelSerializer(Serializer): Return a list of field names to exclude from model validation. """ cls = self.opts.model - opts = get_concrete_model(cls)._meta + opts = cls._meta.concrete_model._meta exclusions = [field.name for field in opts.fields + opts.many_to_many] for field_name, field in self.fields.items(): diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 2776d5500..47377d51f 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -1,4 +1,5 @@ {% load url from future %} +{% load staticfiles %} {% load rest_framework %} diff --git a/rest_framework/templates/rest_framework/login_base.html b/rest_framework/templates/rest_framework/login_base.html index be9a0072a..be83c2f53 100644 --- a/rest_framework/templates/rest_framework/login_base.html +++ b/rest_framework/templates/rest_framework/login_base.html @@ -1,4 +1,5 @@ {% load url from future %} +{% load staticfiles %} {% load rest_framework %} diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index e9c1cdd54..55f361490 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,97 +2,14 @@ from __future__ import unicode_literals, absolute_import from django import template from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict -from django.utils.html import escape +from django.utils.html import escape, smart_urlquote from django.utils.safestring import SafeData, mark_safe -from rest_framework.compat import urlparse, force_text, six, smart_urlquote +from rest_framework.compat import urlparse, force_text, six import re, string register = template.Library() -# Note we don't use 'load staticfiles', because we need a 1.3 compatible -# version, so instead we include the `static` template tag ourselves. - -# When 1.3 becomes unsupported by REST framework, we can instead start to -# use the {% load staticfiles %} tag, remove the following code, -# and add a dependency that `django.contrib.staticfiles` must be installed. - -# Note: We can't put this into the `compat` module because the compat import -# from rest_framework.compat import ... -# conflicts with this rest_framework template tag module. - -try: # Django 1.5+ - from django.contrib.staticfiles.templatetags.staticfiles import StaticFilesNode - - @register.tag('static') - def do_static(parser, token): - return StaticFilesNode.handle_token(parser, token) - -except ImportError: - try: # Django 1.4 - from django.contrib.staticfiles.storage import staticfiles_storage - - @register.simple_tag - def static(path): - """ - A template tag that returns the URL to a file - using staticfiles' storage backend - """ - return staticfiles_storage.url(path) - - except ImportError: # Django 1.3 - from urlparse import urljoin - from django import template - from django.templatetags.static import PrefixNode - - class StaticNode(template.Node): - def __init__(self, varname=None, path=None): - if path is None: - raise template.TemplateSyntaxError( - "Static template nodes must be given a path to return.") - self.path = path - self.varname = varname - - def url(self, context): - path = self.path.resolve(context) - return self.handle_simple(path) - - def render(self, context): - url = self.url(context) - if self.varname is None: - return url - context[self.varname] = url - return '' - - @classmethod - def handle_simple(cls, path): - return urljoin(PrefixNode.handle_simple("STATIC_URL"), path) - - @classmethod - def handle_token(cls, parser, token): - """ - Class method to parse prefix node and return a Node. - """ - bits = token.split_contents() - - if len(bits) < 2: - raise template.TemplateSyntaxError( - "'%s' takes at least one argument (path to file)" % bits[0]) - - path = parser.compile_filter(bits[1]) - - if len(bits) >= 2 and bits[-2] == 'as': - varname = bits[3] - else: - varname = None - - return cls(varname, path) - - @register.tag('static') - def do_static_13(parser, token): - return StaticNode.handle_token(parser, token) - - def replace_query_param(url, key, val): """ Given a URL and a key/val pair, set or replace an item in the query diff --git a/rest_framework/tests/test_authentication.py b/rest_framework/tests/test_authentication.py index a44813b69..e9a817c0f 100644 --- a/rest_framework/tests/test_authentication.py +++ b/rest_framework/tests/test_authentication.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.contrib.auth.models import User from django.http import HttpResponse from django.test import TestCase @@ -18,7 +19,6 @@ from rest_framework.authentication import ( OAuth2Authentication ) from rest_framework.authtoken.models import Token -from rest_framework.compat import patterns, url, include from rest_framework.compat import oauth2_provider, oauth2_provider_models, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient diff --git a/rest_framework/tests/test_breadcrumbs.py b/rest_framework/tests/test_breadcrumbs.py index 41ddf2cea..33740cbb2 100644 --- a/rest_framework/tests/test_breadcrumbs.py +++ b/rest_framework/tests/test_breadcrumbs.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.utils.breadcrumbs import get_breadcrumbs from rest_framework.views import APIView diff --git a/rest_framework/tests/test_filters.py b/rest_framework/tests/test_filters.py index 379db29d8..9697c5eef 100644 --- a/rest_framework/tests/test_filters.py +++ b/rest_framework/tests/test_filters.py @@ -1,12 +1,13 @@ from __future__ import unicode_literals import datetime from decimal import Decimal +from django.conf.urls import patterns, url from django.db import models from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import unittest from rest_framework import generics, serializers, status, filters -from rest_framework.compat import django_filters, patterns, url +from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory from rest_framework.tests.models import BasicModel diff --git a/rest_framework/tests/test_htmlrenderer.py b/rest_framework/tests/test_htmlrenderer.py index 8957a43c7..6c570dfdc 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/rest_framework/tests/test_htmlrenderer.py @@ -1,11 +1,11 @@ from __future__ import unicode_literals from django.core.exceptions import PermissionDenied +from django.conf.urls import patterns, url from django.http import Http404 from django.test import TestCase from django.template import TemplateDoesNotExist, Template import django.template.loader from rest_framework import status -from rest_framework.compat import patterns, url from rest_framework.decorators import api_view, renderer_classes from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response diff --git a/rest_framework/tests/test_hyperlinkedserializers.py b/rest_framework/tests/test_hyperlinkedserializers.py index 61e613d75..ea7f70f2f 100644 --- a/rest_framework/tests/test_hyperlinkedserializers.py +++ b/rest_framework/tests/test_hyperlinkedserializers.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals import json +from django.conf.urls import patterns, url from django.test import TestCase from rest_framework import generics, status, serializers -from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory from rest_framework.tests.models import ( Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, @@ -24,7 +24,7 @@ class BlogPostCommentSerializer(serializers.ModelSerializer): class PhotoSerializer(serializers.Serializer): description = serializers.CharField() - album_url = serializers.HyperlinkedRelatedField(source='album', view_name='album-detail', queryset=Album.objects.all(), lookup_field='title', slug_url_kwarg='title') + album_url = serializers.HyperlinkedRelatedField(source='album', view_name='album-detail', queryset=Album.objects.all(), lookup_field='title') def restore_object(self, attrs, instance=None): return Photo(**attrs) diff --git a/rest_framework/tests/test_relations_hyperlink.py b/rest_framework/tests/test_relations_hyperlink.py index 3c4d39af6..fa6b01aca 100644 --- a/rest_framework/tests/test_relations_hyperlink.py +++ b/rest_framework/tests/test_relations_hyperlink.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase from rest_framework import serializers -from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory from rest_framework.tests.models import ( BlogPost, diff --git a/rest_framework/tests/test_renderers.py b/rest_framework/tests/test_renderers.py index df6f4aa63..9d1dd77e5 100644 --- a/rest_framework/tests/test_renderers.py +++ b/rest_framework/tests/test_renderers.py @@ -2,12 +2,13 @@ from __future__ import unicode_literals from decimal import Decimal +from django.conf.urls import patterns, url, include from django.core.cache import cache from django.test import TestCase from django.utils import unittest from django.utils.translation import ugettext_lazy as _ from rest_framework import status, permissions -from rest_framework.compat import yaml, etree, patterns, url, include, six, StringIO +from rest_framework.compat import yaml, etree, six, StringIO from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ diff --git a/rest_framework/tests/test_request.py b/rest_framework/tests/test_request.py index 969d8024a..d63634258 100644 --- a/rest_framework/tests/test_request.py +++ b/rest_framework/tests/test_request.py @@ -2,13 +2,13 @@ Tests for content parsing, and form-overloaded content parsing. """ from __future__ import unicode_literals +from django.conf.urls import patterns from django.contrib.auth.models import User from django.contrib.auth import authenticate, login, logout from django.contrib.sessions.middleware import SessionMiddleware from django.test import TestCase from rest_framework import status from rest_framework.authentication import SessionAuthentication -from rest_framework.compat import patterns from rest_framework.parsers import ( BaseParser, FormParser, diff --git a/rest_framework/tests/test_response.py b/rest_framework/tests/test_response.py index eea3c6418..1c4c551cf 100644 --- a/rest_framework/tests/test_response.py +++ b/rest_framework/tests/test_response.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.test import TestCase from rest_framework.tests.models import BasicModel, BasicModelSerializer -from rest_framework.compat import patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import generics diff --git a/rest_framework/tests/test_reverse.py b/rest_framework/tests/test_reverse.py index 690a30b11..320b125d2 100644 --- a/rest_framework/tests/test_reverse.py +++ b/rest_framework/tests/test_reverse.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.reverse import reverse from rest_framework.test import APIRequestFactory diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 3f456fefd..1c34648f4 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals +from django.conf.urls import patterns, url, include from django.db import models from django.test import TestCase from django.core.exceptions import ImproperlyConfigured from rest_framework import serializers, viewsets, permissions -from rest_framework.compat import include, patterns, url from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from rest_framework.routers import SimpleRouter, DefaultRouter diff --git a/rest_framework/tests/test_testing.py b/rest_framework/tests/test_testing.py index 48b8956b5..c08dd4932 100644 --- a/rest_framework/tests/test_testing.py +++ b/rest_framework/tests/test_testing.py @@ -1,9 +1,9 @@ # -- coding: utf-8 -- from __future__ import unicode_literals +from django.conf.urls import patterns, url from django.contrib.auth.models import User from django.test import TestCase -from rest_framework.compat import patterns, url from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.test import APIClient, APIRequestFactory, force_authenticate diff --git a/rest_framework/tests/test_urlpatterns.py b/rest_framework/tests/test_urlpatterns.py index 8132ec4c8..e0060e690 100644 --- a/rest_framework/tests/test_urlpatterns.py +++ b/rest_framework/tests/test_urlpatterns.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals from collections import namedtuple +from django.conf.urls import patterns, url, include from django.core import urlresolvers from django.test import TestCase from rest_framework.test import APIRequestFactory -from rest_framework.compat import patterns, url, include from rest_framework.urlpatterns import format_suffix_patterns diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index d9143bb4c..a62530c74 100644 --- a/rest_framework/urlpatterns.py +++ b/rest_framework/urlpatterns.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals +from django.conf.urls import url, include from django.core.urlresolvers import RegexURLResolver -from rest_framework.compat import url, include from rest_framework.settings import api_settings diff --git a/rest_framework/urls.py b/rest_framework/urls.py index 9c4719f1d..87ec0f0ae 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -13,7 +13,7 @@ your authentication settings include `SessionAuthentication`. ) """ from __future__ import unicode_literals -from rest_framework.compat import patterns, url +from django.conf.urls import patterns, url template_name = {'template_name': 'rest_framework/login.html'} diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index 7efd5417b..13a855509 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -2,9 +2,10 @@ Helper classes for parsers. """ from __future__ import unicode_literals +from django.utils import timezone from django.utils.datastructures import SortedDict from django.utils.functional import Promise -from rest_framework.compat import timezone, force_text +from rest_framework.compat import force_text from rest_framework.serializers import DictWithMetadata, SortedDictWithMetadata import datetime import decimal diff --git a/tox.ini b/tox.ini index 6e3b8e0a8..7bd140e1c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] downloadcache = {toxworkdir}/cache/ -envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4,py2.7-django1.3,py2.6-django1.3 +envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4 [testenv] commands = {envpython} rest_framework/runtests/runtests.py @@ -88,23 +88,3 @@ deps = django==1.4.3 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 - -[testenv:py2.7-django1.3] -basepython = python2.7 -deps = django==1.3.5 - django-filter==0.5.4 - defusedxml==0.3 - django-oauth-plus==2.0 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.1.1 - -[testenv:py2.6-django1.3] -basepython = python2.6 -deps = django==1.3.5 - django-filter==0.5.4 - defusedxml==0.3 - django-oauth-plus==2.0 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.1.1 From 1bd8fe415296739521fd2e75c0b604cbf3dd3a83 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Sep 2013 10:36:08 +0100 Subject: [PATCH 18/63] Whitespace fix --- rest_framework/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/compat.py b/rest_framework/compat.py index f048b10ad..efd2581fe 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -17,7 +17,7 @@ except ImportError: import six # Handle django.utils.encoding rename in 1.5 onwards. -# smart_unicode -> smart_text +# smart_unicode -> smart_text # force_unicode -> force_text try: from django.utils.encoding import smart_text From ab4be47379ba49092063f843fd446919534db776 Mon Sep 17 00:00:00 2001 From: Omer Katz Date: Thu, 3 Oct 2013 17:34:34 +0200 Subject: [PATCH 19/63] Fixed code example. --- docs/api-guide/routers.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 730fa876a..f20a695b6 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -42,12 +42,15 @@ The example above would generate the following URL patterns: Any methods on the viewset decorated with `@detail_route` or `@list_route` will also be routed. For example, given a method like this on the `UserViewSet` class: - from myapp.permissions import IsAdminOrIsSelf + from myapp.permissions import IsAdminOrIsSelf from rest_framework.decorators import detail_route - - @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) - def set_password(self, request, pk=None): + + class UserViewSet(ModelViewSet): ... + + @detail_route(methods=['post'], permission_classes=[IsAdminOrIsSelf]) + def set_password(self, request, pk=None): + ... The following URL pattern would additionally be generated: From 9ab0759e38492d9950d66299ed5c58155d39e696 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:21:33 -0800 Subject: [PATCH 20/63] Add tests to pass for get_ident method in BaseThrottle class. --- rest_framework/tests/test_throttling.py | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/rest_framework/tests/test_throttling.py b/rest_framework/tests/test_throttling.py index 41bff6926..031276968 100644 --- a/rest_framework/tests/test_throttling.py +++ b/rest_framework/tests/test_throttling.py @@ -5,6 +5,7 @@ from __future__ import unicode_literals from django.test import TestCase from django.contrib.auth.models import User from django.core.cache import cache +from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory from rest_framework.views import APIView from rest_framework.throttling import BaseThrottle, UserRateThrottle, ScopedRateThrottle @@ -275,3 +276,67 @@ class ScopedRateThrottleTests(TestCase): self.increment_timer() response = self.unscoped_view(request) self.assertEqual(200, response.status_code) + +class XffTestingBase(TestCase): + def setUp(self): + + class Throttle(ScopedRateThrottle): + THROTTLE_RATES = {'test_limit': '1/day'} + TIMER_SECONDS = 0 + timer = lambda self: self.TIMER_SECONDS + + class View(APIView): + throttle_classes = (Throttle,) + throttle_scope = 'test_limit' + + def get(self, request): + return Response('test_limit') + + cache.clear() + self.throttle = Throttle() + self.view = View.as_view() + self.request = APIRequestFactory().get('/some_uri') + self.request.META['REMOTE_ADDR'] = '3.3.3.3' + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 1.1.1.1, 2.2.2.2' + + def config_proxy(self, num_proxies): + setattr(api_settings, 'NUM_PROXIES', num_proxies) + + +class IdWithXffBasicTests(XffTestingBase): + def test_accepts_request_under_limit(self): + self.config_proxy(0) + self.assertEqual(200, self.view(self.request).status_code) + + def test_denies_request_over_limit(self): + self.config_proxy(0) + self.view(self.request) + self.assertEqual(429, self.view(self.request).status_code) + + +class XffSpoofingTests(XffTestingBase): + def test_xff_spoofing_doesnt_change_machine_id_with_one_app_proxy(self): + self.config_proxy(1) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '4.4.4.4, 5.5.5.5, 2.2.2.2' + self.assertEqual(429, self.view(self.request).status_code) + + def test_xff_spoofing_doesnt_change_machine_id_with_two_app_proxies(self): + self.config_proxy(2) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '4.4.4.4, 1.1.1.1, 2.2.2.2' + self.assertEqual(429, self.view(self.request).status_code) + + +class XffUniqueMachinesTest(XffTestingBase): + def test_unique_clients_are_counted_independently_with_one_proxy(self): + self.config_proxy(1) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 1.1.1.1, 7.7.7.7' + self.assertEqual(200, self.view(self.request).status_code) + + def test_unique_clients_are_counted_independently_with_two_proxies(self): + self.config_proxy(2) + self.view(self.request) + self.request.META['HTTP_X_FORWARDED_FOR'] = '0.0.0.0, 7.7.7.7, 2.2.2.2' + self.assertEqual(200, self.view(self.request).status_code) From 89f26c5e040febd27bc9142b0096ca119bb3fa32 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:21:52 -0800 Subject: [PATCH 21/63] Add get_ident method to pass new tests. --- rest_framework/settings.py | 1 + rest_framework/throttling.py | 25 ++++++++++++++++++------- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 8abaf1409..383de72ea 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -63,6 +63,7 @@ DEFAULTS = { 'user': None, 'anon': None, }, + 'NUM_PROXIES': None, # Pagination 'PAGINATE_BY': None, diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index a946d837f..60e46d479 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -18,6 +18,21 @@ class BaseThrottle(object): """ raise NotImplementedError('.allow_request() must be overridden') + def get_ident(self, request): + """ + Identify the machine making the request by parsing HTTP_X_FORWARDED_FOR + if present and number of proxies is > 0. If not use all of + HTTP_X_FORWARDED_FOR if it is available, if not use REMOTE_ADDR. + """ + xff = request.META.get('HTTP_X_FORWARDED_FOR') + remote_addr = request.META.get('REMOTE_ADDR') + num_proxies = api_settings.NUM_PROXIES + + if xff and num_proxies: + return xff.split(',')[-min(num_proxies, len(xff))].strip() + + return xff if xff else remote_addr + def wait(self): """ Optionally, return a recommended number of seconds to wait before @@ -152,13 +167,9 @@ class AnonRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): return None # Only throttle unauthenticated requests. - ident = request.META.get('HTTP_X_FORWARDED_FOR') - if ident is None: - ident = request.META.get('REMOTE_ADDR') - return self.cache_format % { 'scope': self.scope, - 'ident': ident + 'ident': self.get_ident(request) } @@ -176,7 +187,7 @@ class UserRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): ident = request.user.id else: - ident = request.META.get('REMOTE_ADDR', None) + ident = self.get_ident(request) return self.cache_format % { 'scope': self.scope, @@ -224,7 +235,7 @@ class ScopedRateThrottle(SimpleRateThrottle): if request.user.is_authenticated(): ident = request.user.id else: - ident = request.META.get('REMOTE_ADDR', None) + ident = self.get_ident(request) return self.cache_format % { 'scope': self.scope, From 100a933279e3119e2627d744cd7eb472b542f6fe Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:22:08 -0800 Subject: [PATCH 22/63] Add documentation to explain what effect these changes have. --- docs/api-guide/throttling.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index cc4692171..ee57383cd 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -35,11 +35,16 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day' - } + }, + 'NUM_PROXIES': 2, } The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. +By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. + +To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests whenthere are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. + You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. From 196c5952e4f610054e832aef36cb2383b8c129c0 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:24:16 -0800 Subject: [PATCH 23/63] Fix typo --- docs/api-guide/throttling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index ee57383cd..69b15a829 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -43,7 +43,7 @@ The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `mi By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. -To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests whenthere are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. +To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. From 887da7f6c5a9e7b5007f5e4af32a6b93b18c70ea Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:30:33 -0800 Subject: [PATCH 24/63] Add missing tick marks --- docs/api-guide/throttling.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index 69b15a829..34418e84d 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -41,7 +41,7 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. -By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If HTTP_X_FORWARDED_FOR is not present `REMOTE_ADDR` header value will be used. +By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If `HTTP_X_FORWARDED_FOR` is not present `REMOTE_ADDR` header value will be used. To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. From 23db6c98495d7b3c18a3069c6cb770d5cbc18ee1 Mon Sep 17 00:00:00 2001 From: kahnjw Date: Fri, 6 Dec 2013 14:52:39 -0800 Subject: [PATCH 25/63] PEP8 Compliance --- rest_framework/tests/test_throttling.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/tests/test_throttling.py b/rest_framework/tests/test_throttling.py index 031276968..8c5eefe9c 100644 --- a/rest_framework/tests/test_throttling.py +++ b/rest_framework/tests/test_throttling.py @@ -277,6 +277,7 @@ class ScopedRateThrottleTests(TestCase): response = self.unscoped_view(request) self.assertEqual(200, response.status_code) + class XffTestingBase(TestCase): def setUp(self): From 83da4949c099fcf7e7636c98b9052b502e1bf74b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 Dec 2013 00:02:18 +0000 Subject: [PATCH 26/63] Allow NUM_PROXIES=0 and include more docs --- docs/api-guide/settings.md | 6 ++++++ docs/api-guide/throttling.md | 18 ++++++++++++------ rest_framework/throttling.py | 8 ++++++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index 13f96f9a3..d8c878ff8 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -359,5 +359,11 @@ The name of a parameter in the URL conf that may be used to provide a format suf Default: `'format'` +#### NUM_PROXIES + +An integer of 0 or more, that may be used to specify the number of application proxies that the API runs behind. This allows throttling to more accurately identify client IP addresses. If set to `None` then less strict IP matching will be used by the throttle classes. + +Default: `None` + [cite]: http://www.python.org/dev/peps/pep-0020/ [strftime]: http://docs.python.org/2/library/time.html#time.strftime diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index 34418e84d..b2a5bb194 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -35,16 +35,11 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day' - }, - 'NUM_PROXIES': 2, + } } The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. -By default Django REST Framework will try to use the `HTTP_X_FORWARDED_FOR` header to uniquely identify client machines for throttling. If `HTTP_X_FORWARDED_FOR` is not present `REMOTE_ADDR` header value will be used. - -To help Django REST Framework identify unique clients the number of application proxies can be set using `NUM_PROXIES`. This setting will allow the throttle to correctly identify unique requests when there are multiple application side proxies in front of the server. `NUM_PROXIES` should be set to an integer. It is important to understand that if you configure `NUM_PROXIES > 0` all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. - You can also set the throttling policy on a per-view or per-viewset basis, using the `APIView` class based views. @@ -71,6 +66,16 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) +## How clients are identified + +By default the `X-Forwarded-For` HTTP header is used to uniquely identify client machines for throttling. If the `X-Forwarded-For` header is not present, then the value of the `Remote-Addr` header will be used. + +If you need to more strictly identify unique clients, you'll need to configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of 0 or more, and will allow the throttle to identify the client IP as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. + +It is important to understand that if you configure the `NUM_PROXIES` setting, then all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. + +Further context on how the `X-Forwarded-For` header works, and identifier a remote client IP can be [found here][identifing-clients]. + ## Setting up the cache The throttle classes provided by REST framework use Django's cache backend. You should make sure that you've set appropriate [cache settings][cache-setting]. The default value of `LocMemCache` backend should be okay for simple setups. See Django's [cache documentation][cache-docs] for more details. @@ -183,5 +188,6 @@ The following is an example of a rate throttle, that will randomly throttle 1 in [cite]: https://dev.twitter.com/docs/error-codes-responses [permissions]: permissions.md +[identifing-clients]: http://oxpedia.org/wiki/index.php?title=AppSuite:Grizzly#Multiple_Proxies_in_front_of_the_cluster [cache-setting]: https://docs.djangoproject.com/en/dev/ref/settings/#caches [cache-docs]: https://docs.djangoproject.com/en/dev/topics/cache/#setting-up-the-cache diff --git a/rest_framework/throttling.py b/rest_framework/throttling.py index 60e46d479..c40f3065b 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -28,8 +28,12 @@ class BaseThrottle(object): remote_addr = request.META.get('REMOTE_ADDR') num_proxies = api_settings.NUM_PROXIES - if xff and num_proxies: - return xff.split(',')[-min(num_proxies, len(xff))].strip() + if num_proxies is not None: + if num_proxies == 0 or xff is None: + return remote_addr + addrs = xff.split(',') + client_addr = addrs[-min(num_proxies, len(xff))] + return client_addr.strip() return xff if xff else remote_addr From ed931b90ae9e72f963673e6e188b1802a5a65360 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 13 Dec 2013 00:11:59 +0000 Subject: [PATCH 27/63] Further docs tweaks --- docs/api-guide/throttling.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/api-guide/throttling.md b/docs/api-guide/throttling.md index b2a5bb194..536f0ab78 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -68,13 +68,13 @@ Or, if you're using the `@api_view` decorator with function based views. ## How clients are identified -By default the `X-Forwarded-For` HTTP header is used to uniquely identify client machines for throttling. If the `X-Forwarded-For` header is not present, then the value of the `Remote-Addr` header will be used. +The `X-Forwarded-For` and `Remote-Addr` HTTP headers are used to uniquely identify client IP addresses for throttling. If the `X-Forwarded-For` header is present then it will be used, otherwise the value of the `Remote-Addr` header will be used. -If you need to more strictly identify unique clients, you'll need to configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of 0 or more, and will allow the throttle to identify the client IP as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. +If you need to strictly identify unique client IP addresses, you'll need to first configure the number of application proxies that the API runs behind by setting the `NUM_PROXIES` setting. This setting should be an integer of zero or more. If set to non-zero then the client IP will be identified as being the last IP address in the `X-Forwarded-For` header, once any application proxy IP addresses have first been excluded. If set to zero, then the `Remote-Addr` header will always be used as the identifying IP address. It is important to understand that if you configure the `NUM_PROXIES` setting, then all clients behind a unique [NAT'd](http://en.wikipedia.org/wiki/Network_address_translation) gateway will be treated as a single client. -Further context on how the `X-Forwarded-For` header works, and identifier a remote client IP can be [found here][identifing-clients]. +Further context on how the `X-Forwarded-For` header works, and identifing a remote client IP can be [found here][identifing-clients]. ## Setting up the cache From a1d7aa8f712b659f9d8302a2d2a098d2538e6c89 Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Thu, 2 Jan 2014 17:44:47 -0500 Subject: [PATCH 28/63] Allow viewset to specify lookup value regex for routing This patch allows a viewset to define a pattern for its lookup field, which the router will honor. Without this patch, any characters are allowed in the lookup field, and overriding this behavior requires subclassing router and copying and pasting the implementation of get_lookup_regex. It's possible it would be better to remove this functionality from the routers and simply expose a parameter to get_lookup_regex which allows overriding the lookup_regex. That way the viewset config logic could be in the a subclass, which could invoke the super method directly. I'm using this now for PostgreSQL UUID fields using https://github.com/dcramer/django-uuidfield . Without this patch, that field passes the lookup string to the database driver, which raises a DataError to complain about the invalid UUID. It's possible the field ought to signal this error in a different way, which could obviate the need to specify a pattern. --- docs/api-guide/routers.md | 6 ++++++ rest_framework/routers.py | 20 ++++++++++++++------ rest_framework/tests/test_routers.py | 21 +++++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 846ac9f9d..f3beabdd3 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,6 +83,12 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. +With `trailing_slash` set to True, the router will match lookup values containing any characters except slashes and dots. When set to False, dots are allowed. To restrict the lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: + + class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): + lookup_field = 'my_model_id' + lookup_value_regex = '[0-9a-f]{32}' + ## DefaultRouter This router is similar to `SimpleRouter` as above, but additionally includes a default API root view, that returns a response containing hyperlinks to all the list views. It also generates routes for optional `.json` style format suffixes. diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 740d58f0d..8766ecb2f 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -219,13 +219,21 @@ class SimpleRouter(BaseRouter): https://github.com/alanjds/drf-nested-routers """ - if self.trailing_slash: - base_regex = '(?P<{lookup_prefix}{lookup_field}>[^/]+)' - else: - # Don't consume `.json` style suffixes - base_regex = '(?P<{lookup_prefix}{lookup_field}>[^/.]+)' + base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' lookup_field = getattr(viewset, 'lookup_field', 'pk') - return base_regex.format(lookup_field=lookup_field, lookup_prefix=lookup_prefix) + try: + lookup_value = viewset.lookup_value_regex + except AttributeError: + if self.trailing_slash: + lookup_value = '[^/]+' + else: + # Don't consume `.json` style suffixes + lookup_value = '[^/.]+' + return base_regex.format( + lookup_prefix=lookup_prefix, + lookup_field=lookup_field, + lookup_value=lookup_value + ) def get_urls(self): """ diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 1c34648f4..0f6d62c7c 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -121,6 +121,27 @@ class TestCustomLookupFields(TestCase): ) +class TestLookupValueRegex(TestCase): + """ + Ensure the router honors lookup_value_regex when applied + to the viewset. + """ + def setUp(self): + class NoteViewSet(viewsets.ModelViewSet): + queryset = RouterTestModel.objects.all() + lookup_field = 'uuid' + lookup_value_regex = '[0-9a-f]{32}' + + self.router = SimpleRouter() + self.router.register(r'notes', NoteViewSet) + self.urls = self.router.urls + + def test_urls_limited_by_lookup_value_regex(self): + expected = ['^notes/$', '^notes/(?P[0-9a-f]{32})/$'] + for idx in range(len(expected)): + self.assertEqual(expected[idx], self.urls[idx].regex.pattern) + + class TestTrailingSlashIncluded(TestCase): def setUp(self): class NoteViewSet(viewsets.ModelViewSet): From 3cd15fb1713dfc49e1bf1fd48045ca3ae5654e18 Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Sat, 4 Jan 2014 16:57:50 -0500 Subject: [PATCH 29/63] Router: Do not automatically adjust lookup_regex when trailing_slash is True BREAKING CHANGE When trailing_slash is set to True, the router no longer will adjust the lookup regex to allow it to include periods. To simulate the old behavior, the programmer should specify `lookup_regex = '[^/]+'` on the viewset. https://github.com/tomchristie/django-rest-framework/pull/1328#issuecomment-31517099 --- docs/api-guide/routers.md | 2 +- rest_framework/routers.py | 7 ++----- rest_framework/tests/test_routers.py | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index f3beabdd3..6b4ae6dbd 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,7 +83,7 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. -With `trailing_slash` set to True, the router will match lookup values containing any characters except slashes and dots. When set to False, dots are allowed. To restrict the lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: +The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = 'my_model_id' diff --git a/rest_framework/routers.py b/rest_framework/routers.py index 8766ecb2f..df1233fdd 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -224,11 +224,8 @@ class SimpleRouter(BaseRouter): try: lookup_value = viewset.lookup_value_regex except AttributeError: - if self.trailing_slash: - lookup_value = '[^/]+' - else: - # Don't consume `.json` style suffixes - lookup_value = '[^/.]+' + # Don't consume `.json` style suffixes + lookup_value = '[^/.]+' return base_regex.format( lookup_prefix=lookup_prefix, lookup_field=lookup_field, diff --git a/rest_framework/tests/test_routers.py b/rest_framework/tests/test_routers.py index 0f6d62c7c..e41da57f5 100644 --- a/rest_framework/tests/test_routers.py +++ b/rest_framework/tests/test_routers.py @@ -152,7 +152,7 @@ class TestTrailingSlashIncluded(TestCase): self.urls = self.router.urls def test_urls_have_trailing_slash_by_default(self): - expected = ['^notes/$', '^notes/(?P[^/]+)/$'] + expected = ['^notes/$', '^notes/(?P[^/.]+)/$'] for idx in range(len(expected)): self.assertEqual(expected[idx], self.urls[idx].regex.pattern) From 899381575a6038f550a064261ed5c6ba0655211b Mon Sep 17 00:00:00 2001 From: Paul Melnikow Date: Sat, 4 Jan 2014 17:03:01 -0500 Subject: [PATCH 30/63] Fix a typo --- docs/api-guide/routers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 6b4ae6dbd..249e99a48 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -83,7 +83,7 @@ This behavior can be modified by setting the `trailing_slash` argument to `False Trailing slashes are conventional in Django, but are not used by default in some other frameworks such as Rails. Which style you choose to use is largely a matter of preference, although some javascript frameworks may expect a particular routing style. -The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_field_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: +The router will match lookup values containing any characters except slashes and period characters. For a more restrictive (or lenient) lookup pattern, set the `lookup_value_regex` attribute on the viewset. For example, you can limit the lookup to valid UUIDs: class MyModelViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): lookup_field = 'my_model_id' From 46f5c62530744017f744cdcfec91774a0566c179 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:16:55 +0200 Subject: [PATCH 31/63] Regression test for #1330 (Coerce None to '') --- rest_framework/tests/test_serializer.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 86f365dee..8c2c09cf8 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1124,6 +1124,20 @@ class BlankFieldTests(TestCase): serializer = self.model_serializer_class(data={}) self.assertEqual(serializer.is_valid(), True) + def test_create_model_null_field_save(self): + """ + Regression test for #1330. + + https://github.com/tomchristie/django-rest-framework/pull/1330 + """ + serializer = self.model_serializer_class(data={'title': None}) + self.assertEqual(serializer.is_valid(), True) + + try: + serializer.save() + except Exception: + self.fail('Exception raised on save() after validation passes') + #test for issue #460 class SerializerPickleTests(TestCase): From e88e3c6ae163029f0fe564dd214235ab350dbfc9 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:25:16 +0200 Subject: [PATCH 32/63] Possible fix for #1330 Coerce None to '' in CharField.to_native() --- rest_framework/fields.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 5ee752351..22f0120b7 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -452,7 +452,9 @@ class CharField(WritableField): self.validators.append(validators.MaxLengthValidator(max_length)) def from_native(self, value): - if isinstance(value, six.string_types) or value is None: + if value is None: + return '' + if isinstance(value, six.string_types): return value return smart_text(value) From 6e622d644c9b55b905e24497f0fb818d557fd970 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 5 Jan 2014 15:58:46 +0200 Subject: [PATCH 33/63] CharField - add allow_null argument --- docs/api-guide/fields.md | 7 ++++--- rest_framework/fields.py | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/api-guide/fields.md b/docs/api-guide/fields.md index e05c03061..83825350e 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -157,23 +157,24 @@ Corresponds to `django.db.models.fields.BooleanField`. ## CharField A text representation, optionally validates the text to be shorter than `max_length` and longer than `min_length`. +If `allow_none` is `False` (default), `None` values will be converted to an empty string. Corresponds to `django.db.models.fields.CharField` or `django.db.models.fields.TextField`. -**Signature:** `CharField(max_length=None, min_length=None)` +**Signature:** `CharField(max_length=None, min_length=None, allow_none=False)` ## URLField Corresponds to `django.db.models.fields.URLField`. Uses Django's `django.core.validators.URLValidator` for validation. -**Signature:** `CharField(max_length=200, min_length=None)` +**Signature:** `CharField(max_length=200, min_length=None, allow_none=False)` ## SlugField Corresponds to `django.db.models.fields.SlugField`. -**Signature:** `CharField(max_length=50, min_length=None)` +**Signature:** `CharField(max_length=50, min_length=None, allow_none=False)` ## ChoiceField diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 22f0120b7..16485b414 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -443,8 +443,9 @@ class CharField(WritableField): type_label = 'string' form_field_class = forms.CharField - def __init__(self, max_length=None, min_length=None, *args, **kwargs): + def __init__(self, max_length=None, min_length=None, allow_none=False, *args, **kwargs): self.max_length, self.min_length = max_length, min_length + self.allow_none = allow_none super(CharField, self).__init__(*args, **kwargs) if min_length is not None: self.validators.append(validators.MinLengthValidator(min_length)) @@ -452,7 +453,7 @@ class CharField(WritableField): self.validators.append(validators.MaxLengthValidator(max_length)) def from_native(self, value): - if value is None: + if value is None and not self.allow_none: return '' if isinstance(value, six.string_types): return value From e1bbe9d514c95aba596cff64292eb0f0bc7d99fa Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Mon, 6 Jan 2014 13:56:57 +0200 Subject: [PATCH 34/63] Set `allow_none = True` for CharFields with null=True --- rest_framework/serializers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index fa9353061..0164965cd 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -821,10 +821,15 @@ class ModelSerializer(Serializer): kwargs.update({attribute: getattr(model_field, attribute)}) try: - return self.field_mapping[model_field.__class__](**kwargs) + field_class = self.field_mapping[model_field.__class__] except KeyError: return ModelField(model_field=model_field, **kwargs) + if issubclass(field_class, CharField) and model_field.null: + kwargs['allow_none'] = True + + return field_class(**kwargs) + def get_validation_exclusions(self): """ Return a list of field names to exclude from model validation. From 0fd0454a5c1ddcf8676e23b30dfaee40fa7cb0c8 Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Mon, 6 Jan 2014 14:02:00 +0200 Subject: [PATCH 35/63] Test for setting allow_none=True for nullable CharFields --- rest_framework/tests/test_serializer.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 8c2c09cf8..6d9b85ee0 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -1504,6 +1504,7 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): image_field = models.ImageField(max_length=1024, blank=True) slug_field = models.SlugField(max_length=1024, blank=True) url_field = models.URLField(max_length=1024, blank=True) + nullable_char_field = models.CharField(max_length=1024, blank=True, null=True) class AMOAFSerializer(serializers.ModelSerializer): class Meta: @@ -1536,6 +1537,10 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): 'url_field': [ ('max_length', 1024), ], + 'nullable_char_field': [ + ('max_length', 1024), + ('allow_none', True), + ], } def field_test(self, field): @@ -1572,6 +1577,9 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): def test_url_field(self): self.field_test('url_field') + def test_nullable_char_field(self): + self.field_test('nullable_char_field') + class DefaultValuesOnAutogeneratedFieldsTests(TestCase): From cd9a4194ea4f4dc0e43a34485cd8a27eba44a39a Mon Sep 17 00:00:00 2001 From: Yuri Prezument Date: Sun, 12 Jan 2014 16:30:26 +0200 Subject: [PATCH 36/63] Check the modelfield's class instead --- rest_framework/serializers.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 0164965cd..cbf73fc30 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -804,6 +804,10 @@ class ModelSerializer(Serializer): issubclass(model_field.__class__, models.PositiveSmallIntegerField): kwargs['min_value'] = 0 + if model_field.null and \ + issubclass(model_field.__class__, (models.CharField, models.TextField)): + kwargs['allow_none'] = True + attribute_dict = { models.CharField: ['max_length'], models.CommaSeparatedIntegerField: ['max_length'], @@ -821,15 +825,10 @@ class ModelSerializer(Serializer): kwargs.update({attribute: getattr(model_field, attribute)}) try: - field_class = self.field_mapping[model_field.__class__] + return self.field_mapping[model_field.__class__](**kwargs) except KeyError: return ModelField(model_field=model_field, **kwargs) - if issubclass(field_class, CharField) and model_field.null: - kwargs['allow_none'] = True - - return field_class(**kwargs) - def get_validation_exclusions(self): """ Return a list of field names to exclude from model validation. From a90796c0f0d9db1a7d9bfaca8fbdfed22435c628 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 13 Jan 2014 09:56:57 +0000 Subject: [PATCH 37/63] Track changes that need noting in 2.4 announcement --- docs/topics/2.4-accouncement.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index a5425d54e..0cf50ce90 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -1,4 +1,4 @@ * Writable nested serializers. * List/detail routes. * 1.3 Support dropped, install six for <=1.4.?. -* Note title ordering changed \ No newline at end of file +* `allow_none` for char fields From 2911cd64ad67ba193e3d37322ee71692cb482623 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Mon, 13 Jan 2014 15:37:52 +0000 Subject: [PATCH 38/63] Minor tweaks to 'lookup_value_regex' work --- docs/topics/2.4-accouncement.md | 1 + rest_framework/routers.py | 8 +++----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index 0cf50ce90..91472b9c4 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -2,3 +2,4 @@ * List/detail routes. * 1.3 Support dropped, install six for <=1.4.?. * `allow_none` for char fields +* `trailing_slash = True` --> `[^/]`, `trailing_slash = False` --> `[^/.]`, becomes simply `[^/]` and `lookup_value_regex` is added. diff --git a/rest_framework/routers.py b/rest_framework/routers.py index df1233fdd..406ebcf77 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -220,12 +220,10 @@ class SimpleRouter(BaseRouter): https://github.com/alanjds/drf-nested-routers """ base_regex = '(?P<{lookup_prefix}{lookup_field}>{lookup_value})' + # Use `pk` as default field, unset set. Default regex should not + # consume `.json` style suffixes and should break at '/' boundaries. lookup_field = getattr(viewset, 'lookup_field', 'pk') - try: - lookup_value = viewset.lookup_value_regex - except AttributeError: - # Don't consume `.json` style suffixes - lookup_value = '[^/.]+' + lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+') return base_regex.format( lookup_prefix=lookup_prefix, lookup_field=lookup_field, From 4d45865bd73ba16801950e3f47199aa6da0f7c19 Mon Sep 17 00:00:00 2001 From: Simon Charette Date: Sun, 9 Feb 2014 00:50:03 -0500 Subject: [PATCH 39/63] Allow filter model to be a subclass of the queryset one. --- rest_framework/filters.py | 2 +- rest_framework/tests/models.py | 12 +++++++++ rest_framework/tests/test_filters.py | 35 +++++++++++++++++++------ rest_framework/tests/test_pagination.py | 8 +----- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/rest_framework/filters.py b/rest_framework/filters.py index de91caedc..f7ad37baa 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -43,7 +43,7 @@ class DjangoFilterBackend(BaseFilterBackend): if filter_class: filter_model = filter_class.Meta.model - assert issubclass(filter_model, queryset.model), \ + assert issubclass(queryset.model, filter_model), \ 'FilterSet model %s does not match queryset model %s' % \ (filter_model, queryset.model) diff --git a/rest_framework/tests/models.py b/rest_framework/tests/models.py index 32a726c0b..0137d45a0 100644 --- a/rest_framework/tests/models.py +++ b/rest_framework/tests/models.py @@ -60,6 +60,18 @@ class ReadOnlyManyToManyModel(RESTFrameworkModel): rel = models.ManyToManyField(Anchor) +class BaseFilterableItem(RESTFrameworkModel): + text = models.CharField(max_length=100) + + class Meta: + abstract = True + + +class FilterableItem(BaseFilterableItem): + decimal = models.DecimalField(max_digits=4, decimal_places=2) + date = models.DateField() + + # Model for regression test for #285 class Comment(RESTFrameworkModel): diff --git a/rest_framework/tests/test_filters.py b/rest_framework/tests/test_filters.py index 181881865..769d34266 100644 --- a/rest_framework/tests/test_filters.py +++ b/rest_framework/tests/test_filters.py @@ -8,17 +8,12 @@ from django.utils import unittest from rest_framework import generics, serializers, status, filters from rest_framework.compat import django_filters, patterns, url from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from rest_framework.tests.models import (BaseFilterableItem, BasicModel, + FilterableItem) factory = APIRequestFactory() -class FilterableItem(models.Model): - text = models.CharField(max_length=100) - decimal = models.DecimalField(max_digits=4, decimal_places=2) - date = models.DateField() - - if django_filters: # Basic filter on a list view. class FilterFieldsRootView(generics.ListCreateAPIView): @@ -59,6 +54,18 @@ if django_filters: filter_class = SeveralFieldsFilter filter_backends = (filters.DjangoFilterBackend,) + # These classes are used to test base model filter support + class BaseFilterableItemFilter(django_filters.FilterSet): + text = django_filters.CharFilter() + + class Meta: + model = BaseFilterableItem + + class BaseFilterableItemFilterRootView(generics.ListCreateAPIView): + model = FilterableItem + filter_class = BaseFilterableItemFilter + filter_backends = (filters.DjangoFilterBackend,) + # Regression test for #814 class FilterableItemSerializer(serializers.ModelSerializer): class Meta: @@ -226,6 +233,18 @@ class IntegrationTestFiltering(CommonFilteringTestCase): request = factory.get('/') self.assertRaises(AssertionError, view, request) + @unittest.skipUnless(django_filters, 'django-filter not installed') + def test_base_model_filter(self): + """ + The `get_filter_class` model checks should allow base model filters. + """ + view = BaseFilterableItemFilterRootView.as_view() + + request = factory.get('/?text=aaa') + response = view(request).render() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + @unittest.skipUnless(django_filters, 'django-filter not installed') def test_unknown_filter(self): """ @@ -612,4 +631,4 @@ class SensitiveOrderingFilterTests(TestCase): {'id': 2, username_field: 'userB'}, # PassC {'id': 3, username_field: 'userC'}, # PassA ] - ) \ No newline at end of file + ) diff --git a/rest_framework/tests/test_pagination.py b/rest_framework/tests/test_pagination.py index cadb515fa..cd2996134 100644 --- a/rest_framework/tests/test_pagination.py +++ b/rest_framework/tests/test_pagination.py @@ -8,17 +8,11 @@ from django.utils import unittest from rest_framework import generics, status, pagination, filters, serializers from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from rest_framework.tests.models import BasicModel, FilterableItem factory = APIRequestFactory() -class FilterableItem(models.Model): - text = models.CharField(max_length=100) - decimal = models.DecimalField(max_digits=4, decimal_places=2) - date = models.DateField() - - class RootView(generics.ListCreateAPIView): """ Example description for OPTIONS. From 971578ca345c3d3bae7fd93b87c41d43483b6f05 Mon Sep 17 00:00:00 2001 From: Andreas Pelme Date: Sun, 2 Mar 2014 12:40:30 +0100 Subject: [PATCH 40/63] Support for running the test suite with py.test * Get rid of runtests.py * Moved test code from rest_framework/tests and rest_framework/runtests to tests * Invoke py.test from setup.py * Invoke py.test from Travis * Invoke py.test from tox * Changed setUpClass to be just plain setUp in test_permissions.py * Updated contribution guideline to show how to invoke py.test --- .travis.yml | 3 +- CONTRIBUTING.md | 2 +- conftest.py | 85 +++++++++++++++++++ docs/index.md | 16 +--- docs/topics/contributing.md | 2 +- pytest.ini | 2 + requirements.txt | 2 + rest_framework/runtests/runcoverage.py | 78 ----------------- rest_framework/runtests/runtests.py | 48 ----------- rest_framework/runtests/urls.py | 7 -- rest_framework/tests/tests.py | 16 ---- rest_framework/tests/users/__init__.py | 0 setup.py | 17 +++- .../runtests => tests}/__init__.py | 0 .../tests => tests/accounts}/__init__.py | 0 .../tests => tests}/accounts/models.py | 2 +- .../tests => tests}/accounts/serializers.py | 4 +- .../tests => tests}/description.py | 0 .../accounts => tests/extras}/__init__.py | 0 .../tests => tests}/extras/bad_import.py | 0 {rest_framework/tests => tests}/models.py | 0 .../extras => tests/records}/__init__.py | 0 .../tests => tests}/records/models.py | 0 .../tests => tests}/serializers.py | 2 +- .../runtests => tests}/settings.py | 10 +-- .../tests => tests}/test_authentication.py | 10 +-- .../tests => tests}/test_breadcrumbs.py | 2 +- .../tests => tests}/test_decorators.py | 0 .../tests => tests}/test_description.py | 4 +- .../tests => tests}/test_fields.py | 2 +- {rest_framework/tests => tests}/test_files.py | 0 .../tests => tests}/test_filters.py | 6 +- .../tests => tests}/test_genericrelations.py | 0 .../tests => tests}/test_generics.py | 2 +- .../tests => tests}/test_htmlrenderer.py | 4 +- .../test_hyperlinkedserializers.py | 18 ++-- .../test_multitable_inheritance.py | 2 +- .../tests => tests}/test_negotiation.py | 0 .../tests => tests}/test_nullable_fields.py | 8 +- .../tests => tests}/test_pagination.py | 2 +- .../tests => tests}/test_parsers.py | 0 .../tests => tests}/test_permissions.py | 15 +--- .../tests => tests}/test_relations.py | 4 +- .../test_relations_hyperlink.py | 12 +-- .../tests => tests}/test_relations_nested.py | 0 .../tests => tests}/test_relations_pk.py | 2 +- .../tests => tests}/test_relations_slug.py | 2 +- .../tests => tests}/test_renderers.py | 6 +- .../tests => tests}/test_request.py | 2 +- .../tests => tests}/test_response.py | 10 +-- .../tests => tests}/test_reverse.py | 2 +- .../tests => tests}/test_routers.py | 4 +- .../tests => tests}/test_serializer.py | 6 +- .../test_serializer_bulk_update.py | 0 .../tests => tests}/test_serializer_empty.py | 0 .../tests => tests}/test_serializer_import.py | 2 +- .../tests => tests}/test_serializer_nested.py | 0 .../tests => tests}/test_serializers.py | 2 +- .../tests => tests}/test_settings.py | 4 +- .../tests => tests}/test_status.py | 0 .../tests => tests}/test_templatetags.py | 0 .../tests => tests}/test_testing.py | 2 +- .../tests => tests}/test_throttling.py | 0 .../tests => tests}/test_urlpatterns.py | 0 .../tests => tests}/test_validation.py | 0 {rest_framework/tests => tests}/test_views.py | 0 .../tests => tests}/test_write_only_fields.py | 0 tests/urls.py | 6 ++ .../tests/records => tests/users}/__init__.py | 0 .../tests => tests}/users/models.py | 0 .../tests => tests}/users/serializers.py | 2 +- {rest_framework/tests => tests}/views.py | 4 +- tox.ini | 14 ++- 73 files changed, 206 insertions(+), 251 deletions(-) create mode 100644 conftest.py create mode 100644 pytest.ini delete mode 100755 rest_framework/runtests/runcoverage.py delete mode 100755 rest_framework/runtests/runtests.py delete mode 100644 rest_framework/runtests/urls.py delete mode 100644 rest_framework/tests/tests.py delete mode 100644 rest_framework/tests/users/__init__.py rename {rest_framework/runtests => tests}/__init__.py (100%) rename {rest_framework/tests => tests/accounts}/__init__.py (100%) rename {rest_framework/tests => tests}/accounts/models.py (81%) rename {rest_framework/tests => tests}/accounts/serializers.py (58%) rename {rest_framework/tests => tests}/description.py (100%) rename {rest_framework/tests/accounts => tests/extras}/__init__.py (100%) rename {rest_framework/tests => tests}/extras/bad_import.py (100%) rename {rest_framework/tests => tests}/models.py (100%) rename {rest_framework/tests/extras => tests/records}/__init__.py (100%) rename {rest_framework/tests => tests}/records/models.py (100%) rename {rest_framework/tests => tests}/serializers.py (71%) rename {rest_framework/runtests => tests}/settings.py (96%) rename {rest_framework/tests => tests}/test_authentication.py (99%) rename {rest_framework/tests => tests}/test_breadcrumbs.py (98%) rename {rest_framework/tests => tests}/test_decorators.py (100%) rename {rest_framework/tests => tests}/test_description.py (94%) rename {rest_framework/tests => tests}/test_fields.py (99%) rename {rest_framework/tests => tests}/test_files.py (100%) rename {rest_framework/tests => tests}/test_filters.py (99%) rename {rest_framework/tests => tests}/test_genericrelations.py (100%) rename {rest_framework/tests => tests}/test_generics.py (99%) rename {rest_framework/tests => tests}/test_htmlrenderer.py (97%) rename {rest_framework/tests => tests}/test_hyperlinkedserializers.py (95%) rename {rest_framework/tests => tests}/test_multitable_inheritance.py (97%) rename {rest_framework/tests => tests}/test_negotiation.py (100%) rename {rest_framework/tests => tests}/test_nullable_fields.py (76%) rename {rest_framework/tests => tests}/test_pagination.py (99%) rename {rest_framework/tests => tests}/test_parsers.py (100%) rename {rest_framework/tests => tests}/test_permissions.py (98%) rename {rest_framework/tests => tests}/test_relations.py (97%) rename {rest_framework/tests => tests}/test_relations_hyperlink.py (98%) rename {rest_framework/tests => tests}/test_relations_nested.py (100%) rename {rest_framework/tests => tests}/test_relations_pk.py (99%) rename {rest_framework/tests => tests}/test_relations_slug.py (99%) rename {rest_framework/tests => tests}/test_renderers.py (99%) rename {rest_framework/tests => tests}/test_request.py (99%) rename {rest_framework/tests => tests}/test_response.py (97%) rename {rest_framework/tests => tests}/test_reverse.py (93%) rename {rest_framework/tests => tests}/test_routers.py (98%) rename {rest_framework/tests => tests}/test_serializer.py (99%) rename {rest_framework/tests => tests}/test_serializer_bulk_update.py (100%) rename {rest_framework/tests => tests}/test_serializer_empty.py (100%) rename {rest_framework/tests => tests}/test_serializer_import.py (90%) rename {rest_framework/tests => tests}/test_serializer_nested.py (100%) rename {rest_framework/tests => tests}/test_serializers.py (94%) rename {rest_framework/tests => tests}/test_settings.py (83%) rename {rest_framework/tests => tests}/test_status.py (100%) rename {rest_framework/tests => tests}/test_templatetags.py (100%) rename {rest_framework/tests => tests}/test_testing.py (99%) rename {rest_framework/tests => tests}/test_throttling.py (100%) rename {rest_framework/tests => tests}/test_urlpatterns.py (100%) rename {rest_framework/tests => tests}/test_validation.py (100%) rename {rest_framework/tests => tests}/test_views.py (100%) rename {rest_framework/tests => tests}/test_write_only_fields.py (100%) create mode 100644 tests/urls.py rename {rest_framework/tests/records => tests/users}/__init__.py (100%) rename {rest_framework/tests => tests}/users/models.py (100%) rename {rest_framework/tests => tests}/users/serializers.py (71%) rename {rest_framework/tests => tests}/views.py (59%) diff --git a/.travis.yml b/.travis.yml index 2e6ed46a2..061d4c739 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 + - pip install pytest-django==2.6 - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.2.1; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4; fi" @@ -24,7 +25,7 @@ install: - export PYTHONPATH=. script: - - python rest_framework/runtests/runtests.py + - py.test matrix: exclude: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a7aa6fc40..ff6018b82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,7 +65,7 @@ To run the tests, clone the repository, and then: pip install -r optionals.txt # Run the tests - rest_framework/runtests/runtests.py + py.test You can also use the excellent [`tox`][tox] testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..7cfc77f2a --- /dev/null +++ b/conftest.py @@ -0,0 +1,85 @@ +def pytest_configure(): + from django.conf import settings + + settings.configure( + DEBUG_PROPAGATE_EXCEPTIONS=True, + DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:'}}, + SECRET_KEY='not very secret in tests', + USE_I18N=True, + USE_L10N=True, + STATIC_URL='/static/', + ROOT_URLCONF='tests.urls', + TEMPLATE_LOADERS=( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', + ), + MIDDLEWARE_CLASSES=( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + ), + INSTALLED_APPS=( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + + 'rest_framework', + 'rest_framework.authtoken', + 'tests', + 'tests.accounts', + 'tests.records', + 'tests.users', + ), + PASSWORD_HASHERS=( + 'django.contrib.auth.hashers.SHA1PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.BCryptPasswordHasher', + 'django.contrib.auth.hashers.MD5PasswordHasher', + 'django.contrib.auth.hashers.CryptPasswordHasher', + ), + ) + + try: + import oauth_provider + import oauth2 + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'oauth_provider', + ) + + try: + import provider + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'provider', + 'provider.oauth2', + ) + + # guardian is optional + try: + import guardian + except ImportError: + pass + else: + settings.ANONYMOUS_USER_ID = -1 + settings.AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', # default + 'guardian.backends.ObjectPermissionBackend', + ) + settings.INSTALLED_APPS += ( + 'guardian', + ) + + # Force Django to load all models + from django.db.models import get_models + get_models() diff --git a/docs/index.md b/docs/index.md index 2a4ad8859..9ad647ac9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -206,19 +206,9 @@ General guides to using REST framework. ## Development -If you want to work on REST framework itself, clone the repository, then... - -Build the docs: - - ./mkdocs.py - -Run the tests: - - ./rest_framework/runtests/runtests.py - -To run the tests against all supported configurations, first install [the tox testing tool][tox] globally, using `pip install tox`, then simply run `tox`: - - tox +See the [Contribution guidelines][contributing] for information on how to clone +the repository, run the test suite and contribute changes back to REST +Framework. ## Support diff --git a/docs/topics/contributing.md b/docs/topics/contributing.md index 5a5d1a80b..09cc00b3c 100644 --- a/docs/topics/contributing.md +++ b/docs/topics/contributing.md @@ -65,7 +65,7 @@ To run the tests, clone the repository, and then: pip install -r optionals.txt # Run the tests - rest_framework/runtests/runtests.py + py.test You can also use the excellent `[tox][tox]` testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..bbd083ac1 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --tb=short diff --git a/requirements.txt b/requirements.txt index 730c1d07a..360acb14d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,3 @@ +-e . Django>=1.3 +pytest-django==2.6 diff --git a/rest_framework/runtests/runcoverage.py b/rest_framework/runtests/runcoverage.py deleted file mode 100755 index ce11b213e..000000000 --- a/rest_framework/runtests/runcoverage.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python -""" -Useful tool to run the test suite for rest_framework and generate a coverage report. -""" - -# http://ericholscher.com/blog/2009/jun/29/enable-setuppy-test-your-django-apps/ -# http://www.travisswicegood.com/2010/01/17/django-virtualenv-pip-and-fabric/ -# http://code.djangoproject.com/svn/django/trunk/tests/runtests.py -import os -import sys - -# fix sys path so we don't need to setup PYTHONPATH -sys.path.append(os.path.join(os.path.dirname(__file__), "../..")) -os.environ['DJANGO_SETTINGS_MODULE'] = 'rest_framework.runtests.settings' - -from coverage import coverage - - -def main(): - """Run the tests for rest_framework and generate a coverage report.""" - - cov = coverage() - cov.erase() - cov.start() - - from django.conf import settings - from django.test.utils import get_runner - TestRunner = get_runner(settings) - - if hasattr(TestRunner, 'func_name'): - # Pre 1.2 test runners were just functions, - # and did not support the 'failfast' option. - import warnings - warnings.warn( - 'Function-based test runners are deprecated. Test runners should be classes with a run_tests() method.', - DeprecationWarning - ) - failures = TestRunner(['tests']) - else: - test_runner = TestRunner() - failures = test_runner.run_tests(['tests']) - cov.stop() - - # Discover the list of all modules that we should test coverage for - import rest_framework - - project_dir = os.path.dirname(rest_framework.__file__) - cov_files = [] - - for (path, dirs, files) in os.walk(project_dir): - # Drop tests and runtests directories from the test coverage report - if os.path.basename(path) in ['tests', 'runtests', 'migrations']: - continue - - # Drop the compat and six modules from coverage, since we're not interested in the coverage - # of modules which are specifically for resolving environment dependant imports. - # (Because we'll end up getting different coverage reports for it for each environment) - if 'compat.py' in files: - files.remove('compat.py') - - if 'six.py' in files: - files.remove('six.py') - - # Same applies to template tags module. - # This module has to include branching on Django versions, - # so it's never possible for it to have full coverage. - if 'rest_framework.py' in files: - files.remove('rest_framework.py') - - cov_files.extend([os.path.join(path, file) for file in files if file.endswith('.py')]) - - cov.report(cov_files) - if '--html' in sys.argv: - cov.html_report(cov_files, directory='coverage') - sys.exit(failures) - -if __name__ == '__main__': - main() diff --git a/rest_framework/runtests/runtests.py b/rest_framework/runtests/runtests.py deleted file mode 100755 index da36d23fc..000000000 --- a/rest_framework/runtests/runtests.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python - -# http://ericholscher.com/blog/2009/jun/29/enable-setuppy-test-your-django-apps/ -# http://www.travisswicegood.com/2010/01/17/django-virtualenv-pip-and-fabric/ -# http://code.djangoproject.com/svn/django/trunk/tests/runtests.py -import os -import sys - -# fix sys path so we don't need to setup PYTHONPATH -sys.path.append(os.path.join(os.path.dirname(__file__), "../..")) -os.environ['DJANGO_SETTINGS_MODULE'] = 'rest_framework.runtests.settings' - -import django -from django.conf import settings -from django.test.utils import get_runner - - -def usage(): - return """ - Usage: python runtests.py [UnitTestClass].[method] - - You can pass the Class name of the `UnitTestClass` you want to test. - - Append a method name if you only want to test a specific method of that class. - """ - - -def main(): - TestRunner = get_runner(settings) - - test_runner = TestRunner() - if len(sys.argv) == 2: - test_case = '.' + sys.argv[1] - elif len(sys.argv) == 1: - test_case = '' - else: - print(usage()) - sys.exit(1) - test_module_name = 'rest_framework.tests' - if django.VERSION[0] == 1 and django.VERSION[1] < 6: - test_module_name = 'tests' - - failures = test_runner.run_tests([test_module_name + test_case]) - - sys.exit(failures) - -if __name__ == '__main__': - main() diff --git a/rest_framework/runtests/urls.py b/rest_framework/runtests/urls.py deleted file mode 100644 index ed5baeae6..000000000 --- a/rest_framework/runtests/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -""" -Blank URLConf just to keep runtests.py happy. -""" -from rest_framework.compat import patterns - -urlpatterns = patterns('', -) diff --git a/rest_framework/tests/tests.py b/rest_framework/tests/tests.py deleted file mode 100644 index 554ebd1ad..000000000 --- a/rest_framework/tests/tests.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Force import of all modules in this package in order to get the standard test -runner to pick up the tests. Yowzers. -""" -from __future__ import unicode_literals -import os -import django - -modules = [filename.rsplit('.', 1)[0] - for filename in os.listdir(os.path.dirname(__file__)) - if filename.endswith('.py') and not filename.startswith('_')] -__test__ = dict() - -if django.VERSION < (1, 6): - for module in modules: - exec("from rest_framework.tests.%s import *" % module) diff --git a/rest_framework/tests/users/__init__.py b/rest_framework/tests/users/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/setup.py b/setup.py index 78cdb6289..2c56cd758 100755 --- a/setup.py +++ b/setup.py @@ -2,11 +2,26 @@ # -*- coding: utf-8 -*- from setuptools import setup +from setuptools.command.test import test as TestCommand import re import os import sys +# This command has been borrowed from +# https://github.com/getsentry/sentry/blob/master/setup.py +class PyTest(TestCommand): + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = ['tests'] + self.test_suite = True + + def run_tests(self): + import pytest + errno = pytest.main(self.test_args) + sys.exit(errno) + + def get_version(package): """ Return package version as listed in `__version__` in `init.py`. @@ -62,7 +77,7 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=get_packages('rest_framework'), package_data=get_package_data('rest_framework'), - test_suite='rest_framework.runtests.runtests.main', + cmdclass={'test': PyTest}, install_requires=[], classifiers=[ 'Development Status :: 5 - Production/Stable', diff --git a/rest_framework/runtests/__init__.py b/tests/__init__.py similarity index 100% rename from rest_framework/runtests/__init__.py rename to tests/__init__.py diff --git a/rest_framework/tests/__init__.py b/tests/accounts/__init__.py similarity index 100% rename from rest_framework/tests/__init__.py rename to tests/accounts/__init__.py diff --git a/rest_framework/tests/accounts/models.py b/tests/accounts/models.py similarity index 81% rename from rest_framework/tests/accounts/models.py rename to tests/accounts/models.py index 525e601ba..3bf4a0c3c 100644 --- a/rest_framework/tests/accounts/models.py +++ b/tests/accounts/models.py @@ -1,6 +1,6 @@ from django.db import models -from rest_framework.tests.users.models import User +from tests.users.models import User class Account(models.Model): diff --git a/rest_framework/tests/accounts/serializers.py b/tests/accounts/serializers.py similarity index 58% rename from rest_framework/tests/accounts/serializers.py rename to tests/accounts/serializers.py index a27b9ca6f..57a91b92c 100644 --- a/rest_framework/tests/accounts/serializers.py +++ b/tests/accounts/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers -from rest_framework.tests.accounts.models import Account -from rest_framework.tests.users.serializers import UserSerializer +from tests.accounts.models import Account +from tests.users.serializers import UserSerializer class AccountSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/description.py b/tests/description.py similarity index 100% rename from rest_framework/tests/description.py rename to tests/description.py diff --git a/rest_framework/tests/accounts/__init__.py b/tests/extras/__init__.py similarity index 100% rename from rest_framework/tests/accounts/__init__.py rename to tests/extras/__init__.py diff --git a/rest_framework/tests/extras/bad_import.py b/tests/extras/bad_import.py similarity index 100% rename from rest_framework/tests/extras/bad_import.py rename to tests/extras/bad_import.py diff --git a/rest_framework/tests/models.py b/tests/models.py similarity index 100% rename from rest_framework/tests/models.py rename to tests/models.py diff --git a/rest_framework/tests/extras/__init__.py b/tests/records/__init__.py similarity index 100% rename from rest_framework/tests/extras/__init__.py rename to tests/records/__init__.py diff --git a/rest_framework/tests/records/models.py b/tests/records/models.py similarity index 100% rename from rest_framework/tests/records/models.py rename to tests/records/models.py diff --git a/rest_framework/tests/serializers.py b/tests/serializers.py similarity index 71% rename from rest_framework/tests/serializers.py rename to tests/serializers.py index cc943c7d0..f2f85b6ea 100644 --- a/rest_framework/tests/serializers.py +++ b/tests/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from rest_framework.tests.models import NullableForeignKeySource +from tests.models import NullableForeignKeySource class NullableFKSourceSerializer(serializers.ModelSerializer): diff --git a/rest_framework/runtests/settings.py b/tests/settings.py similarity index 96% rename from rest_framework/runtests/settings.py rename to tests/settings.py index 3fc0eb2f4..75f7c54b3 100644 --- a/rest_framework/runtests/settings.py +++ b/tests/settings.py @@ -79,7 +79,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.messages.middleware.MessageMiddleware', ) -ROOT_URLCONF = 'urls' +ROOT_URLCONF = 'tests.urls' TEMPLATE_DIRS = ( # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". @@ -99,10 +99,10 @@ INSTALLED_APPS = ( # 'django.contrib.admindocs', 'rest_framework', 'rest_framework.authtoken', - 'rest_framework.tests', - 'rest_framework.tests.accounts', - 'rest_framework.tests.records', - 'rest_framework.tests.users', + 'tests', + 'tests.accounts', + 'tests.records', + 'tests.users', ) # OAuth is optional and won't work if there is no oauth_provider & oauth2 diff --git a/rest_framework/tests/test_authentication.py b/tests/test_authentication.py similarity index 99% rename from rest_framework/tests/test_authentication.py rename to tests/test_authentication.py index f072b81b7..4ecfef44f 100644 --- a/rest_framework/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -64,7 +64,7 @@ if oauth2_provider is not None: class BasicAuthTests(TestCase): """Basic authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -103,7 +103,7 @@ class BasicAuthTests(TestCase): class SessionAuthTests(TestCase): """User session authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -150,7 +150,7 @@ class SessionAuthTests(TestCase): class TokenAuthTests(TestCase): """Token authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) @@ -244,7 +244,7 @@ class IncorrectCredentialsTests(TestCase): class OAuthTests(TestCase): """OAuth 1.0a authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): # these imports are here because oauth is optional and hiding them in try..except block or compat @@ -474,7 +474,7 @@ class OAuthTests(TestCase): class OAuth2Tests(TestCase): """OAuth 2.0 authentication""" - urls = 'rest_framework.tests.test_authentication' + urls = 'tests.test_authentication' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) diff --git a/rest_framework/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py similarity index 98% rename from rest_framework/tests/test_breadcrumbs.py rename to tests/test_breadcrumbs.py index 41ddf2cea..78edc6032 100644 --- a/rest_framework/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -36,7 +36,7 @@ urlpatterns = patterns('', class BreadcrumbTests(TestCase): """Tests the breadcrumb functionality used by the HTML renderer.""" - urls = 'rest_framework.tests.test_breadcrumbs' + urls = 'tests.test_breadcrumbs' def test_root_breadcrumbs(self): url = '/' diff --git a/rest_framework/tests/test_decorators.py b/tests/test_decorators.py similarity index 100% rename from rest_framework/tests/test_decorators.py rename to tests/test_decorators.py diff --git a/rest_framework/tests/test_description.py b/tests/test_description.py similarity index 94% rename from rest_framework/tests/test_description.py rename to tests/test_description.py index 4c03c1ded..1e481f06c 100644 --- a/rest_framework/tests/test_description.py +++ b/tests/test_description.py @@ -4,8 +4,8 @@ from __future__ import unicode_literals from django.test import TestCase from rest_framework.compat import apply_markdown, smart_text from rest_framework.views import APIView -from rest_framework.tests.description import ViewWithNonASCIICharactersInDocstring -from rest_framework.tests.description import UTF8_TEST_DOCSTRING +from .description import ViewWithNonASCIICharactersInDocstring +from .description import UTF8_TEST_DOCSTRING # We check that docstrings get nicely un-indented. DESCRIPTION = """an example docstring diff --git a/rest_framework/tests/test_fields.py b/tests/test_fields.py similarity index 99% rename from rest_framework/tests/test_fields.py rename to tests/test_fields.py index e127feef9..e65a2fb39 100644 --- a/rest_framework/tests/test_fields.py +++ b/tests/test_fields.py @@ -11,7 +11,7 @@ from django.db import models from django.test import TestCase from django.utils.datastructures import SortedDict from rest_framework import serializers -from rest_framework.tests.models import RESTFrameworkModel +from tests.models import RESTFrameworkModel class TimestampedModel(models.Model): diff --git a/rest_framework/tests/test_files.py b/tests/test_files.py similarity index 100% rename from rest_framework/tests/test_files.py rename to tests/test_files.py diff --git a/rest_framework/tests/test_filters.py b/tests/test_filters.py similarity index 99% rename from rest_framework/tests/test_filters.py rename to tests/test_filters.py index 181881865..d9d8042e2 100644 --- a/rest_framework/tests/test_filters.py +++ b/tests/test_filters.py @@ -8,7 +8,7 @@ from django.utils import unittest from rest_framework import generics, serializers, status, filters from rest_framework.compat import django_filters, patterns, url from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel factory = APIRequestFactory() @@ -243,7 +243,7 @@ class IntegrationTestDetailFiltering(CommonFilteringTestCase): """ Integration tests for filtered detail views. """ - urls = 'rest_framework.tests.test_filters' + urls = 'tests.test_filters' def _get_url(self, item): return reverse('detail-view', kwargs=dict(pk=item.pk)) @@ -612,4 +612,4 @@ class SensitiveOrderingFilterTests(TestCase): {'id': 2, username_field: 'userB'}, # PassC {'id': 3, username_field: 'userC'}, # PassA ] - ) \ No newline at end of file + ) diff --git a/rest_framework/tests/test_genericrelations.py b/tests/test_genericrelations.py similarity index 100% rename from rest_framework/tests/test_genericrelations.py rename to tests/test_genericrelations.py diff --git a/rest_framework/tests/test_generics.py b/tests/test_generics.py similarity index 99% rename from rest_framework/tests/test_generics.py rename to tests/test_generics.py index 996bd5b0e..4389994af 100644 --- a/rest_framework/tests/test_generics.py +++ b/tests/test_generics.py @@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404 from django.test import TestCase from rest_framework import generics, renderers, serializers, status from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel, Comment, SlugBasedModel +from tests.models import BasicModel, Comment, SlugBasedModel from rest_framework.compat import six factory = APIRequestFactory() diff --git a/rest_framework/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py similarity index 97% rename from rest_framework/tests/test_htmlrenderer.py rename to tests/test_htmlrenderer.py index 8957a43c7..c748fbdbb 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -42,7 +42,7 @@ urlpatterns = patterns('', class TemplateHTMLRendererTests(TestCase): - urls = 'rest_framework.tests.test_htmlrenderer' + urls = 'tests.test_htmlrenderer' def setUp(self): """ @@ -82,7 +82,7 @@ class TemplateHTMLRendererTests(TestCase): class TemplateHTMLRendererExceptionTests(TestCase): - urls = 'rest_framework.tests.test_htmlrenderer' + urls = 'tests.test_htmlrenderer' def setUp(self): """ diff --git a/rest_framework/tests/test_hyperlinkedserializers.py b/tests/test_hyperlinkedserializers.py similarity index 95% rename from rest_framework/tests/test_hyperlinkedserializers.py rename to tests/test_hyperlinkedserializers.py index 83d460435..eee179cae 100644 --- a/rest_framework/tests/test_hyperlinkedserializers.py +++ b/tests/test_hyperlinkedserializers.py @@ -5,7 +5,7 @@ from rest_framework import generics, status, serializers from rest_framework.compat import patterns, url from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import ( +from tests.models import ( Anchor, BasicModel, ManyToManyModel, BlogPost, BlogPostComment, Album, Photo, OptionalRelationModel ) @@ -110,7 +110,7 @@ urlpatterns = patterns('', class TestBasicHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -147,7 +147,7 @@ class TestBasicHyperlinkedView(TestCase): class TestManyToManyHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -195,7 +195,7 @@ class TestManyToManyHyperlinkedView(TestCase): class TestHyperlinkedIdentityFieldLookup(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -225,7 +225,7 @@ class TestHyperlinkedIdentityFieldLookup(TestCase): class TestCreateWithForeignKeys(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -250,7 +250,7 @@ class TestCreateWithForeignKeys(TestCase): class TestCreateWithForeignKeysAndCustomSlug(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -275,7 +275,7 @@ class TestCreateWithForeignKeysAndCustomSlug(TestCase): class TestOptionalRelationHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -335,7 +335,7 @@ class TestOverriddenURLField(TestCase): class TestURLFieldNameBySettings(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): self.saved_url_field_name = api_settings.URL_FIELD_NAME @@ -360,7 +360,7 @@ class TestURLFieldNameBySettings(TestCase): class TestURLFieldNameByOptions(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): class Serializer(serializers.HyperlinkedModelSerializer): diff --git a/rest_framework/tests/test_multitable_inheritance.py b/tests/test_multitable_inheritance.py similarity index 97% rename from rest_framework/tests/test_multitable_inheritance.py rename to tests/test_multitable_inheritance.py index 00c153276..ce1bf3ea3 100644 --- a/rest_framework/tests/test_multitable_inheritance.py +++ b/tests/test_multitable_inheritance.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import RESTFrameworkModel +from tests.models import RESTFrameworkModel # Models diff --git a/rest_framework/tests/test_negotiation.py b/tests/test_negotiation.py similarity index 100% rename from rest_framework/tests/test_negotiation.py rename to tests/test_negotiation.py diff --git a/rest_framework/tests/test_nullable_fields.py b/tests/test_nullable_fields.py similarity index 76% rename from rest_framework/tests/test_nullable_fields.py rename to tests/test_nullable_fields.py index 6ee55c005..33a9685f3 100644 --- a/rest_framework/tests/test_nullable_fields.py +++ b/tests/test_nullable_fields.py @@ -2,9 +2,9 @@ from django.core.urlresolvers import reverse from rest_framework.compat import patterns, url from rest_framework.test import APITestCase -from rest_framework.tests.models import NullableForeignKeySource -from rest_framework.tests.serializers import NullableFKSourceSerializer -from rest_framework.tests.views import NullableFKSourceDetail +from tests.models import NullableForeignKeySource +from tests.serializers import NullableFKSourceSerializer +from tests.views import NullableFKSourceDetail urlpatterns = patterns( @@ -18,7 +18,7 @@ class NullableForeignKeyTests(APITestCase): DRF should be able to handle nullable foreign keys when a test Client POST/PUT request is made with its own serialized object. """ - urls = 'rest_framework.tests.test_nullable_fields' + urls = 'tests.test_nullable_fields' def test_updating_object_with_null_fk(self): obj = NullableForeignKeySource(name='example', target=None) diff --git a/rest_framework/tests/test_pagination.py b/tests/test_pagination.py similarity index 99% rename from rest_framework/tests/test_pagination.py rename to tests/test_pagination.py index cadb515fa..65fa9dcd1 100644 --- a/rest_framework/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -8,7 +8,7 @@ from django.utils import unittest from rest_framework import generics, status, pagination, filters, serializers from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel factory = APIRequestFactory() diff --git a/rest_framework/tests/test_parsers.py b/tests/test_parsers.py similarity index 100% rename from rest_framework/tests/test_parsers.py rename to tests/test_parsers.py diff --git a/rest_framework/tests/test_permissions.py b/tests/test_permissions.py similarity index 98% rename from rest_framework/tests/test_permissions.py rename to tests/test_permissions.py index 6e3a63034..a2cb0c362 100644 --- a/rest_framework/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -7,7 +7,7 @@ from rest_framework import generics, status, permissions, authentication, HTTP_H from rest_framework.compat import guardian, get_model_name from rest_framework.filters import DjangoObjectPermissionsFilter from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel import base64 factory = APIRequestFactory() @@ -187,8 +187,7 @@ class ObjectPermissionsIntegrationTests(TestCase): """ Integration tests for the object level permissions API. """ - @classmethod - def setUpClass(cls): + def setUp(self): from guardian.shortcuts import assign_perm # create users @@ -215,21 +214,13 @@ class ObjectPermissionsIntegrationTests(TestCase): assign_perm(perm, everyone) everyone.user_set.add(*users.values()) - cls.perms = perms - cls.users = users - - def setUp(self): - from guardian.shortcuts import assign_perm - perms = self.perms - users = self.users - # appropriate object level permissions readers = Group.objects.create(name='readers') writers = Group.objects.create(name='writers') deleters = Group.objects.create(name='deleters') model = BasicPermModel.objects.create(text='foo') - + assign_perm(perms['view'], readers, model) assign_perm(perms['change'], writers, model) assign_perm(perms['delete'], deleters, model) diff --git a/rest_framework/tests/test_relations.py b/tests/test_relations.py similarity index 97% rename from rest_framework/tests/test_relations.py rename to tests/test_relations.py index f52e0e1e5..bfc8d487e 100644 --- a/rest_framework/tests/test_relations.py +++ b/tests/test_relations.py @@ -5,7 +5,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import BlogPost +from tests.models import BlogPost class NullModel(models.Model): @@ -105,7 +105,7 @@ class RelatedFieldSourceTests(TestCase): Check that the exception message are correct if the source field doesn't exist. """ - from rest_framework.tests.models import ManyToManySource + from tests.models import ManyToManySource class Meta: model = ManyToManySource attrs = { diff --git a/rest_framework/tests/test_relations_hyperlink.py b/tests/test_relations_hyperlink.py similarity index 98% rename from rest_framework/tests/test_relations_hyperlink.py rename to tests/test_relations_hyperlink.py index 3c4d39af6..98f68d29f 100644 --- a/rest_framework/tests/test_relations_hyperlink.py +++ b/tests/test_relations_hyperlink.py @@ -3,7 +3,7 @@ from django.test import TestCase from rest_framework import serializers from rest_framework.compat import patterns, url from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource @@ -71,7 +71,7 @@ class NullableOneToOneTargetSerializer(serializers.HyperlinkedModelSerializer): # TODO: Add test that .data cannot be accessed prior to .is_valid class HyperlinkedManyToManyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): for idx in range(1, 4): @@ -179,7 +179,7 @@ class HyperlinkedManyToManyTests(TestCase): class HyperlinkedForeignKeyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = ForeignKeyTarget(name='target-1') @@ -307,7 +307,7 @@ class HyperlinkedForeignKeyTests(TestCase): class HyperlinkedNullableForeignKeyTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = ForeignKeyTarget(name='target-1') @@ -435,7 +435,7 @@ class HyperlinkedNullableForeignKeyTests(TestCase): class HyperlinkedNullableOneToOneTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def setUp(self): target = OneToOneTarget(name='target-1') @@ -458,7 +458,7 @@ class HyperlinkedNullableOneToOneTests(TestCase): # Regression tests for #694 (`source` attribute on related fields) class HyperlinkedRelatedFieldSourceTests(TestCase): - urls = 'rest_framework.tests.test_relations_hyperlink' + urls = 'tests.test_relations_hyperlink' def test_related_manager_source(self): """ diff --git a/rest_framework/tests/test_relations_nested.py b/tests/test_relations_nested.py similarity index 100% rename from rest_framework/tests/test_relations_nested.py rename to tests/test_relations_nested.py diff --git a/rest_framework/tests/test_relations_pk.py b/tests/test_relations_pk.py similarity index 99% rename from rest_framework/tests/test_relations_pk.py rename to tests/test_relations_pk.py index 3815afdd8..ff59b250a 100644 --- a/rest_framework/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource, ) diff --git a/rest_framework/tests/test_relations_slug.py b/tests/test_relations_slug.py similarity index 99% rename from rest_framework/tests/test_relations_slug.py rename to tests/test_relations_slug.py index 435c821cf..97ebf23a1 100644 --- a/rest_framework/tests/test_relations_slug.py +++ b/tests/test_relations_slug.py @@ -1,6 +1,6 @@ from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.models import NullableForeignKeySource, ForeignKeySource, ForeignKeyTarget +from tests.models import NullableForeignKeySource, ForeignKeySource, ForeignKeyTarget class ForeignKeyTargetSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/test_renderers.py b/tests/test_renderers.py similarity index 99% rename from rest_framework/tests/test_renderers.py rename to tests/test_renderers.py index 0f3432c99..b41cff397 100644 --- a/rest_framework/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -152,7 +152,7 @@ class RendererEndToEndTests(TestCase): End-to-end testing of renderers using an RendererMixin on a generic view. """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' def test_default_renderer_serializes_content(self): """If the Accept header is not set the default renderer should serialize the response.""" @@ -387,7 +387,7 @@ class JSONPRendererTests(TestCase): Tests specific to the JSONP Renderer """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' def test_without_callback_with_json_renderer(self): """ @@ -571,7 +571,7 @@ class CacheRenderTest(TestCase): Tests specific to caching responses """ - urls = 'rest_framework.tests.test_renderers' + urls = 'tests.test_renderers' cache_key = 'just_a_cache_key' diff --git a/rest_framework/tests/test_request.py b/tests/test_request.py similarity index 99% rename from rest_framework/tests/test_request.py rename to tests/test_request.py index c0b50f330..0a9355f0b 100644 --- a/rest_framework/tests/test_request.py +++ b/tests/test_request.py @@ -278,7 +278,7 @@ urlpatterns = patterns('', class TestContentParsingWithAuthentication(TestCase): - urls = 'rest_framework.tests.test_request' + urls = 'tests.test_request' def setUp(self): self.csrf_client = APIClient(enforce_csrf_checks=True) diff --git a/rest_framework/tests/test_response.py b/tests/test_response.py similarity index 97% rename from rest_framework/tests/test_response.py rename to tests/test_response.py index eea3c6418..41c0f49d8 100644 --- a/rest_framework/tests/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,6 @@ from __future__ import unicode_literals from django.test import TestCase -from rest_framework.tests.models import BasicModel, BasicModelSerializer +from tests.models import BasicModel, BasicModelSerializer from rest_framework.compat import patterns, url, include from rest_framework.response import Response from rest_framework.views import APIView @@ -118,7 +118,7 @@ class RendererIntegrationTests(TestCase): End-to-end testing of renderers using an ResponseMixin on a generic view. """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_default_renderer_serializes_content(self): """If the Accept header is not set the default renderer should serialize the response.""" @@ -198,7 +198,7 @@ class Issue122Tests(TestCase): """ Tests that covers #122. """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_only_html_renderer(self): """ @@ -218,7 +218,7 @@ class Issue467Tests(TestCase): Tests for #467 """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_form_has_label_and_help_text(self): resp = self.client.get('/html_new_model') @@ -232,7 +232,7 @@ class Issue807Tests(TestCase): Covers #807 """ - urls = 'rest_framework.tests.test_response' + urls = 'tests.test_response' def test_does_not_append_charset_by_default(self): """ diff --git a/rest_framework/tests/test_reverse.py b/tests/test_reverse.py similarity index 93% rename from rest_framework/tests/test_reverse.py rename to tests/test_reverse.py index 690a30b11..3d14a28f5 100644 --- a/rest_framework/tests/test_reverse.py +++ b/tests/test_reverse.py @@ -19,7 +19,7 @@ class ReverseTests(TestCase): """ Tests for fully qualified URLs when using `reverse`. """ - urls = 'rest_framework.tests.test_reverse' + urls = 'tests.test_reverse' def test_reversed_urls_are_fully_qualified(self): request = factory.get('/view') diff --git a/rest_framework/tests/test_routers.py b/tests/test_routers.py similarity index 98% rename from rest_framework/tests/test_routers.py rename to tests/test_routers.py index e723f7d45..084c0e277 100644 --- a/rest_framework/tests/test_routers.py +++ b/tests/test_routers.py @@ -72,7 +72,7 @@ class TestCustomLookupFields(TestCase): """ Ensure that custom lookup fields are correctly routed. """ - urls = 'rest_framework.tests.test_routers' + urls = 'tests.test_routers' def setUp(self): class NoteSerializer(serializers.HyperlinkedModelSerializer): @@ -91,7 +91,7 @@ class TestCustomLookupFields(TestCase): self.router = SimpleRouter() self.router.register(r'notes', NoteViewSet) - from rest_framework.tests import test_routers + from tests import test_routers urls = getattr(test_routers, 'urlpatterns') urls += patterns('', url(r'^', include(self.router.urls)), diff --git a/rest_framework/tests/test_serializer.py b/tests/test_serializer.py similarity index 99% rename from rest_framework/tests/test_serializer.py rename to tests/test_serializer.py index 6b1e333e4..18484afeb 100644 --- a/rest_framework/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -6,10 +6,10 @@ from django.test import TestCase from django.utils.datastructures import MultiValueDict from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers, fields, relations -from rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, +from tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, RESTFrameworkModel) -from rest_framework.tests.models import BasicModelSerializer +from tests.models import BasicModelSerializer import datetime import pickle @@ -1034,7 +1034,7 @@ class RelatedTraversalTest(TestCase): """ If a component of the dotted.source is None, return None for the field. """ - from rest_framework.tests.models import NullableForeignKeySource + from tests.models import NullableForeignKeySource instance = NullableForeignKeySource.objects.create(name='Source with null FK') class NullableSourceSerializer(serializers.Serializer): diff --git a/rest_framework/tests/test_serializer_bulk_update.py b/tests/test_serializer_bulk_update.py similarity index 100% rename from rest_framework/tests/test_serializer_bulk_update.py rename to tests/test_serializer_bulk_update.py diff --git a/rest_framework/tests/test_serializer_empty.py b/tests/test_serializer_empty.py similarity index 100% rename from rest_framework/tests/test_serializer_empty.py rename to tests/test_serializer_empty.py diff --git a/rest_framework/tests/test_serializer_import.py b/tests/test_serializer_import.py similarity index 90% rename from rest_framework/tests/test_serializer_import.py rename to tests/test_serializer_import.py index 9f30a7ffa..3b8ff4b3c 100644 --- a/rest_framework/tests/test_serializer_import.py +++ b/tests/test_serializer_import.py @@ -1,7 +1,7 @@ from django.test import TestCase from rest_framework import serializers -from rest_framework.tests.accounts.serializers import AccountSerializer +from tests.accounts.serializers import AccountSerializer class ImportingModelSerializerTests(TestCase): diff --git a/rest_framework/tests/test_serializer_nested.py b/tests/test_serializer_nested.py similarity index 100% rename from rest_framework/tests/test_serializer_nested.py rename to tests/test_serializer_nested.py diff --git a/rest_framework/tests/test_serializers.py b/tests/test_serializers.py similarity index 94% rename from rest_framework/tests/test_serializers.py rename to tests/test_serializers.py index 082a400ca..675477833 100644 --- a/rest_framework/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -2,7 +2,7 @@ from django.db import models from django.test import TestCase from rest_framework.serializers import _resolve_model -from rest_framework.tests.models import BasicModel +from tests.models import BasicModel class ResolveModelTests(TestCase): diff --git a/rest_framework/tests/test_settings.py b/tests/test_settings.py similarity index 83% rename from rest_framework/tests/test_settings.py rename to tests/test_settings.py index 857375c21..e29fc34aa 100644 --- a/rest_framework/tests/test_settings.py +++ b/tests/test_settings.py @@ -10,13 +10,13 @@ class TestSettings(TestCase): def test_non_import_errors(self): """Make sure other errors aren't suppressed.""" - settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.tests.extras.bad_import.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) + settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.bad_import.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) with self.assertRaises(ValueError): settings.DEFAULT_MODEL_SERIALIZER_CLASS def test_import_error_message_maintained(self): """Make sure real import errors are captured and raised sensibly.""" - settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.tests.extras.not_here.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) + settings = APISettings({'DEFAULT_MODEL_SERIALIZER_CLASS': 'tests.extras.not_here.ModelSerializer'}, DEFAULTS, IMPORT_STRINGS) with self.assertRaises(ImportError) as cm: settings.DEFAULT_MODEL_SERIALIZER_CLASS self.assertTrue('ImportError' in str(cm.exception)) diff --git a/rest_framework/tests/test_status.py b/tests/test_status.py similarity index 100% rename from rest_framework/tests/test_status.py rename to tests/test_status.py diff --git a/rest_framework/tests/test_templatetags.py b/tests/test_templatetags.py similarity index 100% rename from rest_framework/tests/test_templatetags.py rename to tests/test_templatetags.py diff --git a/rest_framework/tests/test_testing.py b/tests/test_testing.py similarity index 99% rename from rest_framework/tests/test_testing.py rename to tests/test_testing.py index 71bd8b552..8c6086a29 100644 --- a/rest_framework/tests/test_testing.py +++ b/tests/test_testing.py @@ -35,7 +35,7 @@ urlpatterns = patterns('', class TestAPITestClient(TestCase): - urls = 'rest_framework.tests.test_testing' + urls = 'tests.test_testing' def setUp(self): self.client = APIClient() diff --git a/rest_framework/tests/test_throttling.py b/tests/test_throttling.py similarity index 100% rename from rest_framework/tests/test_throttling.py rename to tests/test_throttling.py diff --git a/rest_framework/tests/test_urlpatterns.py b/tests/test_urlpatterns.py similarity index 100% rename from rest_framework/tests/test_urlpatterns.py rename to tests/test_urlpatterns.py diff --git a/rest_framework/tests/test_validation.py b/tests/test_validation.py similarity index 100% rename from rest_framework/tests/test_validation.py rename to tests/test_validation.py diff --git a/rest_framework/tests/test_views.py b/tests/test_views.py similarity index 100% rename from rest_framework/tests/test_views.py rename to tests/test_views.py diff --git a/rest_framework/tests/test_write_only_fields.py b/tests/test_write_only_fields.py similarity index 100% rename from rest_framework/tests/test_write_only_fields.py rename to tests/test_write_only_fields.py diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 000000000..62cad3395 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,6 @@ +""" +Blank URLConf just to keep the test suite happy +""" +from rest_framework.compat import patterns + +urlpatterns = patterns('') diff --git a/rest_framework/tests/records/__init__.py b/tests/users/__init__.py similarity index 100% rename from rest_framework/tests/records/__init__.py rename to tests/users/__init__.py diff --git a/rest_framework/tests/users/models.py b/tests/users/models.py similarity index 100% rename from rest_framework/tests/users/models.py rename to tests/users/models.py diff --git a/rest_framework/tests/users/serializers.py b/tests/users/serializers.py similarity index 71% rename from rest_framework/tests/users/serializers.py rename to tests/users/serializers.py index da4965540..4893ddb34 100644 --- a/rest_framework/tests/users/serializers.py +++ b/tests/users/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from rest_framework.tests.users.models import User +from tests.users.models import User class UserSerializer(serializers.ModelSerializer): diff --git a/rest_framework/tests/views.py b/tests/views.py similarity index 59% rename from rest_framework/tests/views.py rename to tests/views.py index 3917b74a9..55935e924 100644 --- a/rest_framework/tests/views.py +++ b/tests/views.py @@ -1,6 +1,6 @@ from rest_framework import generics -from rest_framework.tests.models import NullableForeignKeySource -from rest_framework.tests.serializers import NullableFKSourceSerializer +from .models import NullableForeignKeySource +from .serializers import NullableFKSourceSerializer class NullableFKSourceDetail(generics.RetrieveUpdateDestroyAPIView): diff --git a/tox.ini b/tox.ini index 77766d20b..2fe39f120 100644 --- a/tox.ini +++ b/tox.ini @@ -3,19 +3,21 @@ downloadcache = {toxworkdir}/cache/ envlist = py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5,py2.7-django1.4,py2.6-django1.4,py2.7-django1.3,py2.6-django1.3 [testenv] -commands = {envpython} rest_framework/runtests/runtests.py +commands = py.test -q [testenv:py3.3-django1.6] basepython = python3.3 deps = Django==1.6 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py3.2-django1.6] basepython = python3.2 deps = Django==1.6 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py2.7-django1.6] basepython = python2.7 @@ -26,6 +28,7 @@ deps = Django==1.6 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.6] basepython = python2.6 @@ -36,18 +39,21 @@ deps = Django==1.6 oauth2==1.5.211 django-oauth2-provider==0.2.4 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py3.3-django1.5] basepython = python3.3 deps = django==1.5.5 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py3.2-django1.5] basepython = python3.2 deps = django==1.5.5 django-filter==0.6a1 defusedxml==0.3 + pytest-django==2.6 [testenv:py2.7-django1.5] basepython = python2.7 @@ -58,6 +64,7 @@ deps = django==1.5.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.5] basepython = python2.6 @@ -68,6 +75,7 @@ deps = django==1.5.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.7-django1.4] basepython = python2.7 @@ -78,6 +86,7 @@ deps = django==1.4.10 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.4] basepython = python2.6 @@ -88,6 +97,7 @@ deps = django==1.4.10 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.7-django1.3] basepython = python2.7 @@ -98,6 +108,7 @@ deps = django==1.3.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 [testenv:py2.6-django1.3] basepython = python2.6 @@ -108,3 +119,4 @@ deps = django==1.3.5 oauth2==1.5.211 django-oauth2-provider==0.2.3 django-guardian==1.1.1 + pytest-django==2.6 From f22ed49c648c6dc3e2cd3c1dfbda77c010189e28 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 17 Apr 2014 11:09:02 +0200 Subject: [PATCH 41/63] Upgraded to pytest-django 2.6.1 --- .travis.yml | 2 +- conftest.py | 4 ---- tox.ini | 27 +++++++++++++++------------ 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 13dc3e289..4f4d0c307 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,7 +16,7 @@ env: install: - pip install $DJANGO - pip install defusedxml==0.3 Pillow==2.3.0 - - pip install pytest-django==2.6 + - pip install pytest-django==2.6.1 - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.2.4; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4; fi" diff --git a/conftest.py b/conftest.py index 7cfc77f2a..b1691a884 100644 --- a/conftest.py +++ b/conftest.py @@ -79,7 +79,3 @@ def pytest_configure(): settings.INSTALLED_APPS += ( 'guardian', ) - - # Force Django to load all models - from django.db.models import get_models - get_models() diff --git a/tox.ini b/tox.ini index 251a40b73..2f6f16125 100644 --- a/tox.ini +++ b/tox.ini @@ -11,6 +11,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.2-django1.7] basepython = python3.2 @@ -18,6 +19,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.7-django1.7] basepython = python2.7 @@ -29,6 +31,7 @@ deps = https://www.djangoproject.com/download/1.7b1/tarball/ django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.3-django1.6] basepython = python3.3 @@ -36,7 +39,7 @@ deps = Django==1.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.2-django1.6] basepython = python3.2 @@ -44,7 +47,7 @@ deps = Django==1.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.6] basepython = python2.7 @@ -56,7 +59,7 @@ deps = Django==1.6 django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.6] basepython = python2.6 @@ -68,7 +71,7 @@ deps = Django==1.6 django-oauth2-provider==0.2.4 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.3-django1.5] basepython = python3.3 @@ -76,7 +79,7 @@ deps = django==1.5.5 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py3.2-django1.5] basepython = python3.2 @@ -84,7 +87,7 @@ deps = django==1.5.5 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.5] basepython = python2.7 @@ -96,7 +99,7 @@ deps = django==1.5.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.5] basepython = python2.6 @@ -108,7 +111,7 @@ deps = django==1.5.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.4] basepython = python2.7 @@ -120,7 +123,7 @@ deps = django==1.4.10 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.4] basepython = python2.6 @@ -132,7 +135,7 @@ deps = django==1.4.10 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.7-django1.3] basepython = python2.7 @@ -144,7 +147,7 @@ deps = django==1.3.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 [testenv:py2.6-django1.3] basepython = python2.6 @@ -156,4 +159,4 @@ deps = django==1.3.5 django-oauth2-provider==0.2.3 django-guardian==1.1.1 Pillow==2.3.0 - pytest-django==2.6 + pytest-django==2.6.1 From c5f68fba0638a15fa3c802f1bafc664e890611dc Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 17 Apr 2014 14:30:33 +0200 Subject: [PATCH 42/63] Fixed the issue with django-filters / django 1.7 / pytest --- conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/conftest.py b/conftest.py index b1691a884..7a49845f9 100644 --- a/conftest.py +++ b/conftest.py @@ -79,3 +79,9 @@ def pytest_configure(): settings.INSTALLED_APPS += ( 'guardian', ) + + try: + import django + django.setup() + except AttributeError: + pass From cd93cd195ef83a443e8fe7d745b2947d2636f4ad Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Wed, 30 Apr 2014 22:32:29 +0200 Subject: [PATCH 43/63] Use url functions from Django itself. --- rest_framework/tests/test_authentication.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_authentication.py b/rest_framework/tests/test_authentication.py index 092030578..af292bf17 100644 --- a/rest_framework/tests/test_authentication.py +++ b/rest_framework/tests/test_authentication.py @@ -20,7 +20,7 @@ from rest_framework.authentication import ( OAuth2Authentication ) from rest_framework.authtoken.models import Token -from rest_framework.compat import patterns, url, include, six +from rest_framework.compat import six from rest_framework.compat import oauth2_provider, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient From 7475fceacc5bc94fde6212937685ef69ae79c751 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 00:54:20 +0200 Subject: [PATCH 44/63] Added missing field for the tests. --- rest_framework/tests/test_serializer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index f0bb112d6..31bd10827 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -30,6 +30,7 @@ if PIL is not None: image_field = models.ImageField(upload_to='test', max_length=1024, blank=True) slug_field = models.SlugField(max_length=1024, blank=True) url_field = models.URLField(max_length=1024, blank=True) + nullable_char_field = models.CharField(max_length=1024, blank=True, null=True) class DVOAFModel(RESTFrameworkModel): positive_integer_field = models.PositiveIntegerField(blank=True) From 38362bb43a19c287319ccfe0538ce5524f09c633 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 01:24:48 +0200 Subject: [PATCH 45/63] Fixed new default for many --- rest_framework/tests/test_genericrelations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_genericrelations.py b/rest_framework/tests/test_genericrelations.py index 46a2d863f..3a8f3c7f1 100644 --- a/rest_framework/tests/test_genericrelations.py +++ b/rest_framework/tests/test_genericrelations.py @@ -84,7 +84,7 @@ class TestGenericRelations(TestCase): exclude = ('content_type', 'object_id') class BookmarkSerializer(serializers.ModelSerializer): - tags = TagSerializer() + tags = TagSerializer(many=True) class Meta: model = Bookmark From c9e6f31166ebccc5c3bf2f27e12a6d6c87f5cf22 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 01:27:51 +0200 Subject: [PATCH 46/63] Fixed new default for many --- rest_framework/tests/test_serializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/tests/test_serializer.py b/rest_framework/tests/test_serializer.py index 31bd10827..44ef8a95c 100644 --- a/rest_framework/tests/test_serializer.py +++ b/rest_framework/tests/test_serializer.py @@ -661,7 +661,7 @@ class ModelValidationTests(TestCase): second_serializer = AlbumsSerializer(data={'title': 'a'}) self.assertFalse(second_serializer.is_valid()) self.assertEqual(second_serializer.errors, {'title': ['Album with this Title already exists.'],}) - third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}]) + third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}], many=True) self.assertFalse(third_serializer.is_valid()) self.assertEqual(third_serializer.errors, [{'ref': ['Album with this Ref already exists.']}, {}]) From eb89ed02f247d903db1cdd488d69b316323d9f60 Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:36:18 +0200 Subject: [PATCH 47/63] Added missing staticfiles app --- conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/conftest.py b/conftest.py index 7a49845f9..fa5184dd8 100644 --- a/conftest.py +++ b/conftest.py @@ -27,6 +27,7 @@ def pytest_configure(): 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', + 'django.contrib.staticfiles', 'rest_framework', 'rest_framework.authtoken', From e5441d845e34f1e1bb2b7464d31aa3df7b02d0fe Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:41:37 +0200 Subject: [PATCH 48/63] Use urls functions from django instead of compat. --- tests/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/urls.py b/tests/urls.py index 62cad3395..41f527dfd 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,6 +1,6 @@ """ Blank URLConf just to keep the test suite happy """ -from rest_framework.compat import patterns +from django.conf.urls import patterns urlpatterns = patterns('') From 15c2c58b43a00ec29af99e0478b70eea57560fce Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Thu, 1 May 2014 08:43:49 +0200 Subject: [PATCH 49/63] Updated the release-notes. --- docs/topics/release-notes.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index d6256b38f..fd5c7029b 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -40,6 +40,7 @@ You can determine your currently installed version using `pip freeze`: ### 2.4.0 +* Use py.test * `@detail_route` and `@list_route` decorators replace `@action` and `@link`. * `six` no longer bundled. For Django <= 1.4.1, install `six` package. * Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. From f34011f8010a5a3358eead9f60be1fb4db4e834a Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Mon, 23 Jun 2014 14:52:18 +0200 Subject: [PATCH 50/63] Allow use of native migrations in 1.7 --- .../authtoken/migrations/0001_initial.py | 84 +++++-------------- .../south_migrations/0001_initial.py | 67 +++++++++++++++ .../authtoken/south_migrations/__init__.py | 0 rest_framework/settings.py | 7 ++ 4 files changed, 96 insertions(+), 62 deletions(-) create mode 100644 rest_framework/authtoken/south_migrations/0001_initial.py create mode 100644 rest_framework/authtoken/south_migrations/__init__.py diff --git a/rest_framework/authtoken/migrations/0001_initial.py b/rest_framework/authtoken/migrations/0001_initial.py index d5965e404..2e5d6b47e 100644 --- a/rest_framework/authtoken/migrations/0001_initial.py +++ b/rest_framework/authtoken/migrations/0001_initial.py @@ -1,67 +1,27 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models +# encoding: utf8 +from __future__ import unicode_literals -from rest_framework.settings import api_settings +from django.db import models, migrations +from django.conf import settings -try: - from django.contrib.auth import get_user_model -except ImportError: # django < 1.5 - from django.contrib.auth.models import User -else: - User = get_user_model() +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'Token' - db.create_table('authtoken_token', ( - ('key', self.gf('django.db.models.fields.CharField')(max_length=40, primary_key=True)), - ('user', self.gf('django.db.models.fields.related.OneToOneField')(related_name='auth_token', unique=True, to=orm['%s.%s' % (User._meta.app_label, User._meta.object_name)])), - ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), - )) - db.send_create_signal('authtoken', ['Token']) - - - def backwards(self, orm): - # Deleting model 'Token' - db.delete_table('authtoken_token') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - "%s.%s" % (User._meta.app_label, User._meta.module_name): { - 'Meta': {'object_name': User._meta.module_name}, - }, - 'authtoken.token': { - 'Meta': {'object_name': 'Token'}, - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), - 'key': ('django.db.models.fields.CharField', [], {'max_length': '40', 'primary_key': 'True'}), - 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'auth_token'", 'unique': 'True', 'to': "orm['%s.%s']" % (User._meta.app_label, User._meta.object_name)}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - } - } - - complete_apps = ['authtoken'] + operations = [ + migrations.CreateModel( + name='Token', + fields=[ + ('key', models.CharField(max_length=40, serialize=False, primary_key=True)), + ('user', models.OneToOneField(to=settings.AUTH_USER_MODEL, to_field='id')), + ('created', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/rest_framework/authtoken/south_migrations/0001_initial.py b/rest_framework/authtoken/south_migrations/0001_initial.py new file mode 100644 index 000000000..d5965e404 --- /dev/null +++ b/rest_framework/authtoken/south_migrations/0001_initial.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +from rest_framework.settings import api_settings + + +try: + from django.contrib.auth import get_user_model +except ImportError: # django < 1.5 + from django.contrib.auth.models import User +else: + User = get_user_model() + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'Token' + db.create_table('authtoken_token', ( + ('key', self.gf('django.db.models.fields.CharField')(max_length=40, primary_key=True)), + ('user', self.gf('django.db.models.fields.related.OneToOneField')(related_name='auth_token', unique=True, to=orm['%s.%s' % (User._meta.app_label, User._meta.object_name)])), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + )) + db.send_create_signal('authtoken', ['Token']) + + + def backwards(self, orm): + # Deleting model 'Token' + db.delete_table('authtoken_token') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + "%s.%s" % (User._meta.app_label, User._meta.module_name): { + 'Meta': {'object_name': User._meta.module_name}, + }, + 'authtoken.token': { + 'Meta': {'object_name': 'Token'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '40', 'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'auth_token'", 'unique': 'True', 'to': "orm['%s.%s']" % (User._meta.app_label, User._meta.object_name)}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['authtoken'] diff --git a/rest_framework/authtoken/south_migrations/__init__.py b/rest_framework/authtoken/south_migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 38753c968..332d5e4f9 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -119,6 +119,13 @@ DEFAULTS = { # Pending deprecation 'FILTER_BACKEND': None, + + + # 1.7 Migration Compatibility + + 'SOUTH_MIGRATION_MODULES': { + 'authtoken': 'rest_framework.authtoken.south_migrations', + } } From 3f727ce738776838d8420450ce28485954fbb097 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Tue, 24 Jun 2014 09:02:44 +0200 Subject: [PATCH 51/63] Added (first pass) notes to docs & release notes. Backed out `SOUTH_MIGRATION_MODULES` setting from `rest_framework.settings` --- docs/api-guide/authentication.md | 24 ++++++++++++++++++++++-- docs/topics/release-notes.md | 16 ++++++++++++---- rest_framework/settings.py | 6 ------ 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index 88a7a0119..0bddd0d03 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -126,7 +126,13 @@ To use the `TokenAuthentication` scheme, include `rest_framework.authtoken` in y 'rest_framework.authtoken' ) -Make sure to run `manage.py syncdb` after changing your settings. The `authtoken` database tables are managed by south (see [Schema migrations](#schema-migrations) below). + +--- + +**Note:** Make sure to run `manage.py syncdb` after changing your settings. Both Django native (from v1.7) and South migrations for the `authtoken` database tables are provided. See [Schema migrations](#schema-migrations) below. + +--- + You'll also need to create tokens for your users. @@ -198,7 +204,21 @@ Note that the default `obtain_auth_token` view explicitly uses JSON requests and #### Schema migrations -The `rest_framework.authtoken` app includes a south migration that will create the authtoken table. +The `rest_framework.authtoken` app includes both a Django native migration (for Django versions >1.7) and a south migration that will create the authtoken table. + +---- + +**Note** By default both Django (>1.7) and South will look for a module named `migrations`. To avoid a collision here, in order to use South you **must** provide the `SOUTH_MIGRATION_MODULES` option in your `settings.py`: + + + SOUTH_MIGRATION_MODULES = { + 'authtoken': 'rest_framework.authtoken.south_migrations', + } + +This tells South to look in the `south_migrations` module for the `authtoken` app. + +---- + If you're using a [custom user model][custom-user-model] you'll need to make sure that any initial migration that creates the user table runs before the authtoken table is created. diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 335497eec..5722d45be 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -43,6 +43,14 @@ You can determine your currently installed version using `pip freeze`: ### 2.3.x **Date**: April 2014 +* Added compatibility with Django 1.7's native migrations. + + **IMPORTANT**: In order to continue to use south with Django <1.7 you **must** provide + the `SOUTH_MIGRATION_MODULES` option in your `settings.py`: + + SOUTH_MIGRATION_MODULES = { + 'authtoken': 'rest_framework.authtoken.south_migrations', + } * Fix nested serializers linked through a backward foreign key relation * Fix bad links for the `BrowsableAPIRenderer` with `YAMLRenderer` @@ -165,9 +173,9 @@ You can determine your currently installed version using `pip freeze`: * Added `trailing_slash` option to routers. * Include support for `HttpStreamingResponse`. * Support wider range of default serializer validation when used with custom model fields. -* UTF-8 Support for browsable API descriptions. +* UTF-8 Support for browsable API descriptions. * OAuth2 provider uses timezone aware datetimes when supported. -* Bugfix: Return error correctly when OAuth non-existent consumer occurs. +* Bugfix: Return error correctly when OAuth non-existent consumer occurs. * Bugfix: Allow `FileUploadParser` to correctly filename if provided as URL kwarg. * Bugfix: Fix `ScopedRateThrottle`. @@ -208,7 +216,7 @@ You can determine your currently installed version using `pip freeze`: * Added SearchFilter * Added OrderingFilter * Added GenericViewSet -* Bugfix: Multiple `@action` and `@link` methods now allowed on viewsets. +* Bugfix: Multiple `@action` and `@link` methods now allowed on viewsets. * Bugfix: Fix API Root view issue with DjangoModelPermissions ### 2.3.2 @@ -261,7 +269,7 @@ You can determine your currently installed version using `pip freeze`: * Long HTTP headers in browsable API are broken in multiple lines when possible. * Bugfix: Fix regression with DjangoFilterBackend not worthing correctly with single object views. * Bugfix: OAuth should fail hard when invalid token used. -* Bugfix: Fix serializer potentially returning `None` object for models that define `__bool__` or `__len__`. +* Bugfix: Fix serializer potentially returning `None` object for models that define `__bool__` or `__len__`. ### 2.2.5 diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 332d5e4f9..fbef6e021 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -120,12 +120,6 @@ DEFAULTS = { # Pending deprecation 'FILTER_BACKEND': None, - - # 1.7 Migration Compatibility - - 'SOUTH_MIGRATION_MODULES': { - 'authtoken': 'rest_framework.authtoken.south_migrations', - } } From ae2ab496c2fa42ed60d325df4579b1ba38d3bfb5 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Mon, 18 Aug 2014 13:48:46 +0200 Subject: [PATCH 52/63] Updated docs for South v1.0 --- docs/api-guide/authentication.md | 11 ++--------- docs/topics/release-notes.md | 7 ++----- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index ad6257dda..ee59b375a 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -204,18 +204,11 @@ Note that the default `obtain_auth_token` view explicitly uses JSON requests and #### Schema migrations -The `rest_framework.authtoken` app includes both a Django native migration (for Django versions >1.7) and a south migration that will create the authtoken table. +The `rest_framework.authtoken` app includes both Django native migrations (for Django versions >1.7) and South migrations (for Django versions <1.7) that will create the authtoken table. ---- -**Note** By default both Django (>1.7) and South will look for a module named `migrations`. To avoid a collision here, in order to use South you **must** provide the `SOUTH_MIGRATION_MODULES` option in your `settings.py`: - - - SOUTH_MIGRATION_MODULES = { - 'authtoken': 'rest_framework.authtoken.south_migrations', - } - -This tells South to look in the `south_migrations` module for the `authtoken` app. +**Note**: From REST Framework v2.4.0 using South with Django <1.7 requires upgrading South v1.0+ ---- diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index 9c87c6c1d..b0e5b1982 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -42,12 +42,9 @@ You can determine your currently installed version using `pip freeze`: * Added compatibility with Django 1.7's native migrations. - **IMPORTANT**: In order to continue to use south with Django <1.7 you **must** provide - the `SOUTH_MIGRATION_MODULES` option in your `settings.py`: + **IMPORTANT**: In order to continue to use South with Django <1.7 you **must** upgrade to + South v1.0. - SOUTH_MIGRATION_MODULES = { - 'authtoken': 'rest_framework.authtoken.south_migrations', - } * Use py.test * `@detail_route` and `@list_route` decorators replace `@action` and `@link`. * `six` no longer bundled. For Django <= 1.4.1, install `six` package. From db4426fc35a92d2b0e263fd8a5702203cb3e06ed Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Mon, 18 Aug 2014 13:52:57 +0200 Subject: [PATCH 53/63] Alter other reference to migrations. --- docs/api-guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index ee59b375a..bc2ca4b69 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -129,7 +129,7 @@ To use the `TokenAuthentication` scheme you'll need to [configure the authentica --- -**Note:** Make sure to run `manage.py syncdb` after changing your settings. Both Django native (from v1.7) and South migrations for the `authtoken` database tables are provided. See [Schema migrations](#schema-migrations) below. +**Note:** Make sure to run `manage.py syncdb` after changing your settings. The 'rest_framework.authtoken' provides both Django (from v1.7) and South database migrations. See [Schema migrations](#schema-migrations) below. --- From 556948661acc4bf038ecdd0cb17b1e19f2080061 Mon Sep 17 00:00:00 2001 From: Carlton Gibson Date: Mon, 18 Aug 2014 13:54:46 +0200 Subject: [PATCH 54/63] Improve wording. --- docs/api-guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index bc2ca4b69..343466eee 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -129,7 +129,7 @@ To use the `TokenAuthentication` scheme you'll need to [configure the authentica --- -**Note:** Make sure to run `manage.py syncdb` after changing your settings. The 'rest_framework.authtoken' provides both Django (from v1.7) and South database migrations. See [Schema migrations](#schema-migrations) below. +**Note:** Make sure to run `manage.py syncdb` after changing your settings. The `rest_framework.authtoken` app provides both Django (from v1.7) and South database migrations. See [Schema migrations](#schema-migrations) below. --- From bf09c32de8f9d528f83e9cb7a2773d1f4c9ab563 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 13:28:07 +0100 Subject: [PATCH 55/63] Code linting and added runtests.py --- .travis.yml | 2 +- pytest.ini | 2 - optionals.txt => requirements-test.txt | 6 ++ requirements.txt | 2 - rest_framework/__init__.py | 8 +- rest_framework/authentication.py | 2 +- rest_framework/authtoken/models.py | 1 - .../south_migrations/0001_initial.py | 9 +- rest_framework/decorators.py | 7 +- rest_framework/exceptions.py | 1 + rest_framework/filters.py | 2 +- rest_framework/generics.py | 15 ++-- rest_framework/negotiation.py | 6 +- rest_framework/permissions.py | 22 +++-- rest_framework/renderers.py | 17 ++-- rest_framework/request.py | 25 ++++-- rest_framework/response.py | 6 +- rest_framework/serializers.py | 40 +++++---- rest_framework/settings.py | 12 +-- rest_framework/status.py | 4 + rest_framework/templatetags/rest_framework.py | 6 +- rest_framework/test.py | 7 +- rest_framework/urls.py | 10 ++- rest_framework/utils/encoders.py | 31 ++++--- rest_framework/utils/formatting.py | 4 +- rest_framework/utils/mediatypes.py | 2 +- rest_framework/views.py | 2 +- rest_framework/viewsets.py | 10 +-- runtests.py | 86 +++++++++++++++++++ conftest.py => tests/conftest.py | 0 tests/serializers.py | 1 - tests/settings.py | 11 ++- tests/test_authentication.py | 73 ++++++++++------ tests/test_breadcrumbs.py | 61 +++++++++---- tests/test_fields.py | 14 +-- tests/test_files.py | 11 +-- tests/test_filters.py | 9 +- tests/test_genericrelations.py | 24 +++--- tests/test_htmlrenderer.py | 3 +- tests/test_hyperlinkedserializers.py | 3 +- tests/test_pagination.py | 12 ++- tests/test_permissions.py | 58 +++++++++---- tests/test_relations.py | 11 ++- tests/test_relations_hyperlink.py | 15 ++-- tests/test_relations_pk.py | 12 +-- tests/test_renderers.py | 59 ++++++++----- tests/test_request.py | 3 +- tests/test_response.py | 3 +- tests/test_reverse.py | 3 +- tests/test_routers.py | 9 +- tests/test_serializer.py | 30 ++++--- tests/test_serializer_bulk_update.py | 6 +- tests/test_serializer_nested.py | 2 + tests/test_serializers.py | 4 +- tests/test_status.py | 2 +- tests/test_templatetags.py | 2 +- tests/test_testing.py | 6 +- tests/test_throttling.py | 48 ++++++----- tests/test_urlizer.py | 1 - tox.ini | 10 ++- 60 files changed, 548 insertions(+), 305 deletions(-) delete mode 100644 pytest.ini rename optionals.txt => requirements-test.txt (62%) create mode 100755 runtests.py rename conftest.py => tests/conftest.py (100%) diff --git a/.travis.yml b/.travis.yml index 5a6900a5e..9894ee4ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,7 +28,7 @@ install: - export PYTHONPATH=. script: - - py.test + - ./runtests.py matrix: exclude: diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index bbd083ac1..000000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -addopts = --tb=short diff --git a/optionals.txt b/requirements-test.txt similarity index 62% rename from optionals.txt rename to requirements-test.txt index 262e76443..a91dd0d4b 100644 --- a/optionals.txt +++ b/requirements-test.txt @@ -1,3 +1,9 @@ +# Test requirements +pytest-django==2.6 +pytest==2.5.2 +pytest-cov==1.6 + +# Optional packages markdown>=2.1.0 PyYAML>=3.10 defusedxml>=0.3 diff --git a/requirements.txt b/requirements.txt index 360acb14d..730c1d07a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1 @@ --e . Django>=1.3 -pytest-django==2.6 diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index 01036cefa..f30012b9b 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -1,9 +1,9 @@ """ -______ _____ _____ _____ __ _ -| ___ \ ___/ ___|_ _| / _| | | -| |_/ / |__ \ `--. | | | |_ _ __ __ _ _ __ ___ _____ _____ _ __| | __ +______ _____ _____ _____ __ +| ___ \ ___/ ___|_ _| / _| | | +| |_/ / |__ \ `--. | | | |_ _ __ __ _ _ __ ___ _____ _____ _ __| |__ | /| __| `--. \ | | | _| '__/ _` | '_ ` _ \ / _ \ \ /\ / / _ \| '__| |/ / -| |\ \| |___/\__/ / | | | | | | | (_| | | | | | | __/\ V V / (_) | | | < +| |\ \| |___/\__/ / | | | | | | | (_| | | | | | | __/\ V V / (_) | | | < \_| \_\____/\____/ \_/ |_| |_| \__,_|_| |_| |_|\___| \_/\_/ \___/|_| |_|\_| """ diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 82cea70fc..5721a869e 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -21,7 +21,7 @@ def get_authorization_header(request): Hide some test client ickyness where the header can be unicode. """ auth = request.META.get('HTTP_AUTHORIZATION', b'') - if type(auth) == type(''): + if isinstance(auth, type('')): # Work around django test client oddness auth = auth.encode(HTTP_HEADER_ENCODING) return auth diff --git a/rest_framework/authtoken/models.py b/rest_framework/authtoken/models.py index 167fa5314..db21d44c3 100644 --- a/rest_framework/authtoken/models.py +++ b/rest_framework/authtoken/models.py @@ -1,6 +1,5 @@ import binascii import os -from hashlib import sha1 from django.conf import settings from django.db import models diff --git a/rest_framework/authtoken/south_migrations/0001_initial.py b/rest_framework/authtoken/south_migrations/0001_initial.py index d5965e404..926de02b1 100644 --- a/rest_framework/authtoken/south_migrations/0001_initial.py +++ b/rest_framework/authtoken/south_migrations/0001_initial.py @@ -1,15 +1,10 @@ # -*- coding: utf-8 -*- -import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models - -from rest_framework.settings import api_settings - try: from django.contrib.auth import get_user_model -except ImportError: # django < 1.5 +except ImportError: # django < 1.5 from django.contrib.auth.models import User else: User = get_user_model() @@ -26,12 +21,10 @@ class Migration(SchemaMigration): )) db.send_create_signal('authtoken', ['Token']) - def backwards(self, orm): # Deleting model 'Token' db.delete_table('authtoken_token') - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index 18e41a18d..e06d6ff56 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -131,6 +131,7 @@ def list_route(methods=['get'], **kwargs): return func return decorator + # These are now pending deprecation, in favor of `detail_route` and `list_route`. def link(**kwargs): @@ -139,11 +140,13 @@ def link(**kwargs): """ msg = 'link is pending deprecation. Use detail_route instead.' warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): func.bind_to_methods = ['get'] func.detail = True func.kwargs = kwargs return func + return decorator @@ -153,9 +156,11 @@ def action(methods=['post'], **kwargs): """ msg = 'action is pending deprecation. Use detail_route instead.' warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) + def decorator(func): func.bind_to_methods = methods func.detail = True func.kwargs = kwargs return func - return decorator \ No newline at end of file + + return decorator diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 5f774a9f3..97dab77ea 100644 --- a/rest_framework/exceptions.py +++ b/rest_framework/exceptions.py @@ -23,6 +23,7 @@ class APIException(Exception): def __str__(self): return self.detail + class ParseError(APIException): status_code = status.HTTP_400_BAD_REQUEST default_detail = 'Malformed request.' diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c3b846aed..538386cee 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -116,7 +116,7 @@ class OrderingFilter(BaseFilterBackend): def get_ordering(self, request): """ Ordering is set by a comma delimited ?ordering=... query parameter. - + The `ordering` query parameter can be overridden by setting the `ordering_param` value on the OrderingFilter or by specifying an `ORDERING_PARAM` value in the API settings. diff --git a/rest_framework/generics.py b/rest_framework/generics.py index cecb548fb..02d243654 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -25,6 +25,7 @@ def strict_positive_int(integer_string, cutoff=None): ret = min(ret, cutoff) return ret + def get_object_or_404(queryset, *filter_args, **filter_kwargs): """ Same as Django's standard shortcut, but make sure to raise 404 @@ -162,10 +163,11 @@ class GenericAPIView(views.APIView): raise Http404(_("Page is not 'last', nor can it be converted to an int.")) try: page = paginator.page(page_number) - except InvalidPage as e: - raise Http404(_('Invalid page (%(page_number)s): %(message)s') % { - 'page_number': page_number, - 'message': str(e) + except InvalidPage as exc: + error_format = _('Invalid page (%(page_number)s): %(message)s') + raise Http404(error_format % { + 'page_number': page_number, + 'message': str(exc) }) if deprecated_style: @@ -208,7 +210,6 @@ class GenericAPIView(views.APIView): return filter_backends - ######################## ### The following methods provide default implementations ### that you may want to override for more complex cases. @@ -284,8 +285,8 @@ class GenericAPIView(views.APIView): if self.model is not None: return self.model._default_manager.all() - raise ImproperlyConfigured("'%s' must define 'queryset' or 'model'" - % self.__class__.__name__) + error_format = "'%s' must define 'queryset' or 'model'" + raise ImproperlyConfigured(error_format % self.__class__.__name__) def get_object(self, queryset=None): """ diff --git a/rest_framework/negotiation.py b/rest_framework/negotiation.py index 4d205c0e8..ca7b53978 100644 --- a/rest_framework/negotiation.py +++ b/rest_framework/negotiation.py @@ -54,8 +54,10 @@ class DefaultContentNegotiation(BaseContentNegotiation): for media_type in media_type_set: if media_type_matches(renderer.media_type, media_type): # Return the most specific media type as accepted. - if (_MediaType(renderer.media_type).precedence > - _MediaType(media_type).precedence): + if ( + _MediaType(renderer.media_type).precedence > + _MediaType(media_type).precedence + ): # Eg client requests '*/*' # Accepted media type is 'application/json' return renderer, renderer.media_type diff --git a/rest_framework/permissions.py b/rest_framework/permissions.py index c95171389..6a1a00770 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -62,9 +62,11 @@ class IsAuthenticatedOrReadOnly(BasePermission): """ def has_permission(self, request, view): - return (request.method in SAFE_METHODS or - request.user and - request.user.is_authenticated()) + return ( + request.method in SAFE_METHODS or + request.user and + request.user.is_authenticated() + ) class DjangoModelPermissions(BasePermission): @@ -122,9 +124,11 @@ class DjangoModelPermissions(BasePermission): perms = self.get_required_permissions(request.method, model_cls) - return (request.user and + return ( + request.user and (request.user.is_authenticated() or not self.authenticated_users_only) and - request.user.has_perms(perms)) + request.user.has_perms(perms) + ) class DjangoModelPermissionsOrAnonReadOnly(DjangoModelPermissions): @@ -212,6 +216,8 @@ class TokenHasReadWriteScope(BasePermission): required = oauth2_constants.READ if read_only else oauth2_constants.WRITE return oauth2_provider_scope.check(required, request.auth.scope) - assert False, ('TokenHasReadWriteScope requires either the' - '`OAuthAuthentication` or `OAuth2Authentication` authentication ' - 'class to be used.') + assert False, ( + 'TokenHasReadWriteScope requires either the' + '`OAuthAuthentication` or `OAuth2Authentication` authentication ' + 'class to be used.' + ) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 7048d87de..3dabd277e 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -8,7 +8,6 @@ REST framework also provides an HTML renderer the renders the browsable API. """ from __future__ import unicode_literals -import copy import json import django from django import forms @@ -75,7 +74,6 @@ class JSONRenderer(BaseRenderer): # E.g. If we're being called by the BrowsableAPIRenderer. return renderer_context.get('indent', None) - def render(self, data, accepted_media_type=None, renderer_context=None): """ Render `data` into JSON, returning a bytestring. @@ -86,8 +84,10 @@ class JSONRenderer(BaseRenderer): renderer_context = renderer_context or {} indent = self.get_indent(accepted_media_type, renderer_context) - ret = json.dumps(data, cls=self.encoder_class, - indent=indent, ensure_ascii=self.ensure_ascii) + ret = json.dumps( + data, cls=self.encoder_class, + indent=indent, ensure_ascii=self.ensure_ascii + ) # On python 2.x json.dumps() returns bytestrings if ensure_ascii=True, # but if ensure_ascii=False, the return type is underspecified, @@ -454,8 +454,10 @@ class BrowsableAPIRenderer(BaseRenderer): if method in ('DELETE', 'OPTIONS'): return True # Don't actually need to return a form - if (not getattr(view, 'get_serializer', None) - or not any(is_form_media_type(parser.media_type) for parser in view.parser_classes)): + if ( + not getattr(view, 'get_serializer', None) + or not any(is_form_media_type(parser.media_type) for parser in view.parser_classes) + ): return serializer = view.get_serializer(instance=obj, data=data, files=files) @@ -576,7 +578,7 @@ class BrowsableAPIRenderer(BaseRenderer): 'version': VERSION, 'breadcrumblist': self.get_breadcrumbs(request), 'allowed_methods': view.allowed_methods, - 'available_formats': [renderer.format for renderer in view.renderer_classes], + 'available_formats': [renderer_cls.format for renderer_cls in view.renderer_classes], 'response_headers': response_headers, 'put_form': self.get_rendered_html_form(view, 'PUT', request), @@ -625,4 +627,3 @@ class MultiPartRenderer(BaseRenderer): def render(self, data, accepted_media_type=None, renderer_context=None): return encode_multipart(self.BOUNDARY, data) - diff --git a/rest_framework/request.py b/rest_framework/request.py index d508f9b43..620b00ada 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -295,8 +295,11 @@ class Request(object): Return the content body of the request, as a stream. """ try: - content_length = int(self.META.get('CONTENT_LENGTH', - self.META.get('HTTP_CONTENT_LENGTH'))) + content_length = int( + self.META.get( + 'CONTENT_LENGTH', self.META.get('HTTP_CONTENT_LENGTH') + ) + ) except (ValueError, TypeError): content_length = 0 @@ -320,9 +323,11 @@ class Request(object): ) # We only need to use form overloading on form POST requests. - if (not USE_FORM_OVERLOADING + if ( + not USE_FORM_OVERLOADING or self._request.method != 'POST' - or not is_form_media_type(self._content_type)): + or not is_form_media_type(self._content_type) + ): return # At this point we're committed to parsing the request as form data. @@ -330,15 +335,19 @@ class Request(object): self._files = self._request.FILES # Method overloading - change the method and remove the param from the content. - if (self._METHOD_PARAM and - self._METHOD_PARAM in self._data): + if ( + self._METHOD_PARAM and + self._METHOD_PARAM in self._data + ): self._method = self._data[self._METHOD_PARAM].upper() # Content overloading - modify the content type, and force re-parse. - if (self._CONTENT_PARAM and + if ( + self._CONTENT_PARAM and self._CONTENTTYPE_PARAM and self._CONTENT_PARAM in self._data and - self._CONTENTTYPE_PARAM in self._data): + self._CONTENTTYPE_PARAM in self._data + ): self._content_type = self._data[self._CONTENTTYPE_PARAM] self._stream = BytesIO(self._data[self._CONTENT_PARAM].encode(self.parser_context['encoding'])) self._data, self._files = (Empty, Empty) diff --git a/rest_framework/response.py b/rest_framework/response.py index 25b785245..80225cac3 100644 --- a/rest_framework/response.py +++ b/rest_framework/response.py @@ -62,8 +62,10 @@ class Response(SimpleTemplateResponse): ret = renderer.render(self.data, media_type, context) if isinstance(ret, six.text_type): - assert charset, 'renderer returned unicode, and did not specify ' \ - 'a charset value.' + assert charset, ( + 'renderer returned unicode, and did not specify ' + 'a charset value.' + ) return bytes(ret.encode(charset)) if not ret: diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 2fdc9b9da..95288671c 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -449,9 +449,11 @@ class BaseSerializer(WritableField): # If we have a model manager or similar object then we need # to iterate through each instance. - if (self.many and + if ( + self.many and not hasattr(obj, '__iter__') and - is_simple_callable(getattr(obj, 'all', None))): + is_simple_callable(getattr(obj, 'all', None)) + ): obj = obj.all() kwargs = { @@ -601,8 +603,10 @@ class BaseSerializer(WritableField): API schemas for auto-documentation. """ return SortedDict( - [(field_name, field.metadata()) - for field_name, field in six.iteritems(self.fields)] + [ + (field_name, field.metadata()) + for field_name, field in six.iteritems(self.fields) + ] ) @@ -656,8 +660,10 @@ class ModelSerializer(Serializer): """ cls = self.opts.model - assert cls is not None, \ - "Serializer class '%s' is missing 'model' Meta option" % self.__class__.__name__ + assert cls is not None, ( + "Serializer class '%s' is missing 'model' Meta option" % + self.__class__.__name__ + ) opts = cls._meta.concrete_model._meta ret = SortedDict() nested = bool(self.opts.depth) @@ -668,9 +674,9 @@ class ModelSerializer(Serializer): # If model is a child via multitable inheritance, use parent's pk pk_field = pk_field.rel.to._meta.pk - field = self.get_pk_field(pk_field) - if field: - ret[pk_field.name] = field + serializer_pk_field = self.get_pk_field(pk_field) + if serializer_pk_field: + ret[pk_field.name] = serializer_pk_field # Deal with forward relationships forward_rels = [field for field in opts.fields if field.serialize] @@ -739,9 +745,11 @@ class ModelSerializer(Serializer): is_m2m = isinstance(relation.field, models.fields.related.ManyToManyField) - if (is_m2m and + if ( + is_m2m and hasattr(relation.field.rel, 'through') and - not relation.field.rel.through._meta.auto_created): + not relation.field.rel.through._meta.auto_created + ): has_through_model = True if nested: @@ -911,10 +919,12 @@ class ModelSerializer(Serializer): for field_name, field in self.fields.items(): field_name = field.source or field_name - if field_name in exclusions \ - and not field.read_only \ - and (field.required or hasattr(instance, field_name)) \ - and not isinstance(field, Serializer): + if ( + field_name in exclusions + and not field.read_only + and (field.required or hasattr(instance, field_name)) + and not isinstance(field, Serializer) + ): exclusions.remove(field_name) return exclusions diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 2727f5960..6806a4689 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -46,16 +46,12 @@ DEFAULTS = { 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.AllowAny', ), - 'DEFAULT_THROTTLE_CLASSES': ( - ), - 'DEFAULT_CONTENT_NEGOTIATION_CLASS': - 'rest_framework.negotiation.DefaultContentNegotiation', + 'DEFAULT_THROTTLE_CLASSES': (), + 'DEFAULT_CONTENT_NEGOTIATION_CLASS': 'rest_framework.negotiation.DefaultContentNegotiation', # Genric view behavior - 'DEFAULT_MODEL_SERIALIZER_CLASS': - 'rest_framework.serializers.ModelSerializer', - 'DEFAULT_PAGINATION_SERIALIZER_CLASS': - 'rest_framework.pagination.PaginationSerializer', + 'DEFAULT_MODEL_SERIALIZER_CLASS': 'rest_framework.serializers.ModelSerializer', + 'DEFAULT_PAGINATION_SERIALIZER_CLASS': 'rest_framework.pagination.PaginationSerializer', 'DEFAULT_FILTER_BACKENDS': (), # Throttling diff --git a/rest_framework/status.py b/rest_framework/status.py index 764353711..90a755089 100644 --- a/rest_framework/status.py +++ b/rest_framework/status.py @@ -10,15 +10,19 @@ from __future__ import unicode_literals def is_informational(code): return code >= 100 and code <= 199 + def is_success(code): return code >= 200 and code <= 299 + def is_redirect(code): return code >= 300 and code <= 399 + def is_client_error(code): return code >= 400 and code <= 499 + def is_server_error(code): return code >= 500 and code <= 599 diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 911b1b622..5b8fa3853 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -152,8 +152,10 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru middle = middle[len(opening):] lead = lead + opening # Keep parentheses at the end only if they're balanced. - if (middle.endswith(closing) - and middle.count(closing) == middle.count(opening) + 1): + if ( + middle.endswith(closing) + and middle.count(closing) == middle.count(opening) + 1 + ): middle = middle[:-len(closing)] trail = closing + trail diff --git a/rest_framework/test.py b/rest_framework/test.py index d4ec50a06..9242cf7c6 100644 --- a/rest_framework/test.py +++ b/rest_framework/test.py @@ -49,9 +49,10 @@ class APIRequestFactory(DjangoRequestFactory): else: format = format or self.default_format - assert format in self.renderer_classes, ("Invalid format '{0}'. " - "Available formats are {1}. Set TEST_REQUEST_RENDERER_CLASSES " - "to enable extra request formats.".format( + assert format in self.renderer_classes, ( + "Invalid format '{0}'. Available formats are {1}. " + "Set TEST_REQUEST_RENDERER_CLASSES to enable " + "extra request formats.".format( format, ', '.join(["'" + fmt + "'" for fmt in self.renderer_classes.keys()]) ) diff --git a/rest_framework/urls.py b/rest_framework/urls.py index eed4bd140..8fa3073e8 100644 --- a/rest_framework/urls.py +++ b/rest_framework/urls.py @@ -8,17 +8,19 @@ your API requires authentication: ... url(r'^auth', include('rest_framework.urls', namespace='rest_framework')) ) - + The urls must be namespaced as 'rest_framework', and you should make sure your authentication settings include `SessionAuthentication`. """ from __future__ import unicode_literals from django.conf.urls import patterns, url +from django.contrib.auth import views template_name = {'template_name': 'rest_framework/login.html'} -urlpatterns = patterns('django.contrib.auth.views', - url(r'^login/$', 'login', template_name, name='login'), - url(r'^logout/$', 'logout', template_name, name='logout'), +urlpatterns = patterns( + '', + url(r'^login/$', views.login, template_name, name='login'), + url(r'^logout/$', views.logout, template_name, name='logout') ) diff --git a/rest_framework/utils/encoders.py b/rest_framework/utils/encoders.py index c125ac8a8..00ffdfbae 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -98,14 +98,23 @@ else: node.flow_style = best_style return node - SafeDumper.add_representer(decimal.Decimal, - SafeDumper.represent_decimal) - - SafeDumper.add_representer(SortedDict, - yaml.representer.SafeRepresenter.represent_dict) - SafeDumper.add_representer(DictWithMetadata, - yaml.representer.SafeRepresenter.represent_dict) - SafeDumper.add_representer(SortedDictWithMetadata, - yaml.representer.SafeRepresenter.represent_dict) - SafeDumper.add_representer(types.GeneratorType, - yaml.representer.SafeRepresenter.represent_list) + SafeDumper.add_representer( + decimal.Decimal, + SafeDumper.represent_decimal + ) + SafeDumper.add_representer( + SortedDict, + yaml.representer.SafeRepresenter.represent_dict + ) + SafeDumper.add_representer( + DictWithMetadata, + yaml.representer.SafeRepresenter.represent_dict + ) + SafeDumper.add_representer( + SortedDictWithMetadata, + yaml.representer.SafeRepresenter.represent_dict + ) + SafeDumper.add_representer( + types.GeneratorType, + yaml.representer.SafeRepresenter.represent_list + ) diff --git a/rest_framework/utils/formatting.py b/rest_framework/utils/formatting.py index 4b59ba840..6d53aed11 100644 --- a/rest_framework/utils/formatting.py +++ b/rest_framework/utils/formatting.py @@ -6,8 +6,6 @@ from __future__ import unicode_literals from django.utils.html import escape from django.utils.safestring import mark_safe from rest_framework.compat import apply_markdown -from rest_framework.settings import api_settings -from textwrap import dedent import re @@ -40,6 +38,7 @@ def dedent(content): return content.strip() + def camelcase_to_spaces(content): """ Translate 'CamelCaseNames' to 'Camel Case Names'. @@ -49,6 +48,7 @@ def camelcase_to_spaces(content): content = re.sub(camelcase_boundry, ' \\1', content).strip() return ' '.join(content.split('_')).title() + def markup_description(description): """ Apply HTML markup to the given description. diff --git a/rest_framework/utils/mediatypes.py b/rest_framework/utils/mediatypes.py index 92f99efd2..727f9c19e 100644 --- a/rest_framework/utils/mediatypes.py +++ b/rest_framework/utils/mediatypes.py @@ -57,7 +57,7 @@ class _MediaType(object): if key != 'q' and other.params.get(key, None) != self.params.get(key, None): return False - if self.sub_type != '*' and other.sub_type != '*' and other.sub_type != self.sub_type: + if self.sub_type != '*' and other.sub_type != '*' and other.sub_type != self.sub_type: return False if self.main_type != '*' and other.main_type != '*' and other.main_type != self.main_type: diff --git a/rest_framework/views.py b/rest_framework/views.py index a2668f2c0..bca0aaef1 100644 --- a/rest_framework/views.py +++ b/rest_framework/views.py @@ -31,6 +31,7 @@ def get_view_name(view_cls, suffix=None): return name + def get_view_description(view_cls, html=False): """ Given a view class, return a textual description to represent the view. @@ -119,7 +120,6 @@ class APIView(View): headers['Vary'] = 'Accept' return headers - def http_method_not_allowed(self, request, *args, **kwargs): """ If `request.method` does not correspond to a handler method, diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 7eb29f99b..bb5b304ee 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -127,11 +127,11 @@ class ReadOnlyModelViewSet(mixins.RetrieveModelMixin, class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - GenericViewSet): + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + GenericViewSet): """ A viewset that provides default `create()`, `retrieve()`, `update()`, `partial_update()`, `destroy()` and `list()` actions. diff --git a/runtests.py b/runtests.py new file mode 100755 index 000000000..4da05ac37 --- /dev/null +++ b/runtests.py @@ -0,0 +1,86 @@ +#! /usr/bin/env python +from __future__ import print_function + +import pytest +import sys +import os +import subprocess + + +PYTEST_ARGS = { + 'default': ['tests'], + 'fast': ['tests', '-q'], +} + +FLAKE8_ARGS = ['rest_framework', 'tests', '--ignore=E501'] + + +sys.path.append(os.path.dirname(__file__)) + +def exit_on_failure(ret, message=None): + if ret: + sys.exit(ret) + +def flake8_main(args): + print('Running flake8 code linting') + ret = subprocess.call(['flake8'] + args) + print('flake8 failed' if ret else 'flake8 passed') + return ret + +def split_class_and_function(string): + class_string, function_string = string.split('.', 1) + return "%s and %s" % (class_string, function_string) + +def is_function(string): + # `True` if it looks like a test function is included in the string. + return string.startswith('test_') or '.test_' in string + +def is_class(string): + # `True` if first character is uppercase - assume it's a class name. + return string[0] == string[0].upper() + + +if __name__ == "__main__": + try: + sys.argv.remove('--nolint') + except ValueError: + run_flake8 = True + else: + run_flake8 = False + + try: + sys.argv.remove('--lintonly') + except ValueError: + run_tests = True + else: + run_tests = False + + try: + sys.argv.remove('--fast') + except ValueError: + style = 'default' + else: + style = 'fast' + run_flake8 = False + + if len(sys.argv) > 1: + pytest_args = sys.argv[1:] + first_arg = pytest_args[0] + if first_arg.startswith('-'): + # `runtests.py [flags]` + pytest_args = ['tests'] + pytest_args + elif is_class(first_arg) and is_function(first_arg): + # `runtests.py TestCase.test_function [flags]` + expression = split_class_and_function(first_arg) + pytest_args = ['tests', '-k', expression] + pytest_args[1:] + elif is_class(first_arg) or is_function(first_arg): + # `runtests.py TestCase [flags]` + # `runtests.py test_function [flags]` + pytest_args = ['tests', '-k', pytest_args[0]] + pytest_args[1:] + else: + pytest_args = PYTEST_ARGS[style] + + if run_tests: + exit_on_failure(pytest.main(pytest_args)) + if run_flake8: + exit_on_failure(flake8_main(FLAKE8_ARGS)) diff --git a/conftest.py b/tests/conftest.py similarity index 100% rename from conftest.py rename to tests/conftest.py diff --git a/tests/serializers.py b/tests/serializers.py index f2f85b6ea..be7b37722 100644 --- a/tests/serializers.py +++ b/tests/serializers.py @@ -1,5 +1,4 @@ from rest_framework import serializers - from tests.models import NullableForeignKeySource diff --git a/tests/settings.py b/tests/settings.py index de41dc66a..91c9ed09e 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -68,7 +68,6 @@ SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy' TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', -# 'django.template.loaders.eggs.Loader', ) MIDDLEWARE_CLASSES = ( @@ -104,8 +103,8 @@ INSTALLED_APPS = ( # OAuth is optional and won't work if there is no oauth_provider & oauth2 try: - import oauth_provider - import oauth2 + import oauth_provider # NOQA + import oauth2 # NOQA except ImportError: pass else: @@ -114,7 +113,7 @@ else: ) try: - import provider + import provider # NOQA except ImportError: pass else: @@ -125,13 +124,13 @@ else: # guardian is optional try: - import guardian + import guardian # NOQA except ImportError: pass else: ANONYMOUS_USER_ID = -1 AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # default + 'django.contrib.auth.backends.ModelBackend', # default 'guardian.backends.ObjectPermissionBackend', ) INSTALLED_APPS += ( diff --git a/tests/test_authentication.py b/tests/test_authentication.py index f5bfc5e61..9db4f62df 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -45,26 +45,39 @@ class MockView(APIView): return HttpResponse({'a': 1, 'b': 2, 'c': 3}) -urlpatterns = patterns('', +urlpatterns = patterns( + '', (r'^session/$', MockView.as_view(authentication_classes=[SessionAuthentication])), (r'^basic/$', MockView.as_view(authentication_classes=[BasicAuthentication])), (r'^token/$', MockView.as_view(authentication_classes=[TokenAuthentication])), (r'^auth-token/$', 'rest_framework.authtoken.views.obtain_auth_token'), (r'^oauth/$', MockView.as_view(authentication_classes=[OAuthAuthentication])), - (r'^oauth-with-scope/$', MockView.as_view(authentication_classes=[OAuthAuthentication], - permission_classes=[permissions.TokenHasReadWriteScope])) + ( + r'^oauth-with-scope/$', + MockView.as_view( + authentication_classes=[OAuthAuthentication], + permission_classes=[permissions.TokenHasReadWriteScope] + ) + ) ) + class OAuth2AuthenticationDebug(OAuth2Authentication): allow_query_params_token = True if oauth2_provider is not None: - urlpatterns += patterns('', + urlpatterns += patterns( + '', url(r'^oauth2/', include('provider.oauth2.urls', namespace='oauth2')), url(r'^oauth2-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication])), url(r'^oauth2-test-debug/$', MockView.as_view(authentication_classes=[OAuth2AuthenticationDebug])), - url(r'^oauth2-with-scope-test/$', MockView.as_view(authentication_classes=[OAuth2Authentication], - permission_classes=[permissions.TokenHasReadWriteScope])), + url( + r'^oauth2-with-scope-test/$', + MockView.as_view( + authentication_classes=[OAuth2Authentication], + permission_classes=[permissions.TokenHasReadWriteScope] + ) + ) ) @@ -278,12 +291,16 @@ class OAuthTests(TestCase): self.TOKEN_KEY = "token_key" self.TOKEN_SECRET = "token_secret" - self.consumer = Consumer.objects.create(key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET, - name='example', user=self.user, status=self.consts.ACCEPTED) + self.consumer = Consumer.objects.create( + key=self.CONSUMER_KEY, secret=self.CONSUMER_SECRET, + name='example', user=self.user, status=self.consts.ACCEPTED + ) self.scope = Scope.objects.create(name="resource name", url="api/") - self.token = OAuthToken.objects.create(user=self.user, consumer=self.consumer, scope=self.scope, - token_type=OAuthToken.ACCESS, key=self.TOKEN_KEY, secret=self.TOKEN_SECRET, is_approved=True + self.token = OAuthToken.objects.create( + user=self.user, consumer=self.consumer, scope=self.scope, + token_type=OAuthToken.ACCESS, key=self.TOKEN_KEY, secret=self.TOKEN_SECRET, + is_approved=True ) def _create_authorization_header(self): @@ -501,24 +518,24 @@ class OAuth2Tests(TestCase): self.REFRESH_TOKEN = "refresh_token" self.oauth2_client = oauth2_provider.oauth2.models.Client.objects.create( - client_id=self.CLIENT_ID, - client_secret=self.CLIENT_SECRET, - redirect_uri='', - client_type=0, - name='example', - user=None, - ) + client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, + redirect_uri='', + client_type=0, + name='example', + user=None, + ) self.access_token = oauth2_provider.oauth2.models.AccessToken.objects.create( - token=self.ACCESS_TOKEN, - client=self.oauth2_client, - user=self.user, - ) + token=self.ACCESS_TOKEN, + client=self.oauth2_client, + user=self.user, + ) self.refresh_token = oauth2_provider.oauth2.models.RefreshToken.objects.create( - user=self.user, - access_token=self.access_token, - client=self.oauth2_client - ) + user=self.user, + access_token=self.access_token, + client=self.oauth2_client + ) def _create_authorization_header(self, token=None): return "Bearer {0}".format(token or self.access_token.token) @@ -569,8 +586,10 @@ class OAuth2Tests(TestCase): @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') def test_post_form_passing_auth_url_transport(self): """Ensure GETing form over OAuth with correct client credentials in form data succeed""" - response = self.csrf_client.post('/oauth2-test/', - data={'access_token': self.access_token.token}) + response = self.csrf_client.post( + '/oauth2-test/', + data={'access_token': self.access_token.token} + ) self.assertEqual(response.status_code, 200) @unittest.skipUnless(oauth2_provider, 'django-oauth2-provider not installed') diff --git a/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py index f26c3eafe..780fd5c4d 100644 --- a/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -24,7 +24,8 @@ class NestedResourceRoot(APIView): class NestedResourceInstance(APIView): pass -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^$', Root.as_view()), url(r'^resource/$', ResourceRoot.as_view()), url(r'^resource/(?P[0-9]+)$', ResourceInstance.as_view()), @@ -40,34 +41,60 @@ class BreadcrumbTests(TestCase): def test_root_breadcrumbs(self): url = '/' - self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) + self.assertEqual( + get_breadcrumbs(url), + [('Root', '/')] + ) def test_resource_root_breadcrumbs(self): url = '/resource/' - self.assertEqual(get_breadcrumbs(url), [('Root', '/'), - ('Resource Root', '/resource/')]) + self.assertEqual( + get_breadcrumbs(url), + [ + ('Root', '/'), + ('Resource Root', '/resource/') + ] + ) def test_resource_instance_breadcrumbs(self): url = '/resource/123' - self.assertEqual(get_breadcrumbs(url), [('Root', '/'), - ('Resource Root', '/resource/'), - ('Resource Instance', '/resource/123')]) + self.assertEqual( + get_breadcrumbs(url), + [ + ('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123') + ] + ) def test_nested_resource_breadcrumbs(self): url = '/resource/123/' - self.assertEqual(get_breadcrumbs(url), [('Root', '/'), - ('Resource Root', '/resource/'), - ('Resource Instance', '/resource/123'), - ('Nested Resource Root', '/resource/123/')]) + self.assertEqual( + get_breadcrumbs(url), + [ + ('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123'), + ('Nested Resource Root', '/resource/123/') + ] + ) def test_nested_resource_instance_breadcrumbs(self): url = '/resource/123/abc' - self.assertEqual(get_breadcrumbs(url), [('Root', '/'), - ('Resource Root', '/resource/'), - ('Resource Instance', '/resource/123'), - ('Nested Resource Root', '/resource/123/'), - ('Nested Resource Instance', '/resource/123/abc')]) + self.assertEqual( + get_breadcrumbs(url), + [ + ('Root', '/'), + ('Resource Root', '/resource/'), + ('Resource Instance', '/resource/123'), + ('Nested Resource Root', '/resource/123/'), + ('Nested Resource Instance', '/resource/123/abc') + ] + ) def test_broken_url_breadcrumbs_handled_gracefully(self): url = '/foobar' - self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) + self.assertEqual( + get_breadcrumbs(url), + [('Root', '/')] + ) diff --git a/tests/test_fields.py b/tests/test_fields.py index 97ef016fa..094ac1eb0 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -648,7 +648,7 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '123'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure this value is less than or equal to 100.']}) + self.assertEqual(s.errors, {'decimal_field': ['Ensure this value is less than or equal to 100.']}) def test_raise_min_value(self): """ @@ -660,7 +660,7 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '99'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure this value is greater than or equal to 100.']}) + self.assertEqual(s.errors, {'decimal_field': ['Ensure this value is greater than or equal to 100.']}) def test_raise_max_digits(self): """ @@ -672,7 +672,7 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '123.456'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 5 digits in total.']}) + self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 5 digits in total.']}) def test_raise_max_decimal_places(self): """ @@ -684,7 +684,7 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '123.4567'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 3 decimal places.']}) + self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 3 decimal places.']}) def test_raise_max_whole_digits(self): """ @@ -696,7 +696,7 @@ class DecimalFieldTest(TestCase): s = DecimalSerializer(data={'decimal_field': '12345.6'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) + self.assertEqual(s.errors, {'decimal_field': ['Ensure that there are no more than 4 digits in total.']}) class ChoiceFieldTests(TestCase): @@ -729,7 +729,7 @@ class ChoiceFieldTests(TestCase): def test_invalid_choice_model(self): s = ChoiceFieldModelSerializer(data={'choice': 'wrong_value'}) self.assertFalse(s.is_valid()) - self.assertEqual(s.errors, {'choice': ['Select a valid choice. wrong_value is not one of the available choices.']}) + self.assertEqual(s.errors, {'choice': ['Select a valid choice. wrong_value is not one of the available choices.']}) self.assertEqual(s.data['choice'], '') def test_empty_choice_model(self): @@ -875,7 +875,7 @@ class SlugFieldTests(TestCase): s = SlugFieldSerializer(data={'slug_field': 'a b'}) self.assertEqual(s.is_valid(), False) - self.assertEqual(s.errors, {'slug_field': ["Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."]}) + self.assertEqual(s.errors, {'slug_field': ["Enter a valid 'slug' consisting of letters, numbers, underscores or hyphens."]}) class URLFieldTests(TestCase): diff --git a/tests/test_files.py b/tests/test_files.py index 78f4cf425..af110df91 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -85,11 +85,8 @@ class FileSerializerTests(TestCase): """ Validation should still function when no data dictionary is provided. """ - now = datetime.datetime.now() - file = BytesIO(six.b('stuff')) - file.name = 'stuff.txt' - file.size = len(file.getvalue()) - uploaded_file = UploadedFile(file=file, created=now) - - serializer = UploadedFileSerializer(files={'file': file}) + uploaded_file = BytesIO(six.b('stuff')) + uploaded_file.name = 'stuff.txt' + uploaded_file.size = len(uploaded_file.getvalue()) + serializer = UploadedFileSerializer(files={'file': uploaded_file}) self.assertFalse(serializer.is_valid()) diff --git a/tests/test_filters.py b/tests/test_filters.py index 85840e018..b29760fa4 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -74,7 +74,8 @@ if django_filters: def get_queryset(self): return FilterableItem.objects.all() - urlpatterns = patterns('', + urlpatterns = patterns( + '', url(r'^(?P\d+)/$', FilterClassDetailView.as_view(), name='detail-view'), url(r'^$', FilterClassRootView.as_view(), name='root-view'), url(r'^get-queryset/$', GetQuerysetView.as_view(), @@ -653,8 +654,8 @@ class SensitiveOrderingFilterTests(TestCase): self.assertEqual( response.data, [ - {'id': 1, username_field: 'userA'}, # PassB - {'id': 2, username_field: 'userB'}, # PassC - {'id': 3, username_field: 'userC'}, # PassA + {'id': 1, username_field: 'userA'}, # PassB + {'id': 2, username_field: 'userB'}, # PassC + {'id': 3, username_field: 'userC'}, # PassA ] ) diff --git a/tests/test_genericrelations.py b/tests/test_genericrelations.py index 3a8f3c7f1..95295eaa8 100644 --- a/tests/test_genericrelations.py +++ b/tests/test_genericrelations.py @@ -117,18 +117,18 @@ class TestGenericRelations(TestCase): serializer = TagSerializer(Tag.objects.all(), many=True) expected = [ - { - 'tag': 'django', - 'tagged_item': 'Bookmark: https://www.djangoproject.com/' - }, - { - 'tag': 'python', - 'tagged_item': 'Bookmark: https://www.djangoproject.com/' - }, - { - 'tag': 'reminder', - 'tagged_item': 'Note: Remember the milk' - } + { + 'tag': 'django', + 'tagged_item': 'Bookmark: https://www.djangoproject.com/' + }, + { + 'tag': 'python', + 'tagged_item': 'Bookmark: https://www.djangoproject.com/' + }, + { + 'tag': 'reminder', + 'tagged_item': 'Note: Remember the milk' + } ] self.assertEqual(serializer.data, expected) diff --git a/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py index 88d11c46b..5a680f99b 100644 --- a/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -34,7 +34,8 @@ def not_found(request): raise Http404() -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^$', example), url(r'^permission_denied$', permission_denied), url(r'^not_found$', not_found), diff --git a/tests/test_hyperlinkedserializers.py b/tests/test_hyperlinkedserializers.py index d478ea730..d45485391 100644 --- a/tests/test_hyperlinkedserializers.py +++ b/tests/test_hyperlinkedserializers.py @@ -94,7 +94,8 @@ class OptionalRelationDetail(generics.RetrieveUpdateDestroyAPIView): model_serializer_class = serializers.HyperlinkedModelSerializer -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^basic/$', BasicList.as_view(), name='basicmodel-list'), url(r'^basic/(?P\d+)/$', BasicDetail.as_view(), name='basicmodel-detail'), url(r'^anchor/(?P\d+)/$', AnchorDetail.as_view(), name='anchor-detail'), diff --git a/tests/test_pagination.py b/tests/test_pagination.py index 293146c08..d5b9244d9 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals import datetime from decimal import Decimal -from django.db import models from django.core.paginator import Paginator from django.test import TestCase from django.utils import unittest @@ -12,6 +11,7 @@ from .models import BasicModel, FilterableItem factory = APIRequestFactory() + # Helper function to split arguments out of an url def split_arguments_from_url(url): if '?' not in url: @@ -274,8 +274,8 @@ class TestUnpaginated(TestCase): BasicModel(text=i).save() self.objects = BasicModel.objects self.data = [ - {'id': obj.id, 'text': obj.text} - for obj in self.objects.all() + {'id': obj.id, 'text': obj.text} + for obj in self.objects.all() ] self.view = DefaultPageSizeKwargView.as_view() @@ -302,8 +302,8 @@ class TestCustomPaginateByParam(TestCase): BasicModel(text=i).save() self.objects = BasicModel.objects self.data = [ - {'id': obj.id, 'text': obj.text} - for obj in self.objects.all() + {'id': obj.id, 'text': obj.text} + for obj in self.objects.all() ] self.view = PaginateByParamView.as_view() @@ -483,8 +483,6 @@ class NonIntegerPaginator(object): class TestNonIntegerPagination(TestCase): - - def test_custom_pagination_serializer(self): objects = ['john', 'paul', 'george', 'ringo'] paginator = NonIntegerPaginator(objects, 2) diff --git a/tests/test_permissions.py b/tests/test_permissions.py index a2cb0c362..93f8020f3 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -12,6 +12,7 @@ import base64 factory = APIRequestFactory() + class RootView(generics.ListCreateAPIView): model = BasicModel authentication_classes = [authentication.BasicAuthentication] @@ -101,42 +102,54 @@ class ModelPermissionsIntegrationTests(TestCase): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_options_permitted(self): - request = factory.options('/', - HTTP_AUTHORIZATION=self.permitted_credentials) + request = factory.options( + '/', + HTTP_AUTHORIZATION=self.permitted_credentials + ) response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) self.assertEqual(list(response.data['actions'].keys()), ['POST']) - request = factory.options('/1', - HTTP_AUTHORIZATION=self.permitted_credentials) + request = factory.options( + '/1', + HTTP_AUTHORIZATION=self.permitted_credentials + ) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) self.assertEqual(list(response.data['actions'].keys()), ['PUT']) def test_options_disallowed(self): - request = factory.options('/', - HTTP_AUTHORIZATION=self.disallowed_credentials) + request = factory.options( + '/', + HTTP_AUTHORIZATION=self.disallowed_credentials + ) response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertNotIn('actions', response.data) - request = factory.options('/1', - HTTP_AUTHORIZATION=self.disallowed_credentials) + request = factory.options( + '/1', + HTTP_AUTHORIZATION=self.disallowed_credentials + ) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertNotIn('actions', response.data) def test_options_updateonly(self): - request = factory.options('/', - HTTP_AUTHORIZATION=self.updateonly_credentials) + request = factory.options( + '/', + HTTP_AUTHORIZATION=self.updateonly_credentials + ) response = root_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertNotIn('actions', response.data) - request = factory.options('/1', - HTTP_AUTHORIZATION=self.updateonly_credentials) + request = factory.options( + '/1', + HTTP_AUTHORIZATION=self.updateonly_credentials + ) response = instance_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertIn('actions', response.data) @@ -153,6 +166,7 @@ class BasicPermModel(models.Model): # add, change, delete built in to django ) + # Custom object-level permission, that includes 'view' permissions class ViewObjectPermissions(permissions.DjangoObjectPermissions): perms_map = { @@ -205,7 +219,7 @@ class ObjectPermissionsIntegrationTests(TestCase): app_label = BasicPermModel._meta.app_label f = '{0}_{1}'.format perms = { - 'view': f('view', model_name), + 'view': f('view', model_name), 'change': f('change', model_name), 'delete': f('delete', model_name) } @@ -246,21 +260,27 @@ class ObjectPermissionsIntegrationTests(TestCase): # Update def test_can_update_permissions(self): - request = factory.patch('/1', {'text': 'foobar'}, format='json', - HTTP_AUTHORIZATION=self.credentials['writeonly']) + request = factory.patch( + '/1', {'text': 'foobar'}, format='json', + HTTP_AUTHORIZATION=self.credentials['writeonly'] + ) response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('text'), 'foobar') def test_cannot_update_permissions(self): - request = factory.patch('/1', {'text': 'foobar'}, format='json', - HTTP_AUTHORIZATION=self.credentials['deleteonly']) + request = factory.patch( + '/1', {'text': 'foobar'}, format='json', + HTTP_AUTHORIZATION=self.credentials['deleteonly'] + ) response = object_permissions_view(request, pk='1') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_cannot_update_permissions_non_existing(self): - request = factory.patch('/999', {'text': 'foobar'}, format='json', - HTTP_AUTHORIZATION=self.credentials['deleteonly']) + request = factory.patch( + '/999', {'text': 'foobar'}, format='json', + HTTP_AUTHORIZATION=self.credentials['deleteonly'] + ) response = object_permissions_view(request, pk='999') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/tests/test_relations.py b/tests/test_relations.py index cd276d30c..bc1db69fc 100644 --- a/tests/test_relations.py +++ b/tests/test_relations.py @@ -108,19 +108,25 @@ class RelatedFieldSourceTests(TestCase): doesn't exist. """ from tests.models import ManyToManySource + class Meta: model = ManyToManySource + attrs = { 'name': serializers.SlugRelatedField( slug_field='name', source='banzai'), 'Meta': Meta, } - TestSerializer = type(str('TestSerializer'), - (serializers.ModelSerializer,), attrs) + TestSerializer = type( + str('TestSerializer'), + (serializers.ModelSerializer,), + attrs + ) with self.assertRaises(AttributeError): TestSerializer(data={'name': 'foo'}) + @unittest.skipIf(get_version() < '1.6.0', 'Upstream behaviour changed in v1.6') class RelatedFieldChoicesTests(TestCase): """ @@ -141,4 +147,3 @@ class RelatedFieldChoicesTests(TestCase): widget_count = len(field.widget.choices) self.assertEqual(widget_count, choice_count + 1, 'BLANK_CHOICE_DASH option should have been added') - diff --git a/tests/test_relations_hyperlink.py b/tests/test_relations_hyperlink.py index ab1c66646..0c8eb2544 100644 --- a/tests/test_relations_hyperlink.py +++ b/tests/test_relations_hyperlink.py @@ -16,7 +16,8 @@ request = factory.get('/') # Just to ensure we have a request in the serializer def dummy_view(request, pk): pass -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^dummyurl/(?P[0-9]+)/$', dummy_view, name='dummy-url'), url(r'^manytomanysource/(?P[0-9]+)/$', dummy_view, name='manytomanysource-detail'), url(r'^manytomanytarget/(?P[0-9]+)/$', dummy_view, name='manytomanytarget-detail'), @@ -86,9 +87,9 @@ class HyperlinkedManyToManyTests(TestCase): queryset = ManyToManySource.objects.all() serializer = ManyToManySourceSerializer(queryset, many=True, context={'request': request}) expected = [ - {'url': 'http://testserver/manytomanysource/1/', 'name': 'source-1', 'targets': ['http://testserver/manytomanytarget/1/']}, - {'url': 'http://testserver/manytomanysource/2/', 'name': 'source-2', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/']}, - {'url': 'http://testserver/manytomanysource/3/', 'name': 'source-3', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']} + {'url': 'http://testserver/manytomanysource/1/', 'name': 'source-1', 'targets': ['http://testserver/manytomanytarget/1/']}, + {'url': 'http://testserver/manytomanysource/2/', 'name': 'source-2', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/']}, + {'url': 'http://testserver/manytomanysource/3/', 'name': 'source-3', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']} ] self.assertEqual(serializer.data, expected) @@ -114,9 +115,9 @@ class HyperlinkedManyToManyTests(TestCase): queryset = ManyToManySource.objects.all() serializer = ManyToManySourceSerializer(queryset, many=True, context={'request': request}) expected = [ - {'url': 'http://testserver/manytomanysource/1/', 'name': 'source-1', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']}, - {'url': 'http://testserver/manytomanysource/2/', 'name': 'source-2', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/']}, - {'url': 'http://testserver/manytomanysource/3/', 'name': 'source-3', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']} + {'url': 'http://testserver/manytomanysource/1/', 'name': 'source-1', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']}, + {'url': 'http://testserver/manytomanysource/2/', 'name': 'source-2', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/']}, + {'url': 'http://testserver/manytomanysource/3/', 'name': 'source-3', 'targets': ['http://testserver/manytomanytarget/1/', 'http://testserver/manytomanytarget/2/', 'http://testserver/manytomanytarget/3/']} ] self.assertEqual(serializer.data, expected) diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index ff59b250a..c051b0769 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -65,9 +65,9 @@ class PKManyToManyTests(TestCase): queryset = ManyToManySource.objects.all() serializer = ManyToManySourceSerializer(queryset, many=True) expected = [ - {'id': 1, 'name': 'source-1', 'targets': [1]}, - {'id': 2, 'name': 'source-2', 'targets': [1, 2]}, - {'id': 3, 'name': 'source-3', 'targets': [1, 2, 3]} + {'id': 1, 'name': 'source-1', 'targets': [1]}, + {'id': 2, 'name': 'source-2', 'targets': [1, 2]}, + {'id': 3, 'name': 'source-3', 'targets': [1, 2, 3]} ] self.assertEqual(serializer.data, expected) @@ -93,9 +93,9 @@ class PKManyToManyTests(TestCase): queryset = ManyToManySource.objects.all() serializer = ManyToManySourceSerializer(queryset, many=True) expected = [ - {'id': 1, 'name': 'source-1', 'targets': [1, 2, 3]}, - {'id': 2, 'name': 'source-2', 'targets': [1, 2]}, - {'id': 3, 'name': 'source-3', 'targets': [1, 2, 3]} + {'id': 1, 'name': 'source-1', 'targets': [1, 2, 3]}, + {'id': 2, 'name': 'source-2', 'targets': [1, 2]}, + {'id': 3, 'name': 'source-3', 'targets': [1, 2, 3]} ] self.assertEqual(serializer.data, expected) diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 1d8adfa74..0403cde28 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -76,7 +76,6 @@ class MockGETView(APIView): return Response({'foo': ['bar', 'baz']}) - class MockPOSTView(APIView): def post(self, request, **kwargs): return Response({'foo': request.DATA}) @@ -102,7 +101,8 @@ class HTMLView1(APIView): def get(self, request, **kwargs): return Response('text') -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^.*\.(?P.+)$', MockView.as_view(renderer_classes=[RendererA, RendererB])), url(r'^$', MockView.as_view(renderer_classes=[RendererA, RendererB])), url(r'^cache$', MockGETView.as_view()), @@ -312,16 +312,22 @@ class JSONRendererTests(TestCase): class Dict(MutableMapping): def __init__(self): self._dict = dict() + def __getitem__(self, key): return self._dict.__getitem__(key) + def __setitem__(self, key, value): return self._dict.__setitem__(key, value) + def __delitem__(self, key): return self._dict.__delitem__(key) + def __iter__(self): return self._dict.__iter__() + def __len__(self): return self._dict.__len__() + def keys(self): return self._dict.keys() @@ -330,22 +336,24 @@ class JSONRendererTests(TestCase): x[2] = 3 ret = JSONRenderer().render(x) data = json.loads(ret.decode('utf-8')) - self.assertEquals(data, {'key': 'string value', '2': 3}) + self.assertEquals(data, {'key': 'string value', '2': 3}) def test_render_obj_with_getitem(self): class DictLike(object): def __init__(self): self._dict = {} + def set(self, value): self._dict = dict(value) + def __getitem__(self, key): return self._dict[key] - + x = DictLike() x.set({'a': 1, 'b': 'string'}) with self.assertRaises(TypeError): JSONRenderer().render(x) - + def test_without_content_type_args(self): """ Test basic JSON rendering. @@ -394,35 +402,47 @@ class JSONPRendererTests(TestCase): """ Test JSONP rendering with View JSON Renderer. """ - resp = self.client.get('/jsonp/jsonrenderer', - HTTP_ACCEPT='application/javascript') + resp = self.client.get( + '/jsonp/jsonrenderer', + HTTP_ACCEPT='application/javascript' + ) self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp['Content-Type'], 'application/javascript; charset=utf-8') - self.assertEqual(resp.content, - ('callback(%s);' % _flat_repr).encode('ascii')) + self.assertEqual( + resp.content, + ('callback(%s);' % _flat_repr).encode('ascii') + ) def test_without_callback_without_json_renderer(self): """ Test JSONP rendering without View JSON Renderer. """ - resp = self.client.get('/jsonp/nojsonrenderer', - HTTP_ACCEPT='application/javascript') + resp = self.client.get( + '/jsonp/nojsonrenderer', + HTTP_ACCEPT='application/javascript' + ) self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp['Content-Type'], 'application/javascript; charset=utf-8') - self.assertEqual(resp.content, - ('callback(%s);' % _flat_repr).encode('ascii')) + self.assertEqual( + resp.content, + ('callback(%s);' % _flat_repr).encode('ascii') + ) def test_with_callback(self): """ Test JSONP rendering with callback function name. """ callback_func = 'myjsonpcallback' - resp = self.client.get('/jsonp/nojsonrenderer?callback=' + callback_func, - HTTP_ACCEPT='application/javascript') + resp = self.client.get( + '/jsonp/nojsonrenderer?callback=' + callback_func, + HTTP_ACCEPT='application/javascript' + ) self.assertEqual(resp.status_code, status.HTTP_200_OK) self.assertEqual(resp['Content-Type'], 'application/javascript; charset=utf-8') - self.assertEqual(resp.content, - ('%s(%s);' % (callback_func, _flat_repr)).encode('ascii')) + self.assertEqual( + resp.content, + ('%s(%s);' % (callback_func, _flat_repr)).encode('ascii') + ) if yaml: @@ -467,7 +487,6 @@ if yaml: def assertYAMLContains(self, content, string): self.assertTrue(string in content, '%r not in %r' % (string, content)) - class UnicodeYAMLRendererTests(TestCase): """ Tests specific for the Unicode YAML Renderer @@ -592,13 +611,13 @@ class CacheRenderTest(TestCase): """ Return any errors that would be raised if `obj' is pickled Courtesy of koffie @ http://stackoverflow.com/a/7218986/109897 """ - if seen == None: + if seen is None: seen = [] try: state = obj.__getstate__() except AttributeError: return - if state == None: + if state is None: return if isinstance(state, tuple): if not isinstance(state[0], dict): diff --git a/tests/test_request.py b/tests/test_request.py index 0cde0fb40..8b048b5c2 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -272,7 +272,8 @@ class MockView(APIView): return Response(status=status.INTERNAL_SERVER_ERROR) -urlpatterns = patterns('', +urlpatterns = patterns( + '', (r'^$', MockView.as_view()), ) diff --git a/tests/test_response.py b/tests/test_response.py index 0551f4a80..c28f186e0 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -100,7 +100,8 @@ new_model_viewset_router = routers.DefaultRouter() new_model_viewset_router.register(r'', HTMLNewModelViewSet) -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^setbyview$', MockViewSettingContentType.as_view(renderer_classes=[RendererA, RendererB, RendererC])), url(r'^.*\.(?P.+)$', MockView.as_view(renderer_classes=[RendererA, RendererB, RendererC])), url(r'^$', MockView.as_view(renderer_classes=[RendererA, RendererB, RendererC])), diff --git a/tests/test_reverse.py b/tests/test_reverse.py index 0d3fddf07..675a9d5a0 100644 --- a/tests/test_reverse.py +++ b/tests/test_reverse.py @@ -10,7 +10,8 @@ factory = APIRequestFactory() def null_view(request): pass -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^view$', null_view, name='view'), ) diff --git a/tests/test_routers.py b/tests/test_routers.py index 381569bde..b076f134e 100644 --- a/tests/test_routers.py +++ b/tests/test_routers.py @@ -93,7 +93,8 @@ class TestCustomLookupFields(TestCase): from tests import test_routers urls = getattr(test_routers, 'urlpatterns') - urls += patterns('', + urls += patterns( + '', url(r'^', include(self.router.urls)), ) @@ -104,7 +105,8 @@ class TestCustomLookupFields(TestCase): def test_retrieve_lookup_field_list_view(self): response = self.client.get('/notes/') - self.assertEqual(response.data, + self.assertEqual( + response.data, [{ "url": "http://testserver/notes/123/", "uuid": "123", "text": "foo bar" @@ -113,7 +115,8 @@ class TestCustomLookupFields(TestCase): def test_retrieve_lookup_field_detail_view(self): response = self.client.get('/notes/123/') - self.assertEqual(response.data, + self.assertEqual( + response.data, { "url": "http://testserver/notes/123/", "uuid": "123", "text": "foo bar" diff --git a/tests/test_serializer.py b/tests/test_serializer.py index 7d57fcf01..d27bdcf16 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -7,10 +7,12 @@ from django.utils import unittest from django.utils.datastructures import MultiValueDict from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers, fields, relations -from tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, - BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, - ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, RESTFrameworkModel, - ForeignKeySource, ManyToManySource) +from tests.models import ( + HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, + BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, + DefaultValueModel, ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, + RESTFrameworkModel, ForeignKeySource +) from tests.models import BasicModelSerializer import datetime import pickle @@ -99,6 +101,7 @@ class ActionItemSerializer(serializers.ModelSerializer): class Meta: model = ActionItem + class ActionItemSerializerOptionalFields(serializers.ModelSerializer): """ Intended to test that fields with `required=False` are excluded from validation. @@ -109,6 +112,7 @@ class ActionItemSerializerOptionalFields(serializers.ModelSerializer): model = ActionItem fields = ('title',) + class ActionItemSerializerCustomRestore(serializers.ModelSerializer): class Meta: @@ -295,8 +299,10 @@ class BasicTests(TestCase): in the Meta data """ serializer = PersonSerializer(self.person) - self.assertEqual(set(serializer.data.keys()), - set(['name', 'age', 'info'])) + self.assertEqual( + set(serializer.data.keys()), + set(['name', 'age', 'info']) + ) def test_field_with_dictionary(self): """ @@ -331,9 +337,9 @@ class BasicTests(TestCase): — id field is not populated if `data` is accessed prior to `save()` """ serializer = ActionItemSerializer(self.actionitem) - self.assertIsNone(serializer.data.get('id',None), 'New instance. `id` should not be set.') + self.assertIsNone(serializer.data.get('id', None), 'New instance. `id` should not be set.') serializer.save() - self.assertIsNotNone(serializer.data.get('id',None), 'Model is saved. `id` should be set.') + self.assertIsNotNone(serializer.data.get('id', None), 'Model is saved. `id` should be set.') def test_fields_marked_as_not_required_are_excluded_from_validation(self): """ @@ -660,10 +666,10 @@ class ModelValidationTests(TestCase): serializer.save() second_serializer = AlbumsSerializer(data={'title': 'a'}) self.assertFalse(second_serializer.is_valid()) - self.assertEqual(second_serializer.errors, {'title': ['Album with this Title already exists.'],}) + self.assertEqual(second_serializer.errors, {'title': ['Album with this Title already exists.']}) third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}], many=True) self.assertFalse(third_serializer.is_valid()) - self.assertEqual(third_serializer.errors, [{'ref': ['Album with this Ref already exists.']}, {}]) + self.assertEqual(third_serializer.errors, [{'ref': ['Album with this Ref already exists.']}, {}]) def test_foreign_key_is_null_with_partial(self): """ @@ -959,7 +965,7 @@ class WritableFieldDefaultValueTests(TestCase): self.assertEqual(got, self.expected) def test_get_default_value_with_callable(self): - field = self.create_field(default=lambda : self.expected) + field = self.create_field(default=lambda: self.expected) got = field.get_default_value() self.assertEqual(got, self.expected) @@ -974,7 +980,7 @@ class WritableFieldDefaultValueTests(TestCase): self.assertIsNone(got) def test_get_default_value_returns_non_True_values(self): - values = [None, '', False, 0, [], (), {}] # values that assumed as 'False' in the 'if' clause + values = [None, '', False, 0, [], (), {}] # values that assumed as 'False' in the 'if' clause for expected in values: field = self.create_field(default=expected) got = field.get_default_value() diff --git a/tests/test_serializer_bulk_update.py b/tests/test_serializer_bulk_update.py index 8b0ded1a8..67a8ed0dc 100644 --- a/tests/test_serializer_bulk_update.py +++ b/tests/test_serializer_bulk_update.py @@ -83,9 +83,9 @@ class BulkCreateSerializerTests(TestCase): self.assertEqual(serializer.is_valid(), False) expected_errors = [ - {'non_field_errors': ['Invalid data']}, - {'non_field_errors': ['Invalid data']}, - {'non_field_errors': ['Invalid data']} + {'non_field_errors': ['Invalid data']}, + {'non_field_errors': ['Invalid data']}, + {'non_field_errors': ['Invalid data']} ] self.assertEqual(serializer.errors, expected_errors) diff --git a/tests/test_serializer_nested.py b/tests/test_serializer_nested.py index 6d69ffbd0..c09c24db2 100644 --- a/tests/test_serializer_nested.py +++ b/tests/test_serializer_nested.py @@ -328,12 +328,14 @@ class NestedModelSerializerUpdateTests(TestCase): class BlogPostSerializer(serializers.ModelSerializer): comments = BlogPostCommentSerializer(many=True, source='blogpostcomment_set') + class Meta: model = models.BlogPost fields = ('id', 'title', 'comments') class PersonSerializer(serializers.ModelSerializer): posts = BlogPostSerializer(many=True, source='blogpost_set') + class Meta: model = models.Person fields = ('id', 'name', 'age', 'posts') diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 2e276f15c..09de9f4c1 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,9 +1,7 @@ -from django.db import models from django.test import TestCase - +from rest_framework.compat import six from rest_framework.serializers import _resolve_model from tests.models import BasicModel -from rest_framework.compat import six class ResolveModelTests(TestCase): diff --git a/tests/test_status.py b/tests/test_status.py index 7b1bdae31..721a6e30b 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -30,4 +30,4 @@ class TestStatus(TestCase): self.assertFalse(is_server_error(499)) self.assertTrue(is_server_error(500)) self.assertTrue(is_server_error(599)) - self.assertFalse(is_server_error(600)) \ No newline at end of file + self.assertFalse(is_server_error(600)) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index d4da0c23b..b96bc0caf 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -48,4 +48,4 @@ class Issue1386Tests(TestCase): self.assertEqual(i, res) # example from issue #1386, this shouldn't raise an exception - _ = urlize_quoted_links("asdf:[/p]zxcv.com") + urlize_quoted_links("asdf:[/p]zxcv.com") diff --git a/tests/test_testing.py b/tests/test_testing.py index 1b126e002..9c472026a 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -28,7 +28,8 @@ def session_view(request): }) -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^view/$', view), url(r'^session-view/$', session_view), ) @@ -142,7 +143,8 @@ class TestAPIRequestFactory(TestCase): assertion error. """ factory = APIRequestFactory() - self.assertRaises(AssertionError, factory.post, + self.assertRaises( + AssertionError, factory.post, path='/view/', data={'example': 1}, format='xml' ) diff --git a/tests/test_throttling.py b/tests/test_throttling.py index 8c5eefe9c..b0cb2fe73 100644 --- a/tests/test_throttling.py +++ b/tests/test_throttling.py @@ -27,7 +27,7 @@ class NonTimeThrottle(BaseThrottle): if not hasattr(self.__class__, 'called'): self.__class__.called = True return True - return False + return False class MockView(APIView): @@ -125,36 +125,42 @@ class ThrottlingTests(TestCase): """ Ensure for second based throttles. """ - self.ensure_response_header_contains_proper_throttle_field(MockView, - ((0, None), - (0, None), - (0, None), - (0, '1') - )) + self.ensure_response_header_contains_proper_throttle_field( + MockView, ( + (0, None), + (0, None), + (0, None), + (0, '1') + ) + ) def test_minutes_fields(self): """ Ensure for minute based throttles. """ - self.ensure_response_header_contains_proper_throttle_field(MockView_MinuteThrottling, - ((0, None), - (0, None), - (0, None), - (0, '60') - )) + self.ensure_response_header_contains_proper_throttle_field( + MockView_MinuteThrottling, ( + (0, None), + (0, None), + (0, None), + (0, '60') + ) + ) def test_next_rate_remains_constant_if_followed(self): """ If a client follows the recommended next request rate, the throttling rate should stay constant. """ - self.ensure_response_header_contains_proper_throttle_field(MockView_MinuteThrottling, - ((0, None), - (20, None), - (40, None), - (60, None), - (80, None) - )) + self.ensure_response_header_contains_proper_throttle_field( + MockView_MinuteThrottling, ( + (0, None), + (20, None), + (40, None), + (60, None), + (80, None) + ) + ) def test_non_time_throttle(self): """ @@ -170,7 +176,7 @@ class ThrottlingTests(TestCase): self.assertTrue(MockView_NonTimeThrottling.throttle_classes[0].called) response = MockView_NonTimeThrottling.as_view()(request) - self.assertFalse('X-Throttle-Wait-Seconds' in response) + self.assertFalse('X-Throttle-Wait-Seconds' in response) class ScopedRateThrottleTests(TestCase): diff --git a/tests/test_urlizer.py b/tests/test_urlizer.py index 3dc8e8fe5..a77aa22ab 100644 --- a/tests/test_urlizer.py +++ b/tests/test_urlizer.py @@ -1,7 +1,6 @@ from __future__ import unicode_literals from django.test import TestCase from rest_framework.templatetags.rest_framework import urlize_quoted_links -import sys class URLizerTests(TestCase): diff --git a/tox.ini b/tox.ini index 484053a69..200dad38d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,19 @@ [tox] downloadcache = {toxworkdir}/cache/ envlist = + flake8, py3.4-django1.7,py3.3-django1.7,py3.2-django1.7,py2.7-django1.7, py3.4-django1.6,py3.3-django1.6,py3.2-django1.6,py2.7-django1.6,py2.6-django1.6, py3.4-django1.5,py3.3-django1.5,py3.2-django1.5,py2.7-django1.5,py2.6-django1.5, - py2.7-django1.4,py2.6-django1.4, + py2.7-django1.4,py2.6-django1.4 [testenv] -commands = py.test -q +commands = ./runtests.py --fast + +[testenv:flake8] +basepython = python2.7 +deps = pytest==2.5.2 +commands = ./runtests.py --lintonly [testenv:py3.4-django1.7] basepython = python3.4 From 19f31340627c949ca07a9e7b59299734fd991f75 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 13:42:01 +0100 Subject: [PATCH 56/63] Add flake8 to requirements --- .travis.yml | 1 + requirements-test.txt | 1 + tox.ini | 1 + 3 files changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 9894ee4ec..ececf3e9d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,6 +19,7 @@ install: - pip install Pillow==2.3.0 - pip install django-guardian==1.2.3 - pip install pytest-django==2.6.1 + - pip install flake8==2.2.2 - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install oauth2==1.5.211; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth-plus==2.2.4; fi" - "if [[ ${TRAVIS_PYTHON_VERSION::1} != '3' ]]; then pip install django-oauth2-provider==0.2.4; fi" diff --git a/requirements-test.txt b/requirements-test.txt index a91dd0d4b..411daeba2 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -2,6 +2,7 @@ pytest-django==2.6 pytest==2.5.2 pytest-cov==1.6 +flake8==2.2.2 # Optional packages markdown>=2.1.0 diff --git a/tox.ini b/tox.ini index 200dad38d..6f588de12 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ commands = ./runtests.py --fast [testenv:flake8] basepython = python2.7 deps = pytest==2.5.2 + flake8==2.2.2 commands = ./runtests.py --lintonly [testenv:py3.4-django1.7] From d2795dd26d7483ea0de119ae135eab0a94cf23d8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 13:54:52 +0100 Subject: [PATCH 57/63] Resolve linting issues --- rest_framework/fields.py | 10 ++++--- rest_framework/generics.py | 26 +++++++------------ rest_framework/relations.py | 16 +++++------- rest_framework/renderers.py | 2 +- rest_framework/request.py | 2 +- rest_framework/templatetags/rest_framework.py | 2 +- tests/conftest.py | 10 +++---- tests/test_pagination.py | 8 +++--- tests/test_serializer.py | 12 ++++----- tests/test_templatetags.py | 2 +- 10 files changed, 42 insertions(+), 48 deletions(-) diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 43a74ae6e..85fcbd965 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -63,8 +63,10 @@ def get_component(obj, attr_name): def readable_datetime_formats(formats): - format = ', '.join(formats).replace(ISO_8601, - 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]') + format = ', '.join(formats).replace( + ISO_8601, + 'YYYY-MM-DDThh:mm[:ss[.uuuuuu]][+HH:MM|-HH:MM|Z]' + ) return humanize_strptime(format) @@ -425,7 +427,7 @@ class ModelField(WritableField): } -##### Typed Fields ##### +# Typed Fields class BooleanField(WritableField): type_name = 'BooleanField' @@ -484,7 +486,7 @@ class URLField(CharField): type_label = 'url' def __init__(self, **kwargs): - if not 'validators' in kwargs: + if 'validators' not in kwargs: kwargs['validators'] = [validators.URLValidator()] super(URLField, self).__init__(**kwargs) diff --git a/rest_framework/generics.py b/rest_framework/generics.py index 02d243654..77deb8e4f 100644 --- a/rest_framework/generics.py +++ b/rest_framework/generics.py @@ -210,9 +210,8 @@ class GenericAPIView(views.APIView): return filter_backends - ######################## - ### The following methods provide default implementations - ### that you may want to override for more complex cases. + # The following methods provide default implementations + # that you may want to override for more complex cases. def get_paginate_by(self, queryset=None): """ @@ -340,12 +339,11 @@ class GenericAPIView(views.APIView): return obj - ######################## - ### The following are placeholder methods, - ### and are intended to be overridden. - ### - ### The are not called by GenericAPIView directly, - ### but are used by the mixin methods. + # The following are placeholder methods, + # and are intended to be overridden. + # + # The are not called by GenericAPIView directly, + # but are used by the mixin methods. def pre_save(self, obj): """ @@ -417,10 +415,8 @@ class GenericAPIView(views.APIView): return ret -########################################################## -### Concrete view classes that provide method handlers ### -### by composing the mixin classes with the base view. ### -########################################################## +# Concrete view classes that provide method handlers +# by composing the mixin classes with the base view. class CreateAPIView(mixins.CreateModelMixin, GenericAPIView): @@ -535,9 +531,7 @@ class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, return self.destroy(request, *args, **kwargs) -########################## -### Deprecated classes ### -########################## +# Deprecated classes class MultipleObjectAPIView(GenericAPIView): def __init__(self, *args, **kwargs): diff --git a/rest_framework/relations.py b/rest_framework/relations.py index 3b234dd58..1acbdce26 100644 --- a/rest_framework/relations.py +++ b/rest_framework/relations.py @@ -19,8 +19,7 @@ from rest_framework.compat import smart_text import warnings -##### Relational fields ##### - +# Relational fields # Not actually Writable, but subclasses may need to be. class RelatedField(WritableField): @@ -66,7 +65,7 @@ class RelatedField(WritableField): else: # Reverse self.queryset = manager.field.rel.to._default_manager.all() - ### We need this stuff to make form choices work... + # We need this stuff to make form choices work... def prepare_value(self, obj): return self.to_native(obj) @@ -113,7 +112,7 @@ class RelatedField(WritableField): choices = property(_get_choices, _set_choices) - ### Default value handling + # Default value handling def get_default_value(self): default = super(RelatedField, self).get_default_value() @@ -121,7 +120,7 @@ class RelatedField(WritableField): return [] return default - ### Regular serializer stuff... + # Regular serializer stuff... def field_to_native(self, obj, field_name): try: @@ -181,7 +180,7 @@ class RelatedField(WritableField): into[(self.source or field_name)] = self.from_native(value) -### PrimaryKey relationships +# PrimaryKey relationships class PrimaryKeyRelatedField(RelatedField): """ @@ -269,8 +268,7 @@ class PrimaryKeyRelatedField(RelatedField): return self.to_native(pk) -### Slug relationships - +# Slug relationships class SlugRelatedField(RelatedField): """ @@ -305,7 +303,7 @@ class SlugRelatedField(RelatedField): raise ValidationError(msg) -### Hyperlinked relationships +# Hyperlinked relationships class HyperlinkedRelatedField(RelatedField): """ diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 3dabd277e..ac7175a7b 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -414,7 +414,7 @@ class BrowsableAPIRenderer(BaseRenderer): """ Returns True if a form should be shown for this method. """ - if not method in view.allowed_methods: + if method not in view.allowed_methods: return # Not a valid method if not api_settings.FORM_METHOD_OVERRIDE: diff --git a/rest_framework/request.py b/rest_framework/request.py index 620b00ada..275326614 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -403,7 +403,7 @@ class Request(object): self._not_authenticated() raise - if not user_auth_tuple is None: + if user_auth_tuple is not None: self._authenticator = authenticator self._user, self._auth = user_auth_tuple return diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 5b8fa3853..9110aedb3 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -166,7 +166,7 @@ def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=Tru url = smart_urlquote_wrapper(middle) elif simple_url_2_re.match(middle): url = smart_urlquote_wrapper('http://%s' % middle) - elif not ':' in middle and simple_email_re.match(middle): + elif ':' not in middle and simple_email_re.match(middle): local, domain = middle.rsplit('@', 1) try: domain = domain.encode('idna').decode('ascii') diff --git a/tests/conftest.py b/tests/conftest.py index fa5184dd8..f3723aeae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,8 +47,8 @@ def pytest_configure(): ) try: - import oauth_provider - import oauth2 + import oauth_provider # NOQA + import oauth2 # NOQA except ImportError: pass else: @@ -57,7 +57,7 @@ def pytest_configure(): ) try: - import provider + import provider # NOQA except ImportError: pass else: @@ -68,13 +68,13 @@ def pytest_configure(): # guardian is optional try: - import guardian + import guardian # NOQA except ImportError: pass else: settings.ANONYMOUS_USER_ID = -1 settings.AUTHENTICATION_BACKENDS = ( - 'django.contrib.auth.backends.ModelBackend', # default + 'django.contrib.auth.backends.ModelBackend', 'guardian.backends.ObjectPermissionBackend', ) settings.INSTALLED_APPS += ( diff --git a/tests/test_pagination.py b/tests/test_pagination.py index d5b9244d9..80c33e2eb 100644 --- a/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -363,11 +363,11 @@ class TestMaxPaginateByParam(TestCase): self.assertEqual(response.data['results'], self.data[:3]) -### Tests for context in pagination serializers +# Tests for context in pagination serializers class CustomField(serializers.Field): def to_native(self, value): - if not 'view' in self.context: + if 'view' not in self.context: raise RuntimeError("context isn't getting passed into custom field") return "value" @@ -377,7 +377,7 @@ class BasicModelSerializer(serializers.Serializer): def __init__(self, *args, **kwargs): super(BasicModelSerializer, self).__init__(*args, **kwargs) - if not 'view' in self.context: + if 'view' not in self.context: raise RuntimeError("context isn't getting passed into serializer init") @@ -398,7 +398,7 @@ class TestContextPassedToCustomField(TestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) -### Tests for custom pagination serializers +# Tests for custom pagination serializers class LinksSerializer(serializers.Serializer): next = pagination.NextPageField(source='*') diff --git a/tests/test_serializer.py b/tests/test_serializer.py index d27bdcf16..90f37cf2e 100644 --- a/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -415,7 +415,7 @@ class ValidationTests(TestCase): mistaken for not having a default.""" data = { 'title': 'Some action item', - #No 'done' value. + # No 'done' value. } serializer = ActionItemSerializer(self.actionitem, data=data) self.assertEqual(serializer.is_valid(), True) @@ -1282,7 +1282,7 @@ class BlankFieldTests(TestCase): self.fail('Exception raised on save() after validation passes') -#test for issue #460 +# Test for issue #460 class SerializerPickleTests(TestCase): """ Test pickleability of the output of Serializers @@ -1506,7 +1506,7 @@ class NestedSerializerContextTests(TestCase): callable = serializers.SerializerMethodField('_callable') def _callable(self, instance): - if not 'context_item' in self.context: + if 'context_item' not in self.context: raise RuntimeError("context isn't getting passed into 2nd level nested serializer") return "success" @@ -1519,7 +1519,7 @@ class NestedSerializerContextTests(TestCase): callable = serializers.SerializerMethodField("_callable") def _callable(self, instance): - if not 'context_item' in self.context: + if 'context_item' not in self.context: raise RuntimeError("context isn't getting passed into 1st level nested serializer") return "success" @@ -1822,7 +1822,7 @@ class MetadataSerializerTestCase(TestCase): self.assertEqual(expected, metadata) -### Regression test for #840 +# Regression test for #840 class SimpleModel(models.Model): text = models.CharField(max_length=100) @@ -1856,7 +1856,7 @@ class FieldValidationRemovingAttr(TestCase): self.assertEqual(serializer.object.text, 'foo') -### Regression test for #878 +# Regression test for #878 class SimpleTargetModel(models.Model): text = models.CharField(max_length=100) diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index b96bc0caf..75ee0eaa8 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -11,7 +11,7 @@ class TemplateTagTests(TestCase): def test_add_query_param_with_non_latin_charactor(self): # Ensure we don't double-escape non-latin characters - # that are present in the querystring. + # that are present in the querystring. # See #1314. request = factory.get("/", {'q': '查询'}) json_url = add_query_param(request, "format", "json") From 2d2737f367c241c29c9c3913f2dba986c7c9a4a5 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 14:11:26 +0100 Subject: [PATCH 58/63] Resolve python3 linting issue --- rest_framework/utils/mediatypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest_framework/utils/mediatypes.py b/rest_framework/utils/mediatypes.py index 727f9c19e..87b3cc6a3 100644 --- a/rest_framework/utils/mediatypes.py +++ b/rest_framework/utils/mediatypes.py @@ -79,7 +79,7 @@ class _MediaType(object): return 3 def __str__(self): - return unicode(self).encode('utf-8') + return self.__unicode__().encode('utf-8') def __unicode__(self): ret = "%s/%s" % (self.main_type, self.sub_type) From 06b6b96f933088ba36d4c98e13893274f29bed6a Mon Sep 17 00:00:00 2001 From: Xavier Ordoquy Date: Tue, 19 Aug 2014 16:32:30 +0200 Subject: [PATCH 59/63] Remove duplicated model declaration. --- tests/models.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/models.py b/tests/models.py index e378c1cfe..fe064b46a 100644 --- a/tests/models.py +++ b/tests/models.py @@ -184,10 +184,3 @@ class NullableOneToOneSource(RESTFrameworkModel): class BasicModelSerializer(serializers.ModelSerializer): class Meta: model = BasicModel - - -# Models to test filters -class FilterableItem(models.Model): - text = models.CharField(max_length=100) - decimal = models.DecimalField(max_digits=4, decimal_places=2) - date = models.DateField() From 00c0dfc66fd2426a63e6eec498395740b2c3e63b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 16:29:05 +0100 Subject: [PATCH 60/63] Documentation on runtests.py --- docs/topics/contributing.md | 38 +++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/topics/contributing.md b/docs/topics/contributing.md index d33843e19..3400bc8f9 100644 --- a/docs/topics/contributing.md +++ b/docs/topics/contributing.md @@ -62,10 +62,44 @@ To run the tests, clone the repository, and then: virtualenv env source env/bin/activate pip install -r requirements.txt - pip install -r optionals.txt + pip install -r requirements-test.txt # Run the tests - py.test + ./runtests.py + +### Test options + +Run using a more concise output style. + + ./runtests -q + +Run the tests using a more concise output style, no coverage, no flake8. + + ./runtests --fast + +Don't run the flake8 code linting. + + ./runtests --nolint + +Only run the flake8 code linting, don't run the tests. + + ./runtests --lintonly + +Run the tests for a given test case. + + ./runtests MyTestCase + +Run the tests for a given test method. + + ./runtests MyTestCase.test_this_method + +Shorter form to run the tests for a given test method. + + ./runtests test_this_method + +Note: The test case and test method matching is fuzzy and will sometimes run other tests that contain a partial string match to the given command line input. + +### Running against multiple environments You can also use the excellent [tox][tox] testing tool to run the tests against all supported versions of Python and Django. Install `tox` globally, and then simply run: From 63d02dbea855a060ec4cdb194497188e2f40cb66 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 17:06:55 +0100 Subject: [PATCH 61/63] Drop six from compat. 1.4.2 is now the lowest supported version. --- README.md | 2 +- docs/index.md | 2 +- docs/topics/2.4-accouncement.md | 1 - rest_framework/compat.py | 7 +------ rest_framework/decorators.py | 2 +- rest_framework/fields.py | 4 ++-- rest_framework/filters.py | 3 ++- rest_framework/parsers.py | 3 ++- rest_framework/renderers.py | 6 ++---- rest_framework/response.py | 2 +- rest_framework/serializers.py | 2 +- rest_framework/settings.py | 5 +---- rest_framework/templatetags/rest_framework.py | 3 ++- rest_framework/test.py | 3 ++- tests/test_authentication.py | 3 +-- tests/test_files.py | 2 +- tests/test_generics.py | 2 +- tests/test_htmlrenderer.py | 4 ++-- tests/test_relations_pk.py | 2 +- tests/test_renderers.py | 4 ++-- tests/test_request.py | 2 +- tests/test_response.py | 2 +- tests/test_serializers.py | 2 +- tests/utils.py | 2 +- 24 files changed, 31 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0eaf5c83c..7052ab638 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ There is a live example API for testing purposes, [available here][sandbox]. # Requirements * Python (2.6.5+, 2.7, 3.2, 3.3) -* Django (1.3, 1.4, 1.5, 1.6) +* Django (1.4.2+, 1.5, 1.6, 1.7) # Installation diff --git a/docs/index.md b/docs/index.md index 6abc4f040..83e30a690 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ Some reasons you might want to use REST framework: REST framework requires the following: * Python (2.6.5+, 2.7, 3.2, 3.3) -* Django (1.3, 1.4, 1.5, 1.6) +* Django (1.4.2+, 1.5, 1.6, 1.7) The following packages are optional: diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index 91472b9c4..cdc99bd5e 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -1,4 +1,3 @@ -* Writable nested serializers. * List/detail routes. * 1.3 Support dropped, install six for <=1.4.?. * `allow_none` for char fields diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 4b16a8ca2..fa0f0bfb1 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -9,14 +9,9 @@ import django import inspect from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.utils import six -# Try to import six from Django, fallback to external `six` package. -try: - from django.utils import six -except ImportError: - import six - # Handle django.utils.encoding rename in 1.5 onwards. # smart_unicode -> smart_text # force_unicode -> force_text diff --git a/rest_framework/decorators.py b/rest_framework/decorators.py index e06d6ff56..449ba0a29 100644 --- a/rest_framework/decorators.py +++ b/rest_framework/decorators.py @@ -7,7 +7,7 @@ based views, as well as the `@detail_route` and `@list_route` decorators, which used to annotate methods on viewsets that should be included by routers. """ from __future__ import unicode_literals -from rest_framework.compat import six +from django.utils import six from rest_framework.views import APIView import types import warnings diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 85fcbd965..9d707c9b5 100644 --- a/rest_framework/fields.py +++ b/rest_framework/fields.py @@ -18,14 +18,14 @@ from django.conf import settings from django.db.models.fields import BLANK_CHOICE_DASH from django.http import QueryDict from django.forms import widgets -from django.utils import timezone +from django.utils import six, timezone from django.utils.encoding import is_protected_type from django.utils.translation import ugettext_lazy as _ from django.utils.datastructures import SortedDict from django.utils.dateparse import parse_date, parse_datetime, parse_time from rest_framework import ISO_8601 from rest_framework.compat import ( - BytesIO, six, smart_text, + BytesIO, smart_text, force_text, is_non_str_iterable ) from rest_framework.settings import api_settings diff --git a/rest_framework/filters.py b/rest_framework/filters.py index 28927eec7..e20800130 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -5,7 +5,8 @@ returned by list views. from __future__ import unicode_literals from django.core.exceptions import ImproperlyConfigured from django.db import models -from rest_framework.compat import django_filters, six, guardian, get_model_name +from django.utils import six +from rest_framework.compat import django_filters, guardian, get_model_name from rest_framework.settings import api_settings from functools import reduce import operator diff --git a/rest_framework/parsers.py b/rest_framework/parsers.py index 4990971b8..aa4fd3f11 100644 --- a/rest_framework/parsers.py +++ b/rest_framework/parsers.py @@ -10,7 +10,8 @@ from django.core.files.uploadhandler import StopFutureHandlers from django.http import QueryDict from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser from django.http.multipartparser import MultiPartParserError, parse_header, ChunkIter -from rest_framework.compat import etree, six, yaml, force_text +from django.utils import six +from rest_framework.compat import etree, yaml, force_text from rest_framework.exceptions import ParseError from rest_framework import renderers import json diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index ac7175a7b..748ebac94 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -15,11 +15,9 @@ from django.core.exceptions import ImproperlyConfigured from django.http.multipartparser import parse_header from django.template import RequestContext, loader, Template from django.test.client import encode_multipart +from django.utils import six from django.utils.xmlutils import SimplerXMLGenerator -from rest_framework.compat import StringIO -from rest_framework.compat import six -from rest_framework.compat import smart_text -from rest_framework.compat import yaml +from rest_framework.compat import StringIO, smart_text, yaml from rest_framework.exceptions import ParseError from rest_framework.settings import api_settings from rest_framework.request import is_form_media_type, override_method diff --git a/rest_framework/response.py b/rest_framework/response.py index 80225cac3..0a7d313f4 100644 --- a/rest_framework/response.py +++ b/rest_framework/response.py @@ -8,7 +8,7 @@ from __future__ import unicode_literals import django from django.core.handlers.wsgi import STATUS_CODE_TEXT from django.template.response import SimpleTemplateResponse -from rest_framework.compat import six +from django.utils import six class Response(SimpleTemplateResponse): diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 95288671c..be8ad3f24 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -20,9 +20,9 @@ from django.contrib.contenttypes.generic import GenericForeignKey from django.core.paginator import Page from django.db import models from django.forms import widgets +from django.utils import six from django.utils.datastructures import SortedDict from django.core.exceptions import ObjectDoesNotExist -from rest_framework.compat import six from rest_framework.settings import api_settings diff --git a/rest_framework/settings.py b/rest_framework/settings.py index 6806a4689..644751f87 100644 --- a/rest_framework/settings.py +++ b/rest_framework/settings.py @@ -18,12 +18,9 @@ REST framework settings, checking for user settings first, then falling back to the defaults. """ from __future__ import unicode_literals - from django.conf import settings -from django.utils import importlib - +from django.utils import importlib, six from rest_framework import ISO_8601 -from rest_framework.compat import six USER_SETTINGS = getattr(settings, 'REST_FRAMEWORK', None) diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index 9110aedb3..b80a7d771 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,11 +2,12 @@ from __future__ import unicode_literals, absolute_import from django import template from django.core.urlresolvers import reverse, NoReverseMatch from django.http import QueryDict +from django.utils import six from django.utils.encoding import iri_to_uri from django.utils.html import escape from django.utils.safestring import SafeData, mark_safe -from rest_framework.compat import urlparse, force_text, six from django.utils.html import smart_urlquote +from rest_framework.compat import urlparse, force_text import re register = template.Library() diff --git a/rest_framework/test.py b/rest_framework/test.py index 9242cf7c6..f89a6dcd0 100644 --- a/rest_framework/test.py +++ b/rest_framework/test.py @@ -8,10 +8,11 @@ from django.conf import settings from django.test.client import Client as DjangoClient from django.test.client import ClientHandler from django.test import testcases +from django.utils import six from django.utils.http import urlencode from rest_framework.settings import api_settings from rest_framework.compat import RequestFactory as DjangoRequestFactory -from rest_framework.compat import force_bytes_or_smart_bytes, six +from rest_framework.compat import force_bytes_or_smart_bytes def force_authenticate(request, user=None, token=None): diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 9db4f62df..2b9d73e4c 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -3,7 +3,7 @@ from django.conf.urls import patterns, url, include from django.contrib.auth.models import User from django.http import HttpResponse from django.test import TestCase -from django.utils import unittest +from django.utils import six, unittest from django.utils.http import urlencode from rest_framework import HTTP_HEADER_ENCODING from rest_framework import exceptions @@ -20,7 +20,6 @@ from rest_framework.authentication import ( OAuth2Authentication ) from rest_framework.authtoken.models import Token -from rest_framework.compat import six from rest_framework.compat import oauth2_provider, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient diff --git a/tests/test_files.py b/tests/test_files.py index af110df91..de4f71d1a 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,8 +1,8 @@ from __future__ import unicode_literals from django.test import TestCase +from django.utils import six from rest_framework import serializers from rest_framework.compat import BytesIO -from rest_framework.compat import six import datetime diff --git a/tests/test_generics.py b/tests/test_generics.py index 36832aff2..e9f5bebdd 100644 --- a/tests/test_generics.py +++ b/tests/test_generics.py @@ -2,11 +2,11 @@ from __future__ import unicode_literals from django.db import models from django.shortcuts import get_object_or_404 from django.test import TestCase +from django.utils import six from rest_framework import generics, renderers, serializers, status from rest_framework.test import APIRequestFactory from tests.models import BasicModel, Comment, SlugBasedModel from tests.models import ForeignKeySource, ForeignKeyTarget -from rest_framework.compat import six factory = APIRequestFactory() diff --git a/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py index 5a680f99b..2edc6b4bd 100644 --- a/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -4,12 +4,12 @@ from django.conf.urls import patterns, url from django.http import Http404 from django.test import TestCase from django.template import TemplateDoesNotExist, Template -import django.template.loader +from django.utils import six from rest_framework import status from rest_framework.decorators import api_view, renderer_classes from rest_framework.renderers import TemplateHTMLRenderer from rest_framework.response import Response -from rest_framework.compat import six +import django.template.loader @api_view(('GET',)) diff --git a/tests/test_relations_pk.py b/tests/test_relations_pk.py index c051b0769..e3f836ed6 100644 --- a/tests/test_relations_pk.py +++ b/tests/test_relations_pk.py @@ -1,12 +1,12 @@ from __future__ import unicode_literals from django.db import models from django.test import TestCase +from django.utils import six from rest_framework import serializers from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource, ) -from rest_framework.compat import six # ManyToMany diff --git a/tests/test_renderers.py b/tests/test_renderers.py index 0403cde28..91244e261 100644 --- a/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -6,10 +6,10 @@ from django.conf.urls import patterns, url, include from django.core.cache import cache from django.db import models from django.test import TestCase -from django.utils import unittest +from django.utils import six, unittest from django.utils.translation import ugettext_lazy as _ from rest_framework import status, permissions -from rest_framework.compat import yaml, etree, six, StringIO +from rest_framework.compat import yaml, etree, StringIO from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.renderers import BaseRenderer, JSONRenderer, YAMLRenderer, \ diff --git a/tests/test_request.py b/tests/test_request.py index 8b048b5c2..8ddaf0a70 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -8,6 +8,7 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.sessions.middleware import SessionMiddleware from django.core.handlers.wsgi import WSGIRequest from django.test import TestCase +from django.utils import six from rest_framework import status from rest_framework.authentication import SessionAuthentication from rest_framework.parsers import ( @@ -21,7 +22,6 @@ from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.test import APIRequestFactory, APIClient from rest_framework.views import APIView -from rest_framework.compat import six from io import BytesIO import json diff --git a/tests/test_response.py b/tests/test_response.py index c28f186e0..2eff83d3d 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from django.conf.urls import patterns, url, include from django.test import TestCase +from django.utils import six from tests.models import BasicModel, BasicModelSerializer from rest_framework.response import Response from rest_framework.views import APIView @@ -14,7 +15,6 @@ from rest_framework.renderers import ( ) from rest_framework import viewsets from rest_framework.settings import api_settings -from rest_framework.compat import six class MockPickleRenderer(BaseRenderer): diff --git a/tests/test_serializers.py b/tests/test_serializers.py index 09de9f4c1..31c417306 100644 --- a/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,5 +1,5 @@ from django.test import TestCase -from rest_framework.compat import six +from django.utils import six from rest_framework.serializers import _resolve_model from tests.models import BasicModel diff --git a/tests/utils.py b/tests/utils.py index a8f2eb0b0..28be81bd1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from rest_framework.compat import six +from django.utils import six from rest_framework.settings import api_settings From 0c65e028b604490d498e43083fc3b46da05144fe Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Tue, 19 Aug 2014 23:25:12 +0100 Subject: [PATCH 62/63] Release notes --- docs/api-guide/viewsets.md | 2 +- docs/topics/2.4-accouncement.md | 111 +++++++++++++++++++++++++++++++- docs/topics/release-notes.md | 21 ++++-- 3 files changed, 125 insertions(+), 9 deletions(-) diff --git a/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index b32f5a805..9030e3ee0 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -146,7 +146,7 @@ The decorators can additionally take extra arguments that will be set for the ro def set_password(self, request, pk=None): ... -The `@action` decorator will route `POST` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: +Theses decorators will route `GET` requests by default, but may also accept other HTTP methods, by using the `methods` argument. For example: @detail_route(methods=['post', 'delete']) def unset_password(self, request, pk=None): diff --git a/docs/topics/2.4-accouncement.md b/docs/topics/2.4-accouncement.md index cdc99bd5e..709a5c969 100644 --- a/docs/topics/2.4-accouncement.md +++ b/docs/topics/2.4-accouncement.md @@ -1,4 +1,111 @@ -* List/detail routes. -* 1.3 Support dropped, install six for <=1.4.?. +# REST framework 2.4 announcement + +The 2.4 release is largely an intermediate step, tying up some outstanding issues prior to the 3.x series. + +## Version requirements + +Support for Django 1.3 has been dropped. +The lowest supported version of Django is now 1.4.2. + +The current plan is for REST framework to remain in lockstep with [Django's long-term support policy][lts-releases]. + +## Django 1.7 support + +The optional authtoken application now includes support for *both* Django 1.7 schema migrations, *and* for old-style `south` migrations. + +**If you are using authtoken, and you want to continue using `south`, you must upgrade your `south` package to version 1.0.** + +## Updated test runner + +We now have a new test runner for developing against the project,, that uses the excellent [py.test](http://pytest.org) library. + +To use it make sure you have first installed the test requirements. + + pip install -r requirements-test.txt + +Then run the `runtests.py` script. + + ./runtests.py + +The new test runner also includes [flake8](https://flake8.readthedocs.org) code linting, which should help keep our coding style consistent. + +#### Test runner flags + +Run using a more concise output style. + + ./runtests -q + +Run the tests using a more concise output style, no coverage, no flake8. + + ./runtests --fast + +Don't run the flake8 code linting. + + ./runtests --nolint + +Only run the flake8 code linting, don't run the tests. + + ./runtests --lintonly + +Run the tests for a given test case. + + ./runtests MyTestCase + +Run the tests for a given test method. + + ./runtests MyTestCase.test_this_method + +Shorter form to run the tests for a given test method. + + ./runtests test_this_method + +Note: The test case and test method matching is fuzzy and will sometimes run other tests that contain a partial string match to the given command line input. + +## Improved viewset routing + +The `@action` and `@link` decorators were inflexible in that they only allowed additional routes to be added against instance style URLs, not against list style URLs. + +The `@action` and `@link` decorators have now been moved to pending deprecation, and the `@list_route` and `@detail_route` decroators have been introduced. + +Here's an example of using the new decorators. Firstly we have a detail-type route named "set_password" that acts on a single instance, and takes a `pk` argument in the URL. Secondly we have a list-type route named "recent_users" that acts on a queryset, and does not take any arguments in the URL. + + class UserViewSet(viewsets.ModelViewSet): + """ + A viewset that provides the standard actions + """ + queryset = User.objects.all() + serializer_class = UserSerializer + + @detail_route(methods=['post']) + def set_password(self, request, pk=None): + user = self.get_object() + serializer = PasswordSerializer(data=request.DATA) + if serializer.is_valid(): + user.set_password(serializer.data['password']) + user.save() + return Response({'status': 'password set'}) + else: + return Response(serializer.errors, + status=status.HTTP_400_BAD_REQUEST) + + @list_route() + def recent_users(self, request): + recent_users = User.objects.all().order('-last_login') + page = self.paginate_queryset(recent_users) + serializer = self.get_pagination_serializer(page) + return Response(serializer.data) + +For more details, see the [viewsets](../api-guide/viewsets.md) documentation. + +## Other features + +## Deprecations + +## Labels and milestones + +TODO + * `allow_none` for char fields * `trailing_slash = True` --> `[^/]`, `trailing_slash = False` --> `[^/.]`, becomes simply `[^/]` and `lookup_value_regex` is added. + +[lts-releases]: https://docs.djangoproject.com/en/dev/internals/release-process/#long-term-support-lts-releases \ No newline at end of file diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index b0e5b1982..a31be28f1 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -38,26 +38,35 @@ You can determine your currently installed version using `pip freeze`: --- +## 2.4.x series + ### 2.4.0 -* Added compatibility with Django 1.7's native migrations. +**Django version requirements**: The lowest supported version of Django is now 1.4.2. - **IMPORTANT**: In order to continue to use South with Django <1.7 you **must** upgrade to - South v1.0. +**South version requirements**: This note applies to any users using the optional `authtoken` application, which includes an associated database migration. You must now *either* upgrade your `south` package to version 1.0, *or* instead use the built-in migration support available with Django 1.7. -* Use py.test +* Added compatibility with Django 1.7's database migration support. +* New test runner, using `py.test`. * `@detail_route` and `@list_route` decorators replace `@action` and `@link`. -* `six` no longer bundled. For Django <= 1.4.1, install `six` package. * Support customizable view name and description functions, using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. * Added `NUM_PROXIES` setting for smarter client IP identification. * Added `MAX_PAGINATE_BY` setting and `max_paginate_by` generic view attribute. * Added `cache` attribute to throttles to allow overriding of default cache. +* Added `lookup_value_regex` attribute to routers, to allow the URL argument matching to be constrainted by the user. +* Added `allow_none` option to `CharField`. +* Support Django's standard `status_code` class attribute on responses. +* More intuitive behavior on the test client, as `client.logout()` now also removes any credentials that have been set. * Bugfix: `?page_size=0` query parameter now falls back to default page size for view, instead of always turning pagination off. +* Bugfix: Always uppercase `X-Http-Method-Override` methods. +* Bugfix: Copy `filter_backends` list before returning it, in order to prevent view code from mutating the class attribute itself. +* Bugfix: Set the `.action` attribute on viewsets when introspected by `OPTIONS` for testing permissions on the view. +* Bugfix: Ensure `ValueError` raised during deserialization results in a error list rather than a single error. This is now consistent with other validation errors. +--- ## 2.3.x series - ### 2.3.14 **Date**: 12th June 2014 From 874d2be83c612fb5e04aa6a28901c2afe4bf9d3b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 20 Aug 2014 00:19:03 +0100 Subject: [PATCH 63/63] Release notes --- docs/img/labels-and-milestones.png | Bin 0 -> 84026 bytes docs/topics/2.4-accouncement.md | 48 +++++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) create mode 100644 docs/img/labels-and-milestones.png diff --git a/docs/img/labels-and-milestones.png b/docs/img/labels-and-milestones.png new file mode 100644 index 0000000000000000000000000000000000000000..e7c829adcc8ca5b0f0b39c5830aad0fc81c4e0f2 GIT binary patch literal 84026 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⋙dIlJ^7*83kQ!uJ6J1jMQyjwvVISU~-m1SyC+dL+I8mJXph%=#>~qIXJwP<)owGcH4o7 zmGwMcf|%#*W%u)!v?=jX<;KpPIRN0G2x+hHoeN`z{30VAmRo>)D8?}{k zm@G+VDX9OcxKxDNgU)j1_>?Wx+6|PMb2JoKhiU3hmyr{?&9ri!`04k-!JPdL2A1>z^d|)j!_$A z_=>MCVQorCx_NsfDbzNx4yMvb3$c`DuJDv95Kys=ieW8$nzEo{JPSlyH}(BE1m72I zz>U35cUME;+th0b(RXa-qiL_7X1|5=k%zNh z2lmw$|H$+ui!ipjOlQLr{WgX@Z8(;|)n2?DR95}Al8q#4=1zJ-Y!2u9n7}`PJ51w; zdt7cCUBS9K(=fyL6@>-srAolVj~pw4pA|k+tbFw{F&Q5leVQIZwH_5a8~7u9Poy>@@g=w7aRs z>CrbBwdw_|-)qNP-`umZ-4maoojV56fzr%j0aaA1DUogo^1Jrpg7Pg_Fqs6SGZw_1 z+3i3{Kdo3dcMUA3#pve6|O1D>{3&2PM2RehW1YDsOv z2)Eq7swInN73F`RetDj5lDoajOk*?bU6kfFPxv?JF8u!ux+7WLX!#Sv_sv?d;H@Y@ zOy=xl@b=KKsHO)krnp((z(5CCv$w~sQqf?RdQqOWaE5|R**>)t;lbMOz54lLi|_YV z1kU>Zq21};{)cw=OZ@$pc4tC(uSm*x81)zJ?s!fOh*a?47x$=FyaFK)uT5b?JC4O!g#&(~f z25cP(_6Mv3KIV~>b^p3&;E){s6)An|i&L_%sjG0=wfKh@CH6g~pS=e&&?bxV=v*=T z$2wj?P99xj?NFSq6y6BiH}1Qk+WzCSyCGqmck6EUnsoY|qg3z$DJMZh14La1^ zJA>0Hk7gTy4p%=4b~5E>+y^-5zn_57iYD?d@VDV)2d!?oE~Uy#^@g=(9|rX$5) zuk6*gWqp12>R;qLPiVNsx%Pu-3pOxY4ZMDwDk%eT{f6I_jXWs*-|#!1z3?2Kp>m(X z$EUE7u7U43^OE-0*LKr`MHO~iD^n9;`jlz#7hWnw{I+21oz8f{*ZTy@iwA!9w5)?4 zvci&Wt)t<6!wE5(ZVZs{98uRppzC6?!hmW0u=|V|aK{PqmzL>{r)%vauOm99g)+$l z;KR9LL)qEzBhq5I9B8+7Y!4s2Wg6`sk;b{}ho5YD-jTHI{B8J|>OPRY83%r+tyhLh zZvHY*F!ur&JnHe5W%9b=)bx@QHE7B@xGgK34L+E(+MF|)sDMK8ePp?0$h+DoQ$0nm zEgy>wha;vFXo8VR^xV7)dzdqj{}=tPoCvXkF@MGjsmwB=9zIYMDwBsZ)|3v|r{QsP zxIh6|0Q;l%eF)%}O80#K9-VlAS){ac>8*;dXRzJ3MIesl=HoK=Hx7^B@)r(I@Qn|@ znwc59*7VYSy-c%>9%xiw@E7{-E4SyqIvK(lhutZWu z-~WK`BB^nYRM2)C`pPCPNNd4}Ms1pk9Pnl|5Eo=$!B2Jeo*$eDrUnaWB)+Ee zGqx=`l7@u&6FylM+U+KwLHAx7jd;Qz1913{aAwvP^>_rx-|h*lJPAyBF!SFx&gm9Q zoUW{74cO2`xiOPxo~}1ib@%ZibfePR%T#>8>u}*xDg`v9tqV zYzqWilMWoU5LjU@{yWQ%hw>`mY%SFFuX&-GpQ3B!LHvn?8VRny{%y0mTTafn1ZdIOmbBLzo_y4DI#%z1Q%cU1zdgB)^z9oD4eQ=Em?Dk9d|>s8;vJ z+mUads5~wT+x$(a`mHq9&NGM$OiUUnXB#7VlIfT3B6UFq4O6ASE9Cus8BJDKfBBO- z9z;ia{vg2EBpu;RT<=LyXW-OKKi@uOB-_8Xjx9sN5IkkYFdw6z%m?4#Rfr3HC=O<3 zqKAHYTyj?SUOX&12&jJr`>w{Q?Ed9w1>PNdW)<$#Z3-Hh5+(sdnHKUJ42C87SNgBw z*f3%YZ^T3BbKC*99y90_p6wfIVinj*mHr5{?Qiv({3kP9L?RFwlPD<{Y_56DKbpHG z9#SWM4o-5*rde26a30OP$e?=s35F1pbgp0Fw^kViKE)qVpFxl`!{Bg0F&W07P@i=X zRf8WyfxvkZFQDjNr2aO5UvtHDc9GqqaJJtY0M|%=&&EmxODhD4r05TYohuZAO?&=s z1Lwkgefy@!Na)hlZ|FXa>(J?jgxz(P>`HS^>N^JFY#UOO*yOE z>9D?W#gYcK(YM<#uZXq%@1s0i6%5oSi(ZGVkDPS&wP!CZRiZt-6^fUY{;MzuSX#vj z*vPgb11ngV+3)vM$fsNLb1XkE26r)eRTnsnCS3}E2BF0v2WO3>QRsygBH-OEeH4be z451lZwU?x6+!yA$@52}TtQ#eYfWSQaBI;{CSh*kq1j6@_&{)obW5X*hm|mUP(Uw2l z=y6((G~o5%qeX2h8!SR zg48{&4B7>Py4Lg=mDHwh<|pxQcNAUEEMy%E)^|8;<`qfNT@Io``x%xOSqO6!o?n@x zZirUvqD6E!X&8t%dL}2|Jc|EOTNcZ#pi5xuG%i#(<1el?&85I+ikAdg{SXK4gP|sl~H|CV;|ctTx4AR zBs+S^@HiK(L{kFG@u2E%s;0Yw67kl9Iy<8w4$S-Yfbm-kuTDnNBlW2hl^dw|aW^|O zBROL>wtxhu21q1@Hi;ewnqgvDUfWoiRoBnFvl$0}*9v@D;E3i91U+}e#i@Fpo2@b7 z9rf$u?^<_1XTdyoYVYT*H2J+XYrXRg^Rweea`StSpw1C#;CHNF2*~qk-)E*uQ@qNS zXs~_ZNh_uw83{U=;nQ+GOZ9sp)t0Omxrm=SRhDs;Z3hTN`Fx%zi0cpU@+Nx+gVvjv z_;aJ@PVlQ}EXz%U_d9iYz9$wqiIDEjg*T4{89Y$*++8yGDO;G+L@_$0oX^=d3vj5S zK}L)G28>df3~lUYe0jYbU);X;N+ut{RlcA1pzG^fZTbR*O=}H>U$QswtWjBB?|)VB ztMLL7aB0t3o)$HIxPv9(?qZ`dM3qJ8 zW4T2TzW!#s{1B5h*XQLbH1a({VP0@*RvGJDf#T!z8M~(?H<7*%#%eQi)k&^x26s6l zoT^mMhK_)7`4f(=?lt#EEkU`1bI3yZ-E?dBIL6w<FCXUSDv8sXQ%#;50iA){+7Vjb!V#avBT;6p>&pc_P zgLp>-1D_{eZGk%!?UF>zZG*`Lv$X1Wh=3e#Z8wr1)gfukC9EWbQ-sfVC&E%+c@=i# zGP%xatT-%TI-#C$IwE46e{P6H^3u6^BPpnc#vG&>KeTc=L zq&(PS9bR<6I?Bcm#T|Wq#H-Q$nEz``BY;7=1jVc(Swps&terGfG-kFzF^h)g41Jbrkq~u>S-vkf9H(d0t{J&-UGyRq#54BVO^K zwr=%Dal{L3i_k!QWSl!RM7V(tJ9wRJB{i%Gl#v1@MP9Xd(O}kjvb`O-FRgS<< z%QO{2tEu_ne4zAAM+un64qJF^+b;X168X9Bvz-uk$B(?m)~b*ilks-%3~{N8ekBMF zCi3W{w5-b@eIUaf*;%wQzep?Ep7cx`{qW8rX!B$Baga7~3myL%+zne39n|@!7&@|x zH7MB^W6XpEREzuLxm^H-TzPdN?FrXbEu?^c3-T0P1EhRqf4Q0QGg%nRd1J~|mx1|; zvOKHA^C_(U>(iQfP~&S}$Mkx;bJP6!;&p=)5~XLBg6zuCg~+~qQX!#pONJZ{H5)v= zug31m?Tf4~m{&U@0me9|LNPq3y?h$RETGUhgnLvnR19rN{=^X;4sT`*B`D;I^(f)(I$R-RLtK^m(BHtNV0t56{N^{xaiG;%D~ zSA?=Hp>0~FKxSi3yq>LP;j7F6#@BWf``L5xnPmeki?Ke_oey|LwqdS1V;Gay7+5jy z4q~N;t2z7Pm^B&QWQLr2@3XK=yG+DrZ(A7A0ke zfXptUKv22sxSL&aP1EAXi^dfBB|`r($IV8?*oybEj8{`DnFhW#tf3o}O&Z(?nuhXa z39&Q_tMFn>bRStL%se4Z_^302(H+<@V2!y`?C36_38yI3j)Qd?S^P8nYupman(M|F z&T%j{c`yWuiq25oYj&mlC3M;+%3YzFul<@lR*uqB*;e^P~22!VWDOs1tu$ zm!k!e?h^BApJM{uVJ*v>p<*?e%z^&O9#+iL*7t|uzQ$I|9~xl}b?Zr3!LMvQ$HI*= z99J%b&Evh@Z0S*HtDRK&oZ^Hgxly+9j25HFcm?GP@`U+f1Q`G72qEjJb7rE8%TSUU z@4eUZik;3QfoWsc!@lm=U67p5esejU(W;$zzlc80>rJ|N2yHV|emceZ&dIwa#+wY^ z&1i&MIRv|TBv%61lDqVJiK*zTcS}rRB?H%ln1h%wbZK z%4pBzP`=Q|d#mv1^{D+vO$IDC7Ben#hn`Md4W16ol#4G<>0 zH6UgW6EF_2MWxim0Mb)oYKW0WX`%eQSAu!0d3y466%xDmIMdS(;QI3NsGVwu*5{o3 zORY9zRY}J6P1j`x_^o^1wy)VtOZ#fL+mu>IJ2pFy`|{u!+JB<TbcM}Q< z-`Snhxx6EIx8~p-EA1tor(xsO^e`h?TbRY1D}x0C7y4#+>`mzD7xBudQwESAgBC0tW9j%v^mtlob^&r5feaIr{9xSq(i9WNJEaRex;QZN$Z9$-ax<6MHaX$zpuXB~uJdkMWa8u|w>M-A(3O|}8l1neU6IpsM4iK6< ziW7VVAEm>Oa)=*tdOs0M^Fcyazh7Mg*eO_oXk`x7TQ#`I=w%|*2tQuk(loTL z_+n+Z?;rw*-Shg?YN2rbsp!#h){=OJEYiRBBs{;~Ccnf9J9}?|AfEBTPz<;~#F7F$ zcnokJVj-qpG@viX_e8fW^B(&YUPgu6`5_fV2t03=2W#ovHbkyxKXn^hXQF)yH*&9s zdRxy=biiSB?RvtXOv=&nr|>lLi+J4}Q`q{xuDyqGrne-6_!~~0uhB3tGJRYsR@r+r zo+E*;D#`SM=lgRZ-j9z~1NSE?*MIDu8Mi3A@+jS98fsXYsV@|+)B6D(thqlJ5_Bz4ta9O6D9m%erIG-80wNd3uP!`ijEiO=~$GBBfdJ!Fs&$NH* zkP0KVW{W6}Wo`O}s{d{&$Z?{NK^|guG%X%rH)l7Pc(_b63Wu3gq^B8o$JU@Z)%rG| z1zy>aXB9%lJ&iFHH@XaQhKIdswp(-=IuRE;udrS}tICbhLM`5dy!RC2s(leQ{)tjV z#?o(uB5poTcltS)ZxL|W(%Mse)uD4?7U8$y(FnrYb0wU&OW}~X0;(u&^jeKPL*dUI zF5}mCf`SD(9w~jrLi#W=uhHLQ;<%M}+C+#F_qF`k)a*%TlHr4j~6j(-^dBvd)%3X)cLZuw9n?PV@(y(`bU+@;+ZMSHP@3-$=xq{+&+UT+2={1LqV9?HNY&g-Fj4EA*xmP)!+={q_hO0NT zayG(ZT%0qc{q5Z#nPyFps2<89juuNu4a3seZBJ%}E@bJ>Q3~)zfF7B#CI-X zmKN_IAf?fobwpEnEji2i{paGRM<)&(twsf-kwUC+tef{RxZ_;Db9Ci8ZIF0v)_E1~ z@={Heo-xp9S99ZaJUbpl_1-@d)=OgrW*2~UPW2i9@2htB@1ov)EUV$JMG1x-P7uhP zdap=5!i9CSi*3Y$3s%W zqcvNnBo7;qq}x)np3m&1R}DXBYCn!p*rfyBUOOw*T@57mh_`k+F512Cz4mX!3Tj&d zkuow313O$n>vhV2{ax%S3<;AgD11p53;3J@uj+m)6c)o`p92RL&$B?l^Fy9!AIYKX z@CBFW!jjKr$=AGCXXhDAM9EB{ordcJb_CnXm)xe2l=q(#`NJPFw#wyw(Jv*Z?@k;P z>kOVgxl`nM8BC{A{XA>O2PsllHS~EyJI=l9zGxuQ-`Ro~_@?`NK}`l3B+hU*ZWq!zi;Z|~OlhL=b_AAQx| z+qgBrxp7fLMHzb58v>5Z2sCH?nwqt64aW^q;`CW&vxNOvHfk)86xU*5Wr4UxqdV5j zFw^1`n>gEFknK=OzYn}~J#MnW{pCUB@*y$rGQ~;OBP{mjb|PSC!GN!oawrXjNy z$EK8Y*iLHV2{vh;SmEm9q!4iL!KTfJLb?C38$f?-#kR|2XOyDwkKrOMFEn?^zj57| zP=k{@mv*1Pj?<+ITzam$#JUvrCRz@5Xf#uv?Bgtgslk$JQrB!5sInN*z zRVvn5AucRu(8b-PW=rx&p@)+fw=OtbPlfE{Tm9eDu{>=THQONm*HMFjrOWoOSm(!y z3t=q$k5TfwbRjT9Y23g!mP$wDpLoHIUIea8R*eJ2pC=fKg9v&)cV<|4^&gY&KWE{> zF&sZh3L$*^w|DnzW#tR=`Z6`gWH9Bg8vZu+x=J8|e2W;CKm1Wpz&XWQd{UCCuXaz0 zVs0S9?Zbn{=aiZ+f7>3|ArPdaYS@tsLBBKg`#kfM?&7CQb(aloneH z?!Sjaz)DwxbD`|*p!xpKTwsUzhh>W};9{ZR7eT1xeaJ4PL7u{H^+#V*(|HubeaLQt z{_oENY8Z1*)^+zJ#k>3}Y#Qs@W>?vWPVIE{b%39K&0K%kS=a&o9qat~XW~`#xJnFA zg))8049LITlK+HGD+qNT=qvj1&eE~j8Zk^Kxm9!s?w{iW$Z^NCr8`Xzie{37cGIk{iD(q+$aBC)eB3g~}MHckb5U-6$WT7cc|MBhdzu5u@ zVWtJP$$aronRM!*>3!YtgzKTOGkOIpjklt5=ng_qAK6$l*$7eQ;;ZZlSAFaV?S`MA z6oSpU71Er`x`Soxz&CJjO`S&-?y~=^+O|aQK-kRy$@7AWLGL}_VZyCTC+(&O?w22( zQn?E0p{$tJx0JV&!2eKP$rr@=!lU_kDa_evs^d8JVj{?j0=M+=sP4_cqkwTvAD;7T z#Q3X$6~SE;+e4w##@f&Uby%kg(!QM)`^1+EPZZu&rD5dJ<&pU2meij6btV5Ykv{Gf z19(@A+}WUyyr0jiy3C0ZvgiKVH5( z`wt@S%XI>Poxx@@px!WOD>_T<%Iwe($L27Iy&5ZIahU~@t9A5V zmtgU#-Ngj@A>R5`qhoFs*~z)`5d8FyL%!$_`nLzTDokx8wlKX_If*ZNk{iP4$Ea$BzyMYf_zgQzr0=B)((tMt0SH-F zCgVB8DJOq4Boe*imT+fzIBpj&+;i+f2%vn}tC)!SdE5_&V>$_RL5=oB9vPqZx|iHt2|f7~xYtB7~LnLZ(aJ#1&R zON#;GR4QItY{V{k&2A+79d~V;y7kI>K;IPi90J5Wc88`&lg00sK8r>?-$dXd)O<6V z8}2jK`38{odlxQz_%nV85lCYV+?=8b4fkOZEjl}}v2>XkOG&5yXZQeLmQ8E{#>n?( z^>u~<=1hJ@(7u3@221X3ID3k^yKBEtPmwi{$!h{%c4FX4<>u!4=2>-$QrpnFaD4YZ zqDXqCEwE*G$XRjp73IexOwowE9IhyiBJ@a~dFbb8mt6HAO!5)j*UPbnB9$!R2#Jk(mKO!#)s9$@1Y!<$+xd-9f)?)nl)YAE#C zy~98IenEN->0iw#+hvF;(;-e-Il?LBi&&@SZQ2ruwl`RL*e0OR>WR;}rJ8PRA+N}2 zi=e2_?f-aQob%8n_^tE+-iHi2!q4gk)xB9Z1JrR|QF5hXqM$dJ7XHUDZj6+qInLx! z5AS5z<4Z^F!BUqFmYlgYAO4vQ!MaGAeV0klrl6b(JpC>}8oB<{|AqEm(O$DW6_hoX zqy8B)T{d|7jdUAcF#hK>LIFtB;E6Vn*nuPUkMcJVnRhGQfti2myJAD|JWoFlY3}@o zJ_9Skj$BY4lUCm*Ju2h%JJe)D;(y}!rEUB>ELO|p-`a`5R;HUDA)$5c{4rl`(X7QA zKy5ppz->@rF;hj(x#(%76*s|(p?9L(CYz`%KN#Qr(K*eugg`ybLr%zAZvU+SB$BEF zq)(3*9oMUj|LSD0#ai=Af-#Wt&;Anc|40)au-G^dGX9tDOfmz@3bVW2>f=9th&>J# zYX `[^/]`, `trailing_slash = False` --> `[^/.]`, becomes simply `[^/]` and `lookup_value_regex` is added. +The [labels that we use in GitHub][github-labels] have been cleaned up, and all existing tickets triaged. Any given ticket should have one and only one label, indicating its current state. -[lts-releases]: https://docs.djangoproject.com/en/dev/internals/release-process/#long-term-support-lts-releases \ No newline at end of file +We've also [started using milestones][github-milestones] in order to track tickets against particular releases. + +--- + +![Labels and milestones](../img/labels-and-milestones.png) + +**Above**: *Overview of our current use of labels and milestones in GitHub.* + +--- + +We hope both of these changes will help make the management process more clear and obvious and help keep tickets well-organised and relevant. + +## Next steps + +The next planned release will be 3.0, featuring an improved and simplified serializer implementation. + +Once again, many thanks to all the generous [backers and sponsors][kickstarter-sponsors] who've helped make this possible! + +[lts-releases]: https://docs.djangoproject.com/en/dev/internals/release-process/#long-term-support-lts-releases +[2-4-release-notes]: ./topics/release-notes/#240 +[view-name-and-description-settings]: ../api-guide/settings/#view-names-and-descriptions +[client-ip-identification]: ../api-guide/throttling/#how-clients-are-identified +[2-3-announcement]: ./topics/2.3-announcement +[github-labels]: https://github.com/tomchristie/django-rest-framework/issues +[github-milestones]: https://github.com/tomchristie/django-rest-framework/milestones +[kickstarter-sponsors]: ./topics/kickstarter-announcement/#sponsors