Coverage for /Users/davegaeddert/Development/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 = "multipart/form-data; boundary=%s" % 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 "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 )

250

251 lines.extend(

252 [

253 to_bytes("--%s--" % 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("--%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 ]

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

627

628

629 class Client(ClientMixin, RequestFactory):

630 """

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

632

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.

638

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

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

641

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

647

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

662

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)

671

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)

686

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

690

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 )

696

697 # Update persistent cookie data.

698 if response.cookies:

699 self.cookies.update(response.cookies)

700 return response

701

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

721

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

749

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

771

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

799

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

827

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

855

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

883

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

905

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

929

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)

937

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)

945

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

960

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

970

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)

982

983 return response