|
|
# This library is free software; you can redistribute it and/or
|
|
|
# modify it under the terms of the GNU Lesser General Public
|
|
|
# License as published by the Free Software Foundation; either
|
|
|
# version 2.1 of the License, or (at your option) any later version.
|
|
|
#
|
|
|
# This library is distributed in the hope that it will be useful,
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
# Lesser General Public License for more details.
|
|
|
#
|
|
|
# You should have received a copy of the GNU Lesser General Public
|
|
|
# License along with this library; if not, write to the
|
|
|
# Free Software Foundation, Inc.,
|
|
|
# 59 Temple Place, Suite 330,
|
|
|
# Boston, MA 02111-1307 USA
|
|
|
|
|
|
# This file is part of urlgrabber, a high-level cross-protocol url-grabber
|
|
|
# Copyright 2002-2004 Michael D. Stenner, Ryan Tomayko
|
|
|
|
|
|
# $Id: byterange.py,v 1.9 2005/02/14 21:55:07 mstenner Exp $
|
|
|
|
|
|
import os
|
|
|
import stat
|
|
|
import urllib
|
|
|
import urllib2
|
|
|
import rfc822
|
|
|
|
|
|
try:
|
|
|
from cStringIO import StringIO
|
|
|
except ImportError, msg:
|
|
|
from StringIO import StringIO
|
|
|
|
|
|
class RangeError(IOError):
|
|
|
"""Error raised when an unsatisfiable range is requested."""
|
|
|
pass
|
|
|
|
|
|
class HTTPRangeHandler(urllib2.BaseHandler):
|
|
|
"""Handler that enables HTTP Range headers.
|
|
|
|
|
|
This was extremely simple. The Range header is a HTTP feature to
|
|
|
begin with so all this class does is tell urllib2 that the
|
|
|
"206 Partial Content" reponse from the HTTP server is what we
|
|
|
expected.
|
|
|
|
|
|
Example:
|
|
|
import urllib2
|
|
|
import byterange
|
|
|
|
|
|
range_handler = range.HTTPRangeHandler()
|
|
|
opener = urllib2.build_opener(range_handler)
|
|
|
|
|
|
# install it
|
|
|
urllib2.install_opener(opener)
|
|
|
|
|
|
# create Request and set Range header
|
|
|
req = urllib2.Request('http://www.python.org/')
|
|
|
req.header['Range'] = 'bytes=30-50'
|
|
|
f = urllib2.urlopen(req)
|
|
|
"""
|
|
|
|
|
|
def http_error_206(self, req, fp, code, msg, hdrs):
|
|
|
# 206 Partial Content Response
|
|
|
r = urllib.addinfourl(fp, hdrs, req.get_full_url())
|
|
|
r.code = code
|
|
|
r.msg = msg
|
|
|
return r
|
|
|
|
|
|
def http_error_416(self, req, fp, code, msg, hdrs):
|
|
|
# HTTP's Range Not Satisfiable error
|
|
|
raise RangeError('Requested Range Not Satisfiable')
|
|
|
|
|
|
class RangeableFileObject:
|
|
|
"""File object wrapper to enable raw range handling.
|
|
|
This was implemented primarilary for handling range
|
|
|
specifications for file:// urls. This object effectively makes
|
|
|
a file object look like it consists only of a range of bytes in
|
|
|
the stream.
|
|
|
|
|
|
Examples:
|
|
|
# expose 10 bytes, starting at byte position 20, from
|
|
|
# /etc/aliases.
|
|
|
>>> fo = RangeableFileObject(file('/etc/passwd', 'r'), (20,30))
|
|
|
# seek seeks within the range (to position 23 in this case)
|
|
|
>>> fo.seek(3)
|
|
|
# tell tells where your at _within the range_ (position 3 in
|
|
|
# this case)
|
|
|
>>> fo.tell()
|
|
|
# read EOFs if an attempt is made to read past the last
|
|
|
# byte in the range. the following will return only 7 bytes.
|
|
|
>>> fo.read(30)
|
|
|
"""
|
|
|
|
|
|
def __init__(self, fo, rangetup):
|
|
|
"""Create a RangeableFileObject.
|
|
|
fo -- a file like object. only the read() method need be
|
|
|
supported but supporting an optimized seek() is
|
|
|
preferable.
|
|
|
rangetup -- a (firstbyte,lastbyte) tuple specifying the range
|
|
|
to work over.
|
|
|
The file object provided is assumed to be at byte offset 0.
|
|
|
"""
|
|
|
self.fo = fo
|
|
|
(self.firstbyte, self.lastbyte) = range_tuple_normalize(rangetup)
|
|
|
self.realpos = 0
|
|
|
self._do_seek(self.firstbyte)
|
|
|
|
|
|
def __getattr__(self, name):
|
|
|
"""This effectively allows us to wrap at the instance level.
|
|
|
Any attribute not found in _this_ object will be searched for
|
|
|
in self.fo. This includes methods."""
|
|
|
if hasattr(self.fo, name):
|
|
|
return getattr(self.fo, name)
|
|
|
raise AttributeError, name
|
|
|
|
|
|
def tell(self):
|
|
|
"""Return the position within the range.
|
|
|
This is different from fo.seek in that position 0 is the
|
|
|
first byte position of the range tuple. For example, if
|
|
|
this object was created with a range tuple of (500,899),
|
|
|
tell() will return 0 when at byte position 500 of the file.
|
|
|
"""
|
|
|
return (self.realpos - self.firstbyte)
|
|
|
|
|
|
def seek(self,offset,whence=0):
|
|
|
"""Seek within the byte range.
|
|
|
Positioning is identical to that described under tell().
|
|
|
"""
|
|
|
assert whence in (0, 1, 2)
|
|
|
if whence == 0: # absolute seek
|
|
|
realoffset = self.firstbyte + offset
|
|
|
elif whence == 1: # relative seek
|
|
|
realoffset = self.realpos + offset
|
|
|
elif whence == 2: # absolute from end of file
|
|
|
# XXX: are we raising the right Error here?
|
|
|
raise IOError('seek from end of file not supported.')
|
|
|
|
|
|
# do not allow seek past lastbyte in range
|
|
|
if self.lastbyte and (realoffset >= self.lastbyte):
|
|
|
realoffset = self.lastbyte
|
|
|
|
|
|
self._do_seek(realoffset - self.realpos)
|
|
|
|
|
|
def read(self, size=-1):
|
|
|
"""Read within the range.
|
|
|
This method will limit the size read based on the range.
|
|
|
"""
|
|
|
size = self._calc_read_size(size)
|
|
|
rslt = self.fo.read(size)
|
|
|
self.realpos += len(rslt)
|
|
|
return rslt
|
|
|
|
|
|
def readline(self, size=-1):
|
|
|
"""Read lines within the range.
|
|
|
This method will limit the size read based on the range.
|
|
|
"""
|
|
|
size = self._calc_read_size(size)
|
|
|
rslt = self.fo.readline(size)
|
|
|
self.realpos += len(rslt)
|
|
|
return rslt
|
|
|
|
|
|
def _calc_read_size(self, size):
|
|
|
"""Handles calculating the amount of data to read based on
|
|
|
the range.
|
|
|
"""
|
|
|
if self.lastbyte:
|
|
|
if size > -1:
|
|
|
if ((self.realpos + size) >= self.lastbyte):
|
|
|
size = (self.lastbyte - self.realpos)
|
|
|
else:
|
|
|
size = (self.lastbyte - self.realpos)
|
|
|
return size
|
|
|
|
|
|
def _do_seek(self,offset):
|
|
|
"""Seek based on whether wrapped object supports seek().
|
|
|
offset is relative to the current position (self.realpos).
|
|
|
"""
|
|
|
assert offset >= 0
|
|
|
if not hasattr(self.fo, 'seek'):
|
|
|
self._poor_mans_seek(offset)
|
|
|
else:
|
|
|
self.fo.seek(self.realpos + offset)
|
|
|
self.realpos+= offset
|
|
|
|
|
|
def _poor_mans_seek(self,offset):
|
|
|
"""Seek by calling the wrapped file objects read() method.
|
|
|
This is used for file like objects that do not have native
|
|
|
seek support. The wrapped objects read() method is called
|
|
|
to manually seek to the desired position.
|
|
|
offset -- read this number of bytes from the wrapped
|
|
|
file object.
|
|
|
raise RangeError if we encounter EOF before reaching the
|
|
|
specified offset.
|
|
|
"""
|
|
|
pos = 0
|
|
|
bufsize = 1024
|
|
|
while pos < offset:
|
|
|
if (pos + bufsize) > offset:
|
|
|
bufsize = offset - pos
|
|
|
buf = self.fo.read(bufsize)
|
|
|
if len(buf) != bufsize:
|
|
|
raise RangeError('Requested Range Not Satisfiable')
|
|
|
pos+= bufsize
|
|
|
|
|
|
class FileRangeHandler(urllib2.FileHandler):
|
|
|
"""FileHandler subclass that adds Range support.
|
|
|
This class handles Range headers exactly like an HTTP
|
|
|
server would.
|
|
|
"""
|
|
|
def open_local_file(self, req):
|
|
|
import mimetypes
|
|
|
import mimetools
|
|
|
host = req.get_host()
|
|
|
file = req.get_selector()
|
|
|
localfile = urllib.url2pathname(file)
|
|
|
stats = os.stat(localfile)
|
|
|
size = stats[stat.ST_SIZE]
|
|
|
modified = rfc822.formatdate(stats[stat.ST_MTIME])
|
|
|
mtype = mimetypes.guess_type(file)[0]
|
|
|
if host:
|
|
|
host, port = urllib.splitport(host)
|
|
|
if port or socket.gethostbyname(host) not in self.get_names():
|
|
|
raise URLError('file not on local host')
|
|
|
fo = open(localfile,'rb')
|
|
|
brange = req.headers.get('Range',None)
|
|
|
brange = range_header_to_tuple(brange)
|
|
|
assert brange != ()
|
|
|
if brange:
|
|
|
(fb,lb) = brange
|
|
|
if lb == '': lb = size
|
|
|
if fb < 0 or fb > size or lb > size:
|
|
|
raise RangeError('Requested Range Not Satisfiable')
|
|
|
size = (lb - fb)
|
|
|
fo = RangeableFileObject(fo, (fb,lb))
|
|
|
headers = mimetools.Message(StringIO(
|
|
|
'Content-Type: %s\nContent-Length: %d\nLast-modified: %s\n' %
|
|
|
(mtype or 'text/plain', size, modified)))
|
|
|
return urllib.addinfourl(fo, headers, 'file:'+file)
|
|
|
|
|
|
|
|
|
# FTP Range Support
|
|
|
# Unfortunately, a large amount of base FTP code had to be copied
|
|
|
# from urllib and urllib2 in order to insert the FTP REST command.
|
|
|
# Code modifications for range support have been commented as
|
|
|
# follows:
|
|
|
# -- range support modifications start/end here
|
|
|
|
|
|
from urllib import splitport, splituser, splitpasswd, splitattr, \
|
|
|
unquote, addclosehook, addinfourl
|
|
|
import ftplib
|
|
|
import socket
|
|
|
import sys
|
|
|
import ftplib
|
|
|
import mimetypes
|
|
|
import mimetools
|
|
|
|
|
|
class FTPRangeHandler(urllib2.FTPHandler):
|
|
|
def ftp_open(self, req):
|
|
|
host = req.get_host()
|
|
|
if not host:
|
|
|
raise IOError, ('ftp error', 'no host given')
|
|
|
host, port = splitport(host)
|
|
|
if port is None:
|
|
|
port = ftplib.FTP_PORT
|
|
|
|
|
|
# username/password handling
|
|
|
user, host = splituser(host)
|
|
|
if user:
|
|
|
user, passwd = splitpasswd(user)
|
|
|
else:
|
|
|
passwd = None
|
|
|
host = unquote(host)
|
|
|
user = unquote(user or '')
|
|
|
passwd = unquote(passwd or '')
|
|
|
|
|
|
try:
|
|
|
host = socket.gethostbyname(host)
|
|
|
except socket.error, msg:
|
|
|
raise URLError(msg)
|
|
|
path, attrs = splitattr(req.get_selector())
|
|
|
dirs = path.split('/')
|
|
|
dirs = map(unquote, dirs)
|
|
|
dirs, file = dirs[:-1], dirs[-1]
|
|
|
if dirs and not dirs[0]:
|
|
|
dirs = dirs[1:]
|
|
|
try:
|
|
|
fw = self.connect_ftp(user, passwd, host, port, dirs)
|
|
|
type = file and 'I' or 'D'
|
|
|
for attr in attrs:
|
|
|
attr, value = splitattr(attr)
|
|
|
if attr.lower() == 'type' and \
|
|
|
value in ('a', 'A', 'i', 'I', 'd', 'D'):
|
|
|
type = value.upper()
|
|
|
|
|
|
# -- range support modifications start here
|
|
|
rest = None
|
|
|
range_tup = range_header_to_tuple(req.headers.get('Range',None))
|
|
|
assert range_tup != ()
|
|
|
if range_tup:
|
|
|
(fb,lb) = range_tup
|
|
|
if fb > 0: rest = fb
|
|
|
# -- range support modifications end here
|
|
|
|
|
|
fp, retrlen = fw.retrfile(file, type, rest)
|
|
|
|
|
|
# -- range support modifications start here
|
|
|
if range_tup:
|
|
|
(fb,lb) = range_tup
|
|
|
if lb == '':
|
|
|
if retrlen is None or retrlen == 0:
|
|
|
raise RangeError('Requested Range Not Satisfiable due to unobtainable file length.')
|
|
|
lb = retrlen
|
|
|
retrlen = lb - fb
|
|
|
if retrlen < 0:
|
|
|
# beginning of range is larger than file
|
|
|
raise RangeError('Requested Range Not Satisfiable')
|
|
|
else:
|
|
|
retrlen = lb - fb
|
|
|
fp = RangeableFileObject(fp, (0,retrlen))
|
|
|
# -- range support modifications end here
|
|
|
|
|
|
headers = ""
|
|
|
mtype = mimetypes.guess_type(req.get_full_url())[0]
|
|
|
if mtype:
|
|
|
headers += "Content-Type: %s\n" % mtype
|
|
|
if retrlen is not None and retrlen >= 0:
|
|
|
headers += "Content-Length: %d\n" % retrlen
|
|
|
sf = StringIO(headers)
|
|
|
headers = mimetools.Message(sf)
|
|
|
return addinfourl(fp, headers, req.get_full_url())
|
|
|
except ftplib.all_errors, msg:
|
|
|
raise IOError, ('ftp error', msg), sys.exc_info()[2]
|
|
|
|
|
|
def connect_ftp(self, user, passwd, host, port, dirs):
|
|
|
fw = ftpwrapper(user, passwd, host, port, dirs)
|
|
|
return fw
|
|
|
|
|
|
class ftpwrapper(urllib.ftpwrapper):
|
|
|
# range support note:
|
|
|
# this ftpwrapper code is copied directly from
|
|
|
# urllib. The only enhancement is to add the rest
|
|
|
# argument and pass it on to ftp.ntransfercmd
|
|
|
def retrfile(self, file, type, rest=None):
|
|
|
self.endtransfer()
|
|
|
if type in ('d', 'D'): cmd = 'TYPE A'; isdir = 1
|
|
|
else: cmd = 'TYPE ' + type; isdir = 0
|
|
|
try:
|
|
|
self.ftp.voidcmd(cmd)
|
|
|
except ftplib.all_errors:
|
|
|
self.init()
|
|
|
self.ftp.voidcmd(cmd)
|
|
|
conn = None
|
|
|
if file and not isdir:
|
|
|
# Use nlst to see if the file exists at all
|
|
|
try:
|
|
|
self.ftp.nlst(file)
|
|
|
except ftplib.error_perm, reason:
|
|
|
raise IOError, ('ftp error', reason), sys.exc_info()[2]
|
|
|
# Restore the transfer mode!
|
|
|
self.ftp.voidcmd(cmd)
|
|
|
# Try to retrieve as a file
|
|
|
try:
|
|
|
cmd = 'RETR ' + file
|
|
|
conn = self.ftp.ntransfercmd(cmd, rest)
|
|
|
except ftplib.error_perm, reason:
|
|
|
if str(reason)[:3] == '501':
|
|
|
# workaround for REST not supported error
|
|
|
fp, retrlen = self.retrfile(file, type)
|
|
|
fp = RangeableFileObject(fp, (rest,''))
|
|
|
return (fp, retrlen)
|
|
|
elif str(reason)[:3] != '550':
|
|
|
raise IOError, ('ftp error', reason), sys.exc_info()[2]
|
|
|
if not conn:
|
|
|
# Set transfer mode to ASCII!
|
|
|
self.ftp.voidcmd('TYPE A')
|
|
|
# Try a directory listing
|
|
|
if file: cmd = 'LIST ' + file
|
|
|
else: cmd = 'LIST'
|
|
|
conn = self.ftp.ntransfercmd(cmd)
|
|
|
self.busy = 1
|
|
|
# Pass back both a suitably decorated object and a retrieval length
|
|
|
return (addclosehook(conn[0].makefile('rb'),
|
|
|
self.endtransfer), conn[1])
|
|
|
|
|
|
|
|
|
####################################################################
|
|
|
# Range Tuple Functions
|
|
|
# XXX: These range tuple functions might go better in a class.
|
|
|
|
|
|
_rangere = None
|
|
|
def range_header_to_tuple(range_header):
|
|
|
"""Get a (firstbyte,lastbyte) tuple from a Range header value.
|
|
|
|
|
|
Range headers have the form "bytes=<firstbyte>-<lastbyte>". This
|
|
|
function pulls the firstbyte and lastbyte values and returns
|
|
|
a (firstbyte,lastbyte) tuple. If lastbyte is not specified in
|
|
|
the header value, it is returned as an empty string in the
|
|
|
tuple.
|
|
|
|
|
|
Return None if range_header is None
|
|
|
Return () if range_header does not conform to the range spec
|
|
|
pattern.
|
|
|
|
|
|
"""
|
|
|
global _rangere
|
|
|
if range_header is None: return None
|
|
|
if _rangere is None:
|
|
|
import re
|
|
|
_rangere = re.compile(r'^bytes=(\d{1,})-(\d*)')
|
|
|
match = _rangere.match(range_header)
|
|
|
if match:
|
|
|
tup = range_tuple_normalize(match.group(1,2))
|
|
|
if tup and tup[1]:
|
|
|
tup = (tup[0],tup[1]+1)
|
|
|
return tup
|
|
|
return ()
|
|
|
|
|
|
def range_tuple_to_header(range_tup):
|
|
|
"""Convert a range tuple to a Range header value.
|
|
|
Return a string of the form "bytes=<firstbyte>-<lastbyte>" or None
|
|
|
if no range is needed.
|
|
|
"""
|
|
|
if range_tup is None: return None
|
|
|
range_tup = range_tuple_normalize(range_tup)
|
|
|
if range_tup:
|
|
|
if range_tup[1]:
|
|
|
range_tup = (range_tup[0],range_tup[1] - 1)
|
|
|
return 'bytes=%s-%s' % range_tup
|
|
|
|
|
|
def range_tuple_normalize(range_tup):
|
|
|
"""Normalize a (first_byte,last_byte) range tuple.
|
|
|
Return a tuple whose first element is guaranteed to be an int
|
|
|
and whose second element will be '' (meaning: the last byte) or
|
|
|
an int. Finally, return None if the normalized tuple == (0,'')
|
|
|
as that is equivelant to retrieving the entire file.
|
|
|
"""
|
|
|
if range_tup is None: return None
|
|
|
# handle first byte
|
|
|
fb = range_tup[0]
|
|
|
if fb in (None,''): fb = 0
|
|
|
else: fb = int(fb)
|
|
|
# handle last byte
|
|
|
try: lb = range_tup[1]
|
|
|
except IndexError: lb = ''
|
|
|
else:
|
|
|
if lb is None: lb = ''
|
|
|
elif lb != '': lb = int(lb)
|
|
|
# check if range is over the entire file
|
|
|
if (fb,lb) == (0,''): return None
|
|
|
# check that the range is valid
|
|
|
if lb < fb: raise RangeError('Invalid byte range: %s-%s' % (fb,lb))
|
|
|
return (fb,lb)
|
|
|
|