/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to breezy/transport/http/response.py

  • Committer: Jelmer Vernooij
  • Date: 2018-11-16 11:42:27 UTC
  • mto: (7143.16.20 even-more-cleanups)
  • mto: This revision was merged to the branch mainline in revision 7175.
  • Revision ID: jelmer@jelmer.uk-20181116114227-lwabsodakoymo3ew
Remove flake8 issues now fixed.

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2006-2011 Canonical Ltd
 
2
#
 
3
# This program is free software; you can redistribute it and/or modify
 
4
# it under the terms of the GNU General Public License as published by
 
5
# the Free Software Foundation; either version 2 of the License, or
 
6
# (at your option) any later version.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program; if not, write to the Free Software
 
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
16
 
 
17
"""Handlers for HTTP Responses.
 
18
 
 
19
The purpose of these classes is to provide a uniform interface for clients
 
20
to standard HTTP responses, single range responses and multipart range
 
21
responses.
 
22
"""
 
23
 
 
24
from __future__ import absolute_import
 
25
 
 
26
import os
 
27
try:
 
28
    import http.client as http_client
 
29
except ImportError:  # python < 3
 
30
    import httplib as http_client
 
31
try:
 
32
    import email.utils as email_utils
 
33
except ImportError:  # python < 3
 
34
    import rfc822 as email_utils
 
35
 
 
36
from ... import (
 
37
    errors,
 
38
    osutils,
 
39
    )
 
40
from ...sixish import (
 
41
    BytesIO,
 
42
    PY3,
 
43
    )
 
44
 
 
45
 
 
46
class ResponseFile(object):
 
47
    """A wrapper around the http socket containing the result of a GET request.
 
48
 
 
49
    Only read() and seek() (forward) are supported.
 
50
 
 
51
    """
 
52
 
 
53
    def __init__(self, path, infile):
 
54
        """Constructor.
 
55
 
 
56
        :param path: File url, for error reports.
 
57
 
 
58
        :param infile: File-like socket set at body start.
 
59
        """
 
60
        self._path = path
 
61
        self._file = infile
 
62
        self._pos = 0
 
63
 
 
64
    def close(self):
 
65
        """Close this file.
 
66
 
 
67
        Dummy implementation for consistency with the 'file' API.
 
68
        """
 
69
 
 
70
    def __enter__(self):
 
71
        return self
 
72
 
 
73
    def __exit__(self, exc_type, exc_val, exc_tb):
 
74
        return False  # propogate exceptions.
 
75
 
 
76
    def read(self, size=None):
 
77
        """Read size bytes from the current position in the file.
 
78
 
 
79
        :param size:  The number of bytes to read.  Leave unspecified or pass
 
80
            -1 to read to EOF.
 
81
        """
 
82
        if size is None and not PY3:
 
83
            size = -1
 
84
        data = self._file.read(size)
 
85
        self._pos += len(data)
 
86
        return data
 
87
 
 
88
    def readline(self):
 
89
        data = self._file.readline()
 
90
        self._pos += len(data)
 
91
        return data
 
92
 
 
93
    def __iter__(self):
 
94
        while True:
 
95
            line = self.readline()
 
96
            if not line:
 
97
                return
 
98
            yield line
 
99
 
 
100
    def tell(self):
 
101
        return self._pos
 
102
 
 
103
    def seek(self, offset, whence=os.SEEK_SET):
 
104
        if whence == os.SEEK_SET:
 
105
            if offset < self._pos:
 
106
                raise AssertionError(
 
107
                    "Can't seek backwards, pos: %s, offset: %s"
 
108
                    % (self._pos, offset))
 
109
            to_discard = offset - self._pos
 
110
        elif whence == os.SEEK_CUR:
 
111
            to_discard = offset
 
112
        else:
 
113
            raise AssertionError("Can't seek backwards")
 
114
        if to_discard:
 
115
            # Just discard the unwanted bytes
 
116
            self.read(to_discard)
 
117
 
 
118
# A RangeFile expects the following grammar (simplified to outline the
 
119
# assumptions we rely upon).
 
120
 
 
121
# file: single_range
 
122
#     | multiple_range
 
123
 
 
124
# single_range: content_range_header data
 
125
 
 
126
# multiple_range: boundary_header boundary (content_range_header data boundary)+
 
