Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/test/client.py: 47%
379 statements
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
« prev ^ index » next coverage.py v7.6.9, created at 2024-12-23 11:16 -0600
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 = f"multipart/form-data; boundary={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 f"Cannot encode None for key '{key}' as POST data. Did you mean "
222 "to pass an empty string or omit the value?"
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 f"--{boundary}",
235 f'Content-Disposition: form-data; name="{key}"',
236 "",
237 item,
238 ]
239 )
240 else:
241 lines.extend(
242 to_bytes(val)
243 for val in [
244 f"--{boundary}",
245 f'Content-Disposition: form-data; name="{key}"',
246 "",
247 value,
248 ]
249 )
251 lines.extend(
252 [
253 to_bytes(f"--{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(f"--{boundary}"),
281 to_bytes(
282 f'Content-Disposition: form-data; name="{key}"; filename="{filename}"'
283 ),
284 to_bytes(f"Content-Type: {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 "{}", not "application/json"'.format(
621 response.get("Content-Type")
622 )
623 )
624 response._json = json.loads(
625 response.content.decode(response.charset), **extra
626 )
627 return response._json
630class Client(ClientMixin, RequestFactory):
631 """
632 A class that can act as a client for testing purposes.
634 It allows the user to compose GET and POST requests, and
635 obtain the response that the server gave to those requests.
636 The server Response objects are annotated with the details
637 of the contexts and templates that were rendered during the
638 process of serving the request.
640 Client objects are stateful - they will retain cookie (and
641 thus session) details for the lifetime of the Client instance.
643 This is not intended as a replacement for Twill/Selenium or
644 the like - it is here to allow testing against the
645 contexts and templates produced by a view, rather than the
646 HTML rendered to the end-user.
647 """
649 def __init__(
650 self,
651 enforce_csrf_checks=False,
652 raise_request_exception=True,
653 *,
654 headers=None,
655 **defaults,
656 ):
657 super().__init__(headers=headers, **defaults)
658 self.handler = ClientHandler(enforce_csrf_checks)
659 self.raise_request_exception = raise_request_exception
660 self.exc_info = None
661 self.extra = None
662 self.headers = None
664 def request(self, **request):
665 """
666 Make a generic request. Compose the environment dictionary and pass
667 to the handler, return the result of the handler. Assume defaults for
668 the query environment, which can be overridden using the arguments to
669 the request.
670 """
671 environ = self._base_environ(**request)
673 # Capture exceptions created by the handler.
674 exception_uid = f"request-exception-{id(request)}"
675 got_request_exception.connect(self.store_exc_info, dispatch_uid=exception_uid)
676 try:
677 response = self.handler(environ)
678 finally:
679 # signals.template_rendered.disconnect(dispatch_uid=signal_uid)
680 got_request_exception.disconnect(dispatch_uid=exception_uid)
681 # Check for signaled exceptions.
682 self.check_exception(response)
683 # Save the client and request that stimulated the response.
684 response.client = self
685 response.request = request
686 response.json = partial(self._parse_json, response)
688 # If the request had a user attached, make it available on the response.
689 if hasattr(response.wsgi_request, "user"):
690 response.user = response.wsgi_request.user
692 # Attach the ResolverMatch instance to the response.
693 urlconf = getattr(response.wsgi_request, "urlconf", None)
694 response.resolver_match = SimpleLazyObject(
695 lambda: resolve(request["PATH_INFO"], urlconf=urlconf),
696 )
698 # Update persistent cookie data.
699 if response.cookies:
700 self.cookies.update(response.cookies)
701 return response
703 def get(
704 self,
705 path,
706 data=None,
707 follow=False,
708 secure=True,
709 *,
710 headers=None,
711 **extra,
712 ):
713 """Request a response from the server using GET."""
714 self.extra = extra
715 self.headers = headers
716 response = super().get(path, data=data, secure=secure, headers=headers, **extra)
717 if follow:
718 response = self._handle_redirects(
719 response, data=data, headers=headers, **extra
720 )
721 return response
723 def post(
724 self,
725 path,
726 data=None,
727 content_type=MULTIPART_CONTENT,
728 follow=False,
729 secure=True,
730 *,
731 headers=None,
732 **extra,
733 ):
734 """Request a response from the server using POST."""
735 self.extra = extra
736 self.headers = headers
737 response = super().post(
738 path,
739 data=data,
740 content_type=content_type,
741 secure=secure,
742 headers=headers,
743 **extra,
744 )
745 if follow:
746 response = self._handle_redirects(
747 response, data=data, content_type=content_type, headers=headers, **extra
748 )
749 return response
751 def head(
752 self,
753 path,
754 data=None,
755 follow=False,
756 secure=True,
757 *,
758 headers=None,
759 **extra,
760 ):
761 """Request a response from the server using HEAD."""
762 self.extra = extra
763 self.headers = headers
764 response = super().head(
765 path, data=data, secure=secure, headers=headers, **extra
766 )
767 if follow:
768 response = self._handle_redirects(
769 response, data=data, headers=headers, **extra
770 )
771 return response
773 def options(
774 self,
775 path,
776 data="",
777 content_type="application/octet-stream",
778 follow=False,
779 secure=True,
780 *,
781 headers=None,
782 **extra,
783 ):
784 """Request a response from the server using OPTIONS."""
785 self.extra = extra
786 self.headers = headers
787 response = super().options(
788 path,
789 data=data,
790 content_type=content_type,
791 secure=secure,
792 headers=headers,
793 **extra,
794 )
795 if follow:
796 response = self._handle_redirects(
797 response, data=data, content_type=content_type, headers=headers, **extra
798 )
799 return response
801 def put(
802 self,
803 path,
804 data="",
805 content_type="application/octet-stream",
806 follow=False,
807 secure=True,
808 *,
809 headers=None,
810 **extra,
811 ):
812 """Send a resource to the server using PUT."""
813 self.extra = extra
814 self.headers = headers
815 response = super().put(
816 path,
817 data=data,
818 content_type=content_type,
819 secure=secure,
820 headers=headers,
821 **extra,
822 )
823 if follow:
824 response = self._handle_redirects(
825 response, data=data, content_type=content_type, headers=headers, **extra
826 )
827 return response
829 def patch(
830 self,
831 path,
832 data="",
833 content_type="application/octet-stream",
834 follow=False,
835 secure=True,
836 *,
837 headers=None,
838 **extra,
839 ):
840 """Send a resource to the server using PATCH."""
841 self.extra = extra
842 self.headers = headers
843 response = super().patch(
844 path,
845 data=data,
846 content_type=content_type,
847 secure=secure,
848 headers=headers,
849 **extra,
850 )
851 if follow:
852 response = self._handle_redirects(
853 response, data=data, content_type=content_type, headers=headers, **extra
854 )
855 return response
857 def delete(
858 self,
859 path,
860 data="",
861 content_type="application/octet-stream",
862 follow=False,
863 secure=True,
864 *,
865 headers=None,
866 **extra,
867 ):
868 """Send a DELETE request to the server."""
869 self.extra = extra
870 self.headers = headers
871 response = super().delete(
872 path,
873 data=data,
874 content_type=content_type,
875 secure=secure,
876 headers=headers,
877 **extra,
878 )
879 if follow:
880 response = self._handle_redirects(
881 response, data=data, content_type=content_type, headers=headers, **extra
882 )
883 return response
885 def trace(
886 self,
887 path,
888 data="",
889 follow=False,
890 secure=True,
891 *,
892 headers=None,
893 **extra,
894 ):
895 """Send a TRACE request to the server."""
896 self.extra = extra
897 self.headers = headers
898 response = super().trace(
899 path, data=data, secure=secure, headers=headers, **extra
900 )
901 if follow:
902 response = self._handle_redirects(
903 response, data=data, headers=headers, **extra
904 )
905 return response
907 def _handle_redirects(
908 self,
909 response,
910 data="",
911 content_type="",
912 headers=None,
913 **extra,
914 ):
915 """
916 Follow any redirects by requesting responses from the server using GET.
917 """
918 response.redirect_chain = []
919 redirect_status_codes = (
920 HTTPStatus.MOVED_PERMANENTLY,
921 HTTPStatus.FOUND,
922 HTTPStatus.SEE_OTHER,
923 HTTPStatus.TEMPORARY_REDIRECT,
924 HTTPStatus.PERMANENT_REDIRECT,
925 )
926 while response.status_code in redirect_status_codes:
927 response_url = response.url
928 redirect_chain = response.redirect_chain
929 redirect_chain.append((response_url, response.status_code))
931 url = urlsplit(response_url)
932 if url.scheme:
933 extra["wsgi.url_scheme"] = url.scheme
934 if url.hostname:
935 extra["SERVER_NAME"] = url.hostname
936 if url.port:
937 extra["SERVER_PORT"] = str(url.port)
939 path = url.path
940 # RFC 3986 Section 6.2.3: Empty path should be normalized to "/".
941 if not path and url.netloc:
942 path = "/"
943 # Prepend the request path to handle relative path redirects
944 if not path.startswith("/"):
945 path = urljoin(response.request["PATH_INFO"], path)
947 if response.status_code in (
948 HTTPStatus.TEMPORARY_REDIRECT,
949 HTTPStatus.PERMANENT_REDIRECT,
950 ):
951 # Preserve request method and query string (if needed)
952 # post-redirect for 307/308 responses.
953 request_method = response.request["REQUEST_METHOD"].lower()
954 if request_method not in ("get", "head"):
955 extra["QUERY_STRING"] = url.query
956 request_method = getattr(self, request_method)
957 else:
958 request_method = self.get
959 data = QueryDict(url.query)
960 content_type = None
962 response = request_method(
963 path,
964 data=data,
965 content_type=content_type,
966 follow=False,
967 headers=headers,
968 **extra,
969 )
970 response.redirect_chain = redirect_chain
972 if redirect_chain[-1] in redirect_chain[:-1]:
973 # Check that we're not redirecting to somewhere we've already
974 # been to, to prevent loops.
975 raise RedirectCycleError(
976 "Redirect loop detected.", last_response=response
977 )
978 if len(redirect_chain) > 20:
979 # Such a lengthy chain likely also means a loop, but one with
980 # a growing path, changing view, or changing query argument;
981 # 20 is the value of "network.http.redirection-limit" from Firefox.
982 raise RedirectCycleError("Too many redirects.", last_response=response)
984 return response