Coverage for /Users/davegaeddert/Development/dropseed/plain/plain/plain/http/response.py: 38%

353 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-10-16 22:04 -0500

1import datetime 

2import io 

3import json 

4import mimetypes 

5import os 

6import re 

7import sys 

8import time 

9from email.header import Header 

10from http.client import responses 

11from http.cookies import SimpleCookie 

12from urllib.parse import urlparse 

13 

14from plain import signals, signing 

15from plain.exceptions import DisallowedRedirect 

16from plain.json import PlainJSONEncoder 

17from plain.runtime import settings 

18from plain.utils import timezone 

19from plain.utils.datastructures import CaseInsensitiveMapping 

20from plain.utils.encoding import iri_to_uri 

21from plain.utils.http import content_disposition_header, http_date 

22from plain.utils.regex_helper import _lazy_re_compile 

23 

24_charset_from_content_type_re = _lazy_re_compile( 

25 r";\s*charset=(?P<charset>[^\s;]+)", re.I 

26) 

27 

28 

29class ResponseHeaders(CaseInsensitiveMapping): 

30 def __init__(self, data): 

31 """ 

32 Populate the initial data using __setitem__ to ensure values are 

33 correctly encoded. 

34 """ 

35 self._store = {} 

36 if data: 

37 for header, value in self._unpack_items(data): 

38 self[header] = value 

39 

40 def _convert_to_charset(self, value, charset, mime_encode=False): 

41 """ 

42 Convert headers key/value to ascii/latin-1 native strings. 

43 `charset` must be 'ascii' or 'latin-1'. If `mime_encode` is True and 

44 `value` can't be represented in the given charset, apply MIME-encoding. 

45 """ 

46 try: 

47 if isinstance(value, str): 

48 # Ensure string is valid in given charset 

49 value.encode(charset) 

50 elif isinstance(value, bytes): 

51 # Convert bytestring using given charset 

52 value = value.decode(charset) 

53 else: 

54 value = str(value) 

55 # Ensure string is valid in given charset. 

56 value.encode(charset) 

57 if "\n" in value or "\r" in value: 

58 raise BadHeaderError( 

59 f"Header values can't contain newlines (got {value!r})" 

60 ) 

61 except UnicodeError as e: 

62 # Encoding to a string of the specified charset failed, but we 

63 # don't know what type that value was, or if it contains newlines, 

64 # which we may need to check for before sending it to be 

65 # encoded for multiple character sets. 

66 if (isinstance(value, bytes) and (b"\n" in value or b"\r" in value)) or ( 

67 isinstance(value, str) and ("\n" in value or "\r" in value) 

68 ): 

69 raise BadHeaderError( 

70 f"Header values can't contain newlines (got {value!r})" 

71 ) from e 

72 if mime_encode: 

73 value = Header(value, "utf-8", maxlinelen=sys.maxsize).encode() 

74 else: 

75 e.reason += ", HTTP response headers must be in %s format" % charset 

76 raise 

77 return value 

78 

79 def __delitem__(self, key): 

80 self.pop(key) 

81 

82 def __setitem__(self, key, value): 

83 key = self._convert_to_charset(key, "ascii") 

84 value = self._convert_to_charset(value, "latin-1", mime_encode=True) 

85 self._store[key.lower()] = (key, value) 

86 

87 def pop(self, key, default=None): 

88 return self._store.pop(key.lower(), default) 

89 

90 def setdefault(self, key, value): 

91 if key not in self: 

92 self[key] = value 

93 

94 

95class BadHeaderError(ValueError): 

96 pass 

97 

98 

99class ResponseBase: 

100 """ 

101 An HTTP response base class with dictionary-accessed headers. 

102 

103 This class doesn't handle content. It should not be used directly. 

104 Use the Response and StreamingResponse subclasses instead. 

105 """ 

106 

107 status_code = 200 

108 

109 def __init__( 

110 self, content_type=None, status=None, reason=None, charset=None, headers=None 

111 ): 

112 self.headers = ResponseHeaders(headers) 

113 self._charset = charset 

