änderung1
This commit is contained in:
1 parent
cc1d096bc3
commit
cbdda613b7
2098 files changed
+274524
No files matched your search
@@ -0,0 +1,18 @@
|
||||
"""Django Unit Test framework."""
|
||||
|
||||
from django.test.client import Client, RequestFactory
|
||||
from django.test.testcases import (
|
||||
LiveServerTestCase, SimpleTestCase, TestCase, TransactionTestCase,
|
||||
skipIfDBFeature, skipUnlessAnyDBFeature, skipUnlessDBFeature,
|
||||
)
|
||||
from django.test.utils import (
|
||||
ignore_warnings, modify_settings, override_settings,
|
||||
override_system_checks, tag,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Client', 'RequestFactory', 'TestCase', 'TransactionTestCase',
|
||||
'SimpleTestCase', 'LiveServerTestCase', 'skipIfDBFeature',
|
||||
'skipUnlessAnyDBFeature', 'skipUnlessDBFeature', 'ignore_warnings',
|
||||
'modify_settings', 'override_settings', 'override_system_checks', 'tag',
|
||||
]
|
||||
@@ -0,0 +1,724 @@
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from copy import copy
|
||||
from functools import partial
|
||||
from http import HTTPStatus
|
||||
from importlib import import_module
|
||||
from io import BytesIO
|
||||
from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.handlers.base import BaseHandler
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.signals import (
|
||||
got_request_exception, request_finished, request_started,
|
||||
)
|
||||
from django.db import close_old_connections
|
||||
from django.http import HttpRequest, QueryDict, SimpleCookie
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.test import signals
|
||||
from django.test.utils import ContextList
|
||||
from django.urls import resolve
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.itercompat import is_iterable
|
||||
|
||||
__all__ = ('Client', 'RedirectCycleError', 'RequestFactory', 'encode_file', 'encode_multipart')
|
||||
|
||||
|
||||
BOUNDARY = 'BoUnDaRyStRiNg'
|
||||
MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY
|
||||
CONTENT_TYPE_RE = re.compile(r'.*; charset=([\w\d-]+);?')
|
||||
# Structured suffix spec: https://tools.ietf.org/html/rfc6838#section-4.2.8
|
||||
JSON_CONTENT_TYPE_RE = re.compile(r'^application\/(.+\+)?json')
|
||||
|
||||
|
||||
class RedirectCycleError(Exception):
|
||||
"""The test client has been asked to follow a redirect loop."""
|
||||
def __init__(self, message, last_response):
|
||||
super().__init__(message)
|
||||
self.last_response = last_response
|
||||
self.redirect_chain = last_response.redirect_chain
|
||||
|
||||
|
||||
class FakePayload:
|
||||
"""
|
||||
A wrapper around BytesIO that restricts what can be read since data from
|
||||
the network can't be seeked and cannot be read outside of its content
|
||||
length. This makes sure that views can't do anything under the test client
|
||||
that wouldn't work in real life.
|
||||
"""
|
||||
def __init__(self, content=None):
|
||||
self.__content = BytesIO()
|
||||
self.__len = 0
|
||||
self.read_started = False
|
||||
if content is not None:
|
||||
self.write(content)
|
||||
|
||||
def __len__(self):
|
||||
return self.__len
|
||||
|
||||
def read(self, num_bytes=None):
|
||||
if not self.read_started:
|
||||
self.__content.seek(0)
|
||||
self.read_started = True
|
||||
if num_bytes is None:
|
||||
num_bytes = self.__len or 0
|
||||
assert self.__len >= num_bytes, "Cannot read more than the available bytes from the HTTP incoming data."
|
||||
content = self.__content.read(num_bytes)
|
||||
self.__len -= num_bytes
|
||||
return content
|
||||
|
||||
def write(self, content):
|
||||
if self.read_started:
|
||||
raise ValueError("Unable to write a payload after he's been read")
|
||||
content = force_bytes(content)
|
||||
self.__content.write(content)
|
||||
self.__len += len(content)
|
||||
|
||||
|
||||
def closing_iterator_wrapper(iterable, close):
|
||||
try:
|
||||
yield from iterable
|
||||
finally:
|
||||
request_finished.disconnect(close_old_connections)
|
||||
close() # will fire request_finished
|
||||
request_finished.connect(close_old_connections)
|
||||
|
||||
|
||||
def conditional_content_removal(request, response):
|
||||
"""
|
||||
Simulate the behavior of most Web servers by removing the content of
|
||||
responses for HEAD requests, 1xx, 204, and 304 responses. Ensure
|
||||
compliance with RFC 7230, section 3.3.3.
|
||||
"""
|
||||
if 100 <= response.status_code < 200 or response.status_code in (204, 304):
|
||||
if response.streaming:
|
||||
response.streaming_content = []
|
||||
else:
|
||||
response.content = b''
|
||||
if request.method == 'HEAD':
|
||||
if response.streaming:
|
||||
response.streaming_content = []
|
||||
else:
|
||||
response.content = b''
|
||||
return response
|
||||
|
||||
|
||||
class ClientHandler(BaseHandler):
|
||||
"""
|
||||
A HTTP Handler that can be used for testing purposes. Use the WSGI
|
||||
interface to compose requests, but return the raw HttpResponse object with
|
||||
the originating WSGIRequest attached to its ``wsgi_request`` attribute.
|
||||
"""
|
||||
def __init__(self, enforce_csrf_checks=True, *args, **kwargs):
|
||||
self.enforce_csrf_checks = enforce_csrf_checks
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def __call__(self, environ):
|
||||
# Set up middleware if needed. We couldn't do this earlier, because
|
||||
# settings weren't available.
|
||||
if self._middleware_chain is None:
|
||||
self.load_middleware()
|
||||
|
||||
request_started.disconnect(close_old_connections)
|
||||
request_started.send(sender=self.__class__, environ=environ)
|
||||
request_started.connect(close_old_connections)
|
||||
request = WSGIRequest(environ)
|
||||
# sneaky little hack so that we can easily get round
|
||||
# CsrfViewMiddleware. This makes life easier, and is probably
|
||||
# required for backwards compatibility with external tests against
|
||||
# admin views.
|
||||
request._dont_enforce_csrf_checks = not self.enforce_csrf_checks
|
||||
|
||||
# Request goes through middleware.
|
||||
response = self.get_response(request)
|
||||
|
||||
# Simulate behaviors of most Web servers.
|
||||
conditional_content_removal(request, response)
|
||||
|
||||
# Attach the originating request to the response so that it could be
|
||||
# later retrieved.
|
||||
response.wsgi_request = request
|
||||
|
||||
# Emulate a WSGI server by calling the close method on completion.
|
||||
if response.streaming:
|
||||
response.streaming_content = closing_iterator_wrapper(
|
||||
response.streaming_content, response.close)
|
||||
else:
|
||||
request_finished.disconnect(close_old_connections)
|
||||
response.close() # will fire request_finished
|
||||
request_finished.connect(close_old_connections)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def store_rendered_templates(store, signal, sender, template, context, **kwargs):
|
||||
"""
|
||||
Store templates and contexts that are rendered.
|
||||
|
||||
The context is copied so that it is an accurate representation at the time
|
||||
of rendering.
|
||||
"""
|
||||
store.setdefault('templates', []).append(template)
|
||||
if 'context' not in store:
|
||||
store['context'] = ContextList()
|
||||
store['context'].append(copy(context))
|
||||
|
||||
|
||||
def encode_multipart(boundary, data):
|
||||
"""
|
||||
Encode multipart POST data from a dictionary of form values.
|
||||
|
||||
The key will be used as the form data name; the value will be transmitted
|
||||
as content. If the value is a file, the contents of the file will be sent
|
||||
as an application/octet-stream; otherwise, str(value) will be sent.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
def to_bytes(s):
|
||||
return force_bytes(s, settings.DEFAULT_CHARSET)
|
||||
|
||||
# Not by any means perfect, but good enough for our purposes.
|
||||
def is_file(thing):
|
||||
return hasattr(thing, "read") and callable(thing.read)
|
||||
|
||||
# Each bit of the multipart form data could be either a form value or a
|
||||
# file, or a *list* of form values and/or files. Remember that HTTP field
|
||||
# names can be duplicated!
|
||||
for (key, value) in data.items():
|
||||
if value is None:
|
||||
raise TypeError(
|
||||
'Cannot encode None as POST data. Did you mean to pass an '
|
||||
'empty string or omit the value?'
|
||||
)
|
||||
elif is_file(value):
|
||||
lines.extend(encode_file(boundary, key, value))
|
||||
elif not isinstance(value, str) and is_iterable(value):
|
||||
for item in value:
|
||||
if is_file(item):
|
||||
lines.extend(encode_file(boundary, key, item))
|
||||
else:
|
||||
lines.extend(to_bytes(val) for val in [
|
||||
'--%s' % boundary,
|
||||
'Content-Disposition: form-data; name="%s"' % key,
|
||||
'',
|
||||
item
|
||||
])
|
||||
else:
|
||||
lines.extend(to_bytes(val) for val in [
|
||||
'--%s' % boundary,
|
||||
'Content-Disposition: form-data; name="%s"' % key,
|
||||
'',
|
||||
value
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
to_bytes('--%s--' % boundary),
|
||||
b'',
|
||||
])
|
||||
return b'\r\n'.join(lines)
|
||||
|
||||
|
||||
def encode_file(boundary, key, file):
|
||||
def to_bytes(s):
|
||||
return force_bytes(s, settings.DEFAULT_CHARSET)
|
||||
|
||||
# file.name might not be a string. For example, it's an int for
|
||||
# tempfile.TemporaryFile().
|
||||
file_has_string_name = hasattr(file, 'name') and isinstance(file.name, str)
|
||||
filename = os.path.basename(file.name) if file_has_string_name else ''
|
||||
|
||||
if hasattr(file, 'content_type'):
|
||||
content_type = file.content_type
|
||||
elif filename:
|
||||
content_type = mimetypes.guess_type(filename)[0]
|
||||
else:
|
||||
content_type = None
|
||||
|
||||
if content_type is None:
|
||||
content_type = 'application/octet-stream'
|
||||
filename = filename or key
|
||||
return [
|
||||
to_bytes('--%s' % boundary),
|
||||
to_bytes('Content-Disposition: form-data; name="%s"; filename="%s"'
|
||||
% (key, filename)),
|
||||
to_bytes('Content-Type: %s' % content_type),
|
||||
b'',
|
||||
to_bytes(file.read())
|
||||
]
|
||||
|
||||
|
||||
class RequestFactory:
|
||||
"""
|
||||
Class that lets you create mock Request objects for use in testing.
|
||||
|
||||
Usage:
|
||||
|
||||
rf = RequestFactory()
|
||||
get_request = rf.get('/hello/')
|
||||
post_request = rf.post('/submit/', {'foo': 'bar'})
|
||||
|
||||
Once you have a request object you can pass it to any view function,
|
||||
just as if that view had been hooked up using a URLconf.
|
||||
"""
|
||||
def __init__(self, *, json_encoder=DjangoJSONEncoder, **defaults):
|
||||
self.json_encoder = json_encoder
|
||||
self.defaults = defaults
|
||||
self.cookies = SimpleCookie()
|
||||
self.errors = BytesIO()
|
||||
|
||||
def _base_environ(self, **request):
|
||||
"""
|
||||
The base environment for a request.
|
||||
"""
|
||||
# This is a minimal valid WSGI environ dictionary, plus:
|
||||
# - HTTP_COOKIE: for cookie support,
|
||||
# - REMOTE_ADDR: often useful, see #8551.
|
||||
# See https://www.python.org/dev/peps/pep-3333/#environ-variables
|
||||
return {
|
||||
'HTTP_COOKIE': '; '.join(sorted(
|
||||
'%s=%s' % (morsel.key, morsel.coded_value)
|
||||
for morsel in self.cookies.values()
|
||||
)),
|
||||
'PATH_INFO': '/',
|
||||
'REMOTE_ADDR': '127.0.0.1',
|
||||
'REQUEST_METHOD': 'GET',
|
||||
'SCRIPT_NAME': '',
|
||||
'SERVER_NAME': 'testserver',
|
||||
'SERVER_PORT': '80',
|
||||
'SERVER_PROTOCOL': 'HTTP/1.1',
|
||||
'wsgi.version': (1, 0),
|
||||
'wsgi.url_scheme': 'http',
|
||||
'wsgi.input': FakePayload(b''),
|
||||
'wsgi.errors': self.errors,
|
||||
'wsgi.multiprocess': True,
|
||||
'wsgi.multithread': False,
|
||||
'wsgi.run_once': False,
|
||||
**self.defaults,
|
||||
**request,
|
||||
}
|
||||
|
||||
def request(self, **request):
|
||||
"Construct a generic request object."
|
||||
return WSGIRequest(self._base_environ(**request))
|
||||
|
||||
def _encode_data(self, data, content_type):
|
||||
if content_type is MULTIPART_CONTENT:
|
||||
return encode_multipart(BOUNDARY, data)
|
||||
else:
|
||||
# Encode the content so that the byte representation is correct.
|
||||
match = CONTENT_TYPE_RE.match(content_type)
|
||||
if match:
|
||||
charset = match.group(1)
|
||||
else:
|
||||
charset = settings.DEFAULT_CHARSET
|
||||
return force_bytes(data, encoding=charset)
|
||||
|
||||
def _encode_json(self, data, content_type):
|
||||
"""
|
||||
Return encoded JSON if data is a dict, list, or tuple and content_type
|
||||
is application/json.
|
||||
"""
|
||||
should_encode = JSON_CONTENT_TYPE_RE.match(content_type) and isinstance(data, (dict, list, tuple))
|
||||
return json.dumps(data, cls=self.json_encoder) if should_encode else data
|
||||
|
||||
def _get_path(self, parsed):
|
||||
path = parsed.path
|
||||
# If there are parameters, add them
|
||||
if parsed.params:
|
||||
path += ";" + parsed.params
|
||||
path = unquote_to_bytes(path)
|
||||
# Replace the behavior where non-ASCII values in the WSGI environ are
|
||||
# arbitrarily decoded with ISO-8859-1.
|
||||
# Refs comment in `get_bytes_from_wsgi()`.
|
||||
return path.decode('iso-8859-1')
|
||||
|
||||
def get(self, path, data=None, secure=False, **extra):
|
||||
"""Construct a GET request."""
|
||||
data = {} if data is None else data
|
||||
return self.generic('GET', path, secure=secure, **{
|
||||
'QUERY_STRING': urlencode(data, doseq=True),
|
||||
**extra,
|
||||
})
|
||||
|
||||
def post(self, path, data=None, content_type=MULTIPART_CONTENT,
|
||||
secure=False, **extra):
|
||||
"""Construct a POST request."""
|
||||
data = self._encode_json({} if data is None else data, content_type)
|
||||
post_data = self._encode_data(data, content_type)
|
||||
|
||||
return self.generic('POST', path, post_data, content_type,
|
||||
secure=secure, **extra)
|
||||
|
||||
def head(self, path, data=None, secure=False, **extra):
|
||||
"""Construct a HEAD request."""
|
||||
data = {} if data is None else data
|
||||
return self.generic('HEAD', path, secure=secure, **{
|
||||
'QUERY_STRING': urlencode(data, doseq=True),
|
||||
**extra,
|
||||
})
|
||||
|
||||
def trace(self, path, secure=False, **extra):
|
||||
"""Construct a TRACE request."""
|
||||
return self.generic('TRACE', path, secure=secure, **extra)
|
||||
|
||||
def options(self, path, data='', content_type='application/octet-stream',
|
||||
secure=False, **extra):
|
||||
"Construct an OPTIONS request."
|
||||
return self.generic('OPTIONS', path, data, content_type,
|
||||
secure=secure, **extra)
|
||||
|
||||
def put(self, path, data='', content_type='application/octet-stream',
|
||||
secure=False, **extra):
|
||||
"""Construct a PUT request."""
|
||||
data = self._encode_json(data, content_type)
|
||||
return self.generic('PUT', path, data, content_type,
|
||||
secure=secure, **extra)
|
||||
|
||||
def patch(self, path, data='', content_type='application/octet-stream',
|
||||
secure=False, **extra):
|
||||
"""Construct a PATCH request."""
|
||||
data = self._encode_json(data, content_type)
|
||||
return self.generic('PATCH', path, data, content_type,
|
||||
secure=secure, **extra)
|
||||
|
||||
def delete(self, path, data='', content_type='application/octet-stream',
|
||||
secure=False, **extra):
|
||||
"""Construct a DELETE request."""
|
||||
data = self._encode_json(data, content_type)
|
||||
return self.generic('DELETE', path, data, content_type,
|
||||
secure=secure, **extra)
|
||||
|
||||
def generic(self, method, path, data='',
|
||||
content_type='application/octet-stream', secure=False,
|
||||
**extra):
|
||||
"""Construct an arbitrary HTTP request."""
|
||||
parsed = urlparse(str(path)) # path can be lazy
|
||||
data = force_bytes(data, settings.DEFAULT_CHARSET)
|
||||
r = {
|
||||
'PATH_INFO': self._get_path(parsed),
|
||||
'REQUEST_METHOD': method,
|
||||
'SERVER_PORT': '443' if secure else '80',
|
||||
'wsgi.url_scheme': 'https' if secure else 'http',
|
||||
}
|
||||
if data:
|
||||
r.update({
|
||||
'CONTENT_LENGTH': str(len(data)),
|
||||
'CONTENT_TYPE': content_type,
|
||||
'wsgi.input': FakePayload(data),
|
||||
})
|
||||
r.update(extra)
|
||||
# If QUERY_STRING is absent or empty, we want to extract it from the URL.
|
||||
if not r.get('QUERY_STRING'):
|
||||
# WSGI requires latin-1 encoded strings. See get_path_info().
|
||||
query_string = parsed[4].encode().decode('iso-8859-1')
|
||||
r['QUERY_STRING'] = query_string
|
||||
return self.request(**r)
|
||||
|
||||
|
||||
class Client(RequestFactory):
|
||||
"""
|
||||
A class that can act as a client for testing purposes.
|
||||
|
||||
It allows the user to compose GET and POST requests, and
|
||||
obtain the response that the server gave to those requests.
|
||||
The server Response objects are annotated with the details
|
||||
of the contexts and templates that were rendered during the
|
||||
process of serving the request.
|
||||
|
||||
Client objects are stateful - they will retain cookie (and
|
||||
thus session) details for the lifetime of the Client instance.
|
||||
|
||||
This is not intended as a replacement for Twill/Selenium or
|
||||
the like - it is here to allow testing against the
|
||||
contexts and templates produced by a view, rather than the
|
||||
HTML rendered to the end-user.
|
||||
"""
|
||||
def __init__(self, enforce_csrf_checks=False, **defaults):
|
||||
super().__init__(**defaults)
|
||||
self.handler = ClientHandler(enforce_csrf_checks)
|
||||
self.exc_info = None
|
||||
|
||||
def store_exc_info(self, **kwargs):
|
||||
"""Store exceptions when they are generated by a view."""
|
||||
self.exc_info = sys.exc_info()
|
||||
|
||||
@property
|
||||
def session(self):
|
||||
"""Return the current session variables."""
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
cookie = self.cookies.get(settings.SESSION_COOKIE_NAME)
|
||||
if cookie:
|
||||
return engine.SessionStore(cookie.value)
|
||||
|
||||
session = engine.SessionStore()
|
||||
session.save()
|
||||
self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
|
||||
return session
|
||||
|
||||
def request(self, **request):
|
||||
"""
|
||||
The master request method. Compose the environment dictionary and pass
|
||||
to the handler, return the result of the handler. Assume defaults for
|
||||
the query environment, which can be overridden using the arguments to
|
||||
the request.
|
||||
"""
|
||||
environ = self._base_environ(**request)
|
||||
|
||||
# Curry a data dictionary into an instance of the template renderer
|
||||
# callback function.
|
||||
data = {}
|
||||
on_template_render = partial(store_rendered_templates, data)
|
||||
signal_uid = "template-render-%s" % id(request)
|
||||
signals.template_rendered.connect(on_template_render, dispatch_uid=signal_uid)
|
||||
# Capture exceptions created by the handler.
|
||||
exception_uid = "request-exception-%s" % id(request)
|
||||
got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
|
||||
try:
|
||||
try:
|
||||
response = self.handler(environ)
|
||||
except TemplateDoesNotExist as e:
|
||||
# If the view raises an exception, Django will attempt to show
|
||||
# the 500.html template. If that template is not available,
|
||||
# we should ignore the error in favor of re-raising the
|
||||
# underlying exception that caused the 500 error. Any other
|
||||
# template found to be missing during view error handling
|
||||
# should be reported as-is.
|
||||
if e.args != ('500.html',):
|
||||
raise
|
||||
|
||||
# Look for a signalled exception, clear the current context
|
||||
# exception data, then re-raise the signalled exception.
|
||||
# Also make sure that the signalled exception is cleared from
|
||||
# the local cache!
|
||||
if self.exc_info:
|
||||
_, exc_value, _ = self.exc_info
|
||||
self.exc_info = None
|
||||
raise exc_value
|
||||
|
||||
# Save the client and request that stimulated the response.
|
||||
response.client = self
|
||||
response.request = request
|
||||
|
||||
# Add any rendered template detail to the response.
|
||||
response.templates = data.get("templates", [])
|
||||
response.context = data.get("context")
|
||||
|
||||
response.json = partial(self._parse_json, response)
|
||||
|
||||
# Attach the ResolverMatch instance to the response
|
||||
response.resolver_match = SimpleLazyObject(lambda: resolve(request['PATH_INFO']))
|
||||
|
||||
# Flatten a single context. Not really necessary anymore thanks to
|
||||
# the __getattr__ flattening in ContextList, but has some edge-case
|
||||
# backwards-compatibility implications.
|
||||
if response.context and len(response.context) == 1:
|
||||
response.context = response.context[0]
|
||||
|
||||
# Update persistent cookie data.
|
||||
if response.cookies:
|
||||
self.cookies.update(response.cookies)
|
||||
|
||||
return response
|
||||
finally:
|
||||
signals.template_rendered.disconnect(dispatch_uid=signal_uid)
|
||||
got_request_exception.disconnect(dispatch_uid=exception_uid)
|
||||
|
||||
def get(self, path, data=None, follow=False, secure=False, **extra):
|
||||
"""Request a response from the server using GET."""
|
||||
response = super().get(path, data=data, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, **extra)
|
||||
return response
|
||||
|
||||
def post(self, path, data=None, content_type=MULTIPART_CONTENT,
|
||||
follow=False, secure=False, **extra):
|
||||
"""Request a response from the server using POST."""
|
||||
response = super().post(path, data=data, content_type=content_type, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
|
||||
return response
|
||||
|
||||
def head(self, path, data=None, follow=False, secure=False, **extra):
|
||||
"""Request a response from the server using HEAD."""
|
||||
response = super().head(path, data=data, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, **extra)
|
||||
return response
|
||||
|
||||
def options(self, path, data='', content_type='application/octet-stream',
|
||||
follow=False, secure=False, **extra):
|
||||
"""Request a response from the server using OPTIONS."""
|
||||
response = super().options(path, data=data, content_type=content_type, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
|
||||
return response
|
||||
|
||||
def put(self, path, data='', content_type='application/octet-stream',
|
||||
follow=False, secure=False, **extra):
|
||||
"""Send a resource to the server using PUT."""
|
||||
response = super().put(path, data=data, content_type=content_type, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
|
||||
return response
|
||||
|
||||
def patch(self, path, data='', content_type='application/octet-stream',
|
||||
follow=False, secure=False, **extra):
|
||||
"""Send a resource to the server using PATCH."""
|
||||
response = super().patch(path, data=data, content_type=content_type, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
|
||||
return response
|
||||
|
||||
def delete(self, path, data='', content_type='application/octet-stream',
|
||||
follow=False, secure=False, **extra):
|
||||
"""Send a DELETE request to the server."""
|
||||
response = super().delete(path, data=data, content_type=content_type, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, content_type=content_type, **extra)
|
||||
return response
|
||||
|
||||
def trace(self, path, data='', follow=False, secure=False, **extra):
|
||||
"""Send a TRACE request to the server."""
|
||||
response = super().trace(path, data=data, secure=secure, **extra)
|
||||
if follow:
|
||||
response = self._handle_redirects(response, data=data, **extra)
|
||||
return response
|
||||
|
||||
def login(self, **credentials):
|
||||
"""
|
||||
Set the Factory to appear as if it has successfully logged into a site.
|
||||
|
||||
Return True if login is possible; False if the provided credentials
|
||||
are incorrect.
|
||||
"""
|
||||
from django.contrib.auth import authenticate
|
||||
user = authenticate(**credentials)
|
||||
if user:
|
||||
self._login(user)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def force_login(self, user, backend=None):
|
||||
def get_backend():
|
||||
from django.contrib.auth import load_backend
|
||||
for backend_path in settings.AUTHENTICATION_BACKENDS:
|
||||
backend = load_backend(backend_path)
|
||||
if hasattr(backend, 'get_user'):
|
||||
return backend_path
|
||||
if backend is None:
|
||||
backend = get_backend()
|
||||
user.backend = backend
|
||||
self._login(user, backend)
|
||||
|
||||
def _login(self, user, backend=None):
|
||||
from django.contrib.auth import login
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
|
||||
# Create a fake request to store login details.
|
||||
request = HttpRequest()
|
||||
|
||||
if self.session:
|
||||
request.session = self.session
|
||||
else:
|
||||
request.session = engine.SessionStore()
|
||||
login(request, user, backend)
|
||||
|
||||
# Save the session values.
|
||||
request.session.save()
|
||||
|
||||
# Set the cookie to represent the session.
|
||||
session_cookie = settings.SESSION_COOKIE_NAME
|
||||
self.cookies[session_cookie] = request.session.session_key
|
||||
cookie_data = {
|
||||
'max-age': None,
|
||||
'path': '/',
|
||||
'domain': settings.SESSION_COOKIE_DOMAIN,
|
||||
'secure': settings.SESSION_COOKIE_SECURE or None,
|
||||
'expires': None,
|
||||
}
|
||||
self.cookies[session_cookie].update(cookie_data)
|
||||
|
||||
def logout(self):
|
||||
"""Log out the user by removing the cookies and session object."""
|
||||
from django.contrib.auth import get_user, logout
|
||||
|
||||
request = HttpRequest()
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
if self.session:
|
||||
request.session = self.session
|
||||
request.user = get_user(request)
|
||||
else:
|
||||
request.session = engine.SessionStore()
|
||||
logout(request)
|
||||
self.cookies = SimpleCookie()
|
||||
|
||||
def _parse_json(self, response, **extra):
|
||||
if not hasattr(response, '_json'):
|
||||
if not JSON_CONTENT_TYPE_RE.match(response.get('Content-Type')):
|
||||
raise ValueError(
|
||||
'Content-Type header is "{0}", not "application/json"'
|
||||
.format(response.get('Content-Type'))
|
||||
)
|
||||
response._json = json.loads(response.content.decode(), **extra)
|
||||
return response._json
|
||||
|
||||
def _handle_redirects(self, response, data='', content_type='', **extra):
|
||||
"""
|
||||
Follow any redirects by requesting responses from the server using GET.
|
||||
"""
|
||||
response.redirect_chain = []
|
||||
redirect_status_codes = (
|
||||
HTTPStatus.MOVED_PERMANENTLY,
|
||||
HTTPStatus.FOUND,
|
||||
HTTPStatus.SEE_OTHER,
|
||||
HTTPStatus.TEMPORARY_REDIRECT,
|
||||
HTTPStatus.PERMANENT_REDIRECT,
|
||||
)
|
||||
while response.status_code in redirect_status_codes:
|
||||
response_url = response.url
|
||||
redirect_chain = response.redirect_chain
|
||||
redirect_chain.append((response_url, response.status_code))
|
||||
|
||||
url = urlsplit(response_url)
|
||||
if url.scheme:
|
||||
extra['wsgi.url_scheme'] = url.scheme
|
||||
if url.hostname:
|
||||
extra['SERVER_NAME'] = url.hostname
|
||||
if url.port:
|
||||
extra['SERVER_PORT'] = str(url.port)
|
||||
|
||||
# Prepend the request path to handle relative path redirects
|
||||
path = url.path
|
||||
if not path.startswith('/'):
|
||||
path = urljoin(response.request['PATH_INFO'], path)
|
||||
|
||||
if response.status_code in (HTTPStatus.TEMPORARY_REDIRECT, HTTPStatus.PERMANENT_REDIRECT):
|
||||
# Preserve request method post-redirect for 307/308 responses.
|
||||
request_method = getattr(self, response.request['REQUEST_METHOD'].lower())
|
||||
else:
|
||||
request_method = self.get
|
||||
data = QueryDict(url.query)
|
||||
content_type = None
|
||||
|
||||
response = request_method(path, data=data, content_type=content_type, follow=False, **extra)
|
||||
response.redirect_chain = redirect_chain
|
||||
|
||||
if redirect_chain[-1] in redirect_chain[:-1]:
|
||||
# Check that we're not redirecting to somewhere we've already
|
||||
# been to, to prevent loops.
|
||||
raise RedirectCycleError("Redirect loop detected.", last_response=response)
|
||||
if len(redirect_chain) > 20:
|
||||
# Such a lengthy chain likely also means a loop, but one with
|
||||
# a growing path, changing view, or changing query argument;
|
||||
# 20 is the value of "network.http.redirection-limit" from Firefox.
|
||||
raise RedirectCycleError("Too many redirects.", last_response=response)
|
||||
|
||||
return response
|
||||
@@ -0,0 +1,743 @@
|
||||
import ctypes
|
||||
import itertools
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import pickle
|
||||
import textwrap
|
||||
import unittest
|
||||
from importlib import import_module
|
||||
from io import StringIO
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.db import connections
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from django.test.utils import (
|
||||
setup_databases as _setup_databases, setup_test_environment,
|
||||
teardown_databases as _teardown_databases, teardown_test_environment,
|
||||
)
|
||||
from django.utils.datastructures import OrderedSet
|
||||
|
||||
try:
|
||||
import tblib.pickling_support
|
||||
except ImportError:
|
||||
tblib = None
|
||||
|
||||
|
||||
class DebugSQLTextTestResult(unittest.TextTestResult):
|
||||
def __init__(self, stream, descriptions, verbosity):
|
||||
self.logger = logging.getLogger('django.db.backends')
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
super().__init__(stream, descriptions, verbosity)
|
||||
|
||||
def startTest(self, test):
|
||||
self.debug_sql_stream = StringIO()
|
||||
self.handler = logging.StreamHandler(self.debug_sql_stream)
|
||||
self.logger.addHandler(self.handler)
|
||||
super().startTest(test)
|
||||
|
||||
def stopTest(self, test):
|
||||
super().stopTest(test)
|
||||
self.logger.removeHandler(self.handler)
|
||||
if self.showAll:
|
||||
self.debug_sql_stream.seek(0)
|
||||
self.stream.write(self.debug_sql_stream.read())
|
||||
self.stream.writeln(self.separator2)
|
||||
|
||||
def addError(self, test, err):
|
||||
super().addError(test, err)
|
||||
self.debug_sql_stream.seek(0)
|
||||
self.errors[-1] = self.errors[-1] + (self.debug_sql_stream.read(),)
|
||||
|
||||
def addFailure(self, test, err):
|
||||
super().addFailure(test, err)
|
||||
self.debug_sql_stream.seek(0)
|
||||
self.failures[-1] = self.failures[-1] + (self.debug_sql_stream.read(),)
|
||||
|
||||
def addSubTest(self, test, subtest, err):
|
||||
super().addSubTest(test, subtest, err)
|
||||
if err is not None:
|
||||
self.debug_sql_stream.seek(0)
|
||||
errors = self.failures if issubclass(err[0], test.failureException) else self.errors
|
||||
errors[-1] = errors[-1] + (self.debug_sql_stream.read(),)
|
||||
|
||||
def printErrorList(self, flavour, errors):
|
||||
for test, err, sql_debug in errors:
|
||||
self.stream.writeln(self.separator1)
|
||||
self.stream.writeln("%s: %s" % (flavour, self.getDescription(test)))
|
||||
self.stream.writeln(self.separator2)
|
||||
self.stream.writeln(err)
|
||||
self.stream.writeln(self.separator2)
|
||||
self.stream.writeln(sql_debug)
|
||||
|
||||
|
||||
class RemoteTestResult:
|
||||
"""
|
||||
Record information about which tests have succeeded and which have failed.
|
||||
|
||||
The sole purpose of this class is to record events in the child processes
|
||||
so they can be replayed in the master process. As a consequence it doesn't
|
||||
inherit unittest.TestResult and doesn't attempt to implement all its API.
|
||||
|
||||
The implementation matches the unpythonic coding style of unittest2.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if tblib is not None:
|
||||
tblib.pickling_support.install()
|
||||
|
||||
self.events = []
|
||||
self.failfast = False
|
||||
self.shouldStop = False
|
||||
self.testsRun = 0
|
||||
|
||||
@property
|
||||
def test_index(self):
|
||||
return self.testsRun - 1
|
||||
|
||||
def _confirm_picklable(self, obj):
|
||||
"""
|
||||
Confirm that obj can be pickled and unpickled as multiprocessing will
|
||||
need to pickle the exception in the child process and unpickle it in
|
||||
the parent process. Let the exception rise, if not.
|
||||
"""
|
||||
pickle.loads(pickle.dumps(obj))
|
||||
|
||||
def _print_unpicklable_subtest(self, test, subtest, pickle_exc):
|
||||
print("""
|
||||
Subtest failed:
|
||||
|
||||
test: {}
|
||||
subtest: {}
|
||||
|
||||
Unfortunately, the subtest that failed cannot be pickled, so the parallel
|
||||
test runner cannot handle it cleanly. Here is the pickling error:
|
||||
|
||||
> {}
|
||||
|
||||
You should re-run this test with --parallel=1 to reproduce the failure
|
||||
with a cleaner failure message.
|
||||
""".format(test, subtest, pickle_exc))
|
||||
|
||||
def check_picklable(self, test, err):
|
||||
# Ensure that sys.exc_info() tuples are picklable. This displays a
|
||||
# clear multiprocessing.pool.RemoteTraceback generated in the child
|
||||
# process instead of a multiprocessing.pool.MaybeEncodingError, making
|
||||
# the root cause easier to figure out for users who aren't familiar
|
||||
# with the multiprocessing module. Since we're in a forked process,
|
||||
# our best chance to communicate with them is to print to stdout.
|
||||
try:
|
||||
self._confirm_picklable(err)
|
||||
except Exception as exc:
|
||||
original_exc_txt = repr(err[1])
|
||||
original_exc_txt = textwrap.fill(original_exc_txt, 75, initial_indent=' ', subsequent_indent=' ')
|
||||
pickle_exc_txt = repr(exc)
|
||||
pickle_exc_txt = textwrap.fill(pickle_exc_txt, 75, initial_indent=' ', subsequent_indent=' ')
|
||||
if tblib is None:
|
||||
print("""
|
||||
|
||||
{} failed:
|
||||
|
||||
{}
|
||||
|
||||
Unfortunately, tracebacks cannot be pickled, making it impossible for the
|
||||
parallel test runner to handle this exception cleanly.
|
||||
|
||||
In order to see the traceback, you should install tblib:
|
||||
|
||||
pip install tblib
|
||||
""".format(test, original_exc_txt))
|
||||
else:
|
||||
print("""
|
||||
|
||||
{} failed:
|
||||
|
||||
{}
|
||||
|
||||
Unfortunately, the exception it raised cannot be pickled, making it impossible
|
||||
for the parallel test runner to handle it cleanly.
|
||||
|
||||
Here's the error encountered while trying to pickle the exception:
|
||||
|
||||
{}
|
||||
|
||||
You should re-run this test with the --parallel=1 option to reproduce the
|
||||
failure and get a correct traceback.
|
||||
""".format(test, original_exc_txt, pickle_exc_txt))
|
||||
raise
|
||||
|
||||
def check_subtest_picklable(self, test, subtest):
|
||||
try:
|
||||
self._confirm_picklable(subtest)
|
||||
except Exception as exc:
|
||||
self._print_unpicklable_subtest(test, subtest, exc)
|
||||
raise
|
||||
|
||||
def stop_if_failfast(self):
|
||||
if self.failfast:
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
self.shouldStop = True
|
||||
|
||||
def startTestRun(self):
|
||||
self.events.append(('startTestRun',))
|
||||
|
||||
def stopTestRun(self):
|
||||
self.events.append(('stopTestRun',))
|
||||
|
||||
def startTest(self, test):
|
||||
self.testsRun += 1
|
||||
self.events.append(('startTest', self.test_index))
|
||||
|
||||
def stopTest(self, test):
|
||||
self.events.append(('stopTest', self.test_index))
|
||||
|
||||
def addError(self, test, err):
|
||||
self.check_picklable(test, err)
|
||||
self.events.append(('addError', self.test_index, err))
|
||||
self.stop_if_failfast()
|
||||
|
||||
def addFailure(self, test, err):
|
||||
self.check_picklable(test, err)
|
||||
self.events.append(('addFailure', self.test_index, err))
|
||||
self.stop_if_failfast()
|
||||
|
||||
def addSubTest(self, test, subtest, err):
|
||||
# Follow Python 3.5's implementation of unittest.TestResult.addSubTest()
|
||||
# by not doing anything when a subtest is successful.
|
||||
if err is not None:
|
||||
# Call check_picklable() before check_subtest_picklable() since
|
||||
# check_picklable() performs the tblib check.
|
||||
self.check_picklable(test, err)
|
||||
self.check_subtest_picklable(test, subtest)
|
||||
self.events.append(('addSubTest', self.test_index, subtest, err))
|
||||
self.stop_if_failfast()
|
||||
|
||||
def addSuccess(self, test):
|
||||
self.events.append(('addSuccess', self.test_index))
|
||||
|
||||
def addSkip(self, test, reason):
|
||||
self.events.append(('addSkip', self.test_index, reason))
|
||||
|
||||
def addExpectedFailure(self, test, err):
|
||||
# If tblib isn't installed, pickling the traceback will always fail.
|
||||
# However we don't want tblib to be required for running the tests
|
||||
# when they pass or fail as expected. Drop the traceback when an
|
||||
# expected failure occurs.
|
||||
if tblib is None:
|
||||
err = err[0], err[1], None
|
||||
self.check_picklable(test, err)
|
||||
self.events.append(('addExpectedFailure', self.test_index, err))
|
||||
|
||||
def addUnexpectedSuccess(self, test):
|
||||
self.events.append(('addUnexpectedSuccess', self.test_index))
|
||||
self.stop_if_failfast()
|
||||
|
||||
|
||||
class RemoteTestRunner:
|
||||
"""
|
||||
Run tests and record everything but don't display anything.
|
||||
|
||||
The implementation matches the unpythonic coding style of unittest2.
|
||||
"""
|
||||
|
||||
resultclass = RemoteTestResult
|
||||
|
||||
def __init__(self, failfast=False, resultclass=None):
|
||||
self.failfast = failfast
|
||||
if resultclass is not None:
|
||||
self.resultclass = resultclass
|
||||
|
||||
def run(self, test):
|
||||
result = self.resultclass()
|
||||
unittest.registerResult(result)
|
||||
result.failfast = self.failfast
|
||||
test(result)
|
||||
return result
|
||||
|
||||
|
||||
def default_test_processes():
|
||||
"""Default number of test processes when using the --parallel option."""
|
||||
# The current implementation of the parallel test runner requires
|
||||
# multiprocessing to start subprocesses with fork().
|
||||
if multiprocessing.get_start_method() != 'fork':
|
||||
return 1
|
||||
try:
|
||||
return int(os.environ['DJANGO_TEST_PROCESSES'])
|
||||
except KeyError:
|
||||
return multiprocessing.cpu_count()
|
||||
|
||||
|
||||
_worker_id = 0
|
||||
|
||||
|
||||
def _init_worker(counter):
|
||||
"""
|
||||
Switch to databases dedicated to this worker.
|
||||
|
||||
This helper lives at module-level because of the multiprocessing module's
|
||||
requirements.
|
||||
"""
|
||||
|
||||
global _worker_id
|
||||
|
||||
with counter.get_lock():
|
||||
counter.value += 1
|
||||
_worker_id = counter.value
|
||||
|
||||
for alias in connections:
|
||||
connection = connections[alias]
|
||||
settings_dict = connection.creation.get_test_db_clone_settings(str(_worker_id))
|
||||
# connection.settings_dict must be updated in place for changes to be
|
||||
# reflected in django.db.connections. If the following line assigned
|
||||
# connection.settings_dict = settings_dict, new threads would connect
|
||||
# to the default database instead of the appropriate clone.
|
||||
connection.settings_dict.update(settings_dict)
|
||||
connection.close()
|
||||
|
||||
|
||||
def _run_subsuite(args):
|
||||
"""
|
||||
Run a suite of tests with a RemoteTestRunner and return a RemoteTestResult.
|
||||
|
||||
This helper lives at module-level and its arguments are wrapped in a tuple
|
||||
because of the multiprocessing module's requirements.
|
||||
"""
|
||||
runner_class, subsuite_index, subsuite, failfast = args
|
||||
runner = runner_class(failfast=failfast)
|
||||
result = runner.run(subsuite)
|
||||
return subsuite_index, result.events
|
||||
|
||||
|
||||
class ParallelTestSuite(unittest.TestSuite):
|
||||
"""
|
||||
Run a series of tests in parallel in several processes.
|
||||
|
||||
While the unittest module's documentation implies that orchestrating the
|
||||
execution of tests is the responsibility of the test runner, in practice,
|
||||
it appears that TestRunner classes are more concerned with formatting and
|
||||
displaying test results.
|
||||
|
||||
Since there are fewer use cases for customizing TestSuite than TestRunner,
|
||||
implementing parallelization at the level of the TestSuite improves
|
||||
interoperability with existing custom test runners. A single instance of a
|
||||
test runner can still collect results from all tests without being aware
|
||||
that they have been run in parallel.
|
||||
"""
|
||||
|
||||
# In case someone wants to modify these in a subclass.
|
||||
init_worker = _init_worker
|
||||
run_subsuite = _run_subsuite
|
||||
runner_class = RemoteTestRunner
|
||||
|
||||
def __init__(self, suite, processes, failfast=False):
|
||||
self.subsuites = partition_suite_by_case(suite)
|
||||
self.processes = processes
|
||||
self.failfast = failfast
|
||||
super().__init__()
|
||||
|
||||
def run(self, result):
|
||||
"""
|
||||
Distribute test cases across workers.
|
||||
|
||||
Return an identifier of each test case with its result in order to use
|
||||
imap_unordered to show results as soon as they're available.
|
||||
|
||||
To minimize pickling errors when getting results from workers:
|
||||
|
||||
- pass back numeric indexes in self.subsuites instead of tests
|
||||
- make tracebacks picklable with tblib, if available
|
||||
|
||||
Even with tblib, errors may still occur for dynamically created
|
||||
exception classes which cannot be unpickled.
|
||||
"""
|
||||
counter = multiprocessing.Value(ctypes.c_int, 0)
|
||||
pool = multiprocessing.Pool(
|
||||
processes=self.processes,
|
||||
initializer=self.init_worker.__func__,
|
||||
initargs=[counter],
|
||||
)
|
||||
args = [
|
||||
(self.runner_class, index, subsuite, self.failfast)
|
||||
for index, subsuite in enumerate(self.subsuites)
|
||||
]
|
||||
test_results = pool.imap_unordered(self.run_subsuite.__func__, args)
|
||||
|
||||
while True:
|
||||
if result.shouldStop:
|
||||
pool.terminate()
|
||||
break
|
||||
|
||||
try:
|
||||
subsuite_index, events = test_results.next(timeout=0.1)
|
||||
except multiprocessing.TimeoutError:
|
||||
continue
|
||||
except StopIteration:
|
||||
pool.close()
|
||||
break
|
||||
|
||||
tests = list(self.subsuites[subsuite_index])
|
||||
for event in events:
|
||||
event_name = event[0]
|
||||
handler = getattr(result, event_name, None)
|
||||
if handler is None:
|
||||
continue
|
||||
test = tests[event[1]]
|
||||
args = event[2:]
|
||||
handler(test, *args)
|
||||
|
||||
pool.join()
|
||||
|
||||
return result
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.subsuites)
|
||||
|
||||
|
||||
class DiscoverRunner:
|
||||
"""A Django test runner that uses unittest2 test discovery."""
|
||||
|
||||
test_suite = unittest.TestSuite
|
||||
parallel_test_suite = ParallelTestSuite
|
||||
test_runner = unittest.TextTestRunner
|
||||
test_loader = unittest.defaultTestLoader
|
||||
reorder_by = (TestCase, SimpleTestCase)
|
||||
|
||||
def __init__(self, pattern=None, top_level=None, verbosity=1,
|
||||
interactive=True, failfast=False, keepdb=False,
|
||||
reverse=False, debug_mode=False, debug_sql=False, parallel=0,
|
||||
tags=None, exclude_tags=None, **kwargs):
|
||||
|
||||
self.pattern = pattern
|
||||
self.top_level = top_level
|
||||
self.verbosity = verbosity
|
||||
self.interactive = interactive
|
||||
self.failfast = failfast
|
||||
self.keepdb = keepdb
|
||||
self.reverse = reverse
|
||||
self.debug_mode = debug_mode
|
||||
self.debug_sql = debug_sql
|
||||
self.parallel = parallel
|
||||
self.tags = set(tags or [])
|
||||
self.exclude_tags = set(exclude_tags or [])
|
||||
|
||||
@classmethod
|
||||
def add_arguments(cls, parser):
|
||||
parser.add_argument(
|
||||
'-t', '--top-level-directory', dest='top_level',
|
||||
help='Top level of project for unittest discovery.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-p', '--pattern', default="test*.py",
|
||||
help='The test matching pattern. Defaults to test*.py.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-k', '--keepdb', action='store_true',
|
||||
help='Preserves the test DB between runs.'
|
||||
)
|
||||
parser.add_argument(
|
||||
'-r', '--reverse', action='store_true',
|
||||
help='Reverses test cases order.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--debug-mode', action='store_true',
|
||||
help='Sets settings.DEBUG to True.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'-d', '--debug-sql', action='store_true',
|
||||
help='Prints logged SQL queries on failure.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--parallel', nargs='?', default=1, type=int,
|
||||
const=default_test_processes(), metavar='N',
|
||||
help='Run tests using up to N parallel processes.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--tag', action='append', dest='tags',
|
||||
help='Run only tests with the specified tag. Can be used multiple times.',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--exclude-tag', action='append', dest='exclude_tags',
|
||||
help='Do not run tests with the specified tag. Can be used multiple times.',
|
||||
)
|
||||
|
||||
def setup_test_environment(self, **kwargs):
|
||||
setup_test_environment(debug=self.debug_mode)
|
||||
unittest.installHandler()
|
||||
|
||||
def build_suite(self, test_labels=None, extra_tests=None, **kwargs):
|
||||
suite = self.test_suite()
|
||||
test_labels = test_labels or ['.']
|
||||
extra_tests = extra_tests or []
|
||||
|
||||
discover_kwargs = {}
|
||||
if self.pattern is not None:
|
||||
discover_kwargs['pattern'] = self.pattern
|
||||
if self.top_level is not None:
|
||||
discover_kwargs['top_level_dir'] = self.top_level
|
||||
|
||||
for label in test_labels:
|
||||
kwargs = discover_kwargs.copy()
|
||||
tests = None
|
||||
|
||||
label_as_path = os.path.abspath(label)
|
||||
|
||||
# if a module, or "module.ClassName[.method_name]", just run those
|
||||
if not os.path.exists(label_as_path):
|
||||
tests = self.test_loader.loadTestsFromName(label)
|
||||
elif os.path.isdir(label_as_path) and not self.top_level:
|
||||
# Try to be a bit smarter than unittest about finding the
|
||||
# default top-level for a given directory path, to avoid
|
||||
# breaking relative imports. (Unittest's default is to set
|
||||
# top-level equal to the path, which means relative imports
|
||||
# will result in "Attempted relative import in non-package.").
|
||||
|
||||
# We'd be happy to skip this and require dotted module paths
|
||||
# (which don't cause this problem) instead of file paths (which
|
||||
# do), but in the case of a directory in the cwd, which would
|
||||
# be equally valid if considered as a top-level module or as a
|
||||
# directory path, unittest unfortunately prefers the latter.
|
||||
|
||||
top_level = label_as_path
|
||||
while True:
|
||||
init_py = os.path.join(top_level, '__init__.py')
|
||||
if os.path.exists(init_py):
|
||||
try_next = os.path.dirname(top_level)
|
||||
if try_next == top_level:
|
||||
# __init__.py all the way down? give up.
|
||||
break
|
||||
top_level = try_next
|
||||
continue
|
||||
break
|
||||
kwargs['top_level_dir'] = top_level
|
||||
|
||||
if not (tests and tests.countTestCases()) and is_discoverable(label):
|
||||
# Try discovery if path is a package or directory
|
||||
tests = self.test_loader.discover(start_dir=label, **kwargs)
|
||||
|
||||
# Make unittest forget the top-level dir it calculated from this
|
||||
# run, to support running tests from two different top-levels.
|
||||
self.test_loader._top_level_dir = None
|
||||
|
||||
suite.addTests(tests)
|
||||
|
||||
for test in extra_tests:
|
||||
suite.addTest(test)
|
||||
|
||||
if self.tags or self.exclude_tags:
|
||||
if self.verbosity >= 2:
|
||||
if self.tags:
|
||||
print('Including test tag(s): %s.' % ', '.join(sorted(self.tags)))
|
||||
if self.exclude_tags:
|
||||
print('Excluding test tag(s): %s.' % ', '.join(sorted(self.exclude_tags)))
|
||||
suite = filter_tests_by_tags(suite, self.tags, self.exclude_tags)
|
||||
suite = reorder_suite(suite, self.reorder_by, self.reverse)
|
||||
|
||||
if self.parallel > 1:
|
||||
parallel_suite = self.parallel_test_suite(suite, self.parallel, self.failfast)
|
||||
|
||||
# Since tests are distributed across processes on a per-TestCase
|
||||
# basis, there's no need for more processes than TestCases.
|
||||
parallel_units = len(parallel_suite.subsuites)
|
||||
self.parallel = min(self.parallel, parallel_units)
|
||||
|
||||
# If there's only one TestCase, parallelization isn't needed.
|
||||
if self.parallel > 1:
|
||||
suite = parallel_suite
|
||||
|
||||
return suite
|
||||
|
||||
def setup_databases(self, **kwargs):
|
||||
return _setup_databases(
|
||||
self.verbosity, self.interactive, self.keepdb, self.debug_sql,
|
||||
self.parallel, **kwargs
|
||||
)
|
||||
|
||||
def get_resultclass(self):
|
||||
return DebugSQLTextTestResult if self.debug_sql else None
|
||||
|
||||
def get_test_runner_kwargs(self):
|
||||
return {
|
||||
'failfast': self.failfast,
|
||||
'resultclass': self.get_resultclass(),
|
||||
'verbosity': self.verbosity,
|
||||
}
|
||||
|
||||
def run_checks(self):
|
||||
# Checks are run after database creation since some checks require
|
||||
# database access.
|
||||
call_command('check', verbosity=self.verbosity)
|
||||
|
||||
def run_suite(self, suite, **kwargs):
|
||||
kwargs = self.get_test_runner_kwargs()
|
||||
runner = self.test_runner(**kwargs)
|
||||
return runner.run(suite)
|
||||
|
||||
def teardown_databases(self, old_config, **kwargs):
|
||||
"""Destroy all the non-mirror databases."""
|
||||
_teardown_databases(
|
||||
old_config,
|
||||
verbosity=self.verbosity,
|
||||
parallel=self.parallel,
|
||||
keepdb=self.keepdb,
|
||||
)
|
||||
|
||||
def teardown_test_environment(self, **kwargs):
|
||||
unittest.removeHandler()
|
||||
teardown_test_environment()
|
||||
|
||||
def suite_result(self, suite, result, **kwargs):
|
||||
return len(result.failures) + len(result.errors)
|
||||
|
||||
def _get_databases(self, suite):
|
||||
databases = set()
|
||||
for test in suite:
|
||||
if isinstance(test, unittest.TestCase):
|
||||
test_databases = getattr(test, 'databases', None)
|
||||
if test_databases == '__all__':
|
||||
return set(connections)
|
||||
if test_databases:
|
||||
databases.update(test_databases)
|
||||
else:
|
||||
databases.update(self._get_databases(test))
|
||||
return databases
|
||||
|
||||
def get_databases(self, suite):
|
||||
databases = self._get_databases(suite)
|
||||
if self.verbosity >= 2:
|
||||
unused_databases = [alias for alias in connections if alias not in databases]
|
||||
if unused_databases:
|
||||
print('Skipping setup of unused database(s): %s.' % ', '.join(sorted(unused_databases)))
|
||||
return databases
|
||||
|
||||
def run_tests(self, test_labels, extra_tests=None, **kwargs):
|
||||
"""
|
||||
Run the unit tests for all the test labels in the provided list.
|
||||
|
||||
Test labels should be dotted Python paths to test modules, test
|
||||
classes, or test methods.
|
||||
|
||||
A list of 'extra' tests may also be provided; these tests
|
||||
will be added to the test suite.
|
||||
|
||||
Return the number of tests that failed.
|
||||
"""
|
||||
self.setup_test_environment()
|
||||
suite = self.build_suite(test_labels, extra_tests)
|
||||
databases = self.get_databases(suite)
|
||||
old_config = self.setup_databases(aliases=databases)
|
||||
run_failed = False
|
||||
try:
|
||||
self.run_checks()
|
||||
result = self.run_suite(suite)
|
||||
except Exception:
|
||||
run_failed = True
|
||||
raise
|
||||
finally:
|
||||
try:
|
||||
self.teardown_databases(old_config)
|
||||
self.teardown_test_environment()
|
||||
except Exception:
|
||||
# Silence teardown exceptions if an exception was raised during
|
||||
# runs to avoid shadowing it.
|
||||
if not run_failed:
|
||||
raise
|
||||
return self.suite_result(suite, result)
|
||||
|
||||
|
||||
def is_discoverable(label):
|
||||
"""
|
||||
Check if a test label points to a Python package or file directory.
|
||||
|
||||
Relative labels like "." and ".." are seen as directories.
|
||||
"""
|
||||
try:
|
||||
mod = import_module(label)
|
||||
except (ImportError, TypeError):
|
||||
pass
|
||||
else:
|
||||
return hasattr(mod, '__path__')
|
||||
|
||||
return os.path.isdir(os.path.abspath(label))
|
||||
|
||||
|
||||
def reorder_suite(suite, classes, reverse=False):
|
||||
"""
|
||||
Reorder a test suite by test type.
|
||||
|
||||
`classes` is a sequence of types
|
||||
|
||||
All tests of type classes[0] are placed first, then tests of type
|
||||
classes[1], etc. Tests with no match in classes are placed last.
|
||||
|
||||
If `reverse` is True, sort tests within classes in opposite order but
|
||||
don't reverse test classes.
|
||||
"""
|
||||
class_count = len(classes)
|
||||
suite_class = type(suite)
|
||||
bins = [OrderedSet() for i in range(class_count + 1)]
|
||||
partition_suite_by_type(suite, classes, bins, reverse=reverse)
|
||||
reordered_suite = suite_class()
|
||||
for i in range(class_count + 1):
|
||||
reordered_suite.addTests(bins[i])
|
||||
return reordered_suite
|
||||
|
||||
|
||||
def partition_suite_by_type(suite, classes, bins, reverse=False):
|
||||
"""
|
||||
Partition a test suite by test type. Also prevent duplicated tests.
|
||||
|
||||
classes is a sequence of types
|
||||
bins is a sequence of TestSuites, one more than classes
|
||||
reverse changes the ordering of tests within bins
|
||||
|
||||
Tests of type classes[i] are added to bins[i],
|
||||
tests with no match found in classes are place in bins[-1]
|
||||
"""
|
||||
suite_class = type(suite)
|
||||
if reverse:
|
||||
suite = reversed(tuple(suite))
|
||||
for test in suite:
|
||||
if isinstance(test, suite_class):
|
||||
partition_suite_by_type(test, classes, bins, reverse=reverse)
|
||||
else:
|
||||
for i in range(len(classes)):
|
||||
if isinstance(test, classes[i]):
|
||||
bins[i].add(test)
|
||||
break
|
||||
else:
|
||||
bins[-1].add(test)
|
||||
|
||||
|
||||
def partition_suite_by_case(suite):
|
||||
"""Partition a test suite by test case, preserving the order of tests."""
|
||||
groups = []
|
||||
suite_class = type(suite)
|
||||
for test_type, test_group in itertools.groupby(suite, type):
|
||||
if issubclass(test_type, unittest.TestCase):
|
||||
groups.append(suite_class(test_group))
|
||||
else:
|
||||
for item in test_group:
|
||||
groups.extend(partition_suite_by_case(item))
|
||||
return groups
|
||||
|
||||
|
||||
def filter_tests_by_tags(suite, tags, exclude_tags):
|
||||
suite_class = type(suite)
|
||||
filtered_suite = suite_class()
|
||||
|
||||
for test in suite:
|
||||
if isinstance(test, suite_class):
|
||||
filtered_suite.addTests(filter_tests_by_tags(test, tags, exclude_tags))
|
||||
else:
|
||||
test_tags = set(getattr(test, 'tags', set()))
|
||||
test_fn_name = getattr(test, '_testMethodName', str(test))
|
||||
test_fn = getattr(test, test_fn_name, test)
|
||||
test_fn_tags = set(getattr(test_fn, 'tags', set()))
|
||||
all_tags = test_tags.union(test_fn_tags)
|
||||
matched_tags = all_tags.intersection(tags)
|
||||
if (matched_tags or not tags) and not all_tags.intersection(exclude_tags):
|
||||
filtered_suite.addTest(test)
|
||||
|
||||
return filtered_suite
|
||||
Reference in new issue
Block a user