1
#   This library is free software; you can redistribute it and/or
 
 
2
#   modify it under the terms of the GNU Lesser General Public
 
 
3
#   License as published by the Free Software Foundation; either
 
 
4
#   version 2.1 of the License, or (at your option) any later version.
 
 
6
#   This library is distributed in the hope that it will be useful,
 
 
7
#   but WITHOUT ANY WARRANTY; without even the implied warranty of
 
 
8
#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 
 
9
#   Lesser General Public License for more details.
 
 
11
#   You should have received a copy of the GNU Lesser General Public
 
 
12
#   License along with this library; if not, write to the 
 
 
13
#      Free Software Foundation, Inc., 
 
 
14
#      59 Temple Place, Suite 330, 
 
 
15
#      Boston, MA  02111-1307  USA
 
 
17
# This file is part of urlgrabber, a high-level cross-protocol url-grabber
 
 
18
# Copyright 2002-2004 Michael D. Stenner, Ryan Tomayko
 
 
20
# $Id: byterange.py,v 1.9 2005/02/14 21:55:07 mstenner Exp $
 
 
29
    from cStringIO import StringIO
 
 
30
except ImportError, msg: 
 
 
31
    from StringIO import StringIO
 
 
33
class RangeError(IOError):
 
 
34
    """Error raised when an unsatisfiable range is requested."""
 
 
37
class HTTPRangeHandler(urllib2.BaseHandler):
 
 
38
    """Handler that enables HTTP Range headers.
 
 
40
    This was extremely simple. The Range header is a HTTP feature to
 
 
41
    begin with so all this class does is tell urllib2 that the 
 
 
42
    "206 Partial Content" reponse from the HTTP server is what we 
 
 
49
        range_handler = range.HTTPRangeHandler()
 
 
50
        opener = urllib2.build_opener(range_handler)
 
 
53
        urllib2.install_opener(opener)
 
 
55
        # create Request and set Range header
 
 
56
        req = urllib2.Request('http://www.python.org/')
 
 
57
        req.header['Range'] = 'bytes=30-50'
 
 
58
        f = urllib2.urlopen(req)
 
 
61
    def http_error_206(self, req, fp, code, msg, hdrs):
 
 
62
        # 206 Partial Content Response
 
 
63
        r = urllib.addinfourl(fp, hdrs, req.get_full_url())
 
 
68
    def http_error_416(self, req, fp, code, msg, hdrs):
 
 
69
        # HTTP's Range Not Satisfiable error
 
 
70
        raise RangeError('Requested Range Not Satisfiable')
 
 
72
class RangeableFileObject:
 
 
73
    """File object wrapper to enable raw range handling.
 
 
74
    This was implemented primarilary for handling range 
 
 
75
    specifications for file:// urls. This object effectively makes 
 
 
76
    a file object look like it consists only of a range of bytes in 
 
 
80
        # expose 10 bytes, starting at byte position 20, from 
 
 
82
        >>> fo = RangeableFileObject(file('/etc/passwd', 'r'), (20,30))
 
 
83
        # seek seeks within the range (to position 23 in this case)
 
 
85
        # tell tells where your at _within the range_ (position 3 in
 
 
88
        # read EOFs if an attempt is made to read past the last
 
 
89
        # byte in the range. the following will return only 7 bytes.
 
 
93
    def __init__(self, fo, rangetup):
 
 
94
        """Create a RangeableFileObject.
 
 
95
        fo       -- a file like object. only the read() method need be 
 
 
96
                    supported but supporting an optimized seek() is 
 
 
98
        rangetup -- a (firstbyte,lastbyte) tuple specifying the range
 
 
100
        The file object provided is assumed to be at byte offset 0.
 
 
103
        (self.firstbyte, self.lastbyte) = range_tuple_normalize(rangetup)
 
 
105
        self._do_seek(self.firstbyte)
 
 
107
    def __getattr__(self, name):
 
 
108
        """This effectively allows us to wrap at the instance level.
 
 
109
        Any attribute not found in _this_ object will be searched for
 
 
110
        in self.fo.  This includes methods."""
 
 
111
        if hasattr(self.fo, name):
 
 
112
            return getattr(self.fo, name)
 
 
113
        raise AttributeError, name
 
 
116
        """Return the position within the range.
 
 