114 if "Content-Type" not in self.headers: 

115 if content_type is None: 

116 content_type = f"text/html; charset={self.charset}" 

117 self.headers["Content-Type"] = content_type 

118 elif content_type: 

119 raise ValueError( 

120 "'headers' must not contain 'Content-Type' when the " 

121 "'content_type' parameter is provided." 

122 ) 

123 self._resource_closers = [] 

124 # This parameter is set by the handler. It's necessary to preserve the 

125 # historical behavior of request_finished. 

126 self._handler_class = None 

127 self.cookies = SimpleCookie() 

128 self.closed = False 

129 if status is not None: 

130 try: 

131 self.status_code = int(status) 

132 except (ValueError, TypeError): 

133 raise TypeError("HTTP status code must be an integer.") 

134 

135 if not 100 <= self.status_code <= 599: 

136 raise ValueError("HTTP status code must be an integer from 100 to 599.") 

137 self._reason_phrase = reason 

138 

139 @property 

140 def reason_phrase(self): 

141 if self._reason_phrase is not None: 

142 return self._reason_phrase 

143 # Leave self._reason_phrase unset in order to use the default 

144 # reason phrase for status code. 

145 return responses.get(self.status_code, "Unknown Status Code") 

146 

147 @reason_phrase.setter 

148 def reason_phrase(self, value): 

149 self._reason_phrase = value 

150 

151 @property 

152 def charset(self): 

153 if self._charset is not None: 

154 return self._charset 

155 # The Content-Type header may not yet be set, because the charset is 

156 # being inserted *into* it. 

157 if content_type := self.headers.get("Content-Type"): 

158 if matched := _charset_from_content_type_re.search(content_type): 

159 # Extract the charset and strip its double quotes. 

160 # Note that having parsed it from the Content-Type, we don't 

161 # store it back into the _charset for later intentionally, to 

162 # allow for the Content-Type to be switched again later. 

163 return matched["charset"].replace('"', "") 

164 return settings.DEFAULT_CHARSET 

165 

166 @charset.setter 

167 def charset(self, value): 

168 self._charset = value 

169 

170 def serialize_headers(self): 

171 """HTTP headers as a bytestring.""" 

172 return b"\r\n".join( 

173 [ 

174 key.encode("ascii") + b": " + value.encode("latin-1") 

175 for key, value in self.headers.items() 

176 ] 

177 ) 

178 

179 __bytes__ = serialize_headers 

180 

181 @property 

182 def _content_type_for_repr(self): 

183 return ( 

184 ', "%s"' % self.headers["Content-Type"] 

185 if "Content-Type" in self.headers 

186 else "" 

187 ) 

188 

189 def __setitem__(self, header, value): 

190 self.headers[header] = value 

191 

192 def __delitem__(self, header): 

193 del self.headers[header] 

194 

195 def __getitem__(self, header): 

196 return self.headers[header] 

197 

198 def has_header(self, header): 

199 """Case-insensitive check for a header.""" 

200 return header in self.headers 

201 

202 __contains__ = has_header 

203 

204 def items(self): 

205 return self.headers.items() 

206 

207 def get(self, header, alternate=None): 

208 return self.headers.get(header, alternate) 

209 

210 def set_cookie( 

211 self, 

212 key, 

213 value="", 

214 max_age=None, 

215 expires=None, 

216 path="/", 

217 domain=None, 

218 secure=False, 

219 httponly=False, 

220 samesite=None, 

221 ): 

222 """ 

223 Set a cookie. 

224 

225 ``expires`` can be: 

226 - a string in the correct format, 

227 - a naive ``datetime.datetime`` object in UTC, 

228 - an aware ``datetime.datetime`` object in any time zone. 

229 If it is a ``datetime.datetime`` object then calculate ``max_age``. 

230 

231 ``max_age`` can be: 

232 - int/float specifying seconds, 

233 - ``datetime.timedelta`` object. 

234 """ 

235 self.cookies[key] = value 

236 if expires is not None: 

237 if isinstance(expires, datetime.datetime): 

238 if timezone.is_naive(expires): 

