urlmap.py 9.1 KB

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