From e909ceae9b3e72b72e0a2baaa92bba9714f18cd2 Mon Sep 17 00:00:00 2001 From: Ramiro Morales Date: Sat, 1 Jun 2013 14:24:46 -0300 Subject: [PATCH] Made django.test.testcases not depend on staticfiles contrib app. Do this by introducing a django.contrib.staticfiles.testing.StaticLiveServerCase unittest TestCase subclass. Fixes #20739. --- django/contrib/staticfiles/testing.py | 14 +++ django/contrib/staticfiles/views.py | 2 +- django/test/testcases.py | 92 ++++++++++++++++--- docs/howto/static-files/index.txt | 27 ++++++ docs/ref/contrib/staticfiles.txt | 23 +++++ docs/releases/1.7.txt | 14 +++ docs/topics/testing/overview.txt | 22 ++++- tests/servers/tests.py | 22 ++--- tests/staticfiles_tests/test_liveserver.py | 101 +++++++++++++++++++++ 9 files changed, 285 insertions(+), 32 deletions(-) create mode 100644 django/contrib/staticfiles/testing.py create mode 100644 tests/staticfiles_tests/test_liveserver.py diff --git a/django/contrib/staticfiles/testing.py b/django/contrib/staticfiles/testing.py new file mode 100644 index 0000000000..868323988b --- /dev/null +++ b/django/contrib/staticfiles/testing.py @@ -0,0 +1,14 @@ +from django.test import LiveServerTestCase + +from django.contrib.staticfiles.handlers import StaticFilesHandler + + +class StaticLiveServerCase(LiveServerTestCase): + """ + Extends django.test.LiveServerTestCase to transparently overlay at test + execution-time the assets provided by the staticfiles app finders. This + means you don't need to run collectstatic before or as a part of your tests + setup. + """ + + static_handler = StaticFilesHandler diff --git a/django/contrib/staticfiles/views.py b/django/contrib/staticfiles/views.py index 7ddc6a1bc2..efba1690c0 100644 --- a/django/contrib/staticfiles/views.py +++ b/django/contrib/staticfiles/views.py @@ -27,7 +27,7 @@ def serve(request, path, insecure=False, **kwargs): in your URLconf. - It uses the django.views.static view to serve the found files. + It uses the django.views.static.serve() view to serve the found files. """ if not settings.DEBUG and not insecure: raise Http404 diff --git a/django/test/testcases.py b/django/test/testcases.py index 5b72d4e8b8..2c65901c80 100644 --- a/django/test/testcases.py +++ b/django/test/testcases.py @@ -6,23 +6,25 @@ import errno from functools import wraps import json import os +import posixpath import re import sys -import socket import threading import unittest from unittest import skipIf # Imported here for backward compatibility from unittest.util import safe_repr try: - from urllib.parse import urlsplit, urlunsplit + from urllib.parse import urlsplit, urlunsplit, urlparse, unquote + from urllib.request import url2pathname except ImportError: # Python 2 - from urlparse import urlsplit, urlunsplit + from urlparse import urlsplit, urlunsplit, urlparse + from urllib import url2pathname, unquote from django.conf import settings -from django.contrib.staticfiles.handlers import StaticFilesHandler from django.core import mail from django.core.exceptions import ValidationError, ImproperlyConfigured from django.core.handlers.wsgi import WSGIHandler +from django.core.handlers.base import get_path_info from django.core.management import call_command from django.core.management.color import no_style from django.core.management.commands import flush @@ -933,10 +935,70 @@ class QuietWSGIRequestHandler(WSGIRequestHandler): pass -class _MediaFilesHandler(StaticFilesHandler): +class FSFilesHandler(WSGIHandler): """ - Handler for serving the media files. This is a private class that is - meant to be used solely as a convenience by LiveServerThread. + WSGI middleware that intercepts calls to a directory, as defined by one of + the *_ROOT settings, and serves those files, publishing them under *_URL. + """ + def __init__(self, application): + self.application = application + self.base_url = urlparse(self.get_base_url()) + super(FSFilesHandler, self).__init__() + + def _should_handle(self, path): + """ + Checks if the path should be handled. Ignores the path if: + + * the host is provided as part of the base_url + * the request's path isn't under the media path (or equal) + """ + return path.startswith(self.base_url[2]) and not self.base_url[1] + + def file_path(self, url): + """ + Returns the relative path to the file on disk for the given URL. + """ + relative_url = url[len(self.base_url[2]):] + return url2pathname(relative_url) + + def get_response(self, request): + from django.http import Http404 + + if self._should_handle(request.path): + try: + return self.serve(request) + except Http404: + pass + return super(FSFilesHandler, self).get_response(request) + + def serve(self, request): + os_rel_path = self.file_path(request.path) + final_rel_path = posixpath.normpath(unquote(os_rel_path)).lstrip('/') + return serve(request, final_rel_path, document_root=self.get_base_dir()) + + def __call__(self, environ, start_response): + if not self._should_handle(get_path_info(environ)): + return self.application(environ, start_response) + return super(FSFilesHandler, self).__call__(environ, start_response) + + +class _StaticFilesHandler(FSFilesHandler): + """ + Handler for serving static files. A private class that is meant to be used + solely as a convenience by LiveServerThread. + """ + + def get_base_dir(self): + return settings.STATIC_ROOT + + def get_base_url(self): + return settings.STATIC_URL + + +class _MediaFilesHandler(FSFilesHandler): + """ + Handler for serving the media files. A private class that is meant to be + used solely as a convenience by LiveServerThread. """ def get_base_dir(self): @@ -945,22 +1007,19 @@ class _MediaFilesHandler(StaticFilesHandler): def get_base_url(self): return settings.MEDIA_URL - def serve(self, request): - relative_url = request.path[len(self.base_url[2]):] - return serve(request, relative_url, document_root=self.get_base_dir()) - class LiveServerThread(threading.Thread): """ Thread for running a live http server while the tests are running. """ - def __init__(self, host, possible_ports, connections_override=None): + def __init__(self, host, possible_ports, static_handler, connections_override=None): self.host = host self.port = None self.possible_ports = possible_ports self.is_ready = threading.Event() self.error = None + self.static_handler = static_handler self.connections_override = connections_override super(LiveServerThread, self).__init__() @@ -976,7 +1035,7 @@ class LiveServerThread(threading.Thread): connections[alias] = conn try: # Create the handler for serving static and media files - handler = StaticFilesHandler(_MediaFilesHandler(WSGIHandler())) + handler = self.static_handler(_MediaFilesHandler(WSGIHandler())) # Go through the list of possible ports, hoping that we can find # one that is free to use for the WSGI server. @@ -1028,6 +1087,8 @@ class LiveServerTestCase(TransactionTestCase): other thread can see the changes. """ + static_handler = _StaticFilesHandler + @property def live_server_url(self): return 'http://%s:%s' % ( @@ -1069,8 +1130,9 @@ class LiveServerTestCase(TransactionTestCase): except Exception: msg = 'Invalid address ("%s") for live server.' % specified_address six.reraise(ImproperlyConfigured, ImproperlyConfigured(msg), sys.exc_info()[2]) - cls.server_thread = LiveServerThread( - host, possible_ports, connections_override) + cls.server_thread = LiveServerThread(host, possible_ports, + cls.static_handler, + connections_override=connections_override) cls.server_thread.daemon = True cls.server_thread.start() diff --git a/docs/howto/static-files/index.txt b/docs/howto/static-files/index.txt index db8bd38e9c..b423119916 100644 --- a/docs/howto/static-files/index.txt +++ b/docs/howto/static-files/index.txt @@ -100,6 +100,33 @@ this by adding the following snippet to your urls.py:: the given prefix is local (e.g. ``/static/``) and not a URL (e.g. ``http://static.example.com/``). +.. _staticfiles-testing-support: + +Testing +======= + +When running tests that use actual HTTP requests instead of the built-in +testing client (i.e. when using the built-in :class:`LiveServerTestCase +`) the static assets need to be served along +the rest of the content so the test environment reproduces the real one as +faithfully as possible, but ``LiveServerTestCase`` has only very basic static +file-serving functionality: It doesn't know about the finders feature of the +``staticfiles`` application and assumes the static content has already been +collected under :setting:`STATIC_ROOT`. + +Because of this, ``staticfiles`` ships its own +:class:`django.contrib.staticfiles.testing.StaticLiveServerCase`, a subclass +of the built-in one that has the ability to transparently serve all the assets +during execution of these tests in a way very similar to what we get at +development time with ``DEBUG = True``, i.e. without having to collect them +using :djadmin:`collectstatic` first. + +.. versionadded:: 1.7 + + :class:`django.contrib.staticfiles.testing.StaticLiveServerCase` is new in + Django 1.7. Previously its functionality was provided by + :class:`django.test.LiveServerTestCase`. + Deployment ========== diff --git a/docs/ref/contrib/staticfiles.txt b/docs/ref/contrib/staticfiles.txt index b9963414f4..8f5c1b25d1 100644 --- a/docs/ref/contrib/staticfiles.txt +++ b/docs/ref/contrib/staticfiles.txt @@ -406,3 +406,26 @@ files in app directories. That's because this view is **grossly inefficient** and probably **insecure**. This is only intended for local development, and should **never be used in production**. + +Specialized test case to support 'live testing' +----------------------------------------------- + +.. class:: testing.StaticLiveServerCase + +This unittest TestCase subclass extends :class:`django.test.LiveServerTestCase`. + +Just like its parent, you can use it to write tests that involve running the +code under test and consuming it with testing tools through HTTP (e.g. Selenium, +PhantomJS, etc.), because of which it's needed that the static assets are also +published. + +But given the fact that it makes use of the +:func:`django.contrib.staticfiles.views.serve` view described above, it can +transparently overlay at test execution-time the assets provided by the +``staticfiles`` finders. This means you don't need to run +:djadmin:`collectstatic` before or as a part of your tests setup. + +.. versionadded:: 1.7 + + ``StaticLiveServerCase`` is new in Django 1.7. Previously its functionality + was provided by :class:`django.test.LiveServerTestCase`. diff --git a/docs/releases/1.7.txt b/docs/releases/1.7.txt index 3c478c249a..10826829a8 100644 --- a/docs/releases/1.7.txt +++ b/docs/releases/1.7.txt @@ -332,6 +332,20 @@ Miscellaneous Define a ``get_absolute_url()`` method on your own custom user object or use :setting:`ABSOLUTE_URL_OVERRIDES` if you want a URL for your user. +* The static asset-serving functionality of the + :class:`django.test.LiveServerTestCase` class has been simplified: Now it's + only able to serve content already present in :setting:`STATIC_ROOT` when + tests are run. The ability to transparently serve all the static assets + (similarly to what one gets with :setting:`DEBUG = True ` at + development-time) has been moved to a new class that lives in the + ``staticfiles`` application (the one actually in charge of such feature): + :class:`django.contrib.staticfiles.testing.StaticLiveServerCase`. In other + words, ``LiveServerTestCase`` itself is less powerful but at the same time + has less magic. + + Rationale behind this is removal of dependency of non-contrib code on + contrib applications. + Features deprecated in 1.7 ========================== diff --git a/docs/topics/testing/overview.txt b/docs/topics/testing/overview.txt index 89b38f7573..d7af823038 100644 --- a/docs/topics/testing/overview.txt +++ b/docs/topics/testing/overview.txt @@ -1041,11 +1041,25 @@ out the `full reference`_ for more details. .. _full reference: http://selenium-python.readthedocs.org/en/latest/api.html .. _Firefox: http://www.mozilla.com/firefox/ -.. note:: +.. versionchanged:: 1.7 - ``LiveServerTestCase`` makes use of the :doc:`staticfiles contrib app - ` so you'll need to have your project configured - accordingly (in particular by setting :setting:`STATIC_URL`). + Before Django 1.7 ``LiveServerTestCase`` used to rely on the + :doc:`staticfiles contrib app ` to get the + static assets of the application(s) under test transparently served at their + expected locations during the execution of these tests. + + In Django 1.7 this dependency of core functionality on a ``contrib`` + appplication has been removed, because of which ``LiveServerTestCase`` + ability in this respect has been retrofitted to simply publish the contents + of the file system under :setting:`STATIC_ROOT` at the :setting:`STATIC_URL` + URL. + + If you use the ``staticfiles`` app in your project and need to perform live + testing then you might want to consider using the + :class:`~django.contrib.staticfiles.testing.StaticLiveServerCase` subclass + shipped with it instead because it's the one that implements the original + behavior now. See :ref:`the relevant documentation + ` for more details. .. note:: diff --git a/tests/servers/tests.py b/tests/servers/tests.py index 0340873013..86b8a4c6c9 100644 --- a/tests/servers/tests.py +++ b/tests/servers/tests.py @@ -82,13 +82,6 @@ class LiveServerAddress(LiveServerBase): cls.raises_exception('localhost:8081-blah', ImproperlyConfigured) cls.raises_exception('localhost:8081-8082-8083', ImproperlyConfigured) - # If contrib.staticfiles isn't configured properly, the exception - # should bubble up to the main thread. - old_STATIC_URL = TEST_SETTINGS['STATIC_URL'] - TEST_SETTINGS['STATIC_URL'] = None - cls.raises_exception('localhost:8081', ImproperlyConfigured) - TEST_SETTINGS['STATIC_URL'] = old_STATIC_URL - # Restore original environment variable if address_predefined: os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = old_address @@ -145,13 +138,18 @@ class LiveServerViews(LiveServerBase): f = self.urlopen('/static/example_static_file.txt') self.assertEqual(f.read().rstrip(b'\r\n'), b'example static file') - def test_collectstatic_emulation(self): + def test_no_collectstatic_emulation(self): """ - Test LiveServerTestCase use of staticfiles' serve() allows it to - discover app's static assets without having to collectstatic first. + Test that LiveServerTestCase reports a 404 status code when HTTP client + tries to access a static file that isn't explictly put under + STATIC_ROOT. """ - f = self.urlopen('/static/another_app/another_app_static_file.txt') - self.assertEqual(f.read().rstrip(b'\r\n'), b'static file from another_app') + try: + self.urlopen('/static/another_app/another_app_static_file.txt') + except HTTPError as err: + self.assertEqual(err.code, 404, 'Expected 404 response') + else: + self.fail('Expected 404 response (got %d)' % err.code) def test_media_files(self): """ diff --git a/tests/staticfiles_tests/test_liveserver.py b/tests/staticfiles_tests/test_liveserver.py new file mode 100644 index 0000000000..80333aae1f --- /dev/null +++ b/tests/staticfiles_tests/test_liveserver.py @@ -0,0 +1,101 @@ +""" +A subset of the tests in tests/servers/tests exercicing +django.contrib.staticfiles.testing.StaticLiveServerCase instead of +django.test.LiveServerTestCase. +""" + +import os +try: + from urllib.request import urlopen +except ImportError: # Python 2 + from urllib2 import urlopen + +from django.core.exceptions import ImproperlyConfigured +from django.test.utils import override_settings +from django.utils._os import upath + +from django.contrib.staticfiles.testing import StaticLiveServerCase + + +TEST_ROOT = os.path.dirname(upath(__file__)) +TEST_SETTINGS = { + 'MEDIA_URL': '/media/', + 'STATIC_URL': '/static/', + 'MEDIA_ROOT': os.path.join(TEST_ROOT, 'project', 'site_media', 'media'), + 'STATIC_ROOT': os.path.join(TEST_ROOT, 'project', 'site_media', 'static'), +} + + +class LiveServerBase(StaticLiveServerCase): + + available_apps = [] + + @classmethod + def setUpClass(cls): + # Override settings + cls.settings_override = override_settings(**TEST_SETTINGS) + cls.settings_override.enable() + super(LiveServerBase, cls).setUpClass() + + @classmethod + def tearDownClass(cls): + # Restore original settings + cls.settings_override.disable() + super(LiveServerBase, cls).tearDownClass() + + +class StaticLiveServerChecks(LiveServerBase): + + @classmethod + def setUpClass(cls): + # Backup original environment variable + address_predefined = 'DJANGO_LIVE_TEST_SERVER_ADDRESS' in os.environ + old_address = os.environ.get('DJANGO_LIVE_TEST_SERVER_ADDRESS') + + # If contrib.staticfiles isn't configured properly, the exception + # should bubble up to the main thread. + old_STATIC_URL = TEST_SETTINGS['STATIC_URL'] + TEST_SETTINGS['STATIC_URL'] = None + cls.raises_exception('localhost:8081', ImproperlyConfigured) + TEST_SETTINGS['STATIC_URL'] = old_STATIC_URL + + # Restore original environment variable + if address_predefined: + os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = old_address + else: + del os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] + + @classmethod + def tearDownClass(cls): + # skip it, as setUpClass doesn't call its parent either + pass + + @classmethod + def raises_exception(cls, address, exception): + os.environ['DJANGO_LIVE_TEST_SERVER_ADDRESS'] = address + try: + super(StaticLiveServerChecks, cls).setUpClass() + raise Exception("The line above should have raised an exception") + except exception: + pass + finally: + super(StaticLiveServerChecks, cls).tearDownClass() + + def test_test_test(self): + # Intentionally empty method so that the test is picked up by the + # test runner and the overridden setUpClass() method is executed. + pass + + +class StaticLiveServerView(LiveServerBase): + + def urlopen(self, url): + return urlopen(self.live_server_url + url) + + def test_collectstatic_emulation(self): + """ + Test that StaticLiveServerCase use of staticfiles' serve() allows it to + discover app's static assets without having to collectstatic first. + """ + f = self.urlopen('/static/test/file.txt') + self.assertEqual(f.read().rstrip(b'\r\n'), b'In app media directory.')