127
 
 
128
 
 
129
class RangeFile(ResponseFile):
 
130
    """File-like object that allow access to partial available data.
 
131
 
 
132
    All accesses should happen sequentially since the acquisition occurs during
 
133
    an http response reception (as sockets can't be seeked, we simulate the
 
134
    seek by just reading and discarding the data).
 
135
 
 
136
    The access pattern is defined by a set of ranges discovered as reading
 
137
    progress. Only one range is available at a given time, so all accesses
 
138
    should happen with monotonically increasing offsets.
 
139
    """
 
140
 
 
141
    # in _checked_read() below, we may have to discard several MB in the worst
 
142
    # case. To avoid buffering that much, we read and discard by chunks
 
143
    # instead. The underlying file is either a socket or a BytesIO, so reading
 
144
    # 8k chunks should be fine.
 
145
    _discarded_buf_size = 8192
 
146
 
 
147
    # maximum size of read requests -- used to avoid MemoryError issues in recv
 
148
    _max_read_size = 512 * 1024
 
149
 
 
150
    def __init__(self, path, infile):
 
151
        """Constructor.
 
152
 
 
153
        :param path: File url, for error reports.
 
154
 
 
155
        :param infile: File-like socket set at body start.
 
156
        """
 
157
        super(RangeFile, self).__init__(path, infile)
 
158
        self._boundary = None
 
159
        # When using multi parts response, this will be set with the headers
 
160
        # associated with the range currently read.
 
161
        self._headers = None
 
162
        # Default to the whole file of unspecified size
 
163
        self.set_range(0, -1)
 
164
 
 
165
    def set_range(self, start, size):
 
166
        """Change the range mapping"""
 
167
        self._start = start
 
168
        self._size = size
 
169
        # Set the new _pos since that's what we want to expose
 
170
        self._pos = self._start
 
171
 
 
172
    def set_boundary(self, boundary):
 
173
        """Define the boundary used in a multi parts message.
 
174
 
 
175
        The file should be at the beginning of the body, the first range
 
176
        definition is read and taken into account.
 
177
        """
 
178
        if not isinstance(boundary, bytes):
 
179
            raise TypeError(boundary)
 
180
        self._boundary = boundary
 
181
        # Decode the headers and setup the first range
 
182
        self.read_boundary()
 
183
        self.read_range_definition()
 
184
 
 
185
    def read_boundary(self):
 
186
        """Read the boundary headers defining a new range"""
 
187
        boundary_line = b'\r\n'
 
188
        while boundary_line == b'\r\n':
 
189
            # RFC2616 19.2 Additional CRLFs may precede the first boundary
 
190
            # string entity.
 
191
            # To be on the safe side we allow it before any boundary line
 
192
            boundary_line = self._file.readline()
 
193
 
 
194
        if boundary_line == b'':
 
195
            # A timeout in the proxy server caused the response to end early.
 
196
            # See launchpad bug 198646.
 
197
            raise errors.HttpBoundaryMissing(
 
198
                self._path,
 
199
                self._boundary)
 
200
 
 
201
        if boundary_line != b'--' + self._boundary + b'\r\n':
 
202
            # email_utils.unquote() incorrectly unquotes strings enclosed in <>
 
203
            # IIS 6 and 7 incorrectly wrap boundary strings in <>
 
204
            # together they make a beautiful bug, which we will be gracious
 
205
            # about here
 
206
            if (self._unquote_boundary(boundary_line) !=
 
207
                    b'--' + self._boundary + b'\r\n'):
 
208
                raise errors.InvalidHttpResponse(
 
209
                    self._path,
 
210
                    "Expected a boundary (%s) line, got '%s'"
 
211
                    % (self._boundary, boundary_line))
 
212
 
 
213
    def _unquote_boundary(self, b):
 
214
        return b[:2] + email_utils.unquote(b[2:-2].decode('ascii')).encode('ascii') + b[-2:]
 
215
 
 
216
    def read_range_definition(self):
 
217
        """Read a new range definition in a multi parts message.
 
218
 
 
219
        Parse the headers including the empty line following them so that we
 
220
        are ready to read the data itself.
 
221
        """
 
222
        if PY3:
 
223
            self._headers = http_client.parse_headers(self._file)
 
