]> granicus.if.org Git - python/commitdiff
EXPERIMENTAL
authorJeremy Hylton <jeremy@alum.mit.edu>
Thu, 20 Jan 2000 18:19:08 +0000 (18:19 +0000)
committerJeremy Hylton <jeremy@alum.mit.edu>
Thu, 20 Jan 2000 18:19:08 +0000 (18:19 +0000)
An extensible library for opening URLs using a variety protocols.
Intended as a replacement for urllib.

Lib/urllib2.py [new file with mode: 0644]

diff --git a/Lib/urllib2.py b/Lib/urllib2.py
new file mode 100644 (file)
index 0000000..40a6715
--- /dev/null
@@ -0,0 +1,1063 @@
+"""An extensible library for opening URLs using a variety protocols
+
+The simplest way to use this module is to call the urlopen function,
+which accepts a string containing a URL or a Request object (described 
+below).  It opens the URL and returns the results as file-like
+object; the returned object has some extra methods described below.
+
+The OpenerDirectory manages a collection of Handler objects that do
+all the actual work.  Each Handler implements a particular protocol or 
+option.  The OpenerDirector is a composite object that invokes the
+Handlers needed to open the requested URL.  For example, the
+HTTPHandler performs HTTP GET and POST requests and deals with
+non-error returns.  The HTTPRedirectHandler automatically deals with
+HTTP 301 & 302 redirect errors, and the HTTPDigestAuthHandler deals
+with digest authentication.
+
+urlopen(url, data=None) -- basic usage is that same as original
+urllib.  pass the url and optionally data to post to an HTTP URL, and
+get a file-like object back.  One difference is that you can also pass 
+a Request instance instead of URL.  Raises a URLError (subclass of
+IOError); for HTTP errors, raises an HTTPError, which can also be
+treated as a valid response.
+
+build_opener -- function that creates a new OpenerDirector instance.
+will install the default handlers.  accepts one or more Handlers as
+arguments, either instances or Handler classes that it will
+instantiate.  if one of the argument is a subclass of the default
+handler, the argument will be installed instead of the default.
+
+install_opener -- installs a new opener as the default opener.
+
+objects of interest:
+OpenerDirector --
+
+Request -- an object that encapsulates the state of a request.  the
+state can be a simple as the URL.  it can also include extra HTTP
+headers, e.g. a User-Agent.
+
+BaseHandler --
+
+exceptions:
+URLError-- a subclass of IOError, individual protocols have their own
+specific subclass
+
+HTTPError-- also a valid HTTP response, so you can treat an HTTP error 
+as an exceptional event or valid response
+
+internals:
+BaseHandler and parent
+_call_chain conventions
+
+Example usage:
+
+import urllib2
+
+# set up authentication info
+authinfo = urllib2.HTTPBasicAuthHandler()
+authinfo.add_password('realm', 'host', 'username', 'password')
+
+# build a new opener that adds authentication and caching FTP handlers 
+opener = urllib2.build_opener(authinfo, urllib2.CacheFTPHandler)
+
+# install it
+urllib2.install_opener(opener)
+
+f = urllib2.urlopen('http://www.python.org/')
+
+
+"""
+
+# XXX issues:
+# If an authentication error handler that tries to perform
+ # authentication for some reason but fails, how should the error be
+ # signalled?  The client needs to know the HTTP error code.  But if
+ # the handler knows that the problem was, e.g., that it didn't know
+ # that hash algo that requested in the challenge, it would be good to 
+ # pass that information along to the client, too.
+
+# XXX to do:
+# name!
+# documentation (getting there)
+# complex proxies
+# abstract factory for opener
+# ftp errors aren't handled cleanly
+# gopher can return a socket.error
+# check digest against correct (i.e. non-apache) implementation
+
+import string
+import socket
+import UserDict
+import httplib
+import re
+import base64
+import types
+import urlparse
+import os
+import md5
+import mimetypes
+import mimetools
+import ftplib
+import sys
+import time
+import gopherlib
+
+try:
+    from cStringIO import StringIO
+except ImportError:
+    from StringIO import StringIO
+
+try:
+    import sha
+except ImportError:
+    # need 1.5.2 final
+    sha = None
+
+# not sure how many of these need to be gotten rid of
+from urllib import unwrap, unquote, splittype, splithost, \
+     addinfourl, splitport, splitgophertype, splitquery, \
+     splitattr, ftpwrapper, noheaders
+
+# support for proxies via environment variables
+from urllib import getproxies
+
+# support for FileHandler
+from urllib import localhost, thishost, url2pathname, pathname2url
+
+# support for GopherHandler
+from urllib import splitgophertype, splitquery
+
+__version__ = "2.0a1"
+
+_opener = None
+def urlopen(url, data=None):
+    global _opener
+    if _opener is None:
+        _opener = build_opener()
+    return _opener.open(url, data)
+
+def install_opener(opener):
+    global _opener
+    _opener = opener
+
+# do these error classes make sense?
+# make sure all of the IOError stuff is overriden.  we just want to be 
+ # subtypes.
+
+class URLError(IOError):
+    # URLError is a sub-type of IOError, but it doesn't share any of
+    # the implementation.  need to override __init__ and __str__
+    def __init__(self, reason):
+       self.reason = reason
+
+    def __str__(self):
+       return '<urlopen error %s>' % self.reason
+
+class HTTPError(URLError, addinfourl):
+    """Raised when HTTP error occurs, but also acts like non-error return"""
+
+    def __init__(self, url, code, msg, hdrs, fp):
+       addinfourl.__init__(self, fp, hdrs, url)
+       self.code = code
+       self.msg = msg
+       self.hdrs = hdrs
+       self.fp = fp
+       # XXX
+       self.filename = url
+       
+    def __str__(self):
+       return 'HTTP Error %s: %s' % (self.code, self.msg)
+
+    def __del__(self):
+       # XXX is this safe? what if user catches exception, then
+       # extracts fp and discards exception?
+       self.fp.close()
+
+class GopherError(URLError):
+    pass
+
+class Request:
+    def __init__(self, url, data=None, headers={}):
+       # unwrap('<URL:type://host/path>') --> 'type://host/path'
+       self.__original = unwrap(url)
+       self.type = None
+       # self.__r_type is what's left after doing the splittype
+       self.host = None
+       self.port = None
+        self.data = data
+       self.headers = {}
+        self.headers.update(headers)
+
+    def __getattr__(self, attr):
+       # XXX this is a fallback mechanism to guard against these
+       # methods getting called in a non-standard order.  this may be 
+       # too complicated and/or unnecessary.
+       # XXX should the __r_XXX attributes be public?
+       if attr[:12] == '_Request__r_':
+           name = attr[12:]
+           if hasattr(Request, 'get_' + name):
+               getattr(self, 'get_' + name)()
+               return getattr(self, attr)
+       raise AttributeError, attr
+
+    def add_data(self, data):
+        self.data = data
+
+    def has_data(self):
+        return self.data is not None
+
+    def get_data(self):
+        return self.data
+
+    def get_full_url(self):
+        return self.__original
+
+    def get_type(self):
+       if self.type is None:
+           self.type, self.__r_type = splittype(self.__original)
+       return self.type
+
+    def get_host(self):
+       if self.host is None:
+           self.host, self.__r_host = splithost(self.__r_type)
+           if self.host:
+               self.host = unquote(self.host)
+       return self.host
+
+    def get_selector(self):
+       return self.__r_host
+
+    def set_proxy(self, proxy):
+       self.__proxy = proxy
+       # XXX this code is based on urllib, but it doesn't seem
+       # correct.  specifically, if the proxy has a port number then
+       # splittype will return the hostname as the type and the port
+       # will be include with everything else
+       self.type, self.__r_type = splittype(self.__proxy)
+       self.host, XXX = splithost(self.__r_type)
+       self.host = unquote(self.host)
+       self.__r_host = self.__original
+
+    def add_header(self, key, val):
+       # useful for something like authentication
+       self.headers[key] = val
+
+class OpenerDirector:
+    def __init__(self):
+        server_version = "Python-urllib/%s" % __version__
+        self.addheaders = [('User-agent', server_version)]
+        # manage the individual handlers
+        self.handlers = []
+        self.handle_open = {}
+        self.handle_error = {}
+
+    def add_handler(self, handler):
+        added = 0
+        for meth in get_methods(handler):
+            if meth[-5:] == '_open':
+                protocol = meth[:-5]
+                if self.handle_open.has_key(protocol): 
+                    self.handle_open[protocol].append(handler)
+                else:
+                    self.handle_open[protocol] = [handler]
+                added = 1
+                continue
+            i = string.find(meth, '_')
+            j = string.find(meth[i+1:], '_') + i + 1
+            if j != -1 and meth[i+1:j] == 'error':
+                proto = meth[:i]
+                kind = meth[j+1:]
+                try:
+                    kind = string.atoi(kind)
+                except ValueError:
+                    pass
+                dict = self.handle_error.get(proto, {})
+                if dict.has_key(kind):
+                    dict[kind].append(handler)
+                else:
+                    dict[kind] = [handler]
+                self.handle_error[proto] = dict
+                added = 1
+                continue
+        if added:
+            self.handlers.append(handler)
+            handler.add_parent(self)
+        
+    def __del__(self):
+        self.close()
+
+    def close(self):
+        for handler in self.handlers:
+            handler.close()
+        self.handlers = []
+
+    def _call_chain(self, chain, kind, meth_name, *args):
+        # XXX raise an exception if no one else should try to handle
+        # this url.  return None if you can't but someone else could.
+        handlers = chain.get(kind, ())
+        for handler in handlers:
+            func = getattr(handler, meth_name)
+            result = apply(func, args)
+            if result is not None:
+                return result
+
+    def open(self, fullurl, data=None):
+       # accept a URL or a Request object
+       if type(fullurl) == types.StringType:
+           req = Request(fullurl, data)
+        else:
+            req = fullurl
+            if data is not None:
+                req.add_data(data)
+       assert isinstance(req, Request) # really only care about interface
+        
+        result = self._call_chain(self.handle_open, 'default',
+                                  'default_open', req)  
+        if result:
+            return result
+
+       type_ = req.get_type()
+        result = self._call_chain(self.handle_open, type_, type_ + \
+                                  '_open', req) 
+        if result:
+            return result
+
+        return self._call_chain(self.handle_open, 'unknown',
+                                'unknown_open', req)
+
+    def error(self, proto, *args):
+        if proto == 'http':
+            # XXX http protocol is special cased
+            dict = self.handle_error[proto]
+            proto = args[2]  # YUCK!
+            meth_name = 'http_error_%d' % proto
+            http_err = 1
+            orig_args = args
+        else:
+            dict = self.handle_error
+            meth_name = proto + '_error'
+            http_err = 0
+        args = (dict, proto, meth_name) + args
+        result = apply(self._call_chain, args)
+        if result:
+            return result
+
+        if http_err:
+            args = (dict, 'default', 'http_error_default') + orig_args
+            return apply(self._call_chain, args)
+
+def is_callable(obj):
+    # not quite like builtin callable (which I didn't know existed),
+    # not entirely sure it needs to be different
+    if type(obj) in (types.BuiltinFunctionType,
+                    types.BuiltinMethodType,  types.LambdaType,
+                    types.MethodType):
+       return 1
+    if type(obj) == types.InstanceType:
+       return hasattr(obj, '__call__')
+    return 0
+
+def get_methods(inst):
+    methods = {}
+    classes = []
+    classes.append(inst.__class__)
+    while classes:
+        klass = classes[0]
+        del classes[0]
+        classes = classes + list(klass.__bases__)
+        for name in dir(klass):
+            attr = getattr(klass, name)
+            if type(attr) == types.UnboundMethodType:
+                methods[name] = 1
+    for name in dir(inst):
+       if is_callable(getattr(inst, name)):
+           methods[name] = 1
+    return methods.keys()
+
+# XXX probably also want an abstract factory that knows things like
+ # the fact that a ProxyHandler needs to get inserted first.
+# would also know when it makes sense to skip a superclass in favor of
+ # a subclass and when it might make sense to include both 
+
+def build_opener(*handlers):
+    """Create an opener object from a list of handlers.
+
+    The opener will use several default handlers, including support
+    for HTTP and FTP.  If there is a ProxyHandler, it must be at the
+    front of the list of handlers.  (Yuck.)
+
+    If any of the handlers passed as arguments are subclasses of the
+    default handlers, the default handlers will not be used.
+    """
+    
+    opener = OpenerDirector()
+    default_classes = [ProxyHandler, UnknownHandler, HTTPHandler,
+                       HTTPDefaultErrorHandler, HTTPRedirectHandler,
+                       FTPHandler, FileHandler]
+    skip = []
+    for klass in default_classes:
+        for check in handlers:
+            if type(check) == types.ClassType:
+                if issubclass(check, klass):
+                    skip.append(klass)
+            elif type(check) == types.InstanceType:
+                if isinstance(check, klass):
+                    skip.append(klass)
+    for klass in skip:
+        default_classes.remove(klass)
+
+    for klass in default_classes:
+        opener.add_handler(klass())
+
+    for h in handlers:
+        if type(h) == types.ClassType:
+            h = h()
+        opener.add_handler(h)
+    return opener
+
+class BaseHandler:
+    def add_parent(self, parent):
+        self.parent = parent
+    def close(self):
+        self.parent = None
+
+class HTTPDefaultErrorHandler(BaseHandler):
+    def http_error_default(self, req, fp, code, msg, hdrs):
+       raise HTTPError(req.get_full_url(), code, msg, hdrs, fp)
+
+class HTTPRedirectHandler(BaseHandler):
+    # Implementation note: To avoid the server sending us into an
+    # infinite loop, the request object needs to track what URLs we
+    # have already seen.  Do this by adding a handler-specific
+    # attribute to the Request object.
+    def http_error_302(self, req, fp, code, msg, headers):
+        if headers.has_key('location'):
+            newurl = headers['location']
+        elif headers.has_key('uri'):
+            newurl = headers['uri']
+        else:
+            return
+        nil = fp.read()
+        fp.close()
+
+        # XXX Probably want to forget about the state of the current
+        # request, although that might interact poorly with other
+        # handlers that also use handler-specific request attributes
+        new = Request(newurl, req.get_data())
+        new.error_302_dict = {}
+        if hasattr(req, 'error_302_dict'):
+            if req.error_302_dict.has_key(newurl):
+                raise HTTPError(req.get_full_url(), code,
+                                self.inf_msg + msg, headers)
+            new.error_302_dict.update(req.error_302_dict)
+        new.error_302_dict[newurl] = newurl
+        return self.parent.open(new)
+
+    http_error_301 = http_error_302
+
+    inf_msg = "The HTTP server returned a redirect error that would" \
+              "lead to an inifinte loop.\n" \
+              "The last 302 error message was:\n"
+
+class ProxyHandler(BaseHandler):
+    def __init__(self, proxies=None):
+       if proxies is None:
+           proxies = getproxies()
+       assert hasattr(proxies, 'has_key'), "proxies must be a mapping"
+       self.proxies = proxies
+       for type, url in proxies.items():
+           setattr(self, '%s_open' % type, 
+                   lambda r, proxy=url, type=type, meth=self.proxy_open: \
+                   meth(r, proxy, type))
+
+    def proxy_open(self, req, proxy, type):
+       orig_type = req.get_type()
+       req.set_proxy(proxy)
+       if orig_type == type:
+           # let other handlers take care of it
+           # XXX this only makes sense if the proxy is before the
+           # other handlers
+           return None
+       else:
+           # need to start over, because the other handlers don't
+           # grok the proxy's URL type
+           return self.parent.open(req)
+
+# feature suggested by Duncan Booth
+# XXX custom is not a good name
+class CustomProxy:
+    # either pass a function to the constructor or override handle
+    def __init__(self, proto, func=None, proxy_addr=None):
+       self.proto = proto
+       self.func = func
+       self.addr = proxy_addr
+
+    def handle(self, req):
+       if self.func and self.func(req):
+           return 1
+
+    def get_proxy(self):
+       return self.addr
+
+class CustomProxyHandler(BaseHandler):
+    def __init__(self, *proxies):
+       self.proxies = {}
+
+    def proxy_open(self, req):
+       proto = req.get_type()
+       try:
+           proxies = self.proxies[proto]
+       except KeyError:
+           return None
+       for p in proxies:
+           if p.handle(req):
+               req.set_proxy(p.get_proxy())
+               return self.parent.open(req)
+       return None
+
+    def do_proxy(self, p, req):
+       p
+       return self.parent.open(req)
+
+    def add_proxy(self, cpo):
+       if self.proxies.has_key(cpo.proto):
+           self.proxies[cpo.proto].append(cpo)
+       else:
+           self.proxies[cpo.proto] = [cpo]
+
+class HTTPPasswordMgr:
+    def __init__(self):
+       self.passwd = {}
+
+    def add_password(self, realm, uri, user, passwd):
+       # uri could be a single URI or a sequence
+       if type(uri) == types.StringType:
+           uri = [uri]
+       uri = tuple(map(self.reduce_uri, uri))
+       if not self.passwd.has_key(realm):
+           self.passwd[realm] = {}
+       self.passwd[realm][uri] = (user, passwd)
+
+    def find_user_password(self, realm, authuri):
+       domains = self.passwd.get(realm, {})
+       authuri = self.reduce_uri(authuri)
+       for uris, authinfo in domains.items():
+           for uri in uris:
+               if self.is_suburi(uri, authuri):
+                   return authinfo
+       return None, None
+
+    def reduce_uri(self, uri):
+       """Accept netloc or URI and extract only the netloc and path"""
+       parts = urlparse.urlparse(uri)
+       if parts[1]:
+           return parts[1], parts[2] or '/'
+       else:
+           return parts[2], '/'
+
+    def is_suburi(self, base, test):
+       """Check if test is below base in a URI tree
+
+       Both args must be URIs in reduced form.
+       """
+       if base == test:
+           return 1
+       if base[0] != test[0]:
+           return 0
+       common = os.path.commonprefix((base[1], test[1]))
+       if len(common) == len(base[1]):
+           return 1
+       return 0
+       
+
+class HTTPBasicAuthHandler(BaseHandler):
+    rx = re.compile('[ \t]*([^ \t]+)[ \t]+realm="([^"]*)"')
+
+    # XXX there can actually be multiple auth-schemes in a
+    # www-authenticate header.  should probably be a lot more careful
+    # in parsing them to extract multiple alternatives
+
+    def __init__(self):
+        self.passwd = HTTPPasswordMgr()
+       self.add_password = self.passwd.add_password
+       self.__current_realm = None
+       # if __current_realm is not None, then the server must have
+       # refused our name/password and is asking for authorization
+       # again.  must be careful to set it to None on successful
+       # return. 
+    
+    def http_error_401(self, req, fp, code, msg, headers):
+       # XXX could be mult. headers
+        authreq = headers.get('www-authenticate', None)
+        if authreq:
+            mo = HTTPBasicAuthHandler.rx.match(authreq)
+            if mo:
+                scheme, realm = mo.groups()
+                if string.lower(scheme) == 'basic':
+                    return self.retry_http_basic_auth(req, realm)
+
+    def retry_http_basic_auth(self, req, realm):
+       if self.__current_realm is None:
+           self.__current_realm = realm
+       else:
+           self.__current_realm = realm
+           return None
+       # XXX host isn't really the correct URI?
+        host = req.get_host()
+        user,pw = self.passwd.find_user_password(realm, host)
+        if pw:
+           raw = "%s:%s" % (user, pw)
+           auth = string.strip(base64.encodestring(raw))
+            req.add_header('Authorization', 'Basic %s' % auth)
+            resp = self.parent.open(req)
+           self.__current_realm = None
+           return resp
+        else:
+           self.__current_realm = None
+            return None
+
+class HTTPDigestAuthHandler(BaseHandler):
+    """An authentication protocol defined by RFC 2069
+
+    Digest authentication improves on basic authentication because it
+    does not transmit passwords in the clear.
+    """
+
+    def __init__(self):
+       self.passwd = HTTPPasswordMgr()
+       self.add_password = self.passwd.add_password
+       self.__current_realm = None
+
+    def http_error_401(self, req, fp, code, msg, headers):
+       # XXX could be mult. headers
+       authreq = headers.get('www-authenticate', None)
+       if authreq:
+           kind = string.split(authreq)[0]
+           if kind == 'Digest':
+               return self.retry_http_digest_auth(req, authreq)
+
+    def retry_http_digest_auth(self, req, auth):
+       token, challenge = string.split(auth, ' ', 1)
+       chal = parse_keqv_list(parse_http_list(challenge))
+       auth = self.get_authorization(req, chal)
+       if auth:
+           req.add_header('Authorization', 'Digest %s' % auth)
+           resp = self.parent.open(req)
+           self.__current_realm = None
+           return resp
+
+    def get_authorization(self, req, chal):
+       try:
+           realm = chal['realm']
+           nonce = chal['nonce']
+           algorithm = chal.get('algorithm', 'MD5')
+           # mod_digest doesn't send an opaque, even though it isn't
+           # supposed to be optional
+           opaque = chal.get('opaque', None)
+       except KeyError:
+           return None
+
+       if self.__current_realm is None:
+           self.__current_realm = realm
+       else:
+           self.__current_realm = realm
+           return None
+
+       H, KD = self.get_algorithm_impls(algorithm)
+       if H is None:
+           return None
+
+       user, pw = self.passwd.find_user_password(realm,
+                                                 req.get_full_url()) 
+       if user is None:
+           return None
+
+       # XXX not implemented yet
+       if req.has_data():
+           entdig = self.get_entity_digest(req.get_data(), chal)
+       else:
+           entdig = None
+
+       A1 = "%s:%s:%s" % (user, realm, pw)
+       A2 = "%s:%s" % (req.has_data() and 'POST' or 'GET',
+                       # XXX selector: what about proxies and full urls
+                       req.get_selector())
+       respdig = KD(H(A1), "%s:%s" % (nonce, H(A2)))
+       # XXX should the partial digests be encoded too?
+
+       base = 'username="%s", realm="%s", nonce="%s", uri="%s", ' \
+              'response="%s"' % (user, realm, nonce, req.get_selector(),
+                                 respdig)
+       if opaque:
+           base = base + ', opaque="%s"' % opaque
+       if entdig:
+           base = base + ', digest="%s"' % entdig
+       if algorithm != 'MD5':
+           base = base + ', algorithm="%s"' % algorithm
+       return base
+
+    def get_algorithm_impls(self, algorithm):
+       # lambdas assume digest modules are imported at the top level
+       if algorithm == 'MD5':
+           H = lambda x, e=encode_digest:e(md5.new(x).digest())
+       elif algorithm == 'SHA':
+           H = lambda x, e=encode_digest:e(sha.new(x).digest())
+       # XXX MD5-sess
+       KD = lambda s, d, H=H: H("%s:%s" % (s, d))
+       return H, KD
+
+    def get_entity_digest(self, data, chal):
+       # XXX not implemented yet
+       return None
+
+def encode_digest(digest):
+    hexrep = []
+    for c in digest:
+       n = (ord(c) >> 4) & 0xf
+       hexrep.append(hex(n)[-1])
+       n = ord(c) & 0xf
+       hexrep.append(hex(n)[-1])
+    return string.join(hexrep, '')
+       
+        
+class HTTPHandler(BaseHandler):
+    def http_open(self, req):
+        # XXX devise a new mechanism to specify user/password
+       host = req.get_host()
+        if not host:
+            raise URLError('no host given')
+
+        h = httplib.HTTP(host) # will parse host:port
+##     h.set_debuglevel(1)
+        if req.has_data():
+            data = req.get_data()
+            h.putrequest('POST', req.get_selector())
+            h.putheader('Content-type', 'application/x-www-form-urlencoded')
+            h.putheader('Content-length', '%d' % len(data))
+        else:
+            h.putrequest('GET', req.get_selector())
+        # XXX proxies would have different host here
+        h.putheader('Host', host)
+        for args in self.parent.addheaders:
+            apply(h.putheader, args)
+       for k, v in req.headers.items():
+           h.putheader(k, v)
+        h.endheaders()
+        if req.has_data():
+            h.send(data + '\r\n')
+
+        code, msg, hdrs = h.getreply()
+        fp = h.getfile()
+        if code == 200:
+            return addinfourl(fp, hdrs, req.get_full_url())
+        else:
+            # want to make sure the socket is closed, even if error
+            # handling doesn't return immediately.  the socket won't
+            # actually be closed until fp is also closed.
+            if h.sock:
+                h.sock.close()
+                h.sock = None
+            return self.parent.error('http', req, fp, code, msg, hdrs)
+
+class UnknownHandler(BaseHandler):
+    def unknown_open(self, req):
+       type = req.get_type()
+        raise URLError('unknown url type: %s' % type)
+
+def parse_keqv_list(l):
+    """Parse list of key=value strings where keys are not duplicated."""
+    parsed = {}
+    for elt in l:
+       k, v = string.split(elt, '=', 1)
+       if v[0] == '"' and v[-1] == '"':
+           v = v[1:-1]
+       parsed[k] = v
+    return parsed
+
+def parse_http_list(s):
+    """Parse lists as described by RFC 2068 Section 2.
+
+    In particular, parse comman-separated lists where the elements of
+    the list may include quoted-strings.  A quoted-string could
+    contain a comma.
+    """
+    # XXX this function could probably use more testing
+
+    list = []
+    end = len(s)
+    i = 0
+    inquote = 0
+    start = 0
+    while i < end:
+       cur = s[i:]
+       c = string.find(cur, ',')
+       q = string.find(cur, '"')
+       if c == -1:
+           list.append(s[start:])
+           break
+       if q == -1:
+           if inquote:
+               raise ValueError, "unbalanced quotes"
+           else:
+               list.append(s[start:i+c])
+               i = i + c + 1
+               continue
+       if inquote:
+           if q < c:
+               list.append(s[start:i+c])
+               i = i + c + 1
+               start = i
+               inquote = 0
+           else:
+               i = i + q 
+       else:
+           if c < q:
+               list.append(s[start:i+c])
+               i = i + c + 1
+               start = i
+           else:
+               inquote = 1
+               i = i + q + 1
+    return map(string.strip, list)
+
+class FileHandler(BaseHandler):
+    # Use local file or FTP depending on form of URL
+    def file_open(self, req):
+       url = req.get_selector()
+       if url[:2] == '//' and url[2:3] != '/':
+           req.type = 'ftp'
+           return self.parent.open(req)
+       else:
+           return self.open_local_file(req)
+
+    # names for the localhost
+    names = None
+    def get_names(self):
+       if FileHandler.names is None:
+           FileHandler.names = (socket.gethostbyname('localhost'), 
+                                socket.gethostbyname(socket.gethostname()))
+       return FileHandler.names
+
+    # not entirely sure what the rules are here
+    def open_local_file(self, req):
+       mtype = mimetypes.guess_type(req.get_selector())[0]
+       headers = mimetools.Message(StringIO('Content-Type: %s\n' \
+                                            % (mtype or 'text/plain')))
+       host = req.get_host()
+       file = req.get_selector()
+       if host:
+           host, port = splitport(host)
+       if not host or \
+          (not port and socket.gethostbyname(host) in self.get_names()):
+           return addinfourl(open(url2pathname(file), 'rb'),
+                             headers, 'file:'+file)
+       raise URLError('file not on local host')
+
+class FTPHandler(BaseHandler):
+    def ftp_open(self, req):
+       host = req.get_host()
+       if not host:
+           raise IOError, ('ftp error', 'no host given')
+       # XXX handle custom username & password
+       host = socket.gethostbyname(host)
+       host, port = splitport(host)
+       if port is None:
+           port = ftplib.FTP_PORT
+       path, attrs = splitattr(req.get_selector())
+       path = unquote(path)
+       dirs = string.splitfields(path, '/')
+       dirs, file = dirs[:-1], dirs[-1]
+       if dirs and not dirs[0]:
+           dirs = dirs[1:]
+       user = passwd = '' # XXX
+       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 string.lower(attr) == 'type' and \
+                  value in ('a', 'A', 'i', 'I', 'd', 'D'):
+                   type = string.upper(value)
+           fp, retrlen = fw.retrfile(file, type)
+           if retrlen is not None and retrlen >= 0:
+               sf = StringIO('Content-Length: %d\n' % retrlen)
+               headers = mimetools.Message(sf)
+           else:
+               headers = noheaders()
+           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)
+##        fw.ftp.set_debuglevel(1)
+        return fw
+
+class CacheFTPHandler(FTPHandler):
+    # XXX would be nice to have pluggable cache strategies
+    # XXX this stuff is definitely not thread safe
+    def __init__(self):
+        self.cache = {}
+        self.timeout = {}
+        self.soonest = 0
+        self.delay = 60
+       self.max_conns = 16
+
+    def setTimeout(self, t):
+        self.delay = t
+
+    def setMaxConns(self, m):
+       self.max_conns = m
+
+    def connect_ftp(self, user, passwd, host, port, dirs):
+        key = user, passwd, host, port
+        if self.cache.has_key(key):
+            self.timeout[key] = time.time() + self.delay
+        else:
+            self.cache[key] = ftpwrapper(user, passwd, host, port, dirs)
+            self.timeout[key] = time.time() + self.delay
+       self.check_cache()
+        return self.cache[key]
+
+    def check_cache(self):
+       # first check for old ones
+        t = time.time()
+        if self.soonest <= t:
+            for k, v in self.timeout.items():
+                if v < t:
+                    self.cache[k].close()
+                    del self.cache[k]
+                    del self.timeout[k]
+        self.soonest = min(self.timeout.values())
+
+        # then check the size
+       if len(self.cache) == self.max_conns:
+           for k, v in self.timeout.items():
+               if v == self.soonest:
+                   del self.cache[k]
+                   del self.timeout[k]
+                   break
+           self.soonest = min(self.timeout.values())
+
+class GopherHandler(BaseHandler):
+    def gopher_open(self, req):
+       host = req.get_host()
+       if not host:
+           raise GopherError('no host given')
+       host = unquote(host)
+       selector = req.get_selector()
+       type, selector = splitgophertype(selector)
+       selector, query = splitquery(selector)
+       selector = unquote(selector)
+       if query:
+           query = unquote(query)
+           fp = gopherlib.send_query(selector, query, host)
+       else:
+           fp = gopherlib.send_selector(selector, host)
+       return addinfourl(fp, noheaders(), req.get_full_url())
+
+#bleck! don't use this yet
+class OpenerFactory:
+
+    default_handlers = [UnknownHandler, HTTPHandler,
+                       HTTPDefaultErrorHandler, HTTPRedirectHandler, 
+                       FTPHandler, FileHandler]
+    proxy_handlers = [ProxyHandler]
+    handlers = []
+    replacement_handlers = []
+
+    def add_proxy_handler(self, ph):
+       self.proxy_handlers = self.proxy_handlers + [ph]
+
+    def add_handler(self, h):
+       self.handlers = self.handlers + [h]
+
+    def replace_handler(self, h):
+       pass
+
+    def build_opener(self):
+       opener = OpenerDirectory()
+       for ph in self.proxy_handlers:
+           if type(ph) == types.ClassType:
+               ph = ph()
+           opener.add_handler(ph)
+
+if __name__ == "__main__":
+    # XXX some of the test code depends on machine configurations that 
+    # are internal to CNRI.   Need to set up a public server with the
+    # right authentication configuration for test purposes.
+    if socket.gethostname() == 'bitdiddle':
+        localhost = 'bitdiddle.cnri.reston.va.us'
+    elif socket.gethostname() == 'walden':
+        localhost = 'localhost'
+    else:
+        localhost = None
+    urls = [
+       # Thanks to Fred for finding these!
+       'gopher://gopher.lib.ncsu.edu/11/library/stacks/Alex',
+       'gopher://gopher.vt.edu:10010/10/33',
+
+       'file:/etc/passwd',
+       'file://nonsensename/etc/passwd',
+       'ftp://www.python.org/pub/tmp/httplib.py',
+        'ftp://www.python.org/pub/tmp/imageop.c',
+        'ftp://www.python.org/pub/tmp/blat',
+       'http://www.espn.com/', # redirect
+       'http://www.python.org/Spanish/Inquistion/',
+       ('http://grail.cnri.reston.va.us/cgi-bin/faqw.py',
+        'query=pythonistas&querytype=simple&casefold=yes&req=search'),
+       'http://www.python.org/',
+        'ftp://prep.ai.mit.edu/welcome.msg',
+        'ftp://www.python.org/pub/tmp/figure.prn',
+        'ftp://www.python.org/pub/tmp/interp.pl',
+       'http://checkproxy.cnri.reston.va.us/test/test.html',
+            ]
+
+    if localhost is not None:
+        urls = urls + [
+            'file://%s/etc/passwd' % localhost,
+            'http://%s/simple/' % localhost,
+            'http://%s/digest/' % localhost,
+            'http://%s/not/found.h' % localhost,
+            ]
+
+        bauth = HTTPBasicAuthHandler()
+        bauth.add_password('basic_test_realm', localhost, 'jhylton',
+                           'password') 
+        dauth = HTTPDigestAuthHandler()
+        dauth.add_password('digest_test_realm', localhost, 'jhylton', 
+                           'password')
+        
+
+    cfh = CacheFTPHandler()
+    cfh.setTimeout(1)
+
+    # XXX try out some custom proxy objects too!
+    def at_cnri(req):
+       host = req.get_host()
+       print host
+       if host[-18:] == '.cnri.reston.va.us':
+           return 1
+    p = CustomProxy('http', at_cnri, 'proxy.cnri.reston.va.us')
+    ph = CustomProxyHandler(p)
+
+    install_opener(build_opener(dauth, bauth, cfh, GopherHandler, ph))
+
+    for url in urls:
+        if type(url) == types.TupleType:
+            url, req = url
+        else:
+            req = None
+        print url
+        try:
+            f = urlopen(url, req)
+        except IOError, err:
+           print "IOError:", err
+       except socket.error, err:
+           print "socket.error:", err
+        else:
+            buf = f.read()
+            f.close()
+            print "read %d bytes" % len(buf)
+        print
+        time.sleep(0.1)