117
        This is different from fo.seek in that position 0 is the 
 
 
118
        first byte position of the range tuple. For example, if
 
 
119
        this object was created with a range tuple of (500,899),
 
 
120
        tell() will return 0 when at byte position 500 of the file.
 
 
122
        return (self.realpos - self.firstbyte)
 
 
124
    def seek(self,offset,whence=0):
 
 
125
        """Seek within the byte range.
 
 
126
        Positioning is identical to that described under tell().
 
 
128
        assert whence in (0, 1, 2)
 
 
129
        if whence == 0:   # absolute seek
 
 
130
            realoffset = self.firstbyte + offset
 
 
131
        elif whence == 1: # relative seek
 
 
132
            realoffset = self.realpos + offset
 
 
133
        elif whence == 2: # absolute from end of file
 
 
134
            # XXX: are we raising the right Error here?
 
 
135
            raise IOError('seek from end of file not supported.')
 
 
137
        # do not allow seek past lastbyte in range
 
 
138
        if self.lastbyte and (realoffset >= self.lastbyte):
 
 
139
            realoffset = self.lastbyte
 
 
141
        self._do_seek(realoffset - self.realpos)
 
 
143
    def read(self, size=-1):
 
 
144
        """Read within the range.
 
 
145
        This method will limit the size read based on the range.
 
 
147
        size = self._calc_read_size(size)
 
 
148
        rslt = self.fo.read(size)
 
 
149
        self.realpos += len(rslt)
 
 
152
    def readline(self, size=-1):
 
 
153
        """Read lines within the range.
 
 
154
        This method will limit the size read based on the range.
 
 
156
        size = self._calc_read_size(size)
 
 
157
        rslt = self.fo.readline(size)
 
 
158
        self.realpos += len(rslt)
 
 
161
    def _calc_read_size(self, size):
 
 
162
        """Handles calculating the amount of data to read based on
 
 
167
                if ((self.realpos + size) >= self.lastbyte):
 
 
168
                    size = (self.lastbyte - self.realpos)
 
 
170
                size = (self.lastbyte - self.realpos)
 
 
173
    def _do_seek(self,offset):
 
 
174
        """Seek based on whether wrapped object supports seek().
 
 
175
        offset is relative to the current position (self.realpos).
 
 
178
        if not hasattr(self.fo, 'seek'):
 
 
179
            self._poor_mans_seek(offset)
 
 
181
            self.fo.seek(self.realpos + offset)
 
 
182
        self.realpos+= offset
 
 
184
    def _poor_mans_seek(self,offset):
 
 
185
        """Seek by calling the wrapped file objects read() method.
 
 
186
        This is used for file like objects that do not have native
 
 
187
        seek support. The wrapped objects read() method is called
 
 
188
        to manually seek to the desired position.
 
 
189
        offset -- read this number of bytes from the wrapped
 
 
191
        raise RangeError if we encounter EOF before reaching the 
 
 
197
            if (pos + bufsize) > offset:
 
 
198
                bufsize = offset - pos
 
 
199
            buf = self.fo.read(bufsize)
 
 
200
            if len(buf) != bufsize:
 
 
201
                raise RangeError('Requested Range Not Satisfiable')
 
 
