diff --git a/.travis.yml b/.travis.yml index 7f1fda837..ececf3e9d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,11 +12,14 @@ env: - DJANGO="django==1.6.5" - DJANGO="django==1.5.8" - DJANGO="django==1.4.13" - - DJANGO="django==1.3.7" install: - pip install $DJANGO - - pip install defusedxml==0.3 Pillow==2.3.0 django-guardian==1.2.3 + - pip install defusedxml==0.3 + - 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" @@ -26,7 +29,7 @@ install: - export PYTHONPATH=. script: - - python rest_framework/runtests/runtests.py + - ./runtests.py matrix: exclude: @@ -34,13 +37,7 @@ matrix: env: DJANGO="https://www.djangoproject.com/download/1.7c2/tarball/" - python: "3.2" env: DJANGO="django==1.4.13" - - python: "3.2" - env: DJANGO="django==1.3.7" - python: "3.3" env: DJANGO="django==1.4.13" - - python: "3.3" - env: DJANGO="django==1.3.7" - python: "3.4" env: DJANGO="django==1.4.13" - - python: "3.4" - env: DJANGO="django==1.3.7" 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/README.md b/README.md index da5f27aef..7052ab638 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ # Overview -Django REST framework is a powerful and flexible toolkit that makes it easy to build Web APIs. +Django REST framework is a powerful and flexible toolkit for building Web APIs. Some reasons you might want to use REST framework: @@ -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/api-guide/authentication.md b/docs/api-guide/authentication.md index 1cb37d67f..343466eee 100755 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -126,7 +126,13 @@ To use the `TokenAuthentication` scheme you'll need to [configure the authentica '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. The `rest_framework.authtoken` app provides both Django (from v1.7) and South database migrations. See [Schema migrations](#schema-migrations) below. + +--- + You'll also need to create tokens for your users. @@ -198,7 +204,14 @@ 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 Django native migrations (for Django versions >1.7) and South migrations (for Django versions <1.7) that will create the authtoken table. + +---- + +**Note**: From REST Framework v2.4.0 using South with Django <1.7 requires upgrading South v1.0+ + +---- + 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/api-guide/fields.md b/docs/api-guide/fields.md index b41e0ebca..95d9fad33 100644 --- a/docs/api-guide/fields.md +++ b/docs/api-guide/fields.md @@ -164,11 +164,12 @@ 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 diff --git a/docs/api-guide/routers.md b/docs/api-guide/routers.md index 64f05af39..2d760ca44 100644 --- a/docs/api-guide/routers.md +++ b/docs/api-guide/routers.md @@ -51,36 +51,41 @@ This means you'll need to explicitly set the `base_name` argument when registeri ### Extra link and actions -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, given a method like this on the `UserViewSet` class: - from myapp.permissions import IsAdminOrIsSelf - from rest_framework.decorators import action - - @action(permission_classes=[IsAdminOrIsSelf]) - def set_password(self, request, pk=None): + from myapp.permissions import IsAdminOrIsSelf + from rest_framework.decorators import detail_route + + 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: * 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 -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. + - - +
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@link decorated method{basename}-{methodname}
POST@action 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. @@ -90,6 +95,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. +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' + 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. @@ -99,12 +110,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@link decorated method{basename}-{methodname} - POST@action 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. @@ -133,28 +144,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 @@ -184,6 +254,7 @@ The [wq.db package][wq.db] provides an advanced [Router][wq.db-router] class (an The [`DRF-extensions` package][drf-extensions] provides [routers][drf-extensions-routers] for creating [nested viewsets][drf-extensions-nested-viewsets], [collection level controllers][drf-extensions-collection-level-controllers] with [customizable endpoint names][drf-extensions-customizable-endpoint-names]. [cite]: http://guides.rubyonrails.org/routing.html +[route-decorators]: viewsets.html#marking-extra-actions-for-routing [drf-nested-routers]: https://github.com/alanjds/drf-nested-routers [wq.db]: http://wq.io/wq.db [wq.db-router]: http://wq.io/docs/app.py @@ -191,4 +262,4 @@ The [`DRF-extensions` package][drf-extensions] provides [routers][drf-extensions [drf-extensions-routers]: http://chibisov.github.io/drf-extensions/docs/#routers [drf-extensions-nested-viewsets]: http://chibisov.github.io/drf-extensions/docs/#nested-routes [drf-extensions-collection-level-controllers]: http://chibisov.github.io/drf-extensions/docs/#collection-level-controllers -[drf-extensions-customizable-endpoint-names]: http://chibisov.github.io/drf-extensions/docs/#controller-endpoint-name \ No newline at end of file +[drf-extensions-customizable-endpoint-names]: http://chibisov.github.io/drf-extensions/docs/#controller-endpoint-name diff --git a/docs/api-guide/settings.md b/docs/api-guide/settings.md index c979019f8..8bde4d87a 100644 --- a/docs/api-guide/settings.md +++ b/docs/api-guide/settings.md @@ -377,5 +377,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 92f4c22bb..832304f15 100644 --- a/docs/api-guide/throttling.md +++ b/docs/api-guide/throttling.md @@ -35,7 +35,7 @@ The default throttling policy may be set globally, using the `DEFAULT_THROTTLE_C 'DEFAULT_THROTTLE_RATES': { 'anon': '100/day', 'user': '1000/day' - } + } } The rate descriptions used in `DEFAULT_THROTTLE_RATES` may include `second`, `minute`, `hour` or `day` as the throttle period. @@ -66,6 +66,16 @@ Or, if you're using the `@api_view` decorator with function based views. } return Response(content) +## How clients are identified + +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 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 identifing 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. @@ -178,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/docs/api-guide/viewsets.md b/docs/api-guide/viewsets.md index aa2ceb7fc..9030e3ee0 100644 --- a/docs/api-guide/viewsets.md +++ b/docs/api-guide/viewsets.md @@ -70,7 +70,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: @@ -101,14 +101,16 @@ 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 `@detail_route` or `@list_route` decorators. + +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 import status - from rest_framework.decorators import action + from rest_framework import viewsets + from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from myapp.serializers import UserSerializer, PasswordSerializer @@ -119,7 +121,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) @@ -131,21 +133,27 @@ 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... + @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) - @action(permission_classes=[IsAdminOrIsSelf]) +The decorators can additionally take extra arguments that will be set for the routed view only. For example... + + @detail_route(methods=['post'], 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 `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: - @action(methods=['POST', 'DELETE']) + @detail_route(methods=['post', 'delete']) def unset_password(self, request, pk=None): ... The two new actions will then be available at the urls `^users/{pk}/set_password/$` and `^users/{pk}/unset_password/$` - --- # API Reference diff --git a/docs/img/1-kuwaitnet.png b/docs/img/1-kuwaitnet.png new file mode 100644 index 000000000..c73b68154 Binary files /dev/null and b/docs/img/1-kuwaitnet.png differ diff --git a/docs/img/labels-and-milestones.png b/docs/img/labels-and-milestones.png new file mode 100644 index 000000000..e7c829adc Binary files /dev/null and b/docs/img/labels-and-milestones.png differ diff --git a/docs/img/sponsors/1-kuwaitnet.png b/docs/img/sponsors/1-kuwaitnet.png index 8b2d0550a..bb779ea77 100644 Binary files a/docs/img/sponsors/1-kuwaitnet.png and b/docs/img/sponsors/1-kuwaitnet.png differ diff --git a/docs/img/sponsors/2-pulsecode.png b/docs/img/sponsors/2-pulsecode.png new file mode 100644 index 000000000..49f9532cf Binary files /dev/null and b/docs/img/sponsors/2-pulsecode.png differ diff --git a/docs/img/sponsors/2-singing-horse.png b/docs/img/sponsors/2-singing-horse.png new file mode 100644 index 000000000..84142ae6c Binary files /dev/null and b/docs/img/sponsors/2-singing-horse.png differ diff --git a/docs/index.md b/docs/index.md index dd407497b..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: @@ -207,19 +207,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/2.4-accouncement.md b/docs/topics/2.4-accouncement.md new file mode 100644 index 000000000..d8e264ff8 --- /dev/null +++ b/docs/topics/2.4-accouncement.md @@ -0,0 +1,160 @@ +# 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` decorators 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 documentation](../api-guide/viewsets.md). + +## Throttle behavior + +There's one bugfix in 2.4 that's worth calling out, as it will *invalidate existing throttle caches* when you upgrade. + +We've now fixed a typo on the `cache_format` attribute. Previously this was named `"throtte_%(scope)s_%(ident)s"`, it is now `"throttle_%(scope)s_%(ident)s"`. + +If you're concerned about the invalidation you have two options. + +* Manually pre-populate your cache with the fixed version. +* Set the `cache_format` attribute on your throttle class in order to retain the previous incorrect spelling. + +## Other features + +There are also a number of other features and bugfixes as [listed in the release notes][2-4-release-notes]. In particular these include: + +[Customizable view name and description functions][view-name-and-description-settings] for use with the browsable API, by using the `VIEW_NAME_FUNCTION` and `VIEW_DESCRIPTION_FUNCTION` settings. + +Smarter [client IP identification for throttling][client-ip-identification], with the addition of the `NUM_PROXIES` setting. + +Added the standardized `Retry-After` header to throttled responses, as per [RFC 6585](http://tools.ietf.org/html/rfc6585). This should now be used in preference to the custom `X-Trottle-Wait-Seconds` header which will be fully deprecated in 3.0. + +## Deprecations + +All API changes in 2.3 that previously raised `PendingDeprecationWarning` will now raise a `DeprecationWarning`, which is loud by default. + +All API changes in 2.3 that previously raised `DeprecationWarning` have now been removed entirely. + +Furter details on these deprecations is available in the [2.3 announcement][2-3-announcement]. + +## Labels and milestones + +Although not strictly part of the 2.4 release it's also worth noting here that we've been working hard towards improving our triage process. + +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. + +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 diff --git a/docs/topics/browsable-api.md b/docs/topics/browsable-api.md index 96cdabe6b..ad812f4b4 100644 --- a/docs/topics/browsable-api.md +++ b/docs/topics/browsable-api.md @@ -69,6 +69,7 @@ For more specific CSS tweaks than simply overriding the default bootstrap theme All of the blocks available in the browsable API base template that can be used in your `api.html`. +* `body` - The entire html ``. * `bodyclass` - Class attribute for the `` tag, empty by default. * `bootstrap_theme` - CSS for the Bootstrap theme. * `bootstrap_navbar_variant` - CSS class for the navbar. diff --git a/docs/topics/contributing.md b/docs/topics/contributing.md index 18a05050c..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 - rest_framework/runtests/runtests.py + ./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: diff --git a/docs/topics/kickstarter-announcement.md b/docs/topics/kickstarter-announcement.md index 84dc8511f..6d0910649 100644 --- a/docs/topics/kickstarter-announcement.md +++ b/docs/topics/kickstarter-announcement.md @@ -81,6 +81,8 @@ Our gold sponsors include companies large and small. Many thanks for their signi
  • Lightning Kite
  • Opbeat
  • Koordinates
  • +
  • Pulsecode Inc.
  • +
  • Singing Horse Studio. Ltd.
  • Heroku
  • Galileo Press
  • Security Compass
  • @@ -95,7 +97,7 @@ Our gold sponsors include companies large and small. Many thanks for their signi
    -**Individual backers**: Xitij Ritesh Patel, Howard Sandford, Simon Haugk. +**Individual backers**: Simon Haugk. --- diff --git a/docs/topics/release-notes.md b/docs/topics/release-notes.md index ea4c912c9..a2b4782f3 100644 --- a/docs/topics/release-notes.md +++ b/docs/topics/release-notes.md @@ -38,6 +38,35 @@ You can determine your currently installed version using `pip freeze`: --- +## 2.4.x series + +### 2.4.0 + +**Django version requirements**: The lowest supported version of Django is now 1.4.2. + +**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. + +* 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`. +* 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 `Retry-After` header to throttled responses, as per [RFC 6585](http://tools.ietf.org/html/rfc6585). This should now be used in preference to the custom `X-Trottle-Wait-Seconds` header which will be fully deprecated in 3.0. +* 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. +* Bugfix: Fix `cache_format` typo on throttle classes, was `"throtte_%(scope)s_%(ident)s"`. Note that this will invalidate existing throttle caches. + +--- + ## 2.3.x series ### 2.3.14 @@ -169,9 +198,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`. @@ -212,7 +241,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 @@ -265,7 +294,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/docs/tutorial/6-viewsets-and-routers.md b/docs/tutorial/6-viewsets-and-routers.md index 04b42f2e7..b20195204 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 the `ReadOnlyModelViewSet` class to automatically provide the de 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/optionals.txt b/requirements-test.txt similarity index 59% rename from optionals.txt rename to requirements-test.txt index 262e76443..411daeba2 100644 --- a/optionals.txt +++ b/requirements-test.txt @@ -1,3 +1,10 @@ +# Test requirements +pytest-django==2.6 +pytest==2.5.2 +pytest-cov==1.6 +flake8==2.2.2 + +# Optional packages markdown>=2.1.0 PyYAML>=3.10 defusedxml>=0.3 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 887ef5d73..5721a869e 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -6,9 +6,9 @@ import base64 from django.contrib.auth import authenticate from django.core.exceptions import ImproperlyConfigured +from django.middleware.csrf import CsrfViewMiddleware from django.conf import settings 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, check_nonce from rest_framework.authtoken.models import Token @@ -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/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/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 new file mode 100644 index 000000000..926de02b1 --- /dev/null +++ b/rest_framework/authtoken/south_migrations/0001_initial.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + +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/runtests/__init__.py b/rest_framework/authtoken/south_migrations/__init__.py similarity index 100% rename from rest_framework/runtests/__init__.py rename to rest_framework/authtoken/south_migrations/__init__.py diff --git a/rest_framework/compat.py b/rest_framework/compat.py index 9ad8b0d28..fa0f0bfb1 100644 --- a/rest_framework/compat.py +++ b/rest_framework/compat.py @@ -5,25 +5,14 @@ versions of django/python, and compatibility wrappers around optional packages. # flake8: noqa from __future__ import unicode_literals - 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 included `six`. -try: - from django.utils import six -except ImportError: - from rest_framework 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: +# Handle django.utils.encoding rename in 1.5 onwards. # smart_unicode -> smart_text # force_unicode -> force_text try: @@ -42,12 +31,14 @@ try: except ImportError: from django.http import HttpResponse as HttpResponseBase + # django-filter is optional try: import django_filters except ImportError: django_filters = None + # Django-guardian is optional. Import only if guardian is in INSTALLED_APPS # Fixes (#1712). We keep the try/except for the test suite. guardian = None @@ -108,46 +99,13 @@ def get_concrete_model(model_cls): return model_cls +# 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)] @@ -157,316 +115,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 as 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): @@ -491,6 +149,7 @@ class RequestFactory(DjangoRequestFactory): r.update(extra) return self.request(**r) + # Markdown is optional try: import markdown @@ -505,7 +164,6 @@ try: safe_mode = False md = markdown.Markdown(extensions=extensions, safe_mode=safe_mode) return md.convert(text) - except ImportError: apply_markdown = None @@ -523,14 +181,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 @@ -552,6 +212,7 @@ except (ImportError, ImproperlyConfigured): oauth_provider_store = None check_nonce = None + # OAuth 2 support is optional try: import provider as oauth2_provider @@ -571,7 +232,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/decorators.py b/rest_framework/decorators.py index c69756a43..449ba0a29 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 django.utils import six from rest_framework.views import APIView import types +import warnings def api_view(http_method_names): @@ -107,23 +108,59 @@ def permission_classes(permission_classes): return decorator -def 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.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. + """ + 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 + + 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. """ + 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 diff --git a/rest_framework/exceptions.py b/rest_framework/exceptions.py index 5f774a9f3..ad52d1726 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.' @@ -71,7 +72,7 @@ class UnsupportedMediaType(APIException): class Throttled(APIException): status_code = status.HTTP_429_TOO_MANY_REQUESTS default_detail = 'Request was throttled.' - extra_detail = "Expected available in %d second%s." + extra_detail = " Expected available in %d second%s." def __init__(self, wait=None, detail=None): if wait is None: diff --git a/rest_framework/fields.py b/rest_framework/fields.py index 6caae9242..9d707c9b5 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 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 ( - timezone, parse_date, parse_datetime, parse_time, BytesIO, six, smart_text, + BytesIO, smart_text, force_text, is_non_str_iterable ) from rest_framework.settings import api_settings @@ -61,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) @@ -265,13 +269,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 @@ -430,7 +427,7 @@ class ModelField(WritableField): } -##### Typed Fields ##### +# Typed Fields class BooleanField(WritableField): type_name = 'BooleanField' @@ -465,8 +462,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)) @@ -477,7 +475,7 @@ class CharField(WritableField): if isinstance(value, six.string_types): return value - if value is None: + if value is None and not self.allow_none: return '' return smart_text(value) @@ -488,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/filters.py b/rest_framework/filters.py index c3b846aed..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 @@ -44,7 +45,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) @@ -116,7 +117,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 aea636f15..77deb8e4f 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 @@ -127,11 +128,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. @@ -142,10 +143,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, @@ -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: @@ -199,19 +201,17 @@ 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] 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): """ @@ -224,8 +224,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: try: @@ -284,8 +284,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): """ @@ -312,16 +312,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: @@ -339,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): """ @@ -416,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): @@ -534,16 +531,14 @@ class RetrieveUpdateDestroyAPIView(mixins.RetrieveModelMixin, return self.destroy(request, *args, **kwargs) -########################## -### Deprecated classes ### -########################## +# Deprecated classes 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) @@ -551,8 +546,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 e1a24dc7e..2cc87eef1 100644 --- a/rest_framework/mixins.py +++ b/rest_framework/mixins.py @@ -26,14 +26,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': @@ -79,10 +79,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/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/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/permissions.py b/rest_framework/permissions.py index f24a51235..6a1a00770 100644 --- a/rest_framework/permissions.py +++ b/rest_framework/permissions.py @@ -2,15 +2,12 @@ Provides a set of pluggable permission policies. """ from __future__ import unicode_literals -import inspect -import warnings - -SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] - from django.http import Http404 from rest_framework.compat import (get_model_name, oauth2_provider_scope, oauth2_constants) +SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'] + class BasePermission(object): """ @@ -27,13 +24,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 @@ -72,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): @@ -132,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): @@ -222,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/relations.py b/rest_framework/relations.py index 3463954dc..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): @@ -41,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: @@ -74,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) @@ -121,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() @@ -129,7 +120,7 @@ class RelatedField(WritableField): return [] return default - ### Regular serializer stuff... + # Regular serializer stuff... def field_to_native(self, obj, field_name): try: @@ -189,7 +180,7 @@ class RelatedField(WritableField): into[(self.source or field_name)] = self.from_native(value) -### PrimaryKey relationships +# PrimaryKey relationships class PrimaryKeyRelatedField(RelatedField): """ @@ -277,8 +268,7 @@ class PrimaryKeyRelatedField(RelatedField): return self.to_native(pk) -### Slug relationships - +# Slug relationships class SlugRelatedField(RelatedField): """ @@ -313,7 +303,7 @@ class SlugRelatedField(RelatedField): raise ValidationError(msg) -### Hyperlinked relationships +# Hyperlinked relationships class HyperlinkedRelatedField(RelatedField): """ @@ -330,7 +320,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 @@ -344,16 +334,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) @@ -396,9 +386,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 @@ -432,14 +422,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: @@ -499,7 +486,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 @@ -515,16 +502,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 @@ -538,11 +525,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. @@ -606,41 +593,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/renderers.py b/rest_framework/renderers.py index 7048d87de..748ebac94 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 @@ -16,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 @@ -75,7 +72,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 +82,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, @@ -414,7 +412,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: @@ -454,8 +452,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 +576,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 +625,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 dc696e36c..275326614 100644 --- a/rest_framework/request.py +++ b/rest_framework/request.py @@ -42,13 +42,20 @@ class override_method(object): self.view = view self.request = request self.method = method + self.action = getattr(view, 'action', None) def __enter__(self): self.view.request = clone_request(self.request, self.method) + if self.action is not None: + # For viewsets we also set the `.action` attribute. + action_map = getattr(self.view, 'action_map', {}) + self.view.action = action_map.get(self.method.lower()) return self.view.request def __exit__(self, *args, **kwarg): self.view.request = self.request + if self.action is not None: + self.view.action = self.action class Empty(object): @@ -288,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 @@ -313,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. @@ -323,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) @@ -387,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/response.py b/rest_framework/response.py index 5c02ea508..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): @@ -20,7 +20,7 @@ class Response(SimpleTemplateResponse): if django.VERSION >= (1, 4): rendering_attrs = SimpleTemplateResponse.rendering_attrs + ['_closable_objects'] - def __init__(self, data=None, status=200, + def __init__(self, data=None, status=None, template_name=None, headers=None, exception=False, content_type=None): """ @@ -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/routers.py b/rest_framework/routers.py index 97b35c10a..406ebcf77 100644 --- a/rest_framework/routers.py +++ b/rest_framework/routers.py @@ -17,15 +17,17 @@ 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 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): @@ -88,6 +90,14 @@ class SimpleRouter(BaseRouter): name='{basename}-list', initkwargs={'suffix': 'List'} ), + # Dynamically generated list routes. + # Generated using @list_route decorator + # on methods of the viewset. + DynamicListRoute( + url=r'^{prefix}/{methodname}{trailing_slash}$', + name='{basename}-{methodnamehyphen}', + initkwargs={} + ), # Detail route. Route( url=r'^{prefix}/{lookup}{trailing_slash}$', @@ -100,13 +110,10 @@ class SimpleRouter(BaseRouter): name='{basename}-detail', initkwargs={'suffix': 'Instance'} ), - # Dynamically generated routes. - # Generated using @action or @link decorators on methods of the viewset. - Route( + # Dynamically generated detail routes. + # Generated using @detail_route decorator on methods of the viewset. + DynamicDetailRoute( url=r'^{prefix}/{lookup}/{methodname}{trailing_slash}$', - mapping={ - '{httpmethod}': '{methodname}', - }, name='{basename}-{methodnamehyphen}', initkwargs={} ), @@ -139,25 +146,42 @@ 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 - dynamic_routes = [] + # Determine any `@detail_route` or `@list_route` decorated methods on the viewset + detail_routes = [] + list_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) + 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 @detail_route or @list_route ' + 'decorators on method "%s" ' + 'as it is an existing route' % methodname) httpmethods = [method.lower() for method in httpmethods] - dynamic_routes.append((httpmethods, methodname)) + if detail: + detail_routes.append((httpmethods, methodname)) + else: + list_routes.append((httpmethods, methodname)) ret = [] for route in self.routes: - if route.mapping == {'{httpmethod}': '{methodname}'}: - # Dynamic routes (@link or @action decorator) - for httpmethods, methodname in dynamic_routes: + if isinstance(route, DynamicDetailRoute): + # Dynamic detail routes (@detail_route decorator) + for httpmethods, methodname in detail_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, + )) + elif isinstance(route, DynamicListRoute): + # Dynamic list routes (@list_route decorator) + for httpmethods, methodname in list_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) ret.append(Route( @@ -195,13 +219,16 @@ 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})' + # 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') - return base_regex.format(lookup_field=lookup_field, lookup_prefix=lookup_prefix) + lookup_value = getattr(viewset, 'lookup_value_regex', '[^/.]+') + 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/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 2daaae4ee..000000000 --- a/rest_framework/runtests/runtests.py +++ /dev/null @@ -1,52 +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(): - try: - django.setup() - except AttributeError: - pass - 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/serializers.py b/rest_framework/serializers.py index 43d339da0..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 get_concrete_model, six from rest_framework.settings import api_settings @@ -182,7 +182,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) @@ -412,12 +412,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) @@ -454,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 = { @@ -606,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) + ] ) @@ -661,9 +660,11 @@ 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 + 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) @@ -673,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] @@ -696,10 +697,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: @@ -708,10 +709,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: @@ -744,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: @@ -881,6 +884,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'], @@ -907,15 +914,17 @@ 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(): 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 38753c968..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) @@ -46,16 +43,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 @@ -63,6 +56,7 @@ DEFAULTS = { 'user': None, 'anon': None, }, + 'NUM_PROXIES': None, # Pagination 'PAGINATE_BY': None, @@ -119,6 +113,7 @@ DEFAULTS = { # Pending deprecation 'FILTER_BACKEND': None, + } 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/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/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 2f4e060b0..a19101e39 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..43860e53e 100644 --- a/rest_framework/templates/rest_framework/login_base.html +++ b/rest_framework/templates/rest_framework/login_base.html @@ -1,17 +1,9 @@ +{% extends "rest_framework/base.html" %} {% load url from future %} +{% load staticfiles %} {% load rest_framework %} - - - - {% block style %} - {% block bootstrap_theme %} - - - {% endblock %} - - {% endblock %} - + {% block body %}
    @@ -50,4 +42,4 @@
    - + {% endblock %} diff --git a/rest_framework/templatetags/rest_framework.py b/rest_framework/templatetags/rest_framework.py index a155d8d25..b80a7d771 100644 --- a/rest_framework/templatetags/rest_framework.py +++ b/rest_framework/templatetags/rest_framework.py @@ -2,98 +2,17 @@ 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, smart_urlquote +from django.utils.html import smart_urlquote +from rest_framework.compat import urlparse, force_text import re 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 @@ -234,8 +153,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 @@ -246,7 +167,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/rest_framework/test.py b/rest_framework/test.py index 284bcee07..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): @@ -49,9 +50,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()]) ) @@ -154,6 +156,10 @@ class APIClient(APIRequestFactory, DjangoClient): kwargs.update(self._credentials) return super(APIClient, self).request(**kwargs) + def logout(self): + self._credentials = {} + return super(APIClient, self).logout() + class APITransactionTestCase(testcases.TransactionTestCase): client_class = APIClient diff --git a/rest_framework/tests/test_breadcrumbs.py b/rest_framework/tests/test_breadcrumbs.py deleted file mode 100644 index 41ddf2cea..000000000 --- a/rest_framework/tests/test_breadcrumbs.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import unicode_literals -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 - - -class Root(APIView): - pass - - -class ResourceRoot(APIView): - pass - - -class ResourceInstance(APIView): - pass - - -class NestedResourceRoot(APIView): - pass - - -class NestedResourceInstance(APIView): - pass - -urlpatterns = patterns('', - url(r'^$', Root.as_view()), - url(r'^resource/$', ResourceRoot.as_view()), - url(r'^resource/(?P[0-9]+)$', ResourceInstance.as_view()), - url(r'^resource/(?P[0-9]+)/$', NestedResourceRoot.as_view()), - url(r'^resource/(?P[0-9]+)/(?P[A-Za-z]+)$', NestedResourceInstance.as_view()), -) - - -class BreadcrumbTests(TestCase): - """Tests the breadcrumb functionality used by the HTML renderer.""" - - urls = 'rest_framework.tests.test_breadcrumbs' - - def test_root_breadcrumbs(self): - url = '/' - self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) - - def test_resource_root_breadcrumbs(self): - url = '/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')]) - - 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/')]) - - 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')]) - - def test_broken_url_breadcrumbs_handled_gracefully(self): - url = '/foobar' - self.assertEqual(get_breadcrumbs(url), [('Root', '/')]) 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/throttling.py b/rest_framework/throttling.py index 91be9cfd5..361dbddf0 100644 --- a/rest_framework/throttling.py +++ b/rest_framework/throttling.py @@ -18,6 +18,25 @@ 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 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 + def wait(self): """ Optionally, return a recommended number of seconds to wait before @@ -41,7 +60,7 @@ class SimpleRateThrottle(BaseThrottle): cache = default_cache timer = time.time - cache_format = 'throtte_%(scope)s_%(ident)s' + cache_format = 'throttle_%(scope)s_%(ident)s' scope = None THROTTLE_RATES = api_settings.DEFAULT_THROTTLE_RATES @@ -162,7 +181,7 @@ class AnonRateThrottle(SimpleRateThrottle): return self.cache_format % { 'scope': self.scope, - 'ident': ident + 'ident': self.get_ident(request) } @@ -180,7 +199,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, @@ -228,7 +247,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, diff --git a/rest_framework/urlpatterns.py b/rest_framework/urlpatterns.py index 0ff137b00..038e9ee38 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 5d70f899a..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 rest_framework.compat import patterns, url +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 e5fa41947..00ffdfbae 100644 --- a/rest_framework/utils/encoders.py +++ b/rest_framework/utils/encoders.py @@ -2,10 +2,11 @@ Helper classes for parsers. """ from __future__ import unicode_literals +from django.utils import timezone from django.db.models.query import QuerySet 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 @@ -97,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..87b3cc6a3 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: @@ -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) diff --git a/rest_framework/views.py b/rest_framework/views.py index a2668f2c0..23df3443f 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. @@ -61,6 +62,7 @@ def exception_handler(exc): headers['WWW-Authenticate'] = exc.auth_header if getattr(exc, 'wait', None): headers['X-Throttle-Wait-Seconds'] = '%d' % exc.wait + headers['Retry-After'] = '%d' % exc.wait return Response({'detail': exc.detail}, status=exc.status_code, @@ -119,7 +121,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/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/tests/__init__.py b/tests/__init__.py similarity index 100% rename from rest_framework/tests/__init__.py rename to tests/__init__.py diff --git a/rest_framework/tests/accounts/__init__.py b/tests/accounts/__init__.py similarity index 100% rename from rest_framework/tests/accounts/__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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..f3723aeae --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +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', + 'django.contrib.staticfiles', + + '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 # NOQA + import oauth2 # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'oauth_provider', + ) + + try: + import provider # NOQA + except ImportError: + pass + else: + settings.INSTALLED_APPS += ( + 'provider', + 'provider.oauth2', + ) + + # guardian is optional + try: + import guardian # NOQA + except ImportError: + pass + else: + settings.ANONYMOUS_USER_ID = -1 + settings.AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'guardian.backends.ObjectPermissionBackend', + ) + settings.INSTALLED_APPS += ( + 'guardian', + ) + + try: + import django + django.setup() + except AttributeError: + pass 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/extras/__init__.py b/tests/extras/__init__.py similarity index 100% rename from rest_framework/tests/extras/__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 97% rename from rest_framework/tests/models.py rename to tests/models.py index fba3f8f7c..fe064b46a 100644 --- a/rest_framework/tests/models.py +++ b/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): @@ -172,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() diff --git a/rest_framework/tests/records/__init__.py b/tests/records/__init__.py similarity index 100% rename from rest_framework/tests/records/__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 70% rename from rest_framework/tests/serializers.py rename to tests/serializers.py index cc943c7d0..be7b37722 100644 --- a/rest_framework/tests/serializers.py +++ b/tests/serializers.py @@ -1,6 +1,5 @@ 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 89% rename from rest_framework/runtests/settings.py rename to tests/settings.py index 3fc0eb2f4..91c9ed09e 100644 --- a/rest_framework/runtests/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 = ( @@ -79,7 +78,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". @@ -93,22 +92,19 @@ 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', - '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 try: - import oauth_provider - import oauth2 + import oauth_provider # NOQA + import oauth2 # NOQA except ImportError: pass else: @@ -117,7 +113,7 @@ else: ) try: - import provider + import provider # NOQA except ImportError: pass else: @@ -128,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/rest_framework/tests/test_authentication.py b/tests/test_authentication.py similarity index 95% rename from rest_framework/tests/test_authentication.py rename to tests/test_authentication.py index 34bf29101..2b9d73e4c 100644 --- a/rest_framework/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -1,8 +1,9 @@ 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 -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 @@ -19,7 +20,6 @@ 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 oauth2_provider, oauth2_provider_scope from rest_framework.compat import oauth, oauth_provider from rest_framework.test import APIRequestFactory, APIClient @@ -44,32 +44,45 @@ 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] + ) + ) ) 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) @@ -108,7 +121,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) @@ -155,7 +168,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) @@ -255,7 +268,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 @@ -277,12 +290,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): @@ -485,7 +502,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) @@ -500,24 +517,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) @@ -568,8 +585,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 new file mode 100644 index 000000000..780fd5c4d --- /dev/null +++ b/tests/test_breadcrumbs.py @@ -0,0 +1,100 @@ +from __future__ import unicode_literals +from django.conf.urls import patterns, url +from django.test import TestCase +from rest_framework.utils.breadcrumbs import get_breadcrumbs +from rest_framework.views import APIView + + +class Root(APIView): + pass + + +class ResourceRoot(APIView): + pass + + +class ResourceInstance(APIView): + pass + + +class NestedResourceRoot(APIView): + pass + + +class NestedResourceInstance(APIView): + pass + +urlpatterns = patterns( + '', + url(r'^$', Root.as_view()), + url(r'^resource/$', ResourceRoot.as_view()), + url(r'^resource/(?P[0-9]+)$', ResourceInstance.as_view()), + url(r'^resource/(?P[0-9]+)/$', NestedResourceRoot.as_view()), + url(r'^resource/(?P[0-9]+)/(?P[A-Za-z]+)$', NestedResourceInstance.as_view()), +) + + +class BreadcrumbTests(TestCase): + """Tests the breadcrumb functionality used by the HTML renderer.""" + + urls = 'tests.test_breadcrumbs' + + def test_root_breadcrumbs(self): + url = '/' + self.assertEqual( + get_breadcrumbs(url), + [('Root', '/')] + ) + + def test_resource_root_breadcrumbs(self): + url = '/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') + ] + ) + + 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/') + ] + ) + + 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') + ] + ) + + def test_broken_url_breadcrumbs_handled_gracefully(self): + url = '/foobar' + self.assertEqual( + get_breadcrumbs(url), + [('Root', '/')] + ) 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 97% rename from rest_framework/tests/test_fields.py rename to tests/test_fields.py index 17d12f231..094ac1eb0 100644 --- a/rest_framework/tests/test_fields.py +++ b/tests/test_fields.py @@ -12,7 +12,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): @@ -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/rest_framework/tests/test_files.py b/tests/test_files.py similarity index 90% rename from rest_framework/tests/test_files.py rename to tests/test_files.py index 78f4cf425..de4f71d1a 100644 --- a/rest_framework/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 @@ -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/rest_framework/tests/test_filters.py b/tests/test_filters.py similarity index 94% rename from rest_framework/tests/test_filters.py rename to tests/test_filters.py index 23226bbcf..47bffd436 100644 --- a/rest_framework/tests/test_filters.py +++ b/tests/test_filters.py @@ -5,12 +5,11 @@ from django.db import models from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import unittest +from django.conf.urls import patterns, url from rest_framework import generics, serializers, status, filters -from rest_framework.compat import django_filters, patterns, url -from rest_framework.settings import api_settings +from rest_framework.compat import django_filters from rest_framework.test import APIRequestFactory -from rest_framework.tests.models import BasicModel -from .models import FilterableItem +from .models import BaseFilterableItem, FilterableItem, BasicModel from .utils import temporary_setting factory = APIRequestFactory() @@ -56,6 +55,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: @@ -75,7 +86,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(), @@ -226,6 +238,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): """ @@ -243,7 +267,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)) @@ -654,8 +678,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/rest_framework/tests/test_genericrelations.py b/tests/test_genericrelations.py similarity index 90% rename from rest_framework/tests/test_genericrelations.py rename to tests/test_genericrelations.py index 46a2d863f..95295eaa8 100644 --- a/rest_framework/tests/test_genericrelations.py +++ b/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 @@ -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/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 57d327cc6..e9f5bebdd 100644 --- a/rest_framework/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 rest_framework.tests.models import BasicModel, Comment, SlugBasedModel -from rest_framework.tests.models import ForeignKeySource, ForeignKeyTarget -from rest_framework.compat import six +from tests.models import BasicModel, Comment, SlugBasedModel +from tests.models import ForeignKeySource, ForeignKeyTarget factory = APIRequestFactory() diff --git a/rest_framework/tests/test_htmlrenderer.py b/tests/test_htmlrenderer.py similarity index 94% rename from rest_framework/tests/test_htmlrenderer.py rename to tests/test_htmlrenderer.py index 514d9e2b6..2edc6b4bd 100644 --- a/rest_framework/tests/test_htmlrenderer.py +++ b/tests/test_htmlrenderer.py @@ -1,15 +1,15 @@ 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 django.utils import six 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 -from rest_framework.compat import six +import django.template.loader @api_view(('GET',)) @@ -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), @@ -42,7 +43,7 @@ urlpatterns = patterns('', class TemplateHTMLRendererTests(TestCase): - urls = 'rest_framework.tests.test_htmlrenderer' + urls = 'tests.test_htmlrenderer' def setUp(self): """ @@ -82,7 +83,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..d45485391 100644 --- a/rest_framework/tests/test_hyperlinkedserializers.py +++ b/tests/test_hyperlinkedserializers.py @@ -2,10 +2,10 @@ from __future__ import unicode_literals import json from django.test import TestCase from rest_framework import generics, status, serializers -from rest_framework.compat import patterns, url +from django.conf.urls 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 ) @@ -25,7 +25,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) @@ -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'), @@ -110,7 +111,7 @@ urlpatterns = patterns('', class TestBasicHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -147,7 +148,7 @@ class TestBasicHyperlinkedView(TestCase): class TestManyToManyHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -195,7 +196,7 @@ class TestManyToManyHyperlinkedView(TestCase): class TestHyperlinkedIdentityFieldLookup(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -225,7 +226,7 @@ class TestHyperlinkedIdentityFieldLookup(TestCase): class TestCreateWithForeignKeys(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -250,7 +251,7 @@ class TestCreateWithForeignKeys(TestCase): class TestCreateWithForeignKeysAndCustomSlug(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -275,7 +276,7 @@ class TestCreateWithForeignKeysAndCustomSlug(TestCase): class TestOptionalRelationHyperlinkedView(TestCase): - urls = 'rest_framework.tests.test_hyperlinkedserializers' + urls = 'tests.test_hyperlinkedserializers' def setUp(self): """ @@ -335,7 +336,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 +361,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 71% rename from rest_framework/tests/test_nullable_fields.py rename to tests/test_nullable_fields.py index 6ee55c005..0c133fc2c 100644 --- a/rest_framework/tests/test_nullable_fields.py +++ b/tests/test_nullable_fields.py @@ -1,10 +1,10 @@ from django.core.urlresolvers import reverse -from rest_framework.compat import patterns, url +from django.conf.urls 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 97% rename from rest_framework/tests/test_pagination.py rename to tests/test_pagination.py index 24c1ba39f..80c33e2eb 100644 --- a/rest_framework/tests/test_pagination.py +++ b/tests/test_pagination.py @@ -1,18 +1,17 @@ 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 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 .models import FilterableItem +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: @@ -275,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() @@ -303,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() @@ -364,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" @@ -378,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") @@ -399,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='*') @@ -484,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/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 89% rename from rest_framework/tests/test_permissions.py rename to tests/test_permissions.py index 6e3a63034..93f8020f3 100644 --- a/rest_framework/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -7,11 +7,12 @@ 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() + 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 = { @@ -187,8 +201,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 @@ -206,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) } @@ -215,21 +228,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) @@ -255,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/rest_framework/tests/test_relations.py b/tests/test_relations.py similarity index 95% rename from rest_framework/tests/test_relations.py rename to tests/test_relations.py index 37ac826b2..bc1db69fc 100644 --- a/rest_framework/tests/test_relations.py +++ b/tests/test_relations.py @@ -7,7 +7,7 @@ from django.db import models from django.test import TestCase from django.utils import unittest from rest_framework import serializers -from rest_framework.tests.models import BlogPost +from tests.models import BlogPost class NullModel(models.Model): @@ -107,20 +107,26 @@ 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 = { '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/rest_framework/tests/test_relations_hyperlink.py b/tests/test_relations_hyperlink.py similarity index 94% rename from rest_framework/tests/test_relations_hyperlink.py rename to tests/test_relations_hyperlink.py index 3c4d39af6..0c8eb2544 100644 --- a/rest_framework/tests/test_relations_hyperlink.py +++ b/tests/test_relations_hyperlink.py @@ -1,9 +1,9 @@ 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 ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource @@ -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'), @@ -71,7 +72,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): @@ -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) @@ -179,7 +180,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 +308,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 +436,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 +459,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 97% rename from rest_framework/tests/test_relations_pk.py rename to tests/test_relations_pk.py index 3815afdd8..e3f836ed6 100644 --- a/rest_framework/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 rest_framework.tests.models import ( +from tests.models import ( BlogPost, ManyToManyTarget, ManyToManySource, ForeignKeyTarget, ForeignKeySource, NullableForeignKeySource, OneToOneTarget, NullableOneToOneSource, ) -from rest_framework.compat import six # ManyToMany @@ -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/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 95% rename from rest_framework/tests/test_renderers.py rename to tests/test_renderers.py index 7cb7d0f93..91244e261 100644 --- a/rest_framework/tests/test_renderers.py +++ b/tests/test_renderers.py @@ -2,13 +2,14 @@ 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.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, patterns, url, include, 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, \ @@ -75,7 +76,6 @@ class MockGETView(APIView): return Response({'foo': ['bar', 'baz']}) - class MockPOSTView(APIView): def post(self, request, **kwargs): return Response({'foo': request.DATA}) @@ -101,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()), @@ -152,7 +153,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.""" @@ -311,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() @@ -329,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. @@ -387,41 +396,53 @@ 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): """ 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: @@ -466,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 @@ -582,7 +602,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' @@ -591,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/rest_framework/tests/test_request.py b/tests/test_request.py similarity index 98% rename from rest_framework/tests/test_request.py rename to tests/test_request.py index c0b50f330..8ddaf0a70 100644 --- a/rest_framework/tests/test_request.py +++ b/tests/test_request.py @@ -2,14 +2,15 @@ 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.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.compat import patterns from rest_framework.parsers import ( BaseParser, FormParser, @@ -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 @@ -272,13 +272,14 @@ class MockView(APIView): return Response(status=status.INTERNAL_SERVER_ERROR) -urlpatterns = patterns('', +urlpatterns = patterns( + '', (r'^$', MockView.as_view()), ) 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 96% rename from rest_framework/tests/test_response.py rename to tests/test_response.py index eea3c6418..2eff83d3d 100644 --- a/rest_framework/tests/test_response.py +++ b/tests/test_response.py @@ -1,7 +1,8 @@ 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 django.utils import six +from tests.models import BasicModel, BasicModelSerializer from rest_framework.response import Response from rest_framework.views import APIView from rest_framework import generics @@ -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): @@ -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])), @@ -118,7 +119,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 +199,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 +219,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 +233,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 82% rename from rest_framework/tests/test_reverse.py rename to tests/test_reverse.py index 690a30b11..675a9d5a0 100644 --- a/rest_framework/tests/test_reverse.py +++ b/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 @@ -10,7 +10,8 @@ factory = APIRequestFactory() def null_view(request): pass -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^view$', null_view, name='view'), ) @@ -19,7 +20,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 66% rename from rest_framework/tests/test_routers.py rename to tests/test_routers.py index e723f7d45..b076f134e 100644 --- a/rest_framework/tests/test_routers.py +++ b/tests/test_routers.py @@ -1,10 +1,10 @@ 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 link, 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'}) @@ -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,9 +91,10 @@ 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('', + 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" @@ -121,6 +124,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): @@ -131,7 +155,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) @@ -175,7 +199,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 +220,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' @@ -214,3 +238,49 @@ class TestActionAppliedToExistingRoute(TestCase): with self.assertRaises(ImproperlyConfigured): self.router.urls + + +class DynamicListAndDetailViewSet(viewsets.ViewSet): + def list(self, request, *args, **kwargs): + return Response({'method': 'list'}) + + @list_route(methods=['post']) + def list_route_post(self, request, *args, **kwargs): + return Response({'method': 'action1'}) + + @detail_route(methods=['post']) + def detail_route_post(self, request, *args, **kwargs): + return Response({'method': 'action2'}) + + @list_route() + def list_route_get(self, request, *args, **kwargs): + return Response({'method': 'link1'}) + + @detail_route() + def detail_route_get(self, request, *args, **kwargs): + return Response({'method': 'link2'}) + + +class TestDynamicListAndDetailRouter(TestCase): + def setUp(self): + self.router = SimpleRouter() + + 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_route_get', 'list_route_post', 'detail_route_get', 'detail_route_post']): + route = decorator_routes[i] + # check url listing + if endpoint.startswith('list_'): + 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('_post'): + method_map = 'post' + else: + method_map = 'get' + self.assertEqual(route.mapping[method_map], endpoint) diff --git a/rest_framework/tests/test_serializer.py b/tests/test_serializer.py similarity index 96% rename from rest_framework/tests/test_serializer.py rename to tests/test_serializer.py index fb2eac0ba..90f37cf2e 100644 --- a/rest_framework/tests/test_serializer.py +++ b/tests/test_serializer.py @@ -7,11 +7,13 @@ 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 rest_framework.tests.models import (HasPositiveIntegerAsChoice, Album, ActionItem, Anchor, BasicModel, - BlankFieldModel, BlogPost, BlogPostComment, Book, CallableDefaultValueModel, DefaultValueModel, - ManyToManyModel, Person, ReadOnlyManyToManyModel, Photo, RESTFrameworkModel, - ForeignKeySource, ManyToManySource) -from rest_framework.tests.models import BasicModelSerializer +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 try: @@ -30,6 +32,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) @@ -98,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. @@ -108,6 +112,7 @@ class ActionItemSerializerOptionalFields(serializers.ModelSerializer): model = ActionItem fields = ('title',) + class ActionItemSerializerCustomRestore(serializers.ModelSerializer): class Meta: @@ -294,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): """ @@ -330,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): """ @@ -408,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) @@ -659,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.'],}) - third_serializer = AlbumsSerializer(data=[{'title': 'b', 'ref': '1'}, {'title': 'c'}]) + 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): """ @@ -958,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) @@ -973,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() @@ -1152,7 +1159,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): @@ -1260,8 +1267,22 @@ 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. -#test for issue #460 + 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): """ Test pickleability of the output of Serializers @@ -1485,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" @@ -1494,11 +1515,11 @@ 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): - 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" @@ -1506,7 +1527,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") @@ -1663,6 +1684,10 @@ class AttributeMappingOnAutogeneratedFieldsTests(TestCase): 'url_field': [ ('max_length', 1024), ], + 'nullable_char_field': [ + ('max_length', 1024), + ('allow_none', True), + ], } def field_test(self, field): @@ -1699,6 +1724,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') + @unittest.skipUnless(PIL is not None, 'PIL is not installed') class DefaultValuesOnAutogeneratedFieldsTests(TestCase): @@ -1794,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) @@ -1828,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/rest_framework/tests/test_serializer_bulk_update.py b/tests/test_serializer_bulk_update.py similarity index 98% rename from rest_framework/tests/test_serializer_bulk_update.py rename to tests/test_serializer_bulk_update.py index 8b0ded1a8..67a8ed0dc 100644 --- a/rest_framework/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/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 99% rename from rest_framework/tests/test_serializer_nested.py rename to tests/test_serializer_nested.py index 6d69ffbd0..c09c24db2 100644 --- a/rest_framework/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/rest_framework/tests/test_serializers.py b/tests/test_serializers.py similarity index 90% rename from rest_framework/tests/test_serializers.py rename to tests/test_serializers.py index 120510ace..31c417306 100644 --- a/rest_framework/tests/test_serializers.py +++ b/tests/test_serializers.py @@ -1,9 +1,7 @@ -from django.db import models from django.test import TestCase - +from django.utils import six from rest_framework.serializers import _resolve_model -from rest_framework.tests.models import BasicModel -from rest_framework.compat import six +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 95% rename from rest_framework/tests/test_status.py rename to tests/test_status.py index 7b1bdae31..721a6e30b 100644 --- a/rest_framework/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/rest_framework/tests/test_templatetags.py b/tests/test_templatetags.py similarity index 93% rename from rest_framework/tests/test_templatetags.py rename to tests/test_templatetags.py index d4da0c23b..75ee0eaa8 100644 --- a/rest_framework/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") @@ -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/rest_framework/tests/test_testing.py b/tests/test_testing.py similarity index 91% rename from rest_framework/tests/test_testing.py rename to tests/test_testing.py index a55d4b225..9c472026a 100644 --- a/rest_framework/tests/test_testing.py +++ b/tests/test_testing.py @@ -1,11 +1,11 @@ # -- coding: utf-8 -- from __future__ import unicode_literals +from django.conf.urls import patterns, url from io import BytesIO 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 @@ -28,14 +28,15 @@ def session_view(request): }) -urlpatterns = patterns('', +urlpatterns = patterns( + '', url(r'^view/$', view), url(r'^session-view/$', session_view), ) class TestAPITestClient(TestCase): - urls = 'rest_framework.tests.test_testing' + urls = 'tests.test_testing' def setUp(self): self.client = APIClient() @@ -99,6 +100,17 @@ class TestAPITestClient(TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.data, expected) + def test_can_logout(self): + """ + `logout()` reset stored credentials + """ + self.client.credentials(HTTP_AUTHORIZATION='example') + response = self.client.get('/view/') + self.assertEqual(response.data['auth'], 'example') + self.client.logout() + response = self.client.get('/view/') + self.assertEqual(response.data['auth'], b'') + class TestAPIRequestFactory(TestCase): def test_csrf_exempt_by_default(self): @@ -131,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/rest_framework/tests/test_throttling.py b/tests/test_throttling.py similarity index 70% rename from rest_framework/tests/test_throttling.py rename to tests/test_throttling.py index 41bff6926..7b696f070 100644 --- a/rest_framework/tests/test_throttling.py +++ b/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 @@ -26,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): @@ -117,43 +118,51 @@ class ThrottlingTests(TestCase): response = view.as_view()(request) if expect is not None: self.assertEqual(response['X-Throttle-Wait-Seconds'], expect) + self.assertEqual(response['Retry-After'], expect) else: self.assertFalse('X-Throttle-Wait-Seconds' in response) + self.assertFalse('Retry-After' in response) def test_seconds_fields(self): """ 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): """ @@ -165,11 +174,13 @@ class ThrottlingTests(TestCase): response = MockView_NonTimeThrottling.as_view()(request) self.assertFalse('X-Throttle-Wait-Seconds' in response) + self.assertFalse('Retry-After' in response) 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) + self.assertFalse('Retry-After' in response) class ScopedRateThrottleTests(TestCase): @@ -275,3 +286,68 @@ 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) diff --git a/rest_framework/tests/test_urlizer.py b/tests/test_urlizer.py similarity index 99% rename from rest_framework/tests/test_urlizer.py rename to tests/test_urlizer.py index 3dc8e8fe5..a77aa22ab 100644 --- a/rest_framework/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/rest_framework/tests/test_urlpatterns.py b/tests/test_urlpatterns.py similarity index 98% rename from rest_framework/tests/test_urlpatterns.py rename to tests/test_urlpatterns.py index 8132ec4c8..e0060e690 100644 --- a/rest_framework/tests/test_urlpatterns.py +++ b/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/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..41f527dfd --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,6 @@ +""" +Blank URLConf just to keep the test suite happy +""" +from django.conf.urls import patterns + +urlpatterns = patterns('') diff --git a/rest_framework/tests/users/__init__.py b/tests/users/__init__.py similarity index 100% rename from rest_framework/tests/users/__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/utils.py b/tests/utils.py similarity index 94% rename from rest_framework/tests/utils.py rename to tests/utils.py index a8f2eb0b0..28be81bd1 100644 --- a/rest_framework/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 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 72d156f9b..6f588de12 100644 --- a/tox.ini +++ b/tox.ini @@ -1,14 +1,20 @@ [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.3,py2.6-django1.3 + py2.7-django1.4,py2.6-django1.4 [testenv] -commands = {envpython} rest_framework/runtests/runtests.py +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] basepython = python3.4 @@ -16,6 +22,7 @@ deps = https://www.djangoproject.com/download/1.7c2/tarball/ django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.3-django1.7] basepython = python3.3 @@ -23,6 +30,7 @@ deps = https://www.djangoproject.com/download/1.7c2/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 @@ -30,6 +38,7 @@ deps = https://www.djangoproject.com/download/1.7c2/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 @@ -41,6 +50,7 @@ deps = https://www.djangoproject.com/download/1.7c2/tarball/ # django-oauth2-provider==0.2.4 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.4-django1.6] basepython = python3.4 @@ -48,6 +58,7 @@ deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.3-django1.6] basepython = python3.3 @@ -55,6 +66,7 @@ deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.2-django1.6] basepython = python3.2 @@ -62,6 +74,7 @@ deps = Django==1.6.3 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.7-django1.6] basepython = python2.7 @@ -73,6 +86,7 @@ deps = Django==1.6.3 django-oauth2-provider==0.2.4 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.6-django1.6] basepython = python2.6 @@ -84,6 +98,7 @@ deps = Django==1.6.3 django-oauth2-provider==0.2.4 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.4-django1.5] basepython = python3.4 @@ -91,6 +106,7 @@ deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.3-django1.5] basepython = python3.3 @@ -98,6 +114,7 @@ deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py3.2-django1.5] basepython = python3.2 @@ -105,6 +122,7 @@ deps = django==1.5.6 django-filter==0.7 defusedxml==0.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.7-django1.5] basepython = python2.7 @@ -116,6 +134,7 @@ deps = django==1.5.6 django-oauth2-provider==0.2.3 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.6-django1.5] basepython = python2.6 @@ -127,6 +146,7 @@ deps = django==1.5.6 django-oauth2-provider==0.2.3 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.7-django1.4] basepython = python2.7 @@ -138,6 +158,7 @@ deps = django==1.4.11 django-oauth2-provider==0.2.3 django-guardian==1.2.3 Pillow==2.3.0 + pytest-django==2.6.1 [testenv:py2.6-django1.4] basepython = python2.6 @@ -149,25 +170,4 @@ deps = django==1.4.11 django-oauth2-provider==0.2.3 django-guardian==1.2.3 Pillow==2.3.0 - -[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.2.1 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.2.3 - Pillow==2.3.0 - -[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.2.1 - oauth2==1.5.211 - django-oauth2-provider==0.2.3 - django-guardian==1.2.3 - Pillow==2.3.0 + pytest-django==2.6.1