239 expires = timezone.make_aware(expires, datetime.timezone.utc) 

240 delta = expires - datetime.datetime.now(tz=datetime.timezone.utc) 

241 # Add one second so the date matches exactly (a fraction of 

242 # time gets lost between converting to a timedelta and 

243 # then the date string). 

244 delta += datetime.timedelta(seconds=1) 

245 # Just set max_age - the max_age logic will set expires. 

246 expires = None 

247 if max_age is not None: 

248 raise ValueError("'expires' and 'max_age' can't be used together.") 

249 max_age = max(0, delta.days * 86400 + delta.seconds) 

250 else: 

251 self.cookies[key]["expires"] = expires 

252 else: 

253 self.cookies[key]["expires"] = "" 

254 if max_age is not None: 

255 if isinstance(max_age, datetime.timedelta): 

256 max_age = max_age.total_seconds() 

257 self.cookies[key]["max-age"] = int(max_age) 

258 # IE requires expires, so set it if hasn't been already. 

259 if not expires: 

260 self.cookies[key]["expires"] = http_date(time.time() + max_age) 

261 if path is not None: 

262 self.cookies[key]["path"] = path 

263 if domain is not None: 

264 self.cookies[key]["domain"] = domain 

265 if secure: 

266 self.cookies[key]["secure"] = True 

267 if httponly: 

268 self.cookies[key]["httponly"] = True 

269 if samesite: 

270 if samesite.lower() not in ("lax", "none", "strict"): 

271 raise ValueError('samesite must be "lax", "none", or "strict".') 

272 self.cookies[key]["samesite"] = samesite 

273 

274 def setdefault(self, key, value): 

275 """Set a header unless it has already been set.""" 

276 self.headers.setdefault(key, value) 

277 

278 def set_signed_cookie(self, key, value, salt="", **kwargs): 

279 value = signing.get_cookie_signer(salt=key + salt).sign(value) 

280 return self.set_cookie(key, value, **kwargs) 

281 

282 def delete_cookie(self, key, path="/", domain=None, samesite=None): 

283 # Browsers can ignore the Set-Cookie header if the cookie doesn't use 

284 # the secure flag and: 

285 # - the cookie name starts with "__Host-" or "__Secure-", or 

286 # - the samesite is "none". 

287 secure = key.startswith(("__Secure-", "__Host-")) or ( 

288 samesite and samesite.lower() == "none" 

289 ) 

290 self.set_cookie( 

291 key, 

292 max_age=0, 

293 path=path, 

294 domain=domain, 

295 secure=secure, 

296 expires="Thu, 01 Jan 1970 00:00:00 GMT", 

297 samesite=samesite, 

298 ) 

299 

300 # Common methods used by subclasses 

301 

302 def make_bytes(self, value): 

303 """Turn a value into a bytestring encoded in the output charset.""" 

304 # Per PEP 3333, this response body must be bytes. To avoid returning 

305 # an instance of a subclass, this function returns `bytes(value)`. 

306 # This doesn't make a copy when `value` already contains bytes. 

307 

308 # Handle string types -- we can't rely on force_bytes here because: 

309 # - Python attempts str conversion first 

310 # - when self._charset != 'utf-8' it re-encodes the content 

311 if isinstance(value, bytes | memoryview): 

312 return bytes(value) 

313 if isinstance(value, str): 

314 return bytes(value.encode(self.charset)) 

315 # Handle non-string types. 

316 return str(value).encode(self.charset) 

317 

318 # These methods partially implement the file-like object interface. 

319 # See https://docs.python.org/library/io.html#io.IOBase 

320 

321 # The WSGI server must call this method upon completion of the request. 

322 # See http://blog.dscpl.com.au/2012/10/obligations-for-calling-close-on.html 

323 def close(self): 

324 for closer in self._resource_closers: 

325 try: 

326 closer() 

327 except Exception: 

328 pass 

329 # Free resources that were still referenced. 

330 self._resource_closers.clear() 

331 self.closed = True 

332 signals.request_finished.send(sender=self._handler_class) 

333 

334 def write(self, content): 

