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:03 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-10-16 22:03 -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
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
24_charset_from_content_type_re = _lazy_re_compile(
25 r";\s*charset=(?P<charset>[^\s;]+)", re.I
26)
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
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
79 def __delitem__(self, key):
80 self.pop(key)
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)
87 def pop(self, key, default=None):
88 return self._store.pop(key.lower(), default)
90 def setdefault(self, key, value):
91 if key not in self:
92 self[key] = value
95class BadHeaderError(ValueError):
96 pass
99class ResponseBase:
100 """
101 An HTTP response base class with dictionary-accessed headers.
103 This class doesn't handle content. It should not be used directly.
104 Use the Response and StreamingResponse subclasses instead.
105 """
107 status_code = 200
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.")
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
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")
147 @reason_phrase.setter
148 def reason_phrase(self, value):
149 self._reason_phrase = value
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
166 @charset.setter
167 def charset(self, value):
168 self._charset = value
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 )
179 __bytes__ = serialize_headers
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 )
189 def __setitem__(self, header, value):
190 self.headers[header] = value
192 def __delitem__(self, header):
193 del self.headers[header]
195 def __getitem__(self, header):
196 return self.headers[header]
198 def has_header(self, header):
199 """Case-insensitive check for a header."""
200 return header in self.headers
202 __contains__ = has_header
204 def items(self):
205 return self.headers.items()
207 def get(self, header, alternate=None):
208 return self.headers.get(header, alternate)
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.
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``.
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
274 def setdefault(self, key, value):
275 """Set a header unless it has already been set."""
276 self.headers.setdefault(key, value)
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)
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 )
300 # Common methods used by subclasses
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.
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)
318 # These methods partially implement the file-like object interface.
319 # See https://docs.python.org/library/io.html#io.IOBase
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)
334 def write(self, content):
335 raise OSError("This %s instance is not writable" % self.__class__.__name__)
337 def flush(self):
338 pass
340 def tell(self):
341 raise OSError(
342 "This %s instance cannot tell its position" % self.__class__.__name__
343 )
345 # These methods partially implement a stream-like object interface.
346 # See https://docs.python.org/library/io.html#io.IOBase
348 def readable(self):
349 return False
351 def seekable(self):
352 return False
354 def writable(self):
355 return False
357 def writelines(self, lines):
358 raise OSError("This %s instance is not writable" % self.__class__.__name__)
361class Response(ResponseBase):
362 """
363 An HTTP response class with a string as content.
365 This content can be read, appended to, or replaced.
366 """
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 )
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
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
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 }
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
403 __bytes__ = serialize
405 @property
406 def content(self):
407 return b"".join(self._container)
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]
426 def __iter__(self):
427 return iter(self._container)
429 def write(self, content):
430 self._container.append(self.make_bytes(content))
432 def tell(self):
433 return len(self.content)
435 def getvalue(self):
436 return self.content
438 def writable(self):
439 return True
441 def writelines(self, lines):
442 for line in lines:
443 self.write(line)
446class StreamingResponse(ResponseBase):
447 """
448 A streaming HTTP response class with an iterator as content.
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 """
455 streaming = True
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
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 }
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 )
477 @property
478 def streaming_content(self):
479 return map(self.make_bytes, self._iterator)
481 @streaming_content.setter
482 def streaming_content(self, value):
483 self._set_streaming_content(value)
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)
491 def __iter__(self):
492 return iter(self.streaming_content)
494 def getvalue(self):
495 return b"".join(self.streaming_content)
498class FileResponse(StreamingResponse):
499 """
500 A streaming HTTP response class optimized for files.
501 """
503 block_size = 4096
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)
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)
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)
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)
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"
574 if content_disposition := content_disposition_header(
575 self.as_attachment, filename
576 ):
577 self.headers["Content-Disposition"] = content_disposition
580class ResponseRedirectBase(Response):
581 allowed_schemes = ["http", "https", "ftp"]
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 )
592 url = property(lambda self: self["Location"])
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 )
606class ResponseRedirect(ResponseRedirectBase):
607 """HTTP 302 response"""
609 status_code = 302
612class ResponsePermanentRedirect(ResponseRedirectBase):
613 """HTTP 301 response"""
615 status_code = 301
618class ResponseNotModified(Response):
619 """HTTP 304 response"""
621 status_code = 304
623 def __init__(self, *args, **kwargs):
624 super().__init__(*args, **kwargs)
625 del self["content-type"]
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 = []
636class ResponseBadRequest(Response):
637 """HTTP 400 response"""
639 status_code = 400
642class ResponseNotFound(Response):
643 """HTTP 404 response"""
645 status_code = 404
648class ResponseForbidden(Response):
649 """HTTP 403 response"""
651 status_code = 403
654class ResponseNotAllowed(Response):
655 """HTTP 405 response"""
657 status_code = 405
659 def __init__(self, permitted_methods, *args, **kwargs):
660 super().__init__(*args, **kwargs)
661 self["Allow"] = ", ".join(permitted_methods)
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 }
672class ResponseGone(Response):
673 """HTTP 410 response"""
675 status_code = 410
678class ResponseServerError(Response):
679 """HTTP 500 response"""
681 status_code = 500
684class Http404(Exception):
685 pass
688class JsonResponse(Response):
689 """
690 An HTTP response class that consumes data to be serialized to JSON.
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 """
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)