Coverage for /Users/davegaeddert/Developer/dropseed/plain/plain/plain/test/client.py: 55%

1 import json

2 import mimetypes

3 import os

4 import sys

5 from functools import partial

6 from http import HTTPStatus

7 from http.cookies import SimpleCookie

8 from importlib import import_module

9 from io import BytesIO, IOBase

10 from itertools import chain

11 from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit

12

13 from plain.http import HttpHeaders, HttpRequest, QueryDict

14 from plain.internal.handlers.base import BaseHandler

15 from plain.internal.handlers.wsgi import WSGIRequest

16 from plain.json import PlainJSONEncoder

17 from plain.runtime import settings

18 from plain.signals import got_request_exception, request_started

19 from plain.urls import resolve

20 from plain.utils.encoding import force_bytes

21 from plain.utils.functional import SimpleLazyObject

22 from plain.utils.http import urlencode

23 from plain.utils.itercompat import is_iterable

24 from plain.utils.regex_helper import _lazy_re_compile

25

26 __all__ = (

27 "Client",

28 "RedirectCycleError",

29 "RequestFactory",

30 "encode_file",

31 "encode_multipart",

32)

33

34

35 BOUNDARY = "BoUnDaRyStRiNg"

36 MULTIPART_CONTENT = f"multipart/form-data; boundary={BOUNDARY}"

37 CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")

38# Structured suffix spec: https://tools.ietf.org/html/rfc6838#section-4.2.8

39 JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")

40

41

42 class ContextList(list):

43 """

44 A wrapper that provides direct key access to context items contained

45 in a list of context objects.

46 """

47

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)

56

57 def get(self, key, default=None):

58 try:

59 return self.__getitem__(key)

60 except KeyError:

61 return default

62

63 def __contains__(self, key):

64 try:

65 self[key]

66 except KeyError:

67 return False

68 return True

69

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))

75

76

77 class RedirectCycleError(Exception):

78 """The test client has been asked to follow a redirect loop."""

79

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

84

85

86 class 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 """

93

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)

100

101 def __len__(self):

102 return self.__len

103

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

116

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

129

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)

136

137

138 def 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

155

156

157 class 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 """

163

164 def __init__(self, enforce_csrf_checks=True, *args, **kwargs):

165 self.enforce_csrf_checks = enforce_csrf_checks

166 super().__init__(*args, **kwargs)

167

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()

173

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

181

182 # Request goes through middleware.

183 response = self.get_response(request)

184

185 # Simulate behaviors of most web servers.

186 conditional_content_removal(request, response)

187

188 # Attach the originating request to the response so that it could be

189 # later retrieved.

190 response.wsgi_request = request

191

192 # Emulate a WSGI server by calling the close method on completion.

193 response.close()

194

195 return response

196

197

198 def encode_multipart(boundary, data):

199 """

200 Encode multipart POST data from a dictionary of form values.

201

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 = []

207

208 def to_bytes(s):

209 return force_bytes(s, settings.DEFAULT_CHARSET)

210

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)

214

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 )

250

251 lines.extend(

252 [

253 to_bytes(f"--{boundary}--"),

254 b"",

255 ]

256 )

257 return b"\r\n".join(lines)

258

259

260 def encode_file(boundary, key, file):

261 def to_bytes(s):

262 return force_bytes(s, settings.DEFAULT_CHARSET)

263

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 ""

268

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

275

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 ]

288

289

290 class RequestFactory:

291 """

292 Class that lets you create mock Request objects for use in testing.

293

294 Usage:

295

296 rf = RequestFactory()

297 get_request = rf.get('/hello/')

298 post_request = rf.post('/submit/', {'foo': 'bar'})

299

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 """

303

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))

311

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 }

344

345 def request(self, **request):

346 "Construct a generic request object."

347 return WSGIRequest(self._base_environ(**request))

348

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)

360

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

370

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")

381

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 )

395

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)

409

410 return self.generic(

411 "POST",

412 path,

413 post_data,

414 content_type,

415 secure=secure,

416 headers=headers,

417 **extra,

418 )

419

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 )

433

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)

437

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 )

452

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 )

468

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 )

484

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 )

500

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)

538

539

540 class ClientMixin:

541 """

542 Mixin with common methods between Client and AsyncClient.

543 """

544

545 def store_exc_info(self, **kwargs):

546 """Store exceptions when they are generated by a view."""

547 self.exc_info = sys.exc_info()

548

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

561

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

573

574 def force_login(self, user):

575 self._login(user)

576

577 def _login(self, user):

578 from plain.auth import login

579

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)

601

602 def logout(self):

603 """Log out the user by removing the cookies and session object."""

604 from plain.auth import get_user, logout

605

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()

615

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

628

629

630 class Client(ClientMixin, RequestFactory):

631 """

632 A class that can act as a client for testing purposes.

633

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.

639

640 Client objects are stateful - they will retain cookie (and

641 thus session) details for the lifetime of the Client instance.

642

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 """

648

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

663

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)

672

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)

687

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

691

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 )

697

698 # Update persistent cookie data.

699 if response.cookies:

700 self.cookies.update(response.cookies)

701 return response

702

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

722

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

750

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

772

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

800

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

828

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

856

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

884

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

906

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))

930

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)

938

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)

946

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

961

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

971

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)

983

984 return response