335 raise OSError("This %s instance is not writable" % self.__class__.__name__) 

336 

337 def flush(self): 

338 pass 

339 

340 def tell(self): 

341 raise OSError( 

342 "This %s instance cannot tell its position" % self.__class__.__name__ 

343 ) 

344 

345 # These methods partially implement a stream-like object interface. 

346 # See https://docs.python.org/library/io.html#io.IOBase 

347 

348 def readable(self): 

349 return False 

350 

351 def seekable(self): 

352 return False 

353 

354 def writable(self): 

355 return False 

356 

357 def writelines(self, lines): 

358 raise OSError("This %s instance is not writable" % self.__class__.__name__) 

359 

360 

361class Response(ResponseBase): 

362 """ 

363 An HTTP response class with a string as content. 

364 

365 This content can be read, appended to, or replaced. 

366 """ 

367 

368 streaming = False 

369 non_picklable_attrs = frozenset( 

370 [ 

371 "resolver_match", 

372 # Non-picklable attributes added by test clients. 

373 "client", 

374 "context", 

375 "json", 

376 "templates", 

377 ] 

378 ) 

379 

380 def __init__(self, content=b"", *args, **kwargs): 

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

382 # Content is a bytestring. See the `content` property methods. 

383 self.content = content 

384 

385 def __getstate__(self): 

386 obj_dict = self.__dict__.copy() 

387 for attr in self.non_picklable_attrs: 

388 if attr in obj_dict: 

389 del obj_dict[attr] 

390 return obj_dict 

391 

392 def __repr__(self): 

393 return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { 

394 "cls": self.__class__.__name__, 

395 "status_code": self.status_code, 

396 "content_type": self._content_type_for_repr, 

397 } 

398 

399 def serialize(self): 

400 """Full HTTP message, including headers, as a bytestring.""" 

401 return self.serialize_headers() + b"\r\n\r\n" + self.content 

402 

403 __bytes__ = serialize 

404 

405 @property 

406 def content(self): 

407 return b"".join(self._container) 

408 

409 @content.setter 

410 def content(self, value): 

411 # Consume iterators upon assignment to allow repeated iteration. 

412 if hasattr(value, "__iter__") and not isinstance( 

413 value, bytes | memoryview | str 

414 ): 

415 content = b"".join(self.make_bytes(chunk) for chunk in value) 

416 if hasattr(value, "close"): 

417 try: 

418 value.close() 

419 except Exception: 

420 pass 

421 else: 

422 content = self.make_bytes(value) 

423 # Create a list of properly encoded bytestrings to support write(). 

424 self._container = [content] 

425 

426 def __iter__(self): 

427 return iter(self._container) 

428 

429 def write(self, content): 

430 self._container.append(self.make_bytes(content)) 

431 

432 def tell(self): 

433 return len(self.content) 

434 

435 def getvalue(self): 

436 return self.content 

437 

438 def writable(self): 

439 return True 

440 

441 def writelines(self, lines): 

442 for line in lines: 

443 self.write(line) 

444 

445 

446class StreamingResponse(ResponseBase): 

447 """ 

448 A streaming HTTP response class with an iterator as content. 

449 

450 This should only be iterated once, when the response is streamed to the 

451 client. However, it can be appended to or replaced with a new iterator 

452 that wraps the original content (or yields entirely new content). 

453 """ 

454 

455 streaming = True 

456 

457 def __init__(self, streaming_content=(), *args, **kwargs): 

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

459 # `streaming_content` should be an iterable of bytestrings. 

460 # See the `streaming_content` property methods. 

461 self.streaming_content = streaming_content 

462 

463 def __repr__(self): 

464 return "<%(cls)s status_code=%(status_code)d%(content_type)s>" % { 

465 "cls": self.__class__.__qualname__, 

466 "status_code": self.status_code, 

467 "content_type": self._content_type_for_repr, 

468 } 

469 

470 @property 

471 def content(self): 

472 raise AttributeError( 

473 "This %s instance has no `content` attribute. Use " 

474 "`streaming_content` instead." % self.__class__.__name__ 

475 ) 