224
        else:
 
225
            self._headers = http_client.HTTPMessage(self._file, seekable=0)
 
226
        # Extract the range definition
 
227
        content_range = self._headers.get('content-range', None)
 
228
        if content_range is None:
 
229
            raise errors.InvalidHttpResponse(
 
230
                self._path,
 
231
                'Content-Range header missing in a multi-part response')
 
232
        self.set_range_from_header(content_range)
 
233
 
 
234
    def set_range_from_header(self, content_range):
 
235
        """Helper to set the new range from its description in the headers"""
 
236
        try:
 
237
            rtype, values = content_range.split()
 
238
        except ValueError:
 
239
            raise errors.InvalidHttpRange(self._path, content_range,
 
240
                                          'Malformed header')
 
241
        if rtype != 'bytes':
 
242
            raise errors.InvalidHttpRange(self._path, content_range,
 
243
                                          "Unsupported range type '%s'" % rtype)
 
244
        try:
 
245
            # We don't need total, but note that it may be either the file size
 
246
            # or '*' if the server can't or doesn't want to return the file
 
247
            # size.
 
248
            start_end, total = values.split('/')
 
249
            start, end = start_end.split('-')
 
250
            start = int(start)
 
251
            end = int(end)
 
252
        except ValueError:
 
253
            raise errors.InvalidHttpRange(self._path, content_range,
 
254
                                          'Invalid range values')
 
255
        size = end - start + 1
 
256
        if size <= 0:
 
257
            raise errors.InvalidHttpRange(self._path, content_range,
 
258
                                          'Invalid range, size <= 0')
 
259
        self.set_range(start, size)
 
260
 
 
261
    def _checked_read(self, size):
 
262
        """Read the file checking for short reads.
 
263
 
 
264
        The data read is discarded along the way.
 
265
        """
 
266
        pos = self._pos
 
267
        remaining = size
 
268
        while remaining > 0:
 
269
            data = self._file.read(min(remaining, self._discarded_buf_size))
 
270
            remaining -= len(data)
 
271
            if not data:
 
272
                raise errors.ShortReadvError(self._path, pos, size,
 
273
                                             size - remaining)
 
274
        self._pos += size
 
275
 
 
276
    def _seek_to_next_range(self):
 
277
        # We will cross range boundaries
 
278
        if self._boundary is None:
 
279
            # If we don't have a boundary, we can't find another range
 
280
            raise errors.InvalidRange(self._path, self._pos,
 
281
                                      "Range (%s, %s) exhausted"
 
282
                                      % (self._start, self._size))
 
283
        self.read_boundary()
 
284
        self.read_range_definition()
 
285
 
 
286
    def read(self, size=-1):
 
287
        """Read size bytes from the current position in the file.
 
288
 
 
289
        Reading across ranges is not supported. We rely on the underlying http
 
290
        client to clean the socket if we leave bytes unread. This may occur for
 
291
        the final boundary line of a multipart response or for any range
 
292
        request not entirely consumed by the client (due to offset coalescing)
 
293
 
 
294
        :param size:  The number of bytes to read.  Leave unspecified or pass
 
295
            -1 to read to EOF.
 
296
        """
 
297
        if (self._size > 0
 
298
                and self._pos == self._start + self._size):
 
299
            if size == 0:
 
300
                return b''
 
301
            else:
 
302
                self._seek_to_next_range()
 
303
        elif self._pos < self._start:
 
304
            raise errors.InvalidRange(
 
305
                self._path, self._pos,
 
306
                "Can't read %s bytes before range (%s, %s)"
 
307
                % (size, self._start, self._size))
 
308
        if self._size > 0:
 
309
            if size > 0 and self._pos + size > self._start + self._size:
 
310
                raise errors.InvalidRange(
 
311
                    self._path, self._pos,
 
312
                    "Can't read %s bytes across range (%s, %s)"
 
313
                    % (size, self._start, self._size))
 
314
 
 
315
        # read data from file
 
316
        buf = BytesIO()
 
317
        limited = size
 
318
        if self._size > 0:
 
319
            # Don't read past the range definition
 
320
            limited = self._start + self._size - self._pos
 
321
            if size >= 0:
 
322
                limited = min(limited, size)
 
323
        osutils.pumpfile(self._file, buf, limited, self._max_read_size)
 
