1 import urllib |
1 import urllib |
2 import sys |
2 import sys |
3 from cStringIO import StringIO |
3 import os |
|
4 try: |
|
5 from cStringIO import StringIO |
|
6 except ImportError: |
|
7 from StringIO import StringIO |
|
8 |
4 from django.conf import settings |
9 from django.conf import settings |
5 from django.contrib.auth import authenticate, login |
10 from django.contrib.auth import authenticate, login |
6 from django.core.handlers.base import BaseHandler |
11 from django.core.handlers.base import BaseHandler |
7 from django.core.handlers.wsgi import WSGIRequest |
12 from django.core.handlers.wsgi import WSGIRequest |
8 from django.core.signals import got_request_exception |
13 from django.core.signals import got_request_exception |
9 from django.dispatch import dispatcher |
|
10 from django.http import SimpleCookie, HttpRequest |
14 from django.http import SimpleCookie, HttpRequest |
11 from django.template import TemplateDoesNotExist |
15 from django.template import TemplateDoesNotExist |
12 from django.test import signals |
16 from django.test import signals |
13 from django.utils.functional import curry |
17 from django.utils.functional import curry |
14 from django.utils.encoding import smart_str |
18 from django.utils.encoding import smart_str |
16 from django.utils.itercompat import is_iterable |
20 from django.utils.itercompat import is_iterable |
17 |
21 |
18 BOUNDARY = 'BoUnDaRyStRiNg' |
22 BOUNDARY = 'BoUnDaRyStRiNg' |
19 MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY |
23 MULTIPART_CONTENT = 'multipart/form-data; boundary=%s' % BOUNDARY |
20 |
24 |
|
25 |
|
26 class FakePayload(object): |
|
27 """ |
|
28 A wrapper around StringIO that restricts what can be read since data from |
|
29 the network can't be seeked and cannot be read outside of its content |
|
30 length. This makes sure that views can't do anything under the test client |
|
31 that wouldn't work in Real Life. |
|
32 """ |
|
33 def __init__(self, content): |
|
34 self.__content = StringIO(content) |
|
35 self.__len = len(content) |
|
36 |
|
37 def read(self, num_bytes=None): |
|
38 if num_bytes is None: |
|
39 num_bytes = self.__len or 1 |
|
40 assert self.__len >= num_bytes, "Cannot read more than the available bytes from the HTTP incoming data." |
|
41 content = self.__content.read(num_bytes) |
|
42 self.__len -= num_bytes |
|
43 return content |
|
44 |
|
45 |
21 class ClientHandler(BaseHandler): |
46 class ClientHandler(BaseHandler): |
22 """ |
47 """ |
23 A HTTP Handler that can be used for testing purposes. |
48 A HTTP Handler that can be used for testing purposes. |
24 Uses the WSGI interface to compose requests, but returns |
49 Uses the WSGI interface to compose requests, but returns |
25 the raw HttpResponse object |
50 the raw HttpResponse object |
31 # Set up middleware if needed. We couldn't do this earlier, because |
56 # Set up middleware if needed. We couldn't do this earlier, because |
32 # settings weren't available. |
57 # settings weren't available. |
33 if self._request_middleware is None: |
58 if self._request_middleware is None: |
34 self.load_middleware() |
59 self.load_middleware() |
35 |
60 |
36 dispatcher.send(signal=signals.request_started) |
61 signals.request_started.send(sender=self.__class__) |
37 try: |
62 try: |
38 request = WSGIRequest(environ) |
63 request = WSGIRequest(environ) |
39 response = self.get_response(request) |
64 response = self.get_response(request) |
40 |
65 |
41 # Apply response middleware |
66 # Apply response middleware. |
42 for middleware_method in self._response_middleware: |
67 for middleware_method in self._response_middleware: |
43 response = middleware_method(request, response) |
68 response = middleware_method(request, response) |
44 response = self.apply_response_fixes(request, response) |
69 response = self.apply_response_fixes(request, response) |
45 finally: |
70 finally: |
46 dispatcher.send(signal=signals.request_finished) |
71 signals.request_finished.send(sender=self.__class__) |
47 |
72 |
48 return response |
73 return response |
49 |
74 |
50 def store_rendered_templates(store, signal, sender, template, context): |
75 def store_rendered_templates(store, signal, sender, template, context, **kwargs): |
51 "A utility function for storing templates and contexts that are rendered" |
76 """ |
|
77 Stores templates and contexts that are rendered. |
|
78 """ |
52 store.setdefault('template',[]).append(template) |
79 store.setdefault('template',[]).append(template) |
53 store.setdefault('context',[]).append(context) |
80 store.setdefault('context',[]).append(context) |
54 |
81 |
55 def encode_multipart(boundary, data): |
82 def encode_multipart(boundary, data): |
56 """ |
83 """ |
57 A simple method for encoding multipart POST data from a dictionary of |
84 Encodes multipart POST data from a dictionary of form values. |
58 form values. |
|
59 |
85 |
60 The key will be used as the form data name; the value will be transmitted |
86 The key will be used as the form data name; the value will be transmitted |
61 as content. If the value is a file, the contents of the file will be sent |
87 as content. If the value is a file, the contents of the file will be sent |
62 as an application/octet-stream; otherwise, str(value) will be sent. |
88 as an application/octet-stream; otherwise, str(value) will be sent. |
63 """ |
89 """ |
64 lines = [] |
90 lines = [] |
65 to_str = lambda s: smart_str(s, settings.DEFAULT_CHARSET) |
91 to_str = lambda s: smart_str(s, settings.DEFAULT_CHARSET) |
|
92 |
|
93 # Not by any means perfect, but good enough for our purposes. |
|
94 is_file = lambda thing: hasattr(thing, "read") and callable(thing.read) |
|
95 |
|
96 # Each bit of the multipart form data could be either a form value or a |
|
97 # file, or a *list* of form values and/or files. Remember that HTTP field |
|
98 # names can be duplicated! |
66 for (key, value) in data.items(): |
99 for (key, value) in data.items(): |
67 if isinstance(value, file): |
100 if is_file(value): |
68 lines.extend([ |
101 lines.extend(encode_file(boundary, key, value)) |
69 '--' + boundary, |
102 elif not isinstance(value, basestring) and is_iterable(value): |
70 'Content-Disposition: form-data; name="%s"; filename="%s"' % (to_str(key), to_str(value.name)), |
103 for item in value: |
71 'Content-Type: application/octet-stream', |
104 if is_file(item): |
72 '', |
105 lines.extend(encode_file(boundary, key, item)) |
73 value.read() |
106 else: |
74 ]) |
|
75 else: |
|
76 if not isinstance(value, basestring) and is_iterable(value): |
|
77 for item in value: |
|
78 lines.extend([ |
107 lines.extend([ |
79 '--' + boundary, |
108 '--' + boundary, |
80 'Content-Disposition: form-data; name="%s"' % to_str(key), |
109 'Content-Disposition: form-data; name="%s"' % to_str(key), |
81 '', |
110 '', |
82 to_str(item) |
111 to_str(item) |
83 ]) |
112 ]) |
84 else: |
113 else: |
85 lines.extend([ |
114 lines.extend([ |
86 '--' + boundary, |
115 '--' + boundary, |
87 'Content-Disposition: form-data; name="%s"' % to_str(key), |
116 'Content-Disposition: form-data; name="%s"' % to_str(key), |
88 '', |
117 '', |
89 to_str(value) |
118 to_str(value) |
90 ]) |
119 ]) |
91 |
120 |
92 lines.extend([ |
121 lines.extend([ |
93 '--' + boundary + '--', |
122 '--' + boundary + '--', |
94 '', |
123 '', |
95 ]) |
124 ]) |
96 return '\r\n'.join(lines) |
125 return '\r\n'.join(lines) |
97 |
126 |
98 class Client: |
127 def encode_file(boundary, key, file): |
|
128 to_str = lambda s: smart_str(s, settings.DEFAULT_CHARSET) |
|
129 return [ |
|
130 '--' + boundary, |
|
131 'Content-Disposition: form-data; name="%s"; filename="%s"' \ |
|
132 % (to_str(key), to_str(os.path.basename(file.name))), |
|
133 'Content-Type: application/octet-stream', |
|
134 '', |
|
135 file.read() |
|
136 ] |
|
137 |
|
138 class Client(object): |
99 """ |
139 """ |
100 A class that can act as a client for testing purposes. |
140 A class that can act as a client for testing purposes. |
101 |
141 |
102 It allows the user to compose GET and POST requests, and |
142 It allows the user to compose GET and POST requests, and |
103 obtain the response that the server gave to those requests. |
143 obtain the response that the server gave to those requests. |
117 self.handler = ClientHandler() |
157 self.handler = ClientHandler() |
118 self.defaults = defaults |
158 self.defaults = defaults |
119 self.cookies = SimpleCookie() |
159 self.cookies = SimpleCookie() |
120 self.exc_info = None |
160 self.exc_info = None |
121 |
161 |
122 def store_exc_info(self, *args, **kwargs): |
162 def store_exc_info(self, **kwargs): |
123 """ |
163 """ |
124 Utility method that can be used to store exceptions when they are |
164 Stores exceptions when they are generated by a view. |
125 generated by a view. |
|
126 """ |
165 """ |
127 self.exc_info = sys.exc_info() |
166 self.exc_info = sys.exc_info() |
128 |
167 |
129 def _session(self): |
168 def _session(self): |
130 "Obtain the current session variables" |
169 """ |
|
170 Obtains the current session variables. |
|
171 """ |
131 if 'django.contrib.sessions' in settings.INSTALLED_APPS: |
172 if 'django.contrib.sessions' in settings.INSTALLED_APPS: |
132 engine = __import__(settings.SESSION_ENGINE, {}, {}, ['']) |
173 engine = __import__(settings.SESSION_ENGINE, {}, {}, ['']) |
133 cookie = self.cookies.get(settings.SESSION_COOKIE_NAME, None) |
174 cookie = self.cookies.get(settings.SESSION_COOKIE_NAME, None) |
134 if cookie: |
175 if cookie: |
135 return engine.SessionStore(cookie.value) |
176 return engine.SessionStore(cookie.value) |
141 The master request method. Composes the environment dictionary |
182 The master request method. Composes the environment dictionary |
142 and passes to the handler, returning the result of the handler. |
183 and passes to the handler, returning the result of the handler. |
143 Assumes defaults for the query environment, which can be overridden |
184 Assumes defaults for the query environment, which can be overridden |
144 using the arguments to the request. |
185 using the arguments to the request. |
145 """ |
186 """ |
146 |
|
147 environ = { |
187 environ = { |
148 'HTTP_COOKIE': self.cookies, |
188 'HTTP_COOKIE': self.cookies, |
149 'PATH_INFO': '/', |
189 'PATH_INFO': '/', |
150 'QUERY_STRING': '', |
190 'QUERY_STRING': '', |
151 'REQUEST_METHOD': 'GET', |
191 'REQUEST_METHOD': 'GET', |
152 'SCRIPT_NAME': None, |
192 'SCRIPT_NAME': '', |
153 'SERVER_NAME': 'testserver', |
193 'SERVER_NAME': 'testserver', |
154 'SERVER_PORT': 80, |
194 'SERVER_PORT': '80', |
155 'SERVER_PROTOCOL': 'HTTP/1.1', |
195 'SERVER_PROTOCOL': 'HTTP/1.1', |
156 } |
196 } |
157 environ.update(self.defaults) |
197 environ.update(self.defaults) |
158 environ.update(request) |
198 environ.update(request) |
159 |
199 |
160 # Curry a data dictionary into an instance of |
200 # Curry a data dictionary into an instance of the template renderer |
161 # the template renderer callback function |
201 # callback function. |
162 data = {} |
202 data = {} |
163 on_template_render = curry(store_rendered_templates, data) |
203 on_template_render = curry(store_rendered_templates, data) |
164 dispatcher.connect(on_template_render, signal=signals.template_rendered) |
204 signals.template_rendered.connect(on_template_render) |
165 |
205 |
166 # Capture exceptions created by the handler |
206 # Capture exceptions created by the handler. |
167 dispatcher.connect(self.store_exc_info, signal=got_request_exception) |
207 got_request_exception.connect(self.store_exc_info) |
168 |
208 |
169 try: |
209 try: |
170 response = self.handler(environ) |
210 response = self.handler(environ) |
171 except TemplateDoesNotExist, e: |
211 except TemplateDoesNotExist, e: |
172 # If the view raises an exception, Django will attempt to show |
212 # If the view raises an exception, Django will attempt to show |
176 # template found to be missing during view error handling |
216 # template found to be missing during view error handling |
177 # should be reported as-is. |
217 # should be reported as-is. |
178 if e.args != ('500.html',): |
218 if e.args != ('500.html',): |
179 raise |
219 raise |
180 |
220 |
181 # Look for a signalled exception and reraise it |
221 # Look for a signalled exception, clear the current context |
|
222 # exception data, then re-raise the signalled exception. |
|
223 # Also make sure that the signalled exception is cleared from |
|
224 # the local cache! |
182 if self.exc_info: |
225 if self.exc_info: |
183 raise self.exc_info[1], None, self.exc_info[2] |
226 exc_info = self.exc_info |
184 |
227 self.exc_info = None |
185 # Save the client and request that stimulated the response |
228 raise exc_info[1], None, exc_info[2] |
|
229 |
|
230 # Save the client and request that stimulated the response. |
186 response.client = self |
231 response.client = self |
187 response.request = request |
232 response.request = request |
188 |
233 |
189 # Add any rendered template detail to the response |
234 # Add any rendered template detail to the response. |
190 # If there was only one template rendered (the most likely case), |
235 # If there was only one template rendered (the most likely case), |
191 # flatten the list to a single element |
236 # flatten the list to a single element. |
192 for detail in ('template', 'context'): |
237 for detail in ('template', 'context'): |
193 if data.get(detail): |
238 if data.get(detail): |
194 if len(data[detail]) == 1: |
239 if len(data[detail]) == 1: |
195 setattr(response, detail, data[detail][0]); |
240 setattr(response, detail, data[detail][0]); |
196 else: |
241 else: |
197 setattr(response, detail, data[detail]) |
242 setattr(response, detail, data[detail]) |
198 else: |
243 else: |
199 setattr(response, detail, None) |
244 setattr(response, detail, None) |
200 |
245 |
201 # Update persistent cookie data |
246 # Update persistent cookie data. |
202 if response.cookies: |
247 if response.cookies: |
203 self.cookies.update(response.cookies) |
248 self.cookies.update(response.cookies) |
204 |
249 |
205 return response |
250 return response |
206 |
251 |
207 def get(self, path, data={}, **extra): |
252 def get(self, path, data={}, **extra): |
208 "Request a response from the server using GET." |
253 """ |
|
254 Requests a response from the server using GET. |
|
255 """ |
209 r = { |
256 r = { |
210 'CONTENT_LENGTH': None, |
257 'CONTENT_LENGTH': None, |
211 'CONTENT_TYPE': 'text/html; charset=utf-8', |
258 'CONTENT_TYPE': 'text/html; charset=utf-8', |
212 'PATH_INFO': urllib.unquote(path), |
259 'PATH_INFO': urllib.unquote(path), |
213 'QUERY_STRING': urlencode(data, doseq=True), |
260 'QUERY_STRING': urlencode(data, doseq=True), |
216 r.update(extra) |
263 r.update(extra) |
217 |
264 |
218 return self.request(**r) |
265 return self.request(**r) |
219 |
266 |
220 def post(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): |
267 def post(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): |
221 "Request a response from the server using POST." |
268 """ |
222 |
269 Requests a response from the server using POST. |
|
270 """ |
223 if content_type is MULTIPART_CONTENT: |
271 if content_type is MULTIPART_CONTENT: |
224 post_data = encode_multipart(BOUNDARY, data) |
272 post_data = encode_multipart(BOUNDARY, data) |
225 else: |
273 else: |
226 post_data = data |
274 post_data = data |
227 |
275 |
228 r = { |
276 r = { |
229 'CONTENT_LENGTH': len(post_data), |
277 'CONTENT_LENGTH': len(post_data), |
230 'CONTENT_TYPE': content_type, |
278 'CONTENT_TYPE': content_type, |
231 'PATH_INFO': urllib.unquote(path), |
279 'PATH_INFO': urllib.unquote(path), |
232 'REQUEST_METHOD': 'POST', |
280 'REQUEST_METHOD': 'POST', |
233 'wsgi.input': StringIO(post_data), |
281 'wsgi.input': FakePayload(post_data), |
234 } |
282 } |
|
283 r.update(extra) |
|
284 |
|
285 return self.request(**r) |
|
286 |
|
287 def head(self, path, data={}, **extra): |
|
288 """ |
|
289 Request a response from the server using HEAD. |
|
290 """ |
|
291 r = { |
|
292 'CONTENT_LENGTH': None, |
|
293 'CONTENT_TYPE': 'text/html; charset=utf-8', |
|
294 'PATH_INFO': urllib.unquote(path), |
|
295 'QUERY_STRING': urlencode(data, doseq=True), |
|
296 'REQUEST_METHOD': 'HEAD', |
|
297 } |
|
298 r.update(extra) |
|
299 |
|
300 return self.request(**r) |
|
301 |
|
302 def options(self, path, data={}, **extra): |
|
303 """ |
|
304 Request a response from the server using OPTIONS. |
|
305 """ |
|
306 r = { |
|
307 'CONTENT_LENGTH': None, |
|
308 'CONTENT_TYPE': None, |
|
309 'PATH_INFO': urllib.unquote(path), |
|
310 'QUERY_STRING': urlencode(data, doseq=True), |
|
311 'REQUEST_METHOD': 'OPTIONS', |
|
312 } |
|
313 r.update(extra) |
|
314 |
|
315 return self.request(**r) |
|
316 |
|
317 def put(self, path, data={}, content_type=MULTIPART_CONTENT, **extra): |
|
318 """ |
|
319 Send a resource to the server using PUT. |
|
320 """ |
|
321 if content_type is MULTIPART_CONTENT: |
|
322 post_data = encode_multipart(BOUNDARY, data) |
|
323 else: |
|
324 post_data = data |
|
325 r = { |
|
326 'CONTENT_LENGTH': len(post_data), |
|
327 'CONTENT_TYPE': content_type, |
|
328 'PATH_INFO': urllib.unquote(path), |
|
329 'REQUEST_METHOD': 'PUT', |
|
330 'wsgi.input': FakePayload(post_data), |
|
331 } |
|
332 r.update(extra) |
|
333 |
|
334 return self.request(**r) |
|
335 |
|
336 def delete(self, path, data={}, **extra): |
|
337 """ |
|
338 Send a DELETE request to the server. |
|
339 """ |
|
340 r = { |
|
341 'CONTENT_LENGTH': None, |
|
342 'CONTENT_TYPE': None, |
|
343 'PATH_INFO': urllib.unquote(path), |
|
344 'REQUEST_METHOD': 'DELETE', |
|
345 } |
235 r.update(extra) |
346 r.update(extra) |
236 |
347 |
237 return self.request(**r) |
348 return self.request(**r) |
238 |
349 |
239 def login(self, **credentials): |
350 def login(self, **credentials): |
240 """Set the Client to appear as if it has sucessfully logged into a site. |
351 """ |
|
352 Sets the Client to appear as if it has successfully logged into a site. |
241 |
353 |
242 Returns True if login is possible; False if the provided credentials |
354 Returns True if login is possible; False if the provided credentials |
243 are incorrect, or the user is inactive, or if the sessions framework is |
355 are incorrect, or the user is inactive, or if the sessions framework is |
244 not available. |
356 not available. |
245 """ |
357 """ |
246 user = authenticate(**credentials) |
358 user = authenticate(**credentials) |
247 if user and user.is_active and 'django.contrib.sessions' in settings.INSTALLED_APPS: |
359 if user and user.is_active \ |
|
360 and 'django.contrib.sessions' in settings.INSTALLED_APPS: |
248 engine = __import__(settings.SESSION_ENGINE, {}, {}, ['']) |
361 engine = __import__(settings.SESSION_ENGINE, {}, {}, ['']) |
249 |
362 |
250 # Create a fake request to store login details |
363 # Create a fake request to store login details. |
251 request = HttpRequest() |
364 request = HttpRequest() |
252 request.session = engine.SessionStore() |
365 if self.session: |
|
366 request.session = self.session |
|
367 else: |
|
368 request.session = engine.SessionStore() |
253 login(request, user) |
369 login(request, user) |
254 |
370 |
255 # Set the cookie to represent the session |
371 # Set the cookie to represent the session. |
256 self.cookies[settings.SESSION_COOKIE_NAME] = request.session.session_key |
372 session_cookie = settings.SESSION_COOKIE_NAME |
257 self.cookies[settings.SESSION_COOKIE_NAME]['max-age'] = None |
373 self.cookies[session_cookie] = request.session.session_key |
258 self.cookies[settings.SESSION_COOKIE_NAME]['path'] = '/' |
374 cookie_data = { |
259 self.cookies[settings.SESSION_COOKIE_NAME]['domain'] = settings.SESSION_COOKIE_DOMAIN |
375 'max-age': None, |
260 self.cookies[settings.SESSION_COOKIE_NAME]['secure'] = settings.SESSION_COOKIE_SECURE or None |
376 'path': '/', |
261 self.cookies[settings.SESSION_COOKIE_NAME]['expires'] = None |
377 'domain': settings.SESSION_COOKIE_DOMAIN, |
262 |
378 'secure': settings.SESSION_COOKIE_SECURE or None, |
263 # Save the session values |
379 'expires': None, |
|
380 } |
|
381 self.cookies[session_cookie].update(cookie_data) |
|
382 |
|
383 # Save the session values. |
264 request.session.save() |
384 request.session.save() |
265 |
385 |
266 return True |
386 return True |
267 else: |
387 else: |
268 return False |
388 return False |
269 |
389 |
270 def logout(self): |
390 def logout(self): |
271 """Removes the authenticated user's cookies. |
391 """ |
|
392 Removes the authenticated user's cookies. |
272 |
393 |
273 Causes the authenticated user to be logged out. |
394 Causes the authenticated user to be logged out. |
274 """ |
395 """ |
275 session = __import__(settings.SESSION_ENGINE, {}, {}, ['']).SessionStore() |
396 session = __import__(settings.SESSION_ENGINE, {}, {}, ['']).SessionStore() |
276 session.delete(session_key=self.cookies[settings.SESSION_COOKIE_NAME].value) |
397 session.delete(session_key=self.cookies[settings.SESSION_COOKIE_NAME].value) |