476 

477 @property 

478 def streaming_content(self): 

479 return map(self.make_bytes, self._iterator) 

480 

481 @streaming_content.setter 

482 def streaming_content(self, value): 

483 self._set_streaming_content(value) 

484 

485 def _set_streaming_content(self, value): 

486 # Ensure we can never iterate on "value" more than once. 

487 self._iterator = iter(value) 

488 if hasattr(value, "close"): 

489 self._resource_closers.append(value.close) 

490 

491 def __iter__(self): 

492 return iter(self.streaming_content) 

493 

494 def getvalue(self): 

495 return b"".join(self.streaming_content) 

496 

497 

498class FileResponse(StreamingResponse): 

499 """ 

500 A streaming HTTP response class optimized for files. 

501 """ 

502 

503 block_size = 4096 

504 

505 def __init__(self, *args, as_attachment=False, filename="", **kwargs): 

506 self.as_attachment = as_attachment 

507 self.filename = filename 

508 self._no_explicit_content_type = ( 

509 "content_type" not in kwargs or kwargs["content_type"] is None 

510 ) 

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

512 

513 def _set_streaming_content(self, value): 

514 if not hasattr(value, "read"): 

515 self.file_to_stream = None 

516 return super()._set_streaming_content(value) 

517 

518 self.file_to_stream = filelike = value 

519 if hasattr(filelike, "close"): 

520 self._resource_closers.append(filelike.close) 

521 value = iter(lambda: filelike.read(self.block_size), b"") 

522 self.set_headers(filelike) 

523 super()._set_streaming_content(value) 

524 

525 def set_headers(self, filelike): 

526 """ 

527 Set some common response headers (Content-Length, Content-Type, and 

528 Content-Disposition) based on the `filelike` response content. 

529 """ 

530 filename = getattr(filelike, "name", "") 

531 filename = filename if isinstance(filename, str) else "" 

532 seekable = hasattr(filelike, "seek") and ( 

533 not hasattr(filelike, "seekable") or filelike.seekable() 

534 ) 

535 if hasattr(filelike, "tell"): 

536 if seekable: 

537 initial_position = filelike.tell() 

538 filelike.seek(0, io.SEEK_END) 

539 self.headers["Content-Length"] = filelike.tell() - initial_position 

540 filelike.seek(initial_position) 

541 elif hasattr(filelike, "getbuffer"): 

542 self.headers["Content-Length"] = ( 

543 filelike.getbuffer().nbytes - filelike.tell() 

544 ) 

545 elif os.path.exists(filename): 

546 self.headers["Content-Length"] = ( 

547 os.path.getsize(filename) - filelike.tell() 

548 ) 

549 elif seekable: 

550 self.headers["Content-Length"] = sum( 

551 iter(lambda: len(filelike.read(self.block_size)), 0) 

552 ) 

553 filelike.seek(-int(self.headers["Content-Length"]), io.SEEK_END) 

554 

555 filename = os.path.basename(self.filename or filename) 

556 if self._no_explicit_content_type: 

557 if filename: 

558 content_type, encoding = mimetypes.guess_type(filename) 

559 # Encoding isn't set to prevent browsers from automatically 

560 # uncompressing files. 

561 content_type = { 

562 "br": "application/x-brotli", 

563 "bzip2": "application/x-bzip", 

564 "compress": "application/x-compress", 

565 "gzip": "application/gzip", 

566 "xz": "application/x-xz", 

567 }.get(encoding, content_type) 

568 self.headers["Content-Type"] = ( 

569 content_type or "application/octet-stream" 

570 ) 

571 else: 

572 self.headers["Content-Type"] = "application/octet-stream" 

573 

574 if content_disposition := content_disposition_header( 

575 self.as_attachment, filename 

576 ): 

577 self.headers["Content-Disposition"] = content_disposition 

578 

579 

580class ResponseRedirectBase(Response): 

581 allowed_schemes = ["http", "https", "ftp"] 

582 

583 def __init__(self, redirect_to, *args, **kwargs): 

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