204
class FileRangeHandler(urllib2.FileHandler):
 
 
205
    """FileHandler subclass that adds Range support.
 
 
206
    This class handles Range headers exactly like an HTTP
 
 
209
    def open_local_file(self, req):
 
 
212
        host = req.get_host()
 
 
213
        file = req.get_selector()
 
 
214
        localfile = urllib.url2pathname(file)
 
 
215
        stats = os.stat(localfile)
 
 
216
        size = stats[stat.ST_SIZE]
 
 
217
        modified = rfc822.formatdate(stats[stat.ST_MTIME])
 
 
218
        mtype = mimetypes.guess_type(file)[0]
 
 
220
            host, port = urllib.splitport(host)
 
 
221
            if port or socket.gethostbyname(host) not in self.get_names():
 
 
222
                raise URLError('file not on local host')
 
 
223
        fo = open(localfile,'rb')
 
 
224
        brange = req.headers.get('Range',None)
 
 
225
        brange = range_header_to_tuple(brange)
 
 
229
            if lb == '': lb = size
 
 
230
            if fb < 0 or fb > size or lb > size:
 
 
231
                raise RangeError('Requested Range Not Satisfiable')
 
 
233
            fo = RangeableFileObject(fo, (fb,lb))
 
 
234
        headers = mimetools.Message(StringIO(
 
 
235
            'Content-Type: %s\nContent-Length: %d\nLast-modified: %s\n' %
 
 
236
            (mtype or 'text/plain', size, modified)))
 
 
237
        return urllib.addinfourl(fo, headers, 'file:'+file)
 
 
241
# Unfortunately, a large amount of base FTP code had to be copied
 
 
242
# from urllib and urllib2 in order to insert the FTP REST command.
 
 
243
# Code modifications for range support have been commented as 
 
 
245
# -- range support modifications start/end here
 
 
247
from urllib import splitport, splituser, splitpasswd, splitattr, \
 
 
248
                   unquote, addclosehook, addinfourl
 
 
256
class FTPRangeHandler(urllib2.FTPHandler):
 
 
257
    def ftp_open(self, req):
 
 
258
        host = req.get_host()
 
 
260
            raise IOError, ('ftp error', 'no host given')
 
 
261
        host, port = splitport(host)
 
 
263
            port = ftplib.FTP_PORT
 
 
265
        # username/password handling
 
 
266
        user, host = splituser(host)
 
 
268
            user, passwd = splitpasswd(user)
 
 
272
        user = unquote(user or '')
 
 
273
        passwd = unquote(passwd or '')
 
 
276
            host = socket.gethostbyname(host)
 
 
277
        except socket.error, msg:
 
 
279
        path, attrs = splitattr(req.get_selector())
 
 
280
        dirs = path.split('/')
 
 
281
        dirs = map(unquote, dirs)
 
 
282
        dirs, file = dirs[:-1], dirs[-1]
 
 
283
        if dirs and not dirs[0]:
 
 
286
            fw = self.connect_ftp(user, passwd, host, port, dirs)
 
 
287
            type = file and 'I' or 'D'
 
 
289
                attr, value = splitattr(attr)
 
 
290
                if attr.lower() == 'type' and \
 
 
291
                   value in ('a', 'A', 'i', 'I', 'd', 'D'):
 
 
294
            # -- range support modifications start here
 
 
296
            range_tup = range_header_to_tuple(req.headers.get('Range',None))    
 
 
297
            assert range_tup != ()
 
 
301
            # -- range support modifications end here
 
 
303
            fp, retrlen = fw.retrfile(file, type, rest)
 
 
305
            # -- range support modifications start here
 
 
309
                    if retrlen is None or retrlen == 0:
 
 
310
                        raise RangeError('Requested Range Not Satisfiable due to unobtainable file length.')
 
 
314
                        # beginning of range is larger than file
 
 
315
                        raise RangeError('Requested Range Not Satisfiable')
 
 
318
                    fp = RangeableFileObject(fp, (0,retrlen))
 
 
319
            # -- range support modifications end here
 
 
322
            mtype = mimetypes.guess_type(req.get_full_url())[0]
 
 
324
                headers += "Content-Type: %s\n" % mtype
 
 
325
            if retrlen is not None and retrlen >= 0:
 
 
326
                headers += "Content-Length: %d\n" % retrlen
 
 
327
            sf = StringIO(headers)
 
 
328
            headers = mimetools.Message(sf)
 
 
329
            return addinfourl(fp, headers, req.get_full_url())
 
 
330
        except ftplib.all_errors, msg:
 
 
331
            raise IOError, ('ftp error', msg), sys.exc_info()[2]
 
 
333
    def connect_ftp(self, user, passwd, host, port, dirs):
 
 
334
        fw = ftpwrapper(user, passwd, host, port, dirs)
 
 
337
class ftpwrapper(urllib.ftpwrapper):
 
 
338
    # range support note:
 
 
339
    # this ftpwrapper code is copied directly from
 
 
340
    # urllib. The only enhancement is to add the rest
 
 
341
    # argument and pass it on to ftp.ntransfercmd
 
 
342
    def retrfile(self, file, type, rest=None):
 
 
344
        if type in ('d', 'D'): cmd = 'TYPE A'; isdir = 1
 
 
345
        else: cmd = 'TYPE ' + type; isdir = 0
 
 
347
            self.ftp.voidcmd(cmd)
 
 
348
        except ftplib.all_errors:
 
 
350
            self.ftp.voidcmd(cmd)
 
 
352
        if file and not isdir:
 
 
353
            # Use nlst to see if the file exists at all
 
 
356
            except ftplib.error_perm, reason:
 
 
357
                raise IOError, ('ftp error', reason), sys.exc_info()[2]
 
 
358
            # Restore the transfer mode!
 
 
359
            self.ftp.voidcmd(cmd)
 
 
360
            # Try to retrieve as a file
 
 
363
                conn = self.ftp.ntransfercmd(cmd, rest)
 
 
364
            except ftplib.error_perm, reason:
 
 
365
                if str(reason)[:3] == '501':
 
 
366
                    # workaround for REST not supported error
 
 
367
                    fp, retrlen = self.retrfile(file, type)
 
 
368
                    fp = RangeableFileObject(fp, (rest,''))
 
 
370
                elif str(reason)[:3] != '550':
 
 
371
                    raise IOError, ('ftp error', reason), sys.exc_info()[2]
 
 
373
            # Set transfer mode to ASCII!
 
 
374
            self.ftp.voidcmd('TYPE A')
 
 
375
            # Try a directory listing
 
 
376
            if file: cmd = 'LIST ' + file
 
 
378
            conn = self.ftp.ntransfercmd(cmd)
 
 
380
        # Pass back both a suitably decorated object and a retrieval length
 
 
381
        return (addclosehook(conn[0].makefile('rb'),
 
 
382
                            self.endtransfer), conn[1])
 
 
385
####################################################################
 
 
386
# Range Tuple Functions
 
 
387
# XXX: These range tuple functions might go better in a class.
 
 
390
def range_header_to_tuple(range_header):
 
 
391
    """Get a (firstbyte,lastbyte) tuple from a Range header value.
 
 
