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

1 import datetime

2 import io

3 import json

4 import mimetypes

5 import os

6 import re

7 import sys

8 import time

9 from email.header import Header

10 from http.client import responses

11 from http.cookies import SimpleCookie

12 from urllib.parse import urlparse

13

14 from plain import signals, signing

15 from plain.exceptions import DisallowedRedirect

16 from plain.json import PlainJSONEncoder

17 from plain.runtime import settings

18 from plain.utils import timezone

19 from plain.utils.datastructures import CaseInsensitiveMapping

20 from plain.utils.encoding import iri_to_uri

21 from plain.utils.http import content_disposition_header, http_date

22 from 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

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

95 class BadHeaderError(ValueError):

96 pass

97

98

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

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

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

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

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

606 class ResponseRedirect(ResponseRedirectBase):

607 """HTTP 302 response"""

608

609 status_code = 302

610

611

612 class ResponsePermanentRedirect(ResponseRedirectBase):

613 """HTTP 301 response"""

614

615 status_code = 301

616

617

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

636 class ResponseBadRequest(Response):

637 """HTTP 400 response"""

638

639 status_code = 400

640

641

642 class ResponseNotFound(Response):

643 """HTTP 404 response"""

644

645 status_code = 404

646

647

648 class ResponseForbidden(Response):

649 """HTTP 403 response"""

650

651 status_code = 403

652

653

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

672 class ResponseGone(Response):

673 """HTTP 410 response"""

674

675 status_code = 410

676

677

678 class ResponseServerError(Response):

679 """HTTP 500 response"""

680

681 status_code = 500

682

683

684 class Http404(Exception):

685 pass

686

687

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