585 self["Location"] = iri_to_uri(redirect_to) 

586 parsed = urlparse(str(redirect_to)) 

587 if parsed.scheme and parsed.scheme not in self.allowed_schemes: 

588 raise DisallowedRedirect( 

589 "Unsafe redirect to URL with protocol '%s'" % parsed.scheme 

590 ) 

591 

592 url = property(lambda self: self["Location"]) 

593 

594 def __repr__(self): 

595 return ( 

596 '<%(cls)s status_code=%(status_code)d%(content_type)s, url="%(url)s">' 

597 % { 

598 "cls": self.__class__.__name__, 

599 "status_code": self.status_code, 

600 "content_type": self._content_type_for_repr, 

601 "url": self.url, 

602 } 

603 ) 

604 

605 

606class ResponseRedirect(ResponseRedirectBase): 

607 """HTTP 302 response""" 

608 

609 status_code = 302 

610 

611 

612class ResponsePermanentRedirect(ResponseRedirectBase): 

613 """HTTP 301 response""" 

614 

615 status_code = 301 

616 

617 

618class ResponseNotModified(Response): 

619 """HTTP 304 response""" 

620 

621 status_code = 304 

622 

623 def __init__(self, *args, **kwargs): 

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

625 del self["content-type"] 

626 

627 @Response.content.setter 

628 def content(self, value): 

629 if value: 

630 raise AttributeError( 

631 "You cannot set content to a 304 (Not Modified) response" 

632 ) 

633 self._container = [] 

634 

635 

636class ResponseBadRequest(Response): 

637 """HTTP 400 response""" 

638 

639 status_code = 400 

640 

641 

642class ResponseNotFound(Response): 

643 """HTTP 404 response""" 

644 

645 status_code = 404 

646 

647 

648class ResponseForbidden(Response): 

649 """HTTP 403 response""" 

650 

651 status_code = 403 

652 

653 

654class ResponseNotAllowed(Response): 

655 """HTTP 405 response""" 

656 

657 status_code = 405 

658 

659 def __init__(self, permitted_methods, *args, **kwargs): 

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

661 self["Allow"] = ", ".join(permitted_methods) 

662 

663 def __repr__(self): 

664 return "<%(cls)s [%(methods)s] status_code=%(status_code)d%(content_type)s>" % { 

665 "cls": self.__class__.__name__, 

666 "status_code": self.status_code, 

667 "content_type": self._content_type_for_repr, 

668 "methods": self["Allow"], 

669 } 

670 

671 

672class ResponseGone(Response): 

673 """HTTP 410 response""" 

674 

675 status_code = 410 

676 

677 

678class ResponseServerError(Response): 

679 """HTTP 500 response""" 

680 

681 status_code = 500 

682 

683 

684class Http404(Exception): 

685 pass 

686 

687 

688class JsonResponse(Response): 

689 """ 

690 An HTTP response class that consumes data to be serialized to JSON. 

691 

692 :param data: Data to be dumped into json. By default only ``dict`` objects 

693 are allowed to be passed due to a security flaw before ECMAScript 5. See 

694 the ``safe`` parameter for more information. 

695 :param encoder: Should be a json encoder class. Defaults to 

696 ``plain.json.PlainJSONEncoder``. 

697 :param safe: Controls if only ``dict`` objects may be serialized. Defaults 

698 to ``True``. 

699 :param json_dumps_params: A dictionary of kwargs passed to json.dumps(). 

700 """ 

701 

702 def __init__( 

703 self, 

704 data, 

705 encoder=PlainJSONEncoder, 

706 safe=True, 

707 json_dumps_params=None, 

708 **kwargs, 

709 ): 

710 if safe and not isinstance(data, dict): 

711 raise TypeError( 

712 "In order to allow non-dict objects to be serialized set the " 

713 "safe parameter to False." 

714 ) 

715 if json_dumps_params is None: 

716 json_dumps_params = {} 

717 kwargs.setdefault("content_type", "application/json") 

718 data = json.dumps(data, cls=encoder, **json_dumps_params) 

719 super().__init__(content=data, **kwargs)