Coverage for /home/martinb/.local/share/virtualenvs/camcops/lib/python3.6/site-packages/webob/response.py : 23%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import re
2import struct
3import zlib
4from base64 import b64encode
5from datetime import datetime, timedelta
6from hashlib import md5
8from webob.byterange import ContentRange
9from webob.cachecontrol import CacheControl, serialize_cache_control
10from webob.compat import (
11 PY2,
12 bytes_,
13 native_,
14 string_types,
15 text_type,
16 url_quote,
17 urlparse,
18)
19from webob.cookies import Cookie, make_cookie
20from webob.datetime_utils import (
21 parse_date_delta,
22 serialize_date_delta,
23 timedelta_to_seconds,
24)
25from webob.descriptors import (
26 CHARSET_RE,
27 SCHEME_RE,
28 converter,
29 date_header,
30 header_getter,
31 list_header,
32 parse_auth,
33 parse_content_range,
34 parse_etag_response,
35 parse_int,
36 parse_int_safe,
37 serialize_auth,
38 serialize_content_range,
39 serialize_etag_response,
40 serialize_int,
41)
42from webob.headers import ResponseHeaders
43from webob.request import BaseRequest
44from webob.util import status_generic_reasons, status_reasons, warn_deprecation
46try:
47 import simplejson as json
48except ImportError:
49 import json
51__all__ = ['Response']
53_PARAM_RE = re.compile(r'([a-z0-9]+)=(?:"([^"]*)"|([a-z0-9_.-]*))', re.I)
54_OK_PARAM_RE = re.compile(r'^[a-z0-9_.-]+$', re.I)
56_gzip_header = b'\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff'
58_marker = object()
60class Response(object):
61 """
62 Represents a WSGI response.
64 If no arguments are passed, creates a :class:`~Response` that uses a
65 variety of defaults. The defaults may be changed by sub-classing the
66 :class:`~Response`. See the :ref:`sub-classing notes
67 <response_subclassing_notes>`.
69 :cvar ~Response.body: If ``body`` is a ``text_type``, then it will be
70 encoded using either ``charset`` when provided or ``default_encoding``
71 when ``charset`` is not provided if the ``content_type`` allows for a
72 ``charset``. This argument is mutually exclusive with ``app_iter``.
74 :vartype ~Response.body: bytes or text_type
76 :cvar ~Response.status: Either an :class:`int` or a string that is
77 an integer followed by the status text. If it is an integer, it will be
78 converted to a proper status that also includes the status text. Any
79 existing status text will be kept. Non-standard values are allowed.
81 :vartype ~Response.status: int or str
83 :cvar ~Response.headerlist: A list of HTTP headers for the response.
85 :vartype ~Response.headerlist: list
87 :cvar ~Response.app_iter: An iterator that is used as the body of the
88 response. Should conform to the WSGI requirements and should provide
89 bytes. This argument is mutually exclusive with ``body``.
91 :vartype ~Response.app_iter: iterable
93 :cvar ~Response.content_type: Sets the ``Content-Type`` header. If no
94 ``content_type`` is provided, and there is no ``headerlist``, the
95 ``default_content_type`` will be automatically set. If ``headerlist``
96 is provided then this value is ignored.
98 :vartype ~Response.content_type: str or None
100 :cvar conditional_response: Used to change the behavior of the
101 :class:`~Response` to check the original request for conditional
102 response headers. See :meth:`~Response.conditional_response_app` for
103 more information.
105 :vartype conditional_response: bool
107 :cvar ~Response.charset: Adds a ``charset`` ``Content-Type`` parameter. If
108 no ``charset`` is provided and the ``Content-Type`` is text, then the
109 ``default_charset`` will automatically be added. Currently the only
110 ``Content-Type``'s that allow for a ``charset`` are defined to be
111 ``text/*``, ``application/xml``, and ``*/*+xml``. Any other
112 ``Content-Type``'s will not have a ``charset`` added. If a
113 ``headerlist`` is provided this value is ignored.
115 :vartype ~Response.charset: str or None
117 All other response attributes may be set on the response by providing them
118 as keyword arguments. A :exc:`TypeError` will be raised for any unexpected
119 keywords.
121 .. _response_subclassing_notes:
123 **Sub-classing notes:**
125 * The ``default_content_type`` is used as the default for the
126 ``Content-Type`` header that is returned on the response. It is
127 ``text/html``.
129 * The ``default_charset`` is used as the default character set to return on
130 the ``Content-Type`` header, if the ``Content-Type`` allows for a
131 ``charset`` parameter. Currently the only ``Content-Type``'s that allow
132 for a ``charset`` are defined to be: ``text/*``, ``application/xml``, and
133 ``*/*+xml``. Any other ``Content-Type``'s will not have a ``charset``
134 added.
136 * The ``unicode_errors`` is set to ``strict``, and access on a
137 :attr:`~Response.text` will raise an error if it fails to decode the
138 :attr:`~Response.body`.
140 * ``default_conditional_response`` is set to ``False``. This flag may be
141 set to ``True`` so that all ``Response`` objects will attempt to check
142 the original request for conditional response headers. See
143 :meth:`~Response.conditional_response_app` for more information.
145 * ``default_body_encoding`` is set to 'UTF-8' by default. It exists to
146 allow users to get/set the ``Response`` object using ``.text``, even if
147 no ``charset`` has been set for the ``Content-Type``.
148 """
150 default_content_type = 'text/html'
151 default_charset = 'UTF-8'
152 unicode_errors = 'strict'
153 default_conditional_response = False
154 default_body_encoding = 'UTF-8'
156 # These two are only around so that when people pass them into the
157 # constructor they correctly get saved and set, however they are not used
158 # by any part of the Response. See commit
159 # 627593bbcd4ab52adc7ee569001cdda91c670d5d for rationale.
160 request = None
161 environ = None
163 #
164 # __init__, from_file, copy
165 #
167 def __init__(self, body=None, status=None, headerlist=None, app_iter=None,
168 content_type=None, conditional_response=None, charset=_marker,
169 **kw):
170 # Do some sanity checking, and turn json_body into an actual body
171 if app_iter is None and body is None and ('json_body' in kw or 'json' in kw):
172 if 'json_body' in kw:
173 json_body = kw.pop('json_body')
174 else:
175 json_body = kw.pop('json')
176 body = json.dumps(json_body, separators=(',', ':')).encode('UTF-8')
178 if content_type is None:
179 content_type = 'application/json'
181 if app_iter is None:
182 if body is None:
183 body = b''
184 elif body is not None:
185 raise TypeError(
186 "You may only give one of the body and app_iter arguments")
188 # Set up Response.status
189 if status is None:
190 self._status = '200 OK'
191 else:
192 self.status = status
194 # Initialize headers
195 self._headers = None
196 if headerlist is None:
197 self._headerlist = []
198 else:
199 self._headerlist = headerlist
201 # Set the encoding for the Response to charset, so if a charset is
202 # passed but the Content-Type does not allow for a charset, we can
203 # still encode text_type body's.
204 # r = Response(
205 # content_type='application/foo',
206 # charset='UTF-8',
207 # body=u'somebody')
208 # Should work without issues, and the header will be correctly set to
209 # Content-Type: application/foo with no charset on it.
211 encoding = None
212 if charset is not _marker:
213 encoding = charset
215 # Does the status code have a body or not?
216 code_has_body = (
217 self._status[0] != '1' and
218 self._status[:3] not in ('204', '205', '304')
219 )
221 # We only set the content_type to the one passed to the constructor or
222 # the default content type if there is none that exists AND there was
223 # no headerlist passed. If a headerlist was provided then most likely
224 # the ommission of the Content-Type is on purpose and we shouldn't try
225 # to be smart about it.
226 #
227 # Also allow creation of a empty Response with just the status set to a
228 # Response with empty body, such as Response(status='204 No Content')
229 # without the default content_type being set (since empty bodies have
230 # no Content-Type)
231 #
232 # Check if content_type is set because default_content_type could be
233 # None, in which case there is no content_type, and thus we don't need
234 # to anything
236 content_type = content_type or self.default_content_type
238 if headerlist is None and code_has_body and content_type:
239 # Set up the charset, if the content_type doesn't already have one
241 has_charset = 'charset=' in content_type
243 # If the Content-Type already has a charset, we don't set the user
244 # provided charset on the Content-Type, so we shouldn't use it as
245 # the encoding for text_type based body's.
246 if has_charset:
247 encoding = None
249 # Do not use the default_charset for the encoding because we
250 # want things like
251 # Response(content_type='image/jpeg',body=u'foo') to raise when
252 # trying to encode the body.
254 new_charset = encoding
256 if (
257 not has_charset and
258 charset is _marker and
259 self.default_charset
260 ):
261 new_charset = self.default_charset
263 # Optimize for the default_content_type as shipped by
264 # WebOb, becuase we know that 'text/html' has a charset,
265 # otherwise add a charset if the content_type has a charset.
266 #
267 # Even if the user supplied charset explicitly, we do not add
268 # it to the Content-Type unless it has has a charset, instead
269 # the user supplied charset is solely used for encoding the
270 # body if it is a text_type
272 if (
273 new_charset and
274 (
275 content_type == 'text/html' or
276 _content_type_has_charset(content_type)
277 )
278 ):
279 content_type += '; charset=' + new_charset
281 self._headerlist.append(('Content-Type', content_type))
283 # Set up conditional response
284 if conditional_response is None:
285 self.conditional_response = self.default_conditional_response
286 else:
287 self.conditional_response = bool(conditional_response)
289 # Set up app_iter if the HTTP Status code has a body
290 if app_iter is None and code_has_body:
291 if isinstance(body, text_type):
292 # Fall back to trying self.charset if encoding is not set. In
293 # most cases encoding will be set to the default value.
294 encoding = encoding or self.charset
295 if encoding is None:
296 raise TypeError(
297 "You cannot set the body to a text value without a "
298 "charset")
299 body = body.encode(encoding)
300 app_iter = [body]
302 if headerlist is not None:
303 self._headerlist[:] = [
304 (k, v)
305 for (k, v)
306 in self._headerlist
307 if k.lower() != 'content-length'
308 ]
309 self._headerlist.append(('Content-Length', str(len(body))))
310 elif app_iter is None and not code_has_body:
311 app_iter = [b'']
313 self._app_iter = app_iter
315 # Loop through all the remaining keyword arguments
316 for name, value in kw.items():
317 if not hasattr(self.__class__, name):
318 # Not a basic attribute
319 raise TypeError(
320 "Unexpected keyword: %s=%r" % (name, value))
321 setattr(self, name, value)
323 @classmethod
324 def from_file(cls, fp):
325 """Reads a response from a file-like object (it must implement
326 ``.read(size)`` and ``.readline()``).
328 It will read up to the end of the response, not the end of the
329 file.
331 This reads the response as represented by ``str(resp)``; it
332 may not read every valid HTTP response properly. Responses
333 must have a ``Content-Length``."""
334 headerlist = []
335 status = fp.readline().strip()
336 is_text = isinstance(status, text_type)
338 if is_text:
339 _colon = ':'
340 _http = 'HTTP/'
341 else:
342 _colon = b':'
343 _http = b'HTTP/'
345 if status.startswith(_http):
346 (http_ver, status_num, status_text) = status.split(None, 2)
347 status = '%s %s' % (native_(status_num), native_(status_text))
349 while 1:
350 line = fp.readline().strip()
351 if not line:
352 # end of headers
353 break
354 try:
355 header_name, value = line.split(_colon, 1)
356 except ValueError:
357 raise ValueError('Bad header line: %r' % line)
358 value = value.strip()
359 headerlist.append((
360 native_(header_name, 'latin-1'),
361 native_(value, 'latin-1')
362 ))
363 r = cls(
364 status=status,
365 headerlist=headerlist,
366 app_iter=(),
367 )
368 body = fp.read(r.content_length or 0)
369 if is_text:
370 r.text = body
371 else:
372 r.body = body
373 return r
375 def copy(self):
376 """Makes a copy of the response."""
377 # we need to do this for app_iter to be reusable
378 app_iter = list(self._app_iter)
379 iter_close(self._app_iter)
380 # and this to make sure app_iter instances are different
381 self._app_iter = list(app_iter)
382 return self.__class__(
383 status=self._status,
384 headerlist=self._headerlist[:],
385 app_iter=app_iter,
386 conditional_response=self.conditional_response)
388 #
389 # __repr__, __str__
390 #
392 def __repr__(self):
393 return '<%s at 0x%x %s>' % (self.__class__.__name__, abs(id(self)),
394 self.status)
396 def __str__(self, skip_body=False):
397 parts = [self.status]
398 if not skip_body:
399 # Force enumeration of the body (to set content-length)
400 self.body
401 parts += map('%s: %s'.__mod__, self.headerlist)
402 if not skip_body and self.body:
403 parts += ['', self.body if PY2 else self.text]
404 return '\r\n'.join(parts)
406 #
407 # status, status_code/status_int
408 #
410 def _status__get(self):
411 """
412 The status string.
413 """
414 return self._status
416 def _status__set(self, value):
417 try:
418 code = int(value)
419 except (ValueError, TypeError):
420 pass
421 else:
422 self.status_code = code
423 return
424 if not PY2:
425 if isinstance(value, bytes):
426 value = value.decode('ascii')
427 elif isinstance(value, text_type):
428 value = value.encode('ascii')
429 if not isinstance(value, str):
430 raise TypeError(
431 "You must set status to a string or integer (not %s)"
432 % type(value))
434 # Attempt to get the status code itself, if this fails we should fail
435 try:
436 # We don't need this value anywhere, we just want to validate it's
437 # an integer. So we are using the side-effect of int() raises a
438 # ValueError as a test
439 int(value.split()[0])
440 except ValueError:
441 raise ValueError('Invalid status code, integer required.')
442 self._status = value
444 status = property(_status__get, _status__set, doc=_status__get.__doc__)
446 def _status_code__get(self):
447 """
448 The status as an integer.
449 """
450 return int(self._status.split()[0])
452 def _status_code__set(self, code):
453 try:
454 self._status = '%d %s' % (code, status_reasons[code])
455 except KeyError:
456 self._status = '%d %s' % (code, status_generic_reasons[code // 100])
458 status_code = status_int = property(_status_code__get, _status_code__set,
459 doc=_status_code__get.__doc__)
461 #
462 # headerslist, headers
463 #
465 def _headerlist__get(self):
466 """
467 The list of response headers.
468 """
469 return self._headerlist
471 def _headerlist__set(self, value):
472 self._headers = None
473 if not isinstance(value, list):
474 if hasattr(value, 'items'):
475 value = value.items()
476 value = list(value)
477 self._headerlist = value
479 def _headerlist__del(self):
480 self.headerlist = []
482 headerlist = property(_headerlist__get, _headerlist__set,
483 _headerlist__del, doc=_headerlist__get.__doc__)
485 def _headers__get(self):
486 """
487 The headers in a dictionary-like object.
488 """
489 if self._headers is None:
490 self._headers = ResponseHeaders.view_list(self._headerlist)
491 return self._headers
493 def _headers__set(self, value):
494 if hasattr(value, 'items'):
495 value = value.items()
496 self.headerlist = value
497 self._headers = None
499 headers = property(_headers__get, _headers__set, doc=_headers__get.__doc__)
501 #
502 # body
503 #
505 def _body__get(self):
506 """
507 The body of the response, as a :class:`bytes`. This will read in
508 the entire app_iter if necessary.
509 """
510 app_iter = self._app_iter
511# try:
512# if len(app_iter) == 1:
513# return app_iter[0]
514# except:
515# pass
516 if isinstance(app_iter, list) and len(app_iter) == 1:
517 return app_iter[0]
518 if app_iter is None:
519 raise AttributeError("No body has been set")
520 try:
521 body = b''.join(app_iter)
522 finally:
523 iter_close(app_iter)
524 if isinstance(body, text_type):
525 raise _error_unicode_in_app_iter(app_iter, body)
526 self._app_iter = [body]
527 if len(body) == 0:
528 # if body-length is zero, we assume it's a HEAD response and
529 # leave content_length alone
530 pass
531 elif self.content_length is None:
532 self.content_length = len(body)
533 elif self.content_length != len(body):
534 raise AssertionError(
535 "Content-Length is different from actual app_iter length "
536 "(%r!=%r)"
537 % (self.content_length, len(body))
538 )
539 return body
541 def _body__set(self, value=b''):
542 if not isinstance(value, bytes):
543 if isinstance(value, text_type):
544 msg = ("You cannot set Response.body to a text object "
545 "(use Response.text)")
546 else:
547 msg = ("You can only set the body to a binary type (not %s)" %
548 type(value))
549 raise TypeError(msg)
550 if self._app_iter is not None:
551 self.content_md5 = None
552 self._app_iter = [value]
553 self.content_length = len(value)
555# def _body__del(self):
556# self.body = ''
557# #self.content_length = None
559 body = property(_body__get, _body__set, _body__set)
561 def _json_body__get(self):
562 """
563 Set/get the body of the response as JSON.
565 .. note::
567 This will automatically :meth:`~bytes.decode` the
568 :attr:`~Response.body` as ``UTF-8`` on get, and
569 :meth:`~str.encode` the :meth:`json.dumps` as ``UTF-8``
570 before assigning to :attr:`~Response.body`.
572 """
573 # Note: UTF-8 is a content-type specific default for JSON
574 return json.loads(self.body.decode('UTF-8'))
576 def _json_body__set(self, value):
577 self.body = json.dumps(value, separators=(',', ':')).encode('UTF-8')
579 def _json_body__del(self):
580 del self.body
582 json = json_body = property(_json_body__get, _json_body__set, _json_body__del)
584 def _has_body__get(self):
585 """
586 Determine if the the response has a :attr:`~Response.body`. In
587 contrast to simply accessing :attr:`~Response.body`, this method
588 will **not** read the underlying :attr:`~Response.app_iter`.
589 """
591 app_iter = self._app_iter
593 if isinstance(app_iter, list) and len(app_iter) == 1:
594 if app_iter[0] != b'':
595 return True
596 else:
597 return False
599 if app_iter is None: # pragma: no cover
600 return False
602 return True
604 has_body = property(_has_body__get)
606 #
607 # text, unicode_body, ubody
608 #
610 def _text__get(self):
611 """
612 Get/set the text value of the body using the ``charset`` of the
613 ``Content-Type`` or the ``default_body_encoding``.
614 """
615 if not self.charset and not self.default_body_encoding:
616 raise AttributeError(
617 "You cannot access Response.text unless charset or default_body_encoding"
618 " is set"
619 )
620 decoding = self.charset or self.default_body_encoding
621 body = self.body
622 return body.decode(decoding, self.unicode_errors)
624 def _text__set(self, value):
625 if not self.charset and not self.default_body_encoding:
626 raise AttributeError(
627 "You cannot access Response.text unless charset or default_body_encoding"
628 " is set"
629 )
630 if not isinstance(value, text_type):
631 raise TypeError(
632 "You can only set Response.text to a unicode string "
633 "(not %s)" % type(value))
634 encoding = self.charset or self.default_body_encoding
635 self.body = value.encode(encoding)
637 def _text__del(self):
638 del self.body
640 text = property(_text__get, _text__set, _text__del, doc=_text__get.__doc__)
642 unicode_body = ubody = property(_text__get, _text__set, _text__del,
643 "Deprecated alias for .text")
645 #
646 # body_file, write(text)
647 #
649 def _body_file__get(self):
650 """
651 A file-like object that can be used to write to the
652 body. If you passed in a list ``app_iter``, that ``app_iter`` will be
653 modified by writes.
654 """
655 return ResponseBodyFile(self)
657 def _body_file__set(self, file):
658 self.app_iter = iter_file(file)
660 def _body_file__del(self):
661 del self.body
663 body_file = property(_body_file__get, _body_file__set, _body_file__del,
664 doc=_body_file__get.__doc__)
666 def write(self, text):
667 if not isinstance(text, bytes):
668 if not isinstance(text, text_type):
669 msg = "You can only write str to a Response.body_file, not %s"
670 raise TypeError(msg % type(text))
671 if not self.charset:
672 msg = ("You can only write text to Response if charset has "
673 "been set")
674 raise TypeError(msg)
675 text = text.encode(self.charset)
676 app_iter = self._app_iter
677 if not isinstance(app_iter, list):
678 try:
679 new_app_iter = self._app_iter = list(app_iter)
680 finally:
681 iter_close(app_iter)
682 app_iter = new_app_iter
683 self.content_length = sum(len(chunk) for chunk in app_iter)
684 app_iter.append(text)
685 if self.content_length is not None:
686 self.content_length += len(text)
688 #
689 # app_iter
690 #
692 def _app_iter__get(self):
693 """
694 Returns the ``app_iter`` of the response.
696 If ``body`` was set, this will create an ``app_iter`` from that
697 ``body`` (a single-item list).
698 """
699 return self._app_iter
701 def _app_iter__set(self, value):
702 if self._app_iter is not None:
703 # Undo the automatically-set content-length
704 self.content_length = None
705 self._app_iter = value
707 def _app_iter__del(self):
708 self._app_iter = []
709 self.content_length = None
711 app_iter = property(_app_iter__get, _app_iter__set, _app_iter__del,
712 doc=_app_iter__get.__doc__)
714 #
715 # headers attrs
716 #
718 allow = list_header('Allow', '14.7')
719 # TODO: (maybe) support response.vary += 'something'
720 # TODO: same thing for all listy headers
721 vary = list_header('Vary', '14.44')
723 content_length = converter(
724 header_getter('Content-Length', '14.17'),
725 parse_int, serialize_int, 'int')
727 content_encoding = header_getter('Content-Encoding', '14.11')
728 content_language = list_header('Content-Language', '14.12')
729 content_location = header_getter('Content-Location', '14.14')
730 content_md5 = header_getter('Content-MD5', '14.14')
731 content_disposition = header_getter('Content-Disposition', '19.5.1')
733 accept_ranges = header_getter('Accept-Ranges', '14.5')
734 content_range = converter(
735 header_getter('Content-Range', '14.16'),
736 parse_content_range, serialize_content_range, 'ContentRange object')
738 date = date_header('Date', '14.18')
739 expires = date_header('Expires', '14.21')
740 last_modified = date_header('Last-Modified', '14.29')
742 _etag_raw = header_getter('ETag', '14.19')
743 etag = converter(
744 _etag_raw,
745 parse_etag_response, serialize_etag_response,
746 'Entity tag'
747 )
748 @property
749 def etag_strong(self):
750 return parse_etag_response(self._etag_raw, strong=True)
752 location = header_getter('Location', '14.30')
753 pragma = header_getter('Pragma', '14.32')
754 age = converter(
755 header_getter('Age', '14.6'),
756 parse_int_safe, serialize_int, 'int')
758 retry_after = converter(
759 header_getter('Retry-After', '14.37'),
760 parse_date_delta, serialize_date_delta, 'HTTP date or delta seconds')
762 server = header_getter('Server', '14.38')
764 # TODO: the standard allows this to be a list of challenges
765 www_authenticate = converter(
766 header_getter('WWW-Authenticate', '14.47'),
767 parse_auth, serialize_auth,
768 )
770 #
771 # charset
772 #
774 def _charset__get(self):
775 """
776 Get/set the ``charset`` specified in ``Content-Type``.
778 There is no checking to validate that a ``content_type`` actually
779 allows for a ``charset`` parameter.
780 """
781 header = self.headers.get('Content-Type')
782 if not header:
783 return None
784 match = CHARSET_RE.search(header)
785 if match:
786 return match.group(1)
787 return None
789 def _charset__set(self, charset):
790 if charset is None:
791 self._charset__del()
792 return
793 header = self.headers.get('Content-Type', None)
794 if header is None:
795 raise AttributeError("You cannot set the charset when no "
796 "content-type is defined")
797 match = CHARSET_RE.search(header)
798 if match:
799 header = header[:match.start()] + header[match.end():]
800 header += '; charset=%s' % charset
801 self.headers['Content-Type'] = header
803 def _charset__del(self):
804 header = self.headers.pop('Content-Type', None)
805 if header is None:
806 # Don't need to remove anything
807 return
808 match = CHARSET_RE.search(header)
809 if match:
810 header = header[:match.start()] + header[match.end():]
811 self.headers['Content-Type'] = header
813 charset = property(_charset__get, _charset__set, _charset__del,
814 doc=_charset__get.__doc__)
816 #
817 # content_type
818 #
820 def _content_type__get(self):
821 """
822 Get/set the ``Content-Type`` header. If no ``Content-Type`` header is
823 set, this will return ``None``.
825 .. versionchanged:: 1.7
827 Setting a new ``Content-Type`` will remove all ``Content-Type``
828 parameters and reset the ``charset`` to the default if the
829 ``Content-Type`` is ``text/*`` or XML (``application/xml`` or
830 ``*/*+xml``).
832 To preserve all ``Content-Type`` parameters, you may use the
833 following code:
835 .. code-block:: python
837 resp = Response()
838 params = resp.content_type_params
839 resp.content_type = 'application/something'
840 resp.content_type_params = params
841 """
842 header = self.headers.get('Content-Type')
843 if not header:
844 return None
845 return header.split(';', 1)[0]
847 def _content_type__set(self, value):
848 if not value:
849 self._content_type__del()
850 return
851 else:
852 if PY2 and isinstance(value, text_type):
853 value = value.encode("latin-1")
855 if not isinstance(value, string_types):
856 raise TypeError("content_type requires value to be of string_types")
858 content_type = value
860 # Set up the charset if the content-type doesn't have one
862 has_charset = 'charset=' in content_type
864 new_charset = None
866 if (
867 not has_charset and
868 self.default_charset
869 ):
870 new_charset = self.default_charset
872 # Optimize for the default_content_type as shipped by
873 # WebOb, becuase we know that 'text/html' has a charset,
874 # otherwise add a charset if the content_type has a charset.
875 #
876 # We add the default charset if the content-type is "texty".
877 if (
878 new_charset and
879 (
880 content_type == 'text/html' or
881 _content_type_has_charset(content_type)
882 )
883 ):
884 content_type += '; charset=' + new_charset
886 self.headers['Content-Type'] = content_type
888 def _content_type__del(self):
889 self.headers.pop('Content-Type', None)
891 content_type = property(_content_type__get, _content_type__set,
892 _content_type__del, doc=_content_type__get.__doc__)
894 #
895 # content_type_params
896 #
898 def _content_type_params__get(self):
899 """
900 A dictionary of all the parameters in the content type.
902 (This is not a view, set to change, modifications of the dict will not
903 be applied otherwise.)
904 """
905 params = self.headers.get('Content-Type', '')
906 if ';' not in params:
907 return {}
908 params = params.split(';', 1)[1]
909 result = {}
910 for match in _PARAM_RE.finditer(params):
911 result[match.group(1)] = match.group(2) or match.group(3) or ''
912 return result
914 def _content_type_params__set(self, value_dict):
915 if not value_dict:
916 self._content_type_params__del()
917 return
919 params = []
920 for k, v in sorted(value_dict.items()):
921 if not _OK_PARAM_RE.search(v):
922 v = '"%s"' % v.replace('"', '\\"')
923 params.append('; %s=%s' % (k, v))
924 ct = self.headers.pop('Content-Type', '').split(';', 1)[0]
925 ct += ''.join(params)
926 self.headers['Content-Type'] = ct
928 def _content_type_params__del(self):
929 self.headers['Content-Type'] = self.headers.get(
930 'Content-Type', '').split(';', 1)[0]
932 content_type_params = property(
933 _content_type_params__get,
934 _content_type_params__set,
935 _content_type_params__del,
936 _content_type_params__get.__doc__
937 )
939 #
940 # set_cookie, unset_cookie, delete_cookie, merge_cookies
941 #
943 def set_cookie(self, name, value='', max_age=None,
944 path='/', domain=None, secure=False, httponly=False,
945 comment=None, expires=None, overwrite=False,
946 samesite=None):
947 """
948 Set (add) a cookie for the response.
950 Arguments are:
952 ``name``
954 The cookie name.
956 ``value``
958 The cookie value, which should be a string or ``None``. If
959 ``value`` is ``None``, it's equivalent to calling the
960 :meth:`webob.response.Response.unset_cookie` method for this
961 cookie key (it effectively deletes the cookie on the client).
963 ``max_age``
965 An integer representing a number of seconds, ``datetime.timedelta``,
966 or ``None``. This value is used as the ``Max-Age`` of the generated
967 cookie. If ``expires`` is not passed and this value is not
968 ``None``, the ``max_age`` value will also influence the ``Expires``
969 value of the cookie (``Expires`` will be set to ``now`` +
970 ``max_age``). If this value is ``None``, the cookie will not have a
971 ``Max-Age`` value (unless ``expires`` is set). If both ``max_age``
972 and ``expires`` are set, this value takes precedence.
974 ``path``
976 A string representing the cookie ``Path`` value. It defaults to
977 ``/``.
979 ``domain``
981 A string representing the cookie ``Domain``, or ``None``. If
982 domain is ``None``, no ``Domain`` value will be sent in the
983 cookie.
985 ``secure``
987 A boolean. If it's ``True``, the ``secure`` flag will be sent in
988 the cookie, if it's ``False``, the ``secure`` flag will not be
989 sent in the cookie.
991 ``httponly``
993 A boolean. If it's ``True``, the ``HttpOnly`` flag will be sent
994 in the cookie, if it's ``False``, the ``HttpOnly`` flag will not
995 be sent in the cookie.
997 ``samesite``
999 A string representing the ``SameSite`` attribute of the cookie or
1000 ``None``. If samesite is ``None`` no ``SameSite`` value will be sent
1001 in the cookie. Should only be ``"strict"``, ``"lax"``, or ``"none"``.
1003 ``comment``
1005 A string representing the cookie ``Comment`` value, or ``None``.
1006 If ``comment`` is ``None``, no ``Comment`` value will be sent in
1007 the cookie.
1009 ``expires``
1011 A ``datetime.timedelta`` object representing an amount of time,
1012 ``datetime.datetime`` or ``None``. A non-``None`` value is used to
1013 generate the ``Expires`` value of the generated cookie. If
1014 ``max_age`` is not passed, but this value is not ``None``, it will
1015 influence the ``Max-Age`` header. If this value is ``None``, the
1016 ``Expires`` cookie value will be unset (unless ``max_age`` is set).
1017 If ``max_age`` is set, it will be used to generate the ``expires``
1018 and this value is ignored.
1020 If a ``datetime.datetime`` is provided it has to either be timezone
1021 aware or be based on UTC. ``datetime.datetime`` objects that are
1022 local time are not supported. Timezone aware ``datetime.datetime``
1023 objects are converted to UTC.
1025 This argument will be removed in future versions of WebOb (version
1026 1.9).
1028 ``overwrite``
1030 If this key is ``True``, before setting the cookie, unset any
1031 existing cookie.
1033 """
1035 # Remove in WebOb 1.10
1036 if expires:
1037 warn_deprecation('Argument "expires" will be removed in a future '
1038 'version of WebOb, please use "max_age".', 1.10, 1)
1040 if overwrite:
1041 self.unset_cookie(name, strict=False)
1043 # If expires is set, but not max_age we set max_age to expires
1044 if not max_age and isinstance(expires, timedelta):
1045 max_age = expires
1047 # expires can also be a datetime
1048 if not max_age and isinstance(expires, datetime):
1050 # If expires has a timezone attached, convert it to UTC
1051 if expires.tzinfo and expires.utcoffset():
1052 expires = (expires - expires.utcoffset()).replace(tzinfo=None)
1054 max_age = expires - datetime.utcnow()
1056 value = bytes_(value, 'utf-8')
1058 cookie = make_cookie(name, value, max_age=max_age, path=path,
1059 domain=domain, secure=secure, httponly=httponly,
1060 comment=comment, samesite=samesite)
1061 self.headerlist.append(('Set-Cookie', cookie))
1063 def delete_cookie(self, name, path='/', domain=None):
1064 """
1065 Delete a cookie from the client. Note that ``path`` and ``domain``
1066 must match how the cookie was originally set.
1068 This sets the cookie to the empty string, and ``max_age=0`` so
1069 that it should expire immediately.
1070 """
1071 self.set_cookie(name, None, path=path, domain=domain)
1073 def unset_cookie(self, name, strict=True):
1074 """
1075 Unset a cookie with the given name (remove it from the response).
1076 """
1077 existing = self.headers.getall('Set-Cookie')
1078 if not existing and not strict:
1079 return
1080 cookies = Cookie()
1081 for header in existing:
1082 cookies.load(header)
1083 if isinstance(name, text_type):
1084 name = name.encode('utf8')
1085 if name in cookies:
1086 del cookies[name]
1087 del self.headers['Set-Cookie']
1088 for m in cookies.values():
1089 self.headerlist.append(('Set-Cookie', m.serialize()))
1090 elif strict:
1091 raise KeyError("No cookie has been set with the name %r" % name)
1093 def merge_cookies(self, resp):
1094 """Merge the cookies that were set on this response with the
1095 given ``resp`` object (which can be any WSGI application).
1097 If the ``resp`` is a :class:`webob.Response` object, then the
1098 other object will be modified in-place.
1099 """
1100 if not self.headers.get('Set-Cookie'):
1101 return resp
1102 if isinstance(resp, Response):
1103 for header in self.headers.getall('Set-Cookie'):
1104 resp.headers.add('Set-Cookie', header)
1105 return resp
1106 else:
1107 c_headers = [h for h in self.headerlist if
1108 h[0].lower() == 'set-cookie']
1109 def repl_app(environ, start_response):
1110 def repl_start_response(status, headers, exc_info=None):
1111 return start_response(status, headers + c_headers,
1112 exc_info=exc_info)
1113 return resp(environ, repl_start_response)
1114 return repl_app
1116 #
1117 # cache_control
1118 #
1120 _cache_control_obj = None
1122 def _cache_control__get(self):
1123 """
1124 Get/set/modify the Cache-Control header (`HTTP spec section 14.9
1125 <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9>`_).
1126 """
1127 value = self.headers.get('cache-control', '')
1128 if self._cache_control_obj is None:
1129 self._cache_control_obj = CacheControl.parse(
1130 value, updates_to=self._update_cache_control, type='response')
1131 self._cache_control_obj.header_value = value
1132 if self._cache_control_obj.header_value != value:
1133 new_obj = CacheControl.parse(value, type='response')
1134 self._cache_control_obj.properties.clear()
1135 self._cache_control_obj.properties.update(new_obj.properties)
1136 self._cache_control_obj.header_value = value
1137 return self._cache_control_obj
1139 def _cache_control__set(self, value):
1140 # This actually becomes a copy
1141 if not value:
1142 value = ""
1143 if isinstance(value, dict):
1144 value = CacheControl(value, 'response')
1145 if isinstance(value, text_type):
1146 value = str(value)
1147 if isinstance(value, str):
1148 if self._cache_control_obj is None:
1149 self.headers['Cache-Control'] = value
1150 return
1151 value = CacheControl.parse(value, 'response')
1152 cache = self.cache_control
1153 cache.properties.clear()
1154 cache.properties.update(value.properties)
1156 def _cache_control__del(self):
1157 self.cache_control = {}
1159 def _update_cache_control(self, prop_dict):
1160 value = serialize_cache_control(prop_dict)
1161 if not value:
1162 if 'Cache-Control' in self.headers:
1163 del self.headers['Cache-Control']
1164 else:
1165 self.headers['Cache-Control'] = value
1167 cache_control = property(
1168 _cache_control__get, _cache_control__set,
1169 _cache_control__del, doc=_cache_control__get.__doc__)
1171 #
1172 # cache_expires
1173 #
1175 def _cache_expires(self, seconds=0, **kw):
1176 """
1177 Set expiration on this request. This sets the response to
1178 expire in the given seconds, and any other attributes are used
1179 for ``cache_control`` (e.g., ``private=True``).
1180 """
1181 if seconds is True:
1182 seconds = 0
1183 elif isinstance(seconds, timedelta):
1184 seconds = timedelta_to_seconds(seconds)
1185 cache_control = self.cache_control
1186 if seconds is None:
1187 pass
1188 elif not seconds:
1189 # To really expire something, you have to force a
1190 # bunch of these cache control attributes, and IE may
1191 # not pay attention to those still so we also set
1192 # Expires.
1193 cache_control.no_store = True
1194 cache_control.no_cache = True
1195 cache_control.must_revalidate = True
1196 cache_control.max_age = 0
1197 cache_control.post_check = 0
1198 cache_control.pre_check = 0
1199 self.expires = datetime.utcnow()
1200 if 'last-modified' not in self.headers:
1201 self.last_modified = datetime.utcnow()
1202 self.pragma = 'no-cache'
1203 else:
1204 cache_control.properties.clear()
1205 cache_control.max_age = seconds
1206 self.expires = datetime.utcnow() + timedelta(seconds=seconds)
1207 self.pragma = None
1208 for name, value in kw.items():
1209 setattr(cache_control, name, value)
1211 cache_expires = property(lambda self: self._cache_expires, _cache_expires)
1213 #
1214 # encode_content, decode_content, md5_etag
1215 #
1217 def encode_content(self, encoding='gzip', lazy=False):
1218 """
1219 Encode the content with the given encoding (only ``gzip`` and
1220 ``identity`` are supported).
1221 """
1222 assert encoding in ('identity', 'gzip'), \
1223 "Unknown encoding: %r" % encoding
1224 if encoding == 'identity':
1225 self.decode_content()
1226 return
1227 if self.content_encoding == 'gzip':
1228 return
1229 if lazy:
1230 self.app_iter = gzip_app_iter(self._app_iter)
1231 self.content_length = None
1232 else:
1233 self.app_iter = list(gzip_app_iter(self._app_iter))
1234 self.content_length = sum(map(len, self._app_iter))
1235 self.content_encoding = 'gzip'
1237 def decode_content(self):
1238 content_encoding = self.content_encoding or 'identity'
1239 if content_encoding == 'identity':
1240 return
1241 if content_encoding not in ('gzip', 'deflate'):
1242 raise ValueError(
1243 "I don't know how to decode the content %s" % content_encoding)
1244 if content_encoding == 'gzip':
1245 from gzip import GzipFile
1246 from io import BytesIO
1247 gzip_f = GzipFile(filename='', mode='r', fileobj=BytesIO(self.body))
1248 self.body = gzip_f.read()
1249 self.content_encoding = None
1250 gzip_f.close()
1251 else:
1252 # Weird feature: http://bugs.python.org/issue5784
1253 self.body = zlib.decompress(self.body, -15)
1254 self.content_encoding = None
1256 def md5_etag(self, body=None, set_content_md5=False):
1257 """
1258 Generate an etag for the response object using an MD5 hash of
1259 the body (the ``body`` parameter, or ``self.body`` if not given).
1261 Sets ``self.etag``.
1263 If ``set_content_md5`` is ``True``, sets ``self.content_md5`` as well.
1264 """
1265 if body is None:
1266 body = self.body
1267 md5_digest = md5(body).digest()
1268 md5_digest = b64encode(md5_digest)
1269 md5_digest = md5_digest.replace(b'\n', b'')
1270 md5_digest = native_(md5_digest)
1271 self.etag = md5_digest.strip('=')
1272 if set_content_md5:
1273 self.content_md5 = md5_digest
1275 @staticmethod
1276 def _make_location_absolute(environ, value):
1277 if SCHEME_RE.search(value):
1278 return value
1280 new_location = urlparse.urljoin(_request_uri(environ), value)
1281 return new_location
1283 def _abs_headerlist(self, environ):
1284 # Build the headerlist, if we have a Location header, make it absolute
1285 return [
1286 (k, v) if k.lower() != 'location'
1287 else (k, self._make_location_absolute(environ, v))
1288 for (k, v)
1289 in self._headerlist
1290 ]
1292 #
1293 # __call__, conditional_response_app
1294 #
1296 def __call__(self, environ, start_response):
1297 """
1298 WSGI application interface
1299 """
1300 if self.conditional_response:
1301 return self.conditional_response_app(environ, start_response)
1303 headerlist = self._abs_headerlist(environ)
1305 start_response(self.status, headerlist)
1306 if environ['REQUEST_METHOD'] == 'HEAD':
1307 # Special case here...
1308 return EmptyResponse(self._app_iter)
1309 return self._app_iter
1311 _safe_methods = ('GET', 'HEAD')
1313 def conditional_response_app(self, environ, start_response):
1314 """
1315 Like the normal ``__call__`` interface, but checks conditional headers:
1317 * ``If-Modified-Since`` (``304 Not Modified``; only on ``GET``,
1318 ``HEAD``)
1319 * ``If-None-Match`` (``304 Not Modified``; only on ``GET``,
1320 ``HEAD``)
1321 * ``Range`` (``406 Partial Content``; only on ``GET``,
1322 ``HEAD``)
1323 """
1324 req = BaseRequest(environ)
1326 headerlist = self._abs_headerlist(environ)
1328 method = environ.get('REQUEST_METHOD', 'GET')
1329 if method in self._safe_methods:
1330 status304 = False
1331 if req.if_none_match and self.etag:
1332 status304 = self.etag in req.if_none_match
1333 elif req.if_modified_since and self.last_modified:
1334 status304 = self.last_modified <= req.if_modified_since
1335 if status304:
1336 start_response('304 Not Modified', filter_headers(headerlist))
1337 return EmptyResponse(self._app_iter)
1338 if (
1339 req.range and self in req.if_range and
1340 self.content_range is None and
1341 method in ('HEAD', 'GET') and
1342 self.status_code == 200 and
1343 self.content_length is not None
1344 ):
1345 content_range = req.range.content_range(self.content_length)
1346 if content_range is None:
1347 iter_close(self._app_iter)
1348 body = bytes_("Requested range not satisfiable: %s" % req.range)
1349 headerlist = [
1350 ('Content-Length', str(len(body))),
1351 ('Content-Range', str(ContentRange(None, None,
1352 self.content_length))),
1353 ('Content-Type', 'text/plain'),
1354 ] + filter_headers(headerlist)
1355 start_response('416 Requested Range Not Satisfiable',
1356 headerlist)
1357 if method == 'HEAD':
1358 return ()
1359 return [body]
1360 else:
1361 app_iter = self.app_iter_range(content_range.start,
1362 content_range.stop)
1363 if app_iter is not None:
1364 # the following should be guaranteed by
1365 # Range.range_for_length(length)
1366 assert content_range.start is not None
1367 headerlist = [
1368 ('Content-Length',
1369 str(content_range.stop - content_range.start)),
1370 ('Content-Range', str(content_range)),
1371 ] + filter_headers(headerlist, ('content-length',))
1372 start_response('206 Partial Content', headerlist)
1373 if method == 'HEAD':
1374 return EmptyResponse(app_iter)
1375 return app_iter
1377 start_response(self.status, headerlist)
1378 if method == 'HEAD':
1379 return EmptyResponse(self._app_iter)
1380 return self._app_iter
1382 def app_iter_range(self, start, stop):
1383 """
1384 Return a new ``app_iter`` built from the response ``app_iter``, that
1385 serves up only the given ``start:stop`` range.
1386 """
1387 app_iter = self._app_iter
1388 if hasattr(app_iter, 'app_iter_range'):
1389 return app_iter.app_iter_range(start, stop)
1390 return AppIterRange(app_iter, start, stop)
1393def filter_headers(hlist, remove_headers=('content-length', 'content-type')):
1394 return [h for h in hlist if (h[0].lower() not in remove_headers)]
1397def iter_file(file, block_size=1 << 18): # 256Kb
1398 while True:
1399 data = file.read(block_size)
1400 if not data:
1401 break
1402 yield data
1404class ResponseBodyFile(object):
1405 mode = 'wb'
1406 closed = False
1408 def __init__(self, response):
1409 """
1410 Represents a :class:`~Response` as a file like object.
1411 """
1412 self.response = response
1413 self.write = response.write
1415 def __repr__(self):
1416 return '<body_file for %r>' % self.response
1418 encoding = property(
1419 lambda self: self.response.charset,
1420 doc="The encoding of the file (inherited from response.charset)"
1421 )
1423 def writelines(self, seq):
1424 """
1425 Write a sequence of lines to the response.
1426 """
1427 for item in seq:
1428 self.write(item)
1430 def close(self):
1431 raise NotImplementedError("Response bodies cannot be closed")
1433 def flush(self):
1434 pass
1436 def tell(self):
1437 """
1438 Provide the current location where we are going to start writing.
1439 """
1440 if not self.response.has_body:
1441 return 0
1443 return sum([len(chunk) for chunk in self.response.app_iter])
1446class AppIterRange(object):
1447 """
1448 Wraps an ``app_iter``, returning just a range of bytes.
1449 """
1451 def __init__(self, app_iter, start, stop):
1452 assert start >= 0, "Bad start: %r" % start
1453 assert stop is None or (stop >= 0 and stop >= start), (
1454 "Bad stop: %r" % stop)
1455 self.app_iter = iter(app_iter)
1456 self._pos = 0 # position in app_iter
1457 self.start = start
1458 self.stop = stop
1460 def __iter__(self):
1461 return self
1463 def _skip_start(self):
1464 start, stop = self.start, self.stop
1465 for chunk in self.app_iter:
1466 self._pos += len(chunk)
1467 if self._pos < start:
1468 continue
1469 elif self._pos == start:
1470 return b''
1471 else:
1472 chunk = chunk[start - self._pos:]
1473 if stop is not None and self._pos > stop:
1474 chunk = chunk[:stop - self._pos]
1475 assert len(chunk) == stop - start
1476 return chunk
1477 else:
1478 raise StopIteration()
1480 def next(self):
1481 if self._pos < self.start:
1482 # need to skip some leading bytes
1483 return self._skip_start()
1484 stop = self.stop
1485 if stop is not None and self._pos >= stop:
1486 raise StopIteration
1488 chunk = next(self.app_iter)
1489 self._pos += len(chunk)
1491 if stop is None or self._pos <= stop:
1492 return chunk
1493 else:
1494 return chunk[:stop - self._pos]
1496 __next__ = next # py3
1498 def close(self):
1499 iter_close(self.app_iter)
1502class EmptyResponse(object):
1503 """
1504 An empty WSGI response.
1506 An iterator that immediately stops. Optionally provides a close
1507 method to close an underlying ``app_iter`` it replaces.
1508 """
1510 def __init__(self, app_iter=None):
1511 if app_iter is not None and hasattr(app_iter, 'close'):
1512 self.close = app_iter.close
1514 def __iter__(self):
1515 return self
1517 def __len__(self):
1518 return 0
1520 def next(self):
1521 raise StopIteration()
1523 __next__ = next # py3
1525def _is_xml(content_type):
1526 return (
1527 content_type.startswith('application/xml') or
1528 (
1529 content_type.startswith('application/') and
1530 content_type.endswith('+xml')
1531 ) or
1532 (
1533 content_type.startswith('image/') and
1534 content_type.endswith('+xml')
1535 )
1536 )
1538def _content_type_has_charset(content_type):
1539 return (
1540 content_type.startswith('text/') or
1541 _is_xml(content_type)
1542 )
1544def _request_uri(environ):
1545 """Like ``wsgiref.url.request_uri``, except eliminates ``:80`` ports.
1547 Returns the full request URI."""
1548 url = environ['wsgi.url_scheme'] + '://'
1550 if environ.get('HTTP_HOST'):
1551 url += environ['HTTP_HOST']
1552 else:
1553 url += environ['SERVER_NAME'] + ':' + environ['SERVER_PORT']
1554 if url.endswith(':80') and environ['wsgi.url_scheme'] == 'http':
1555 url = url[:-3]
1556 elif url.endswith(':443') and environ['wsgi.url_scheme'] == 'https':
1557 url = url[:-4]
1559 if PY2:
1560 script_name = environ.get('SCRIPT_NAME', '/')
1561 path_info = environ.get('PATH_INFO', '')
1562 else:
1563 script_name = bytes_(environ.get('SCRIPT_NAME', '/'), 'latin-1')
1564 path_info = bytes_(environ.get('PATH_INFO', ''), 'latin-1')
1566 url += url_quote(script_name)
1567 qpath_info = url_quote(path_info)
1568 if 'SCRIPT_NAME' not in environ:
1569 url += qpath_info[1:]
1570 else:
1571 url += qpath_info
1572 return url
1575def iter_close(iter):
1576 if hasattr(iter, 'close'):
1577 iter.close()
1579def gzip_app_iter(app_iter):
1580 size = 0
1581 crc = zlib.crc32(b"") & 0xffffffff
1582 compress = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS,
1583 zlib.DEF_MEM_LEVEL, 0)
1585 yield _gzip_header
1586 for item in app_iter:
1587 size += len(item)
1588 crc = zlib.crc32(item, crc) & 0xffffffff
1590 # The compress function may return zero length bytes if the input is
1591 # small enough; it buffers the input for the next iteration or for a
1592 # flush.
1593 result = compress.compress(item)
1594 if result:
1595 yield result
1597 # Similarly, flush may also not yield a value.
1598 result = compress.flush()
1599 if result:
1600 yield result
1601 yield struct.pack("<2L", crc, size & 0xffffffff)
1603def _error_unicode_in_app_iter(app_iter, body):
1604 app_iter_repr = repr(app_iter)
1605 if len(app_iter_repr) > 50:
1606 app_iter_repr = (
1607 app_iter_repr[:30] + '...' + app_iter_repr[-10:])
1608 raise TypeError(
1609 'An item of the app_iter (%s) was text, causing a '
1610 'text body: %r' % (app_iter_repr, body))