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

379 statements  

« 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 

12 

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 

25 

26__all__ = ( 

27 "Client", 

28 "RedirectCycleError", 

29 "RequestFactory", 

30 "encode_file", 

31 "encode_multipart", 

32) 

33 

34 

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

40 

41 

42class 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 

77class 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 

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

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 

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 

155 

156 

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

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 

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

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

290class 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 

540class 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 

630class 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