Home | History | Annotate | Download | only in paste
      1 # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
      2 # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
      3 """
      4 Map URL prefixes to WSGI applications.  See ``URLMap``
      5 """
      6 
      7 import re
      8 import os
      9 import cgi
     10 try:
     11     # Python 3
     12     from collections import MutableMapping as DictMixin
     13 except ImportError:
     14     # Python 2
     15     from UserDict import DictMixin
     16 
     17 from paste import httpexceptions
     18 
     19 __all__ = ['URLMap', 'PathProxyURLMap']
     20 
     21 def urlmap_factory(loader, global_conf, **local_conf):
     22     if 'not_found_app' in local_conf:
     23         not_found_app = local_conf.pop('not_found_app')
     24     else:
     25         not_found_app = global_conf.get('not_found_app')
     26     if not_found_app:
     27         not_found_app = loader.get_app(not_found_app, global_conf=global_conf)
     28     urlmap = URLMap(not_found_app=not_found_app)
     29     for path, app_name in local_conf.items():
     30         path = parse_path_expression(path)
     31         app = loader.get_app(app_name, global_conf=global_conf)
     32         urlmap[path] = app
     33     return urlmap
     34 
     35 def parse_path_expression(path):
     36     """
     37     Parses a path expression like 'domain foobar.com port 20 /' or
     38     just '/foobar' for a path alone.  Returns as an address that
     39     URLMap likes.
     40     """
     41     parts = path.split()
     42     domain = port = path = None
     43     while parts:
     44         if parts[0] == 'domain':
     45             parts.pop(0)
     46             if not parts:
     47                 raise ValueError("'domain' must be followed with a domain name")
     48             if domain:
     49                 raise ValueError("'domain' given twice")
     50             domain = parts.pop(0)
     51         elif parts[0] == 'port':
     52             parts.pop(0)
     53             if not parts:
     54                 raise ValueError("'port' must be followed with a port number")
     55             if port:
     56                 raise ValueError("'port' given twice")
     57             port = parts.pop(0)
     58         else:
     59             if path:
     60                 raise ValueError("more than one path given (have %r, got %r)"
     61                                  % (path, parts[0]))
     62             path = parts.pop(0)
     63     s = ''
     64     if domain:
     65         s = 'http://%s' % domain
     66     if port:
     67         if not domain:
     68             raise ValueError("If you give a port, you must also give a domain")
     69         s += ':' + port
     70     if path:
     71         if s:
     72             s += '/'
     73         s += path
     74     return s
     75 
     76 class URLMap(DictMixin):
     77 
     78     """
     79     URLMap instances are dictionary-like object that dispatch to one
     80     of several applications based on the URL.
     81 
     82     The dictionary keys are URLs to match (like
     83     ``PATH_INFO.startswith(url)``), and the values are applications to
     84     dispatch to.  URLs are matched most-specific-first, i.e., longest
     85     URL first.  The ``SCRIPT_NAME`` and ``PATH_INFO`` environmental
     86     variables are adjusted to indicate the new context.
     87 
     88     URLs can also include domains, like ``http://blah.com/foo``, or as
     89     tuples ``('blah.com', '/foo')``.  This will match domain names; without
     90     the ``http://domain`` or with a domain of ``None`` any domain will be
     91     matched (so long as no other explicit domain matches).  """
     92 
     93     def __init__(self, not_found_app=None):
     94         self.applications = []
     95         if not not_found_app:
     96             not_found_app = self.not_found_app
     97         self.not_found_application = not_found_app
     98 
     99     def __len__(self):
    100         return len(self.applications)
    101 
    102     def __iter__(self):
    103         for app_url, app in self.applications:
    104             yield app_url
    105 
    106     norm_url_re = re.compile('//+')
    107     domain_url_re = re.compile('^(http|https)://')
    108 
    109     def not_found_app(self, environ, start_response):
    110         mapper = environ.get('paste.urlmap_object')
    111         if mapper:
    112             matches = [p for p, a in mapper.applications]
    113             extra = 'defined apps: %s' % (
    114                 ',\n  '.join(map(repr, matches)))
    115         else:
    116             extra = ''
    117         extra += '\nSCRIPT_NAME: %r' % cgi.escape(environ.get('SCRIPT_NAME'))
    118         extra += '\nPATH_INFO: %r' % cgi.escape(environ.get('PATH_INFO'))
    119         extra += '\nHTTP_HOST: %r' % cgi.escape(environ.get('HTTP_HOST'))
    120         app = httpexceptions.HTTPNotFound(
    121             environ['PATH_INFO'],
    122             comment=cgi.escape(extra)).wsgi_application
    123         return app(environ, start_response)
    124 
    125     def normalize_url(self, url, trim=True):
    126         if isinstance(url, (list, tuple)):
    127             domain = url[0]
    128             url = self.normalize_url(url[1])[1]
    129             return domain, url
    130         assert (not url or url.startswith('/')
    131                 or self.domain_url_re.search(url)), (
    132             "URL fragments must start with / or http:// (you gave %r)" % url)
    133         match = self.domain_url_re.search(url)
    134         if match:
    135             url = url[match.end():]
    136             if '/' in url:
    137                 domain, url = url.split('/', 1)
    138                 url = '/' + url
    139             else:
    140                 domain, url = url, ''
    141         else:
    142             domain = None
    143         url = self.norm_url_re.sub('/', url)
    144         if trim:
    145             url = url.rstrip('/')
    146         return domain, url
    147 
    148     def sort_apps(self):
    149         """
    150         Make sure applications are sorted with longest URLs first
    151         """
    152         def key(app_desc):
    153             (domain, url), app = app_desc
    154             if not domain:
    155                 # Make sure empty domains sort last:
    156                 return '\xff', -len(url)
    157             else:
    158                 return domain, -len(url)
    159         apps = [(key(desc), desc) for desc in self.applications]
    160         apps.sort()
    161         self.applications = [desc for (sortable, desc) in apps]
    162 
    163     def __setitem__(self, url, app):
    164         if app is None:
    165             try:
    166                 del self[url]
    167             except KeyError:
    168                 pass
    169             return
    170         dom_url = self.normalize_url(url)
    171         if dom_url in self:
    172             del self[dom_url]
    173         self.applications.append((dom_url, app))
    174         self.sort_apps()
    175 
    176     def __getitem__(self, url):
    177         dom_url = self.normalize_url(url)
    178         for app_url, app in self.applications:
    179             if app_url == dom_url:
    180                 return app
    181         raise KeyError(
    182             "No application with the url %r (domain: %r; existing: %s)"
    183             % (url[1], url[0] or '*', self.applications))
    184 
    185     def __delitem__(self, url):
    186         url = self.normalize_url(url)
    187         for app_url, app in self.applications:
    188             if app_url == url:
    189                 self.applications.remove((app_url, app))
    190                 break
    191         else:
    192             raise KeyError(
    193                 "No application with the url %r" % (url,))
    194 
    195     def keys(self):
    196         return [app_url for app_url, app in self.applications]
    197 
    198     def __call__(self, environ, start_response):
    199         host = environ.get('HTTP_HOST', environ.get('SERVER_NAME')).lower()
    200         if ':' in host:
    201             host, port = host.split(':', 1)
    202         else:
    203             if environ['wsgi.url_scheme'] == 'http':
    204                 port = '80'
    205             else:
    206                 port = '443'
    207         path_info = environ.get('PATH_INFO')
    208         path_info = self.normalize_url(path_info, False)[1]
    209         for (domain, app_url), app in self.applications:
    210             if domain and domain != host and domain != host+':'+port:
    211                 continue
    212             if (path_info == app_url
    213                 or path_info.startswith(app_url + '/')):
    214                 environ['SCRIPT_NAME'] += app_url
    215                 environ['PATH_INFO'] = path_info[len(app_url):]
    216                 return app(environ, start_response)
    217         environ['paste.urlmap_object'] = self
    218         return self.not_found_application(environ, start_response)
    219 
    220 
    221 class PathProxyURLMap(object):
    222 
    223     """
    224     This is a wrapper for URLMap that catches any strings that
    225     are passed in as applications; these strings are treated as
    226     filenames (relative to `base_path`) and are passed to the
    227     callable `builder`, which will return an application.
    228 
    229     This is intended for cases when configuration files can be
    230     treated as applications.
    231 
    232     `base_paste_url` is the URL under which all applications added through
    233     this wrapper must go.  Use ``""`` if you want this to not
    234     change incoming URLs.
    235     """
    236 
    237     def __init__(self, map, base_paste_url, base_path, builder):
    238         self.map = map
    239         self.base_paste_url = self.map.normalize_url(base_paste_url)
    240         self.base_path = base_path
    241         self.builder = builder
    242 
    243     def __setitem__(self, url, app):
    244         if isinstance(app, (str, unicode)):
    245             app_fn = os.path.join(self.base_path, app)
    246             app = self.builder(app_fn)
    247         url = self.map.normalize_url(url)
    248         # @@: This means http://foo.com/bar will potentially
    249         # match foo.com, but /base_paste_url/bar, which is unintuitive
    250         url = (url[0] or self.base_paste_url[0],
    251                self.base_paste_url[1] + url[1])
    252         self.map[url] = app
    253 
    254     def __getattr__(self, attr):
    255         return getattr(self.map, attr)
    256 
    257     # This is really the only settable attribute
    258     def not_found_application__get(self):
    259         return self.map.not_found_application
    260     def not_found_application__set(self, value):
    261         self.map.not_found_application = value
    262     not_found_application = property(not_found_application__get,
    263                                      not_found_application__set)
    264