393
    Range headers have the form "bytes=<firstbyte>-<lastbyte>". This
 
 
394
    function pulls the firstbyte and lastbyte values and returns
 
 
395
    a (firstbyte,lastbyte) tuple. If lastbyte is not specified in
 
 
396
    the header value, it is returned as an empty string in the
 
 
399
    Return None if range_header is None
 
 
400
    Return () if range_header does not conform to the range spec 
 
 
405
    if range_header is None: return None
 
 
408
        _rangere = re.compile(r'^bytes=(\d{1,})-(\d*)')
 
 
409
    match = _rangere.match(range_header)
 
 
411
        tup = range_tuple_normalize(match.group(1,2))
 
 
413
            tup = (tup[0],tup[1]+1)
 
 
417
def range_tuple_to_header(range_tup):
 
 
418
    """Convert a range tuple to a Range header value.
 
 
419
    Return a string of the form "bytes=<firstbyte>-<lastbyte>" or None
 
 
420
    if no range is needed.
 
 
422
    if range_tup is None: return None
 
 
423
    range_tup = range_tuple_normalize(range_tup)
 
 
426
            range_tup = (range_tup[0],range_tup[1] - 1)
 
 
427
        return 'bytes=%s-%s' % range_tup
 
 
429
def range_tuple_normalize(range_tup):
 
 
430
    """Normalize a (first_byte,last_byte) range tuple.
 
 
431
    Return a tuple whose first element is guaranteed to be an int
 
 
432
    and whose second element will be '' (meaning: the last byte) or 
 
 
433
    an int. Finally, return None if the normalized tuple == (0,'')
 
 
434
    as that is equivelant to retrieving the entire file.
 
 
436
    if range_tup is None: return None
 
 
439
    if fb in (None,''): fb = 0
 
 
442
    try: lb = range_tup[1]
 
 
443
    except IndexError: lb = ''
 
 
445
        if lb is None: lb = ''
 
 
446
        elif lb != '': lb = int(lb)
 
 
447
    # check if range is over the entire file
 
 
448
    if (fb,lb) == (0,''): return None
 
 
449
    # check that the range is valid
 
 
450
    if lb < fb: raise RangeError('Invalid byte range: %s-%s' % (fb,lb))