Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/test/client.py: 55%
379 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-16 22:27 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-16 22:27 -0500
1import json
2import mimetypes
3import os
4import sys
5from functools import partial
6from http import HTTPStatus
7from http.cookies import SimpleCookie
8from importlib import import_module
9from io import BytesIO, IOBase
10from itertools import chain
11from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
13from plain.http import HttpHeaders, HttpRequest, QueryDict
14from plain.internal.handlers.base import BaseHandler
15from plain.internal.handlers.wsgi import WSGIRequest
16from plain.json import PlainJSONEncoder
17from plain.runtime import settings
18from plain.signals import got_request_exception, request_started
19from plain.urls import resolve
20from plain.utils.encoding import force_bytes
21from plain.utils.functional import SimpleLazyObject
22from plain.utils.http import urlencode
23from plain.utils.itercompat import is_iterable
24from plain.utils.regex_helper import _lazy_re_compile
26__all__ = (
27 "Client",
28 "RedirectCycleError",
29 "RequestFactory",
30 "encode_file",
31 "encode_multipart",
32)
35BOUNDARY = "BoUnDaRyStRiNg"
36MULTIPART_CONTENT = "multipart/form-data; boundary=%s" % BOUNDARY
37CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")
38# Structured suffix spec: https://tools.ietf.org/html/rfc6838#section-4.2.8
39JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")
42class ContextList(list):
43 """
44 A wrapper that provides direct key access to context items contained
45 in a list of context objects.
46 """
48 def __getitem__(self, key):
49 if isinstance(key, str):
50 for subcontext in self:
51 if key in subcontext:
52 return subcontext[key]
53 raise KeyError(key)
54 else:
55 return super().__getitem__(key)
57 def get(self, key, default=None):
58 try:
59 return self.__getitem__(key)
60 except KeyError:
61 return default
63 def __contains__(self, key):
64 try:
65 self[key]
66 except KeyError:
67 return False
68 return True
70 def keys(self):
71 """
72 Flattened keys of subcontexts.
73 """
74 return set(chain.from_iterable(d for subcontext in self for d in subcontext))
77class RedirectCycleError(Exception):
78 """The test client has been asked to follow a redirect loop."""
80 def __init__(self, message, last_response):
81 super().__init__(message)
82 self.last_response = last_response
83 self.redirect_chain = last_response.redirect_chain
86class FakePayload(IOBase):
87 """
88 A wrapper around BytesIO that restricts what can be read since data from
89 the network can't be sought and cannot be read outside of its content
90 length. This makes sure that views can't do anything under the test client
91 that wouldn't work in real life.
92 """
94 def __init__(self, initial_bytes=None):
95 self.__content = BytesIO()
96 self.__len = 0
97 self.read_started = False
98 if initial_bytes is not None:
99 self.write(initial_bytes)
101 def __len__(self):
102 return self.__len
104 def read(self, size=-1, /):
105 if not self.read_started:
106 self.__content.seek(0)
107 self.read_started = True
108 if size == -1 or size is None:
109 size = self.__len
110 assert (
111 self.__len >= size
112 ), "Cannot read more than the available bytes from the HTTP incoming data."
113 content = self.__content.read(size)
114 self.__len -= len(content)
115 return content
117 def readline(self, size=-1, /):
118 if not self.read_started:
119 self.__content.seek(0)
120 self.read_started = True
121 if size == -1 or size is None:
122 size = self.__len
123 assert (
124 self.__len >= size
125 ), "Cannot read more than the available bytes from the HTTP incoming data."
126 content = self.__content.readline(size)
127 self.__len -= len(content)
128 return content
130 def write(self, b, /):
131 if self.read_started:
132 raise ValueError("Unable to write a payload after it's been read")
133 content = force_bytes(b)
134 self.__content.write(content)
135 self.__len += len(content)
138def conditional_content_removal(request, response):
139 """
140 Simulate the behavior of most web servers by removing the content of
141 responses for HEAD requests, 1xx, 204, and 304 responses. Ensure
142 compliance with RFC 9112 Section 6.3.
143 """
144 if 100 <= response.status_code < 200 or response.status_code in (204, 304):
145 if response.streaming:
146 response.streaming_content = []
147 else:
148 response.content = b""
149 if request.method == "HEAD":
150 if response.streaming:
151 response.streaming_content = []
152 else:
153 response.content = b""
154 return response
157class ClientHandler(BaseHandler):
158 """
159 An HTTP Handler that can be used for testing purposes. Use the WSGI
160 interface to compose requests, but return the raw Response object with
161 the originating WSGIRequest attached to its ``wsgi_request`` attribute.
162 """
164 def __init__(self, enforce_csrf_checks=True, *args, **kwargs):
165 self.enforce_csrf_checks = enforce_csrf_checks
166 super().__init__(*args, **kwargs)
168 def __call__(self, environ):
169 # Set up middleware if needed. We couldn't do this earlier, because
170 # settings weren't available.
171 if self._middleware_chain is None:
172 self.load_middleware()
174 request_started.send(sender=self.__class__, environ=environ)
175 request = WSGIRequest(environ)
176 # sneaky little hack so that we can easily get round
177 # CsrfViewMiddleware. This makes life easier, and is probably
178 # required for backwards compatibility with external tests against
179 # admin views.
180 request._dont_enforce_csrf_checks = not self.enforce_csrf_checks
182 # Request goes through middleware.
183 response = self.get_response(request)
185 # Simulate behaviors of most web servers.
186 conditional_content_removal(request, response)
188 # Attach the originating request to the response so that it could be
189 # later retrieved.
190 response.wsgi_request = request
192 # Emulate a WSGI server by calling the close method on completion.
193 response.close()
195 return response
198def encode_multipart(boundary, data):
199 """
200 Encode multipart POST data from a dictionary of form values.
202 The key will be used as the form data name; the value will be transmitted
203 as content. If the value is a file, the contents of the file will be sent
204 as an application/octet-stream; otherwise, str(value) will be sent.
205 """
206 lines = []
208 def to_bytes(s):
209 return force_bytes(s, settings.DEFAULT_CHARSET)
211 # Not by any means perfect, but good enough for our purposes.
212 def is_file(thing):
213 return hasattr(thing, "read") and callable(thing.read)
215 # Each bit of the multipart form data could be either a form value or a
216 # file, or a *list* of form values and/or files. Remember that HTTP field
217 # names can be duplicated!
218 for key, value in data.items():
219 if value is None:
220 raise TypeError(
221 "Cannot encode None for key '%s' as POST data. Did you mean "
222 "to pass an empty string or omit the value?" % key
223 )
224 elif is_file(value):
225 lines.extend(encode_file(boundary, key, value))
226 elif not isinstance(value, str) and is_iterable(value):
227 for item in value:
228 if is_file(item):
229 lines.extend(encode_file(boundary, key, item))
230 else:
231 lines.extend(
232 to_bytes(val)
233 for val in [
234 "--%s" % boundary,
235 'Content-Disposition: form-data; name="%s"' % key,
236 "",
237 item,
238 ]
239 )
240 else:
241 lines.extend(
242 to_bytes(val)
243 for val in [
244 "--%s" % boundary,
245 'Content-Disposition: form-data; name="%s"' % key,
246 "",
247 value,
248 ]
249 )
251 lines.extend(
252 [
253 to_bytes("--%s--" % boundary),
254 b"",
255 ]
256 )
257 return b"\r\n".join(lines)
260def encode_file(boundary, key, file):
261 def to_bytes(s):
262 return force_bytes(s, settings.DEFAULT_CHARSET)
264 # file.name might not be a string. For example, it's an int for
265 # tempfile.TemporaryFile().
266 file_has_string_name = hasattr(file, "name") and isinstance(file.name, str)
267 filename = os.path.basename(file.name) if file_has_string_name else ""
269 if hasattr(file, "content_type"):
270 content_type = file.content_type
271 elif filename:
272 content_type = mimetypes.guess_type(filename)[0]
273 else:
274 content_type = None
276 if content_type is None:
277 content_type = "application/octet-stream"
278 filename = filename or key
279 return [
280 to_bytes("--%s" % boundary),
281 to_bytes(
282 f'Content-Disposition: form-data; name="{key}"; filename="{filename}"'
283 ),
284 to_bytes("Content-Type: %s" % content_type),
285 b"",
286 to_bytes(file.read()),
287 ]
290class RequestFactory:
291 """
292 Class that lets you create mock Request objects for use in testing.
294 Usage:
296 rf = RequestFactory()
297 get_request = rf.get('/hello/')
298 post_request = rf.post('/submit/', {'foo': 'bar'})
300 Once you have a request object you can pass it to any view function,
301 just as if that view had been hooked up using a URLconf.
302 """
304 def __init__(self, *, json_encoder=PlainJSONEncoder, headers=None, **defaults):
305 self.json_encoder = json_encoder
306 self.defaults = defaults
307 self.cookies = SimpleCookie()
308 self.errors = BytesIO()
309 if headers:
310 self.defaults.update(HttpHeaders.to_wsgi_names(headers))
312 def _base_environ(self, **request):
313 """
314 The base environment for a request.
315 """
316 # This is a minimal valid WSGI environ dictionary, plus:
317 # - HTTP_COOKIE: for cookie support,
318 # - REMOTE_ADDR: often useful, see #8551.
319 # See https://www.python.org/dev/peps/pep-3333/#environ-variables
320 return {
321 "HTTP_COOKIE": "; ".join(
322 sorted(
323 f"{morsel.key}={morsel.coded_value}"
324 for morsel in self.cookies.values()
325 )
326 ),
327 "PATH_INFO": "/",
328 "REMOTE_ADDR": "127.0.0.1",
329 "REQUEST_METHOD": "GET",
330 "SCRIPT_NAME": "",
331 "SERVER_NAME": "testserver",
332 "SERVER_PORT": "80",
333 "SERVER_PROTOCOL": "HTTP/1.1",
334 "wsgi.version": (1, 0),
335 "wsgi.url_scheme": "http",
336 "wsgi.input": FakePayload(b""),
337 "wsgi.errors": self.errors,
338 "wsgi.multiprocess": True,
339 "wsgi.multithread": False,
340 "wsgi.run_once": False,
341 **self.defaults,
342 **request,
343 }
345 def request(self, **request):
346 "Construct a generic request object."
347 return WSGIRequest(self._base_environ(**request))
349 def _encode_data(self, data, content_type):
350 if content_type is MULTIPART_CONTENT:
351 return encode_multipart(BOUNDARY, data)
352 else:
353 # Encode the content so that the byte representation is correct.
354 match = CONTENT_TYPE_RE.match(content_type)
355 if match:
356 charset = match[1]
357 else:
358 charset = settings.DEFAULT_CHARSET
359 return force_bytes(data, encoding=charset)
361 def _encode_json(self, data, content_type):
362 """
363 Return encoded JSON if data is a dict, list, or tuple and content_type
364 is application/json.
365 """
366 should_encode = JSON_CONTENT_TYPE_RE.match(content_type) and isinstance(
367 data, dict | list | tuple
368 )
369 return json.dumps(data, cls=self.json_encoder) if should_encode else data
371 def _get_path(self, parsed):
372 path = parsed.path
373 # If there are parameters, add them
374 if parsed.params:
375 path += ";" + parsed.params
376 path = unquote_to_bytes(path)
377 # Replace the behavior where non-ASCII values in the WSGI environ are
378 # arbitrarily decoded with ISO-8859-1.
379 # Refs comment in `get_bytes_from_wsgi()`.
380 return path.decode("iso-8859-1")
382 def get(self, path, data=None, secure=True, *, headers=None, **extra):
383 """Construct a GET request."""
384 data = {} if data is None else data
385 return self.generic(
386 "GET",
387 path,
388 secure=secure,
389 headers=headers,
390 **{
391 "QUERY_STRING": urlencode(data, doseq=True),
392 **extra,
393 },
394 )
396 def post(
397 self,
398 path,
399 data=None,
400 content_type=MULTIPART_CONTENT,
401 secure=True,
402 *,
403 headers=None,
404 **extra,
405 ):
406 """Construct a POST request."""
407 data = self._encode_json({} if data is None else data, content_type)
408 post_data = self._encode_data(data, content_type)
410 return self.generic(
411 "POST",
412 path,
413 post_data,
414 content_type,
415 secure=secure,
416 headers=headers,
417 **extra,
418 )
420 def head(self, path, data=None, secure=True, *, headers=None, **extra):
421 """Construct a HEAD request."""
422 data = {} if data is None else data
423 return self.generic(
424 "HEAD",
425 path,
426 secure=secure,
427 headers=headers,
428 **{
429 "QUERY_STRING": urlencode(data, doseq=True),
430 **extra,
431 },
432 )
434 def trace(self, path, secure=True, *, headers=None, **extra):
435 """Construct a TRACE request."""
436 return self.generic("TRACE", path, secure=secure, headers=headers, **extra)
438 def options(
439 self,
440 path,
441 data="",
442 content_type="application/octet-stream",
443 secure=True,
444 *,
445 headers=None,
446 **extra,
447 ):
448 "Construct an OPTIONS request."
449 return self.generic(
450 "OPTIONS", path, data, content_type, secure=secure, headers=headers, **extra
451 )
453 def put(
454 self,
455 path,
456 data="",
457 content_type="application/octet-stream",
458 secure=True,
459 *,
460 headers=None,
461 **extra,
462 ):
463 """Construct a PUT request."""
464 data = self._encode_json(data, content_type)
465 return self.generic(
466 "PUT", path, data, content_type, secure=secure, headers=headers, **extra
467 )
469 def patch(
470 self,
471 path,
472 data="",
473 content_type="application/octet-stream",
474 secure=True,
475 *,
476 headers=None,
477 **extra,
478 ):
479 """Construct a PATCH request."""
480 data = self._encode_json(data, content_type)
481 return self.generic(
482 "PATCH", path, data, content_type, secure=secure, headers=headers, **extra
483 )
485 def delete(
486 self,
487 path,
488 data="",
489 content_type="application/octet-stream",
490 secure=True,
491 *,
492 headers=None,
493 **extra,
494 ):
495 """Construct a DELETE request."""
496 data = self._encode_json(data, content_type)
497 return self.generic(
498 "DELETE", path, data, content_type, secure=secure, headers=headers, **extra
499 )
501 def generic(
502 self,
503 method,
504 path,
505 data="",
506 content_type="application/octet-stream",
507 secure=True,
508 *,
509 headers=None,
510 **extra,
511 ):
512 """Construct an arbitrary HTTP request."""
513 parsed = urlparse(str(path)) # path can be lazy
514 data = force_bytes(data, settings.DEFAULT_CHARSET)
515 r = {
516 "PATH_INFO": self._get_path(parsed),
517 "REQUEST_METHOD": method,
518 "SERVER_PORT": "443" if secure else "80",
519 "wsgi.url_scheme": "https" if secure else "http",
520 }
521 if data:
522 r.update(
523 {
524 "CONTENT_LENGTH": str(len(data)),
525 "CONTENT_TYPE": content_type,
526 "wsgi.input": FakePayload(data),
527 }
528 )
529 if headers:
530 extra.update(HttpHeaders.to_wsgi_names(headers))
531 r.update(extra)
532 # If QUERY_STRING is absent or empty, we want to extract it from the URL.
533 if not r.get("QUERY_STRING"):
534 # WSGI requires latin-1 encoded strings. See get_path_info().
535 query_string = parsed[4].encode().decode("iso-8859-1")
536 r["QUERY_STRING"] = query_string
537 return self.request(**r)
540class ClientMixin:
541 """
542 Mixin with common methods between Client and AsyncClient.
543 """
545 def store_exc_info(self, **kwargs):
546 """Store exceptions when they are generated by a view."""
547 self.exc_info = sys.exc_info()
549 def check_exception(self, response):
550 """
551 Look for a signaled exception, clear the current context exception
552 data, re-raise the signaled exception, and clear the signaled exception
553 from the local cache.
554 """
555 response.exc_info = self.exc_info
556 if self.exc_info:
557 _, exc_value, _ = self.exc_info
558 self.exc_info = None
559 if self.raise_request_exception:
560 raise exc_value
562 @property
563 def session(self):
564 """Return the current session variables."""
565 engine = import_module(settings.SESSION_ENGINE)
566 cookie = self.cookies.get(settings.SESSION_COOKIE_NAME)
567 if cookie:
568 return engine.SessionStore(cookie.value)
569 session = engine.SessionStore()
570 session.save()
571 self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
572 return session
574 def force_login(self, user):
575 self._login(user)
577 def _login(self, user):
578 from plain.auth import login
580 # Create a fake request to store login details.
581 request = HttpRequest()
582 if self.session:
583 request.session = self.session
584 else:
585 engine = import_module(settings.SESSION_ENGINE)
586 request.session = engine.SessionStore()
587 login(request, user)
588 # Save the session values.
589 request.session.save()
590 # Set the cookie to represent the session.
591 session_cookie = settings.SESSION_COOKIE_NAME
592 self.cookies[session_cookie] = request.session.session_key
593 cookie_data = {
594 "max-age": None,
595 "path": "/",
596 "domain": settings.SESSION_COOKIE_DOMAIN,
597 "secure": settings.SESSION_COOKIE_SECURE or None,
598 "expires": None,
599 }
600 self.cookies[session_cookie].update(cookie_data)
602 def logout(self):
603 """Log out the user by removing the cookies and session object."""
604 from plain.auth import get_user, logout
606 request = HttpRequest()
607 if self.session:
608 request.session = self.session
609 request.user = get_user(request)
610 else:
611 engine = import_module(settings.SESSION_ENGINE)
612 request.session = engine.SessionStore()
613 logout(request)
614 self.cookies = SimpleCookie()
616 def _parse_json(self, response, **extra):
617 if not hasattr(response, "_json"):
618 if not JSON_CONTENT_TYPE_RE.match(response.get("Content-Type")):
619 raise ValueError(
620 'Content-Type header is "%s", not "application/json"'
621 % response.get("Content-Type")
622 )
623 response._json = json.loads(
624 response.content.decode(response.charset), **extra
625 )
626 return response._json
629class Client(ClientMixin, RequestFactory):
630 """
631 A class that can act as a client for testing purposes.
633 It allows the user to compose GET and POST requests, and
634 obtain the response that the server gave to those requests.
635 The server Response objects are annotated with the details
636 of the contexts and templates that were rendered during the
637 process of serving the request.
639 Client objects are stateful - they will retain cookie (and
640 thus session) details for the lifetime of the Client instance.
642 This is not intended as a replacement for Twill/Selenium or
643 the like - it is here to allow testing against the
644 contexts and templates produced by a view, rather than the
645 HTML rendered to the end-user.
646 """
648 def __init__(
649 self,
650 enforce_csrf_checks=False,
651 raise_request_exception=True,
652 *,
653 headers=None,
654 **defaults,
655 ):
656 super().__init__(headers=headers, **defaults)
657 self.handler = ClientHandler(enforce_csrf_checks)
658 self.raise_request_exception = raise_request_exception
659 self.exc_info = None
660 self.extra = None
661 self.headers = None
663 def request(self, **request):
664 """
665 Make a generic request. Compose the environment dictionary and pass
666 to the handler, return the result of the handler. Assume defaults for
667 the query environment, which can be overridden using the arguments to
668 the request.
669 """
670 environ = self._base_environ(**request)
672 # Capture exceptions created by the handler.
673 exception_uid = "request-exception-%s" % id(request)
674 got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
675 try:
676 response = self.handler(environ)
677 finally:
678 # signals.template_rendered.disconnect(dispatch_uid=signal_uid)
679 got_request_exception.disconnect(dispatch_uid=exception_uid)
680 # Check for signaled exceptions.
681 self.check_exception(response)
682 # Save the client and request that stimulated the response.
683 response.client = self
684 response.request = request
685 response.json = partial(self._parse_json, response)
687 # If the request had a user attached, make it available on the response.
688 if hasattr(response.wsgi_request, "user"):
689 response.user = response.wsgi_request.user
691 # Attach the ResolverMatch instance to the response.
692 urlconf = getattr(response.wsgi_request, "urlconf", None)
693 response.resolver_match = SimpleLazyObject(
694 lambda: resolve(request["PATH_INFO"], urlconf=urlconf),
695 )
697 # Update persistent cookie data.
698 if response.cookies:
699 self.cookies.update(response.cookies)
700 return response
702 def get(
703 self,
704 path,
705 data=None,
706 follow=False,
707 secure=True,
708 *,
709 headers=None,
710 **extra,
711 ):
712 """Request a response from the server using GET."""
713 self.extra = extra
714 self.headers = headers
715 response = super().get(path, data=data, secure=secure, headers=headers, **extra)
716 if follow:
717 response = self._handle_redirects(
718 response, data=data, headers=headers, **extra
719 )
720 return response
722 def post(
723 self,
724 path,
725 data=None,
726 content_type=MULTIPART_CONTENT,
727 follow=False,
728 secure=True,
729 *,
730 headers=None,
731 **extra,
732 ):
733 """Request a response from the server using POST."""
734 self.extra = extra
735 self.headers = headers
736 response = super().post(
737 path,
738 data=data,
739 content_type=content_type,
740 secure=secure,
741 headers=headers,
742 **extra,
743 )
744 if follow:
745 response = self._handle_redirects(
746 response, data=data, content_type=content_type, headers=headers, **extra
747 )
748 return response
750 def head(
751 self,
752 path,
753 data=None,
754 follow=False,
755 secure=True,
756 *,
757 headers=None,
758 **extra,
759 ):
760 """Request a response from the server using HEAD."""
761 self.extra = extra
762 self.headers = headers
763 response = super().head(
764 path, data=data, secure=secure, headers=headers, **extra
765 )
766 if follow:
767 response = self._handle_redirects(
768 response, data=data, headers=headers, **extra
769 )
770 return response
772 def options(
773 self,
774 path,
775 data="",
776 content_type="application/octet-stream",
777 follow=False,
778 secure=True,
779 *,
780 headers=None,
781 **extra,
782 ):
783 """Request a response from the server using OPTIONS."""
784 self.extra = extra
785 self.headers = headers
786 response = super().options(
787 path,
788 data=data,
789 content_type=content_type,
790 secure=secure,
791 headers=headers,
792 **extra,
793 )
794 if follow:
795 response = self._handle_redirects(
796 response, data=data, content_type=content_type, headers=headers, **extra
797 )
798 return response
800 def put(
801 self,
802 path,
803 data="",
804 content_type="application/octet-stream",
805 follow=False,
806 secure=True,
807 *,
808 headers=None,
809 **extra,
810 ):
811 """Send a resource to the server using PUT."""
812 self.extra = extra
813 self.headers = headers
814 response = super().put(
815 path,
816 data=data,
817 content_type=content_type,
818 secure=secure,
819 headers=headers,
820 **extra,
821 )
822 if follow:
823 response = self._handle_redirects(
824 response, data=data, content_type=content_type, headers=headers, **extra
825 )
826 return response
828 def patch(
829 self,
830 path,
831 data="",
832 content_type="application/octet-stream",
833 follow=False,
834 secure=True,
835 *,
836 headers=None,
837 **extra,
838 ):
839 """Send a resource to the server using PATCH."""
840 self.extra = extra
841 self.headers = headers
842 response = super().patch(
843 path,
844 data=data,
845 content_type=content_type,
846 secure=secure,
847 headers=headers,
848 **extra,
849 )
850 if follow:
851 response = self._handle_redirects(
852 response, data=data, content_type=content_type, headers=headers, **extra
853 )
854 return response
856 def delete(
857 self,
858 path,
859 data="",
860 content_type="application/octet-stream",
861 follow=False,
862 secure=True,
863 *,
864 headers=None,
865 **extra,
866 ):
867 """Send a DELETE request to the server."""
868 self.extra = extra
869 self.headers = headers
870 response = super().delete(
871 path,
872 data=data,
873 content_type=content_type,
874 secure=secure,
875 headers=headers,
876 **extra,
877 )
878 if follow:
879 response = self._handle_redirects(
880 response, data=data, content_type=content_type, headers=headers, **extra
881 )
882 return response
884 def trace(
885 self,
886 path,
887 data="",
888 follow=False,
889 secure=True,
890 *,
891 headers=None,
892 **extra,
893 ):
894 """Send a TRACE request to the server."""
895 self.extra = extra
896 self.headers = headers
897 response = super().trace(
898 path, data=data, secure=secure, headers=headers, **extra
899 )
900 if follow:
901 response = self._handle_redirects(
902 response, data=data, headers=headers, **extra
903 )
904 return response
906 def _handle_redirects(
907 self,
908 response,
909 data="",
910 content_type="",
911 headers=None,
912 **extra,
913 ):
914 """
915 Follow any redirects by requesting responses from the server using GET.
916 """
917 response.redirect_chain = []
918 redirect_status_codes = (
919 HTTPStatus.MOVED_PERMANENTLY,
920 HTTPStatus.FOUND,
921 HTTPStatus.SEE_OTHER,
922 HTTPStatus.TEMPORARY_REDIRECT,
923 HTTPStatus.PERMANENT_REDIRECT,
924 )
925 while response.status_code in redirect_status_codes:
926 response_url = response.url
927 redirect_chain = response.redirect_chain
928 redirect_chain.append((response_url, response.status_code))
930 url = urlsplit(response_url)
931 if url.scheme:
932 extra["wsgi.url_scheme"] = url.scheme
933 if url.hostname:
934 extra["SERVER_NAME"] = url.hostname
935 if url.port:
936 extra["SERVER_PORT"] = str(url.port)
938 path = url.path
939 # RFC 3986 Section 6.2.3: Empty path should be normalized to "/".
940 if not path and url.netloc:
941 path = "/"
942 # Prepend the request path to handle relative path redirects
943 if not path.startswith("/"):
944 path = urljoin(response.request["PATH_INFO"], path)
946 if response.status_code in (
947 HTTPStatus.TEMPORARY_REDIRECT,
948 HTTPStatus.PERMANENT_REDIRECT,
949 ):
950 # Preserve request method and query string (if needed)
951 # post-redirect for 307/308 responses.
952 request_method = response.request["REQUEST_METHOD"].lower()
953 if request_method not in ("get", "head"):
954 extra["QUERY_STRING"] = url.query
955 request_method = getattr(self, request_method)
956 else:
957 request_method = self.get
958 data = QueryDict(url.query)
959 content_type = None
961 response = request_method(
962 path,
963 data=data,
964 content_type=content_type,
965 follow=False,
966 headers=headers,
967 **extra,
968 )
969 response.redirect_chain = redirect_chain
971 if redirect_chain[-1] in redirect_chain[:-1]:
972 # Check that we're not redirecting to somewhere we've already
973 # been to, to prevent loops.
974 raise RedirectCycleError(
975 "Redirect loop detected.", last_response=response
976 )
977 if len(redirect_chain) > 20:
978 # Such a lengthy chain likely also means a loop, but one with
979 # a growing path, changing view, or changing query argument;
980 # 20 is the value of "network.http.redirection-limit" from Firefox.
981 raise RedirectCycleError("Too many redirects.", last_response=response)
983 return response