PageRenderTime 55ms CodeModel.GetById 26ms RepoModel.GetById 0ms app.codeStats 0ms

/lib/smisk/mvc/routing.py

https://github.com/rsms/smisk
Python | 477 lines | 412 code | 28 blank | 37 comment | 26 complexity | d5f8efa5fec2e20befb1620f3351ff02 MD5 | raw file
  1. #!/usr/bin/env python
  2. # encoding: utf-8
  3. '''URL-to-function routing.
  4. '''
  5. import sys, re, logging, new
  6. from smisk.mvc import http
  7. from smisk.mvc import control
  8. from smisk.core import URL
  9. from smisk.config import config
  10. from smisk.util.type import *
  11. from smisk.util.python import wrap_exc_in_callable
  12. from smisk.util.string import tokenize_path
  13. from smisk.util.introspect import introspect
  14. __all__ = ['Destination', 'Filter', 'Router']
  15. log = logging.getLogger(__name__)
  16. def _prep_path(path):
  17. return unicode(path).rstrip(u'/').lower()
  18. def _node_name(node, fallback):
  19. n = getattr(node, 'slug', None)
  20. if n is None:
  21. return fallback
  22. return n
  23. def _find_canonical_leaf(leaf, rel_im_leaf):
  24. canonical_leaf = leaf
  25. try:
  26. while 1:
  27. canonical_leaf = canonical_leaf.parent_leaf
  28. except AttributeError:
  29. pass
  30. if isinstance(canonical_leaf, FunctionType) \
  31. and canonical_leaf.__name__ not in ('va_kwa_wrapper', 'exc_wrapper'):
  32. # In this case, the leaf has been decorated and thus need to be bound into
  33. # a proper instance method.
  34. canonical_leaf = new.instancemethod(canonical_leaf, rel_im_leaf.im_self, rel_im_leaf.im_class)
  35. return canonical_leaf
  36. class Destination(object):
  37. '''A callable destination.
  38. '''
  39. leaf = None
  40. ''':type: callable
  41. '''
  42. def __init__(self, leaf):
  43. self.leaf = leaf
  44. self.formats = None
  45. try:
  46. self.formats = self.leaf.formats
  47. except AttributeError:
  48. pass
  49. def _call_leaf(self, *args, **params):
  50. return self.leaf(*args, **params)
  51. def __call__(self, *args, **params):
  52. '''Call leaf
  53. '''
  54. try:
  55. return self._call_leaf(*args, **params)
  56. except TypeError, e:
  57. desc = e.args[0]
  58. # Find out if the problem was caused in self._call_leaf or originates someplace else
  59. tb = sys.exc_info()[2]
  60. if not tb:
  61. raise
  62. while 1:
  63. if tb.tb_next:
  64. tb = tb.tb_next
  65. else:
  66. break
  67. if tb.tb_lineno != self._call_leaf.im_func.func_code.co_firstlineno+1:
  68. raise
  69. GOT_MUL = ' got multiple values for keyword argument '
  70. def req_args():
  71. info = introspect.callable_info(self.leaf)
  72. args = []
  73. for k,v in info['args']:
  74. if v is Undefined:
  75. args.append(k)
  76. return ', '.join(args)
  77. if (desc.find(' takes at least ') > 0 and desc.find(' arguments ') > 0) or (desc.find(' takes exactly ') > 0):
  78. log.debug('TypeError', exc_info=1)
  79. raise http.BadRequest('Missing required parameters: %r (Received %r, %r)' % \
  80. (req_args(), params, args))
  81. else:
  82. p = desc.find(GOT_MUL)
  83. if p > 0:
  84. raise http.BadRequest('%s got multiple values for keyword argument %s'\
  85. ' -- received args %r and params %r' % \
  86. (self.uri, desc[p+len(GOT_MUL):], args, params))
  87. raise
  88. @property
  89. # compatibility -- remove when we remove support for deprecated name "action"
  90. def action(self):
  91. return self.leaf
  92. _canonical_leaf = None
  93. @property
  94. def canonical_leaf(self):
  95. if self._canonical_leaf is None:
  96. self._canonical_leaf = _find_canonical_leaf(self.leaf, self.leaf)
  97. log.debug('%r.canonical_leaf = %r', self, self._canonical_leaf)
  98. return self._canonical_leaf
  99. @property
  100. def path(self):
  101. '''Canonical exposed path.
  102. :rtype: list
  103. '''
  104. return control.path_to(self.canonical_leaf)
  105. @property
  106. def uri(self):
  107. '''Canonical exposed URI.
  108. :rtype: string
  109. '''
  110. return control.uri_for(self.canonical_leaf)
  111. @property
  112. def template_path(self):
  113. '''Template path.
  114. :rtype: list
  115. '''
  116. return control.template_for(self.canonical_leaf)
  117. def __str__(self):
  118. if self.path:
  119. return '/'+'/'.join(self.path)
  120. else:
  121. return self.__repr__()
  122. def __repr__(self):
  123. return '%s(canonical_leaf=%r, uri=%r)' \
  124. % (self.__class__.__name__, self.canonical_leaf, self.uri)
  125. class Filter(object):
  126. def match(self, method, url):
  127. '''Test this filter against *method* and *url*.
  128. :returns: (list args, dict params) or None if no match
  129. :rtype: tuple
  130. '''
  131. return None2
  132. class RegExpFilter(Filter):
  133. def __init__(self, pattern, destination_path, regexp_flags=re.I, match_on_full_url=False,
  134. methods=None, params={}):
  135. '''Create a new regular expressions-based filter.
  136. :param pattern: Pattern
  137. :type pattern: string or re.Regex
  138. :param destination_path: Path to leaf, expressed in internal canonical form.
  139. i.e. "/controller/leaf".
  140. :type destination_path: string
  141. :param regexp_flags: Defaults to ``re.I`` (case-insensitive)
  142. :type regexp_flags: int
  143. :param match_on_full_url: Where there or not to perform matches on complete
  144. URL (i.e. "https://foo.tld/bar?question=2").
  145. Defauts to False (i.e.matches on path only. "/bar")
  146. :type match_on_full_url: bool
  147. :param params: Parameters are saved and later included in every call to
  148. leafs taking this route.
  149. :type params: dict
  150. '''
  151. if not isinstance(regexp_flags, int):
  152. regexp_flags = 0
  153. if isinstance(pattern, RegexType):
  154. self.pattern = pattern
  155. elif not isinstance(pattern, basestring):
  156. raise ValueError('first argument "pattern" must be a Regex object or a string, not %s'\
  157. % type(pattern).__name__)
  158. else:
  159. self.pattern = re.compile(pattern, regexp_flags)
  160. if not isinstance(destination_path, (basestring, URL)):
  161. raise ValueError('second argument "destination_path" must be a string or URL, not %s'\
  162. % type(destination_path).__name__)
  163. self.destination_path = _prep_path(destination_path)
  164. self.match_on_full_url = match_on_full_url
  165. self.params = params
  166. if isinstance(methods, (list, tuple)):
  167. self.methods = methods
  168. elif methods is not None:
  169. if not isinstance(methods, basestring):
  170. raise TypeError('methods must be a tuple or list of strings, '\
  171. 'alternatively a string, not a %s.' % type(methods))
  172. self.methods = (methods,)
  173. else:
  174. self.methods = None
  175. def match(self, method, url):
  176. '''Test this filter against *method* and *url*.
  177. :returns: (list args, dict params) or None if no match
  178. :rtype: tuple
  179. '''
  180. if method and self.methods is not None and method not in self.methods\
  181. and (not control.enable_reflection or method != 'OPTIONS'):
  182. return None2
  183. if self.match_on_full_url:
  184. m = self.pattern.match(unicode(url))
  185. else:
  186. m = self.pattern.match(unicode(url.path))
  187. if m is not None:
  188. if self.params:
  189. params = self.params.copy()
  190. else:
  191. params = {}
  192. for k,v in m.groupdict().items():
  193. if isinstance(k, unicode):
  194. k = k.encode('utf-8')
  195. params[k] = v
  196. return [], params
  197. return None2
  198. def __repr__(self):
  199. return '<%s.%s(%r, %r, %r) @0x%x>' %\
  200. (self.__module__, self.__class__.__name__, \
  201. self.methods, self.pattern.pattern, self.destination_path, id(self))
  202. class Router(object):
  203. '''
  204. Default router handling both RegExp mappings and class tree mappings.
  205. Consider the following tree of controllers::
  206. class root(Controller):
  207. def __call__(self, *args, **params):
  208. return 'Welcome!'
  209. class employees(root):
  210. def __call__(self, *args, **params):
  211. return {'employees': Employee.query.all()}
  212. def show(self, name, *args, **params):
  213. return {'employee': Employee.get_by(name=name)}
  214. class edit(employees):
  215. def save(self, employee_id, *args, **params):
  216. Employee.get_by(id=employee_id).save_or_update(**params)
  217. Now, this list shows what URIs would map to what begin called::
  218. / => root().__call__()
  219. /employees => employees().__call__()
  220. /employees/ => employees().__call__()
  221. /employees/show => employees().show()
  222. /employees/show?name=foo => employees().show(name='foo')
  223. /employees/show/123 => None
  224. /employees/edit/save => employees.edit().save()
  225. See source of ``smisk.test.routing`` for more examples.
  226. '''
  227. def __init__(self):
  228. self.cache = {}
  229. self.filters = []
  230. def configure(self, config_key='smisk.mvc.routes'):
  231. filters = config.get(config_key, [])
  232. if not isinstance(filters, (list, tuple)):
  233. raise TypeError('configuration parameter %r must be a list' % config_key)
  234. for filter in filters:
  235. try:
  236. # Convert a list or tuple mapping
  237. if isinstance(filter, (tuple, list)):
  238. if len(filter) > 2:
  239. filter = {'methods':filter[0], 'pattern': filter[1], 'destination': filter[2]}
  240. else:
  241. filter = {'pattern': filter[0], 'destination': filter[1]}
  242. # Create a filter from the mapping
  243. dest = URL(filter['destination'])
  244. self.filter(filter['pattern'], dest, match_on_full_url=dest.scheme,
  245. methods=filter.get('methods', None))
  246. except TypeError, e:
  247. e.args = ('configuration parameter %r must contain dictionaries or lists' % config_key,)
  248. raise
  249. except IndexError, e:
  250. e.args = ('%r in configuration parameter %r' % (e.message, config_key),)
  251. raise
  252. except KeyError, e:
  253. e.args = ('%r in configuration parameter %r' % (e.message, config_key),)
  254. raise
  255. def filter(self, pattern, destination_path, regexp_flags=re.I, match_on_full_url=False,
  256. params={}, methods=None):
  257. '''Explicitly map an leaf to paths or urls matching regular expression `pattern`.
  258. :param pattern: Pattern
  259. :type pattern: string or re.Regex
  260. :param destination_path: Path to leaf, expressed in internal canonical form.
  261. i.e. "/controller/leaf".
  262. :type destination_path: string
  263. :param regexp_flags: Defaults to ``re.I`` (case-insensitive)
  264. :type regexp_flags: int
  265. :param match_on_full_url: Where there or not to perform matches on complete
  266. URL (i.e. "https://foo.tld/bar?question=2").
  267. Defauts to False (i.e.matches on path only. "/bar")
  268. :type match_on_full_url: bool
  269. :param params: Parameters are saved and later included in every call to
  270. leafs taking this route.
  271. :type params: dict
  272. :rtype: RegExpFilter
  273. '''
  274. filter = RegExpFilter(pattern, destination_path, regexp_flags, match_on_full_url, methods)
  275. # already exists?
  276. for i in range(len(self.filters)):
  277. f = self.filters[i]
  278. if isinstance(f, RegExpFilter) and f.pattern.pattern == pattern and f.methods == methods:
  279. # replace
  280. self.filters[i] = filter
  281. log.debug('updated filter %r', filter)
  282. return filter
  283. self.filters.append(filter)
  284. log.debug('added filter %r', filter)
  285. return filter
  286. def __call__(self, method, url, args, params):
  287. '''
  288. Find destination for route `url`.
  289. :param method: HTTP method
  290. :type method: str
  291. :param url: The URL to consider
  292. :type url: smisk.core.URL
  293. :return: ('Destionation' ``dest``, list ``args``, dict ``params``).
  294. ``dest`` might be none if no route to destination.
  295. :rtype: tuple
  296. '''
  297. # Explicit mapping? (never cached)
  298. for filter in self.filters:
  299. dargs, dparams = filter.match(method, url)
  300. if dargs != None:
  301. dargs.extend(args)
  302. dparams.update(params)
  303. return self._resolve_cached(filter.destination_path), dargs, dparams
  304. return self._resolve_cached(_prep_path(url.path)), args, params
  305. def _resolve_cached(self, raw_path):
  306. try:
  307. return self.cache[raw_path]
  308. except KeyError:
  309. dest = introspect.ensure_va_kwa(self._resolve(raw_path))
  310. if dest is not None:
  311. dest = Destination(dest)
  312. self.cache[raw_path] = dest
  313. return dest
  314. def _resolve(self, raw_path):
  315. # Tokenize path
  316. path = tokenize_path(raw_path)
  317. node = control.root_controller()
  318. cls = node
  319. log.debug('resolving %s (%r) on tree %r', raw_path, path, node)
  320. # Check root
  321. if node is None:
  322. return wrap_exc_in_callable(http.ControllerNotFound('No root controller exists'))
  323. # Special case: empty path == root.__call__
  324. if not path:
  325. try:
  326. node = node().__call__
  327. log.debug('found leaf: %s', node)
  328. return node
  329. except AttributeError:
  330. return wrap_exc_in_callable(http.MethodNotFound('/'))
  331. # Traverse tree
  332. for part in path:
  333. log.debug('looking at part %r', part)
  334. found = None
  335. # 1. Search subclasses first
  336. log.debug('matching %r to subclasses of %r', part, node)
  337. try:
  338. subclasses = node.__subclasses__()
  339. except AttributeError:
  340. log.debug('node %r does not have subclasses -- returning MethodNotFound')
  341. return wrap_exc_in_callable(http.MethodNotFound(raw_path))
  342. for subclass in node.__subclasses__():
  343. if _node_name(subclass, subclass.controller_name()) == part:
  344. if getattr(subclass, 'hidden', False):
  345. continue
  346. found = subclass
  347. break
  348. if found is not None:
  349. node = found
  350. cls = node
  351. continue
  352. # 2. Search methods
  353. log.debug('matching %r to methods of %r', part, node)
  354. # Aquire instance
  355. if type(node) is type:
  356. node = node()
  357. for k,v in node.__dict__.items():
  358. if _node_name(v, k.lower()) == part:
  359. # If the leaf is hidden, we skip it
  360. if getattr(v, 'hidden', False):
  361. continue
  362. # If the leaf is not defined directly on parent node node, and
  363. # node.delegate evaluates to False, we bail out
  364. if not control.leaf_is_visible(v, cls):
  365. node = None
  366. else:
  367. found = v
  368. break
  369. # Check found node
  370. if found is not None:
  371. node = found
  372. node_type = type(node)
  373. # The following two lines enables accepting prefix routes:
  374. #if node_type is MethodType or node_type is FunctionType:
  375. # break
  376. else:
  377. # Not found
  378. return wrap_exc_in_callable(http.MethodNotFound(raw_path))
  379. # Did we hit a class/type at the end? If so, get its instance.
  380. if type(node) is type:
  381. try:
  382. cls = node
  383. node = cls().__call__
  384. if not control.leaf_is_visible(node, cls):
  385. node = None
  386. except AttributeError:
  387. # Uncallable leaf
  388. node = None
  389. # Not callable?
  390. if node is None or not callable(node):
  391. return wrap_exc_in_callable(http.MethodNotFound(raw_path))
  392. log.debug('found leaf: %s', node)
  393. return node