324
        data = buf.getvalue()
 
325
 
 
326
        # Update _pos respecting the data effectively read
 
327
        self._pos += len(data)
 
328
        return data
 
329
 
 
330
    def seek(self, offset, whence=0):
 
331
        start_pos = self._pos
 
332
        if whence == 0:
 
333
            final_pos = offset
 
334
        elif whence == 1:
 
335
            final_pos = start_pos + offset
 
336
        elif whence == 2:
 
337
            if self._size > 0:
 
338
                final_pos = self._start + self._size + offset  # offset < 0
 
339
            else:
 
340
                raise errors.InvalidRange(
 
341
                    self._path, self._pos,
 
342
                    "RangeFile: can't seek from end while size is unknown")
 
343
        else:
 
344
            raise ValueError("Invalid value %s for whence." % whence)
 
345
 
 
346
        if final_pos < self._pos:
 
347
            # Can't seek backwards
 
348
            raise errors.InvalidRange(
 
349
                self._path, self._pos,
 
350
                'RangeFile: trying to seek backwards to %s' % final_pos)
 
351
 
 
352
        if self._size > 0:
 
353
            cur_limit = self._start + self._size
 
354
            while final_pos > cur_limit:
 
355
                # We will cross range boundaries
 
356
                remain = cur_limit - self._pos
 
357
                if remain > 0:
 
358
                    # Finish reading the current range
 
359
                    self._checked_read(remain)
 
360
                self._seek_to_next_range()
 
361
                cur_limit = self._start + self._size
 
362
 
 
363
        size = final_pos - self._pos
 
364
        if size > 0:  # size can be < 0 if we crossed a range boundary
 
365
            # We don't need the data, just read it and throw it away
 
366
            self._checked_read(size)
 
367
 
 
368
    def tell(self):
 
369
        return self._pos
 
370
 
 
371
 
 
372
def handle_response(url, code, msg, data):
 
373
    """Interpret the code & headers and wrap the provided data in a RangeFile.
 
374
 
 
375
    This is a factory method which returns an appropriate RangeFile based on
 
376
    the code & headers it's given.
 
377
 
 
378
    :param url: The url being processed. Mostly for error reporting
 
379
    :param code: The integer HTTP response code
 
380
    :param msg: An HTTPMessage containing the headers for the response
 
381
    :param data: A file-like object that can be read() to get the
 
382
                 requested data
 
383
    :return: A file-like object that can seek()+read() the
 
384
             ranges indicated by the headers.
 
385
    """
 
386
    if code == 200:
 
387
        # A whole file
 
388
        rfile = ResponseFile(url, data)
 
389
    elif code == 206:
 
390
        rfile = RangeFile(url, data)
 
391
        content_type = msg.get('content-type', None)
 
392
        if content_type is None:
 
393
            # When there is no content-type header we treat the response as
 
394
            # being of type 'application/octet-stream' as per RFC2616 section
 
395
            # 7.2.1.
 
396
            # Therefore it is obviously not multipart
 
397
            content_type = 'application/octet-stream'
 
398
            is_multipart = False
 
399
        else:
 
400
            if PY3:
 
401
                is_multipart = (msg.get_content_maintype() == 'multipart'
 
402
                                and msg.get_content_subtype() == 'byteranges')
 
403
            else:
 
404
                is_multipart = (msg.getmaintype() == 'multipart'
 
405
                                and msg.getsubtype() == 'byteranges')
 
406
 
 
407
        if is_multipart:
 
408
            # Full fledged multipart response
 
409
            if PY3:
 
410
                boundary = msg.get_param('boundary')
 
411
            else:
 
412
                boundary = msg.getparam('boundary')
 
413
            rfile.set_boundary(boundary.encode('ascii'))
 
414
        else:
 
415
            # A response to a range request, but not multipart
 
416
            content_range = msg.get('content-range', None)
 
417
            if content_range is None:
 
418
                raise errors.InvalidHttpResponse(url,
 
419
                                                 'Missing the Content-Range header in a 206 range response')
 
420
            rfile.set_range_from_header(content_range)
 
421
    else:
 
422
        raise errors.InvalidHttpResponse(url,
 
423
                                         'Unknown response code %s' % code)
 
424
 
 
425
    return rfile