'''Routing classes for matching and parsing urls.
.. note::
The :mod:`~pulsar.apps.wsgi.route` module was originally from the routing
module in werkzeug_. Original License:
copyright (c) 2011 by the Werkzeug Team. License BSD
.. _werkzeug: https://github.com/mitsuhiko/werkzeug
A :class:`Route` is a class for relative url paths::
r1 = Route('bla')
r2 = Route('bla/foo')
Integers::
# accept any integer
Route('<int:size>')
# accept an integer between 1 and 200 only
Route('<int(min=1,max=200):size>')
Paths::
# accept any path (including slashes)
Route('<path:pages>')
Route('<path:pages>/edit')
.. _wsgi-route-decorator:
Route decorator
==================
.. autoclass:: route
:members:
:member-order: bysource
.. _apps-wsgi-route:
Route
================
.. autoclass:: Route
:members:
:member-order: bysource
'''
import re
from collections import namedtuple
from pulsar import Http404
from pulsar.utils.httpurl import (iri_to_uri, remove_double_slash,
ENCODE_URL_METHODS, ENCODE_BODY_METHODS)
from pulsar.utils.pep import to_string
from pulsar.utils.slugify import slugify
class rule_info(namedtuple('rinfo', 'rule method parameters position order')):
def override(self, parent):
if self.position is None:
return rule_info(self.rule, self.method, self.parameters,
parent.position, parent.order)
else:
return self
_rule_re = re.compile(r'''
(?:
(?P<converter>[a-zA-Z_][a-zA-Z0-9_]*) # converter name
(?:\((?P<args>.*?)\))? # converter parameters
\: # variable delimiter
)?
(?P<variable>[a-zA-Z_][a-zA-Z0-9_]*) # variable name
''', re.VERBOSE)
_converter_args_re = re.compile(r'''
((?P<name>\w+)\s*=\s*)?
(?P<value>
True|False|
\d+.\d+|
\d+.|
\d+|
\w+|
[urUR]?(?P<stringval>"[^"]*?"|'[^']*')
)\s*,
''', re.VERBOSE | re.UNICODE)
_PYTHON_CONSTANTS = {
'None': None,
'True': True,
'False': False
}
def _pythonize(value):
if value in _PYTHON_CONSTANTS:
return _PYTHON_CONSTANTS[value]
for convert in int, float:
try:
return convert(value)
except ValueError:
pass
if value[:1] == value[-1:] and value[0] in '"\'':
value = value[1:-1]
return str(value)
def parse_rule(rule):
"""Parse a rule and return it as generator. Each iteration yields tuples
in the form ``(converter, parameters, variable)``. If the converter is
`None` it's a static url part, otherwise it's a dynamic one.
:internal:
"""
m = _rule_re.match(rule)
if m is None or m.end() < len(rule):
raise ValueError('Error while parsing rule {0}'.format(rule))
data = m.groupdict()
converter = data['converter'] or 'default'
return converter, data['args'] or None, data['variable']
[docs]class route:
'''Decorator to create a child route from a :class:`.Router` method.
Typical usage::
from pulsar.apps.wsgi import Router, route
class View(Router):
def get(self, request):
...
@route()
def foo(self, request):
...
@route('/bla', method='post')
def handle2(self, request):
...
In this example, ``View`` is the **parent router** which handle get
requests only.
The decorator injects the :attr:`rule_method` attribute to the
method it decorates. The attribute is a **five elements tuple**
which contains the :class:`Route`, the HTTP ``method``, a
dictionary of additional ``parameters``, ``position`` and
the ``order``. Position and order are internal integers used by the
:class:`.Router` when deciding the order of url matching.
If ``position`` is not passed,
the order will be given by the position of each method in the
:class:`.Router` class.
The decorated method are stored in the :attr:`.Router.rule_methods` class
attribute.
>>> len(View.rule_methods)
2
>>> View.rule_methods['foo'].rule
/foo
>>> View.rule_methods['foo'].method
'get'
>>> View.rule_methods['handle2'].rule
/bla
>>> View.rule_methods['handle2'].method
'post'
Check the :ref:`HttpBin example <tutorials-httpbin>`
for a sample usage.
:param rule: Optional string for the relative url served by the method
which is decorated. If not supplied, the method name is used.
:param method: Optional HTTP method name. Default is `get`.
:param defaults: Optional dictionary of default variable values used when
initialising the :class:`Route` instance.
:param position: Optional positioning of the router within the
list of child routers of the parent router
:param parameters: Additional parameters used when initialising
the :class:`.Router` created by this decorator
'''
creation_count = 0
def __init__(self, rule=None, method=None, defaults=None,
position=None, re=False, **parameters):
self.__class__.creation_count += 1
self.position = position
self.creation_count = self.__class__.creation_count
self.rule = rule
self.re = re
self.defaults = defaults
self.method = method
self.parameters = parameters
@property
def order(self):
return self.creation_count if self.position is None else self.position
def __call__(self, callable):
bits = callable.__name__.split('_')
method = None
if len(bits) > 1:
m = bits[0].upper()
if m in ENCODE_URL_METHODS or m in ENCODE_BODY_METHODS:
method = m
bits = bits[1:]
name = self.parameters.get('name', '_'.join(bits))
self.parameters['name'] = name
method = self.method or method or 'get'
if isinstance(method, (list, tuple)):
method = tuple((m.lower() for m in method))
else:
method = method.lower()
rule = Route(self.rule or name, defaults=self.defaults, is_re=self.re)
callable.rule_method = rule_info(rule, method, self.parameters,
self.position, self.order)
return callable
[docs]class Route:
'''A Route is a class with a relative :attr:`path`.
:parameter rule: a normal URL path with ``placeholders`` in the
format ``<converter(parameters):name>``
where both the ``converter`` and the ``parameters`` are optional.
If no ``converter`` is defined the `default` converter is used which
means ``string``, ``name`` is the variable name.
:parameter defaults: optional dictionary of default values for the rule
variables.
.. attribute:: is_leaf
If ``True``, the route is equivalent to a file.
For example ``/bla/foo``
.. attribute:: rule
The rule string, does not include the initial ``'/'``
.. attribute:: path
The full rule for this route including initial ``'/'``.
.. attribute:: variables
a set of variable names for this route. If the route has no
variables, the set is empty.
.. _werkzeug: https://github.com/mitsuhiko/werkzeug
'''
def __init__(self, rule, defaults=None, is_re=False):
rule = remove_double_slash('/%s' % rule)
self.defaults = defaults if defaults is not None else {}
self.is_leaf = not rule.endswith('/')
self.rule = rule[1:]
self.variables = set(map(str, self.defaults))
breadcrumbs = []
self._converters = {}
regex_parts = []
if self.rule:
for bit in self.rule.split('/'):
if not bit:
continue
s = bit[0]
e = bit[-1]
if s == '<' or e == '>':
if s + e != '<>':
raise ValueError(
'malformed rule {0}'.format(self.rule))
converter, parameters, variable = parse_rule(bit[1:-1])
if variable in self._converters:
raise ValueError('variable name {0} used twice in '
'rule {1}.'.format(variable,
self.rule))
convobj = get_converter(converter, parameters)
regex_parts.append('(?P<%s>%s)' % (variable,
convobj.regex))
breadcrumbs.append((True, variable))
self._converters[variable] = convobj
self.variables.add(str(variable))
else:
variable = bit if is_re else re.escape(bit)
regex_parts.append(variable)
breadcrumbs.append((False, bit))
self.breadcrumbs = tuple(breadcrumbs)
self._regex_string = '/'.join(regex_parts)
if self._regex_string and not self.is_leaf:
self._regex_string += '/'
self._regex = re.compile(self.regex, re.UNICODE)
@property
def level(self):
return len(self.breadcrumbs)
@property
def path(self):
return '/' + self.rule
@property
def name(self):
'''A nice name for the route.
Derived from :attr:`rule` replacing underscores and dashes.
'''
return slugify(self.rule, separator='_')
@property
def regex(self):
if self.is_leaf:
return '^' + self._regex_string + '$'
else:
return '^' + self._regex_string
@property
def bits(self):
return tuple((b[1] for b in self.breadcrumbs))
@property
def ordered_variables(self):
'''Tuple of ordered url :attr:`variables`
'''
return tuple((b for dyn, b in self.breadcrumbs if dyn))
def __hash__(self):
return hash(self.rule)
def __repr__(self):
return self.path
def __eq__(self, other):
if isinstance(other, self.__class__):
return str(self) == str(other)
else:
return False
def __lt__(self, other):
if isinstance(other, self.__class__):
return to_string(self) < to_string(other)
else:
raise TypeError('Cannot compare {0} with {1}'.format(self, other))
def _url_generator(self, values):
for is_dynamic, val in self.breadcrumbs:
if is_dynamic:
val = self._converters[val].to_url(values[val])
yield val
[docs] def url(self, **urlargs):
'''Build a ``url`` from ``urlargs`` key-value parameters
'''
if self.defaults:
d = self.defaults.copy()
d.update(urlargs)
urlargs = d
url = '/'.join(self._url_generator(urlargs))
if not url:
return '/'
else:
url = '/' + url
return url if self.is_leaf else url + '/'
def safe_url(self, params=None):
try:
if params:
return self.url(**params)
else:
return self.url()
except KeyError:
return None
[docs] def match(self, path):
'''Match a path and return ``None`` if no matching, otherwise
a dictionary of matched variables with values. If there is more
to be match in the path, the remaining string is placed in the
``__remaining__`` key of the dictionary.'''
match = self._regex.search(path)
if match is not None:
remaining = path[match.end():]
groups = match.groupdict()
result = {}
for name, value in groups.items():
try:
value = self._converters[name].to_python(value)
except Http404:
return
result[str(name)] = value
if remaining:
result['__remaining__'] = remaining
return result
[docs] def split(self):
'''Return a two element tuple containing the parent route and
the last url bit as route. If this route is the root route, it returns
the root route and ``None``. '''
rule = self.rule
if not self.is_leaf:
rule = rule[:-1]
if not rule:
return Route('/'), None
bits = ('/'+rule).split('/')
last = Route(bits[-1] if self.is_leaf else bits[-1] + '/')
if len(bits) > 1:
return Route('/'.join(bits[:-1]) + '/'), last
else:
return last, None
def __add__(self, other):
cls = self.__class__
defaults = self.defaults.copy()
is_re = False
if isinstance(other, cls):
rule = other.rule
defaults.update(other.defaults)
is_re = True
else:
rule = str(other)
return cls('%s/%s' % (self.rule, rule), defaults, is_re=is_re)
class BaseConverter:
"""Base class for all converters."""
regex = '[^/]+'
def to_python(self, value):
return value
def to_url(self, value):
return iri_to_uri(value)
class StringConverter(BaseConverter):
"""This converter is the default converter and accepts any string but
only one path segment. Thus the string can not include a slash.
This is the default validator.
Example::
Rule('/pages/<page>'),
Rule('/<string(length=2):lang_code>')
:param minlength: the minimum length of the string. Must be greater
or equal 1.
:param maxlength: the maximum length of the string.
:param length: the exact length of the string.
"""
def __init__(self, minlength=1, maxlength=None, length=None):
if length is not None:
length = '{%d}' % int(length)
else:
if maxlength is None:
maxlength = ''
else:
maxlength = int(maxlength)
length = '{%s,%s}' % (
int(minlength),
maxlength
)
self.regex = '[^/]' + length
class AnyConverter(BaseConverter):
"""Matches one of the items provided. Items can either be Python
identifiers or strings::
Rule('/<any(about, help, imprint, class, "foo,bar"):page_name>')
:param items: this function accepts the possible items as positional
parameters.
"""
def __init__(self, *items):
self.regex = '(?:%s)' % '|'.join([re.escape(x) for x in items])
class PathConverter(BaseConverter):
"""Like the default :class:`StringConverter`, but it also matches
slashes. This is useful for wikis and similar applications::
Rule('/<path:wikipage>')
Rule('/<path:wikipage>/edit')
"""
regex = '.*'
class NumberConverter(BaseConverter):
"""Baseclass for `IntegerConverter` and `FloatConverter`.
:internal:
"""
def __init__(self, fixed_digits=0, min=None, max=None):
self.fixed_digits = fixed_digits
self.min = min
self.max = max
def to_python(self, value):
if (self.fixed_digits and len(value) != self.fixed_digits):
raise Http404()
value = self.num_convert(value)
if (self.min is not None and value < self.min) or \
(self.max is not None and value > self.max):
raise Http404()
return value
def to_url(self, value):
if (self.fixed_digits and len(str(value)) > self.fixed_digits):
raise ValueError()
value = self.num_convert(value)
if (self.min is not None and value < self.min) or \
(self.max is not None and value > self.max):
raise ValueError()
if self.fixed_digits:
value = ('%%0%sd' % self.fixed_digits) % value
return str(value)
class IntegerConverter(NumberConverter):
"""This converter only accepts integer values::
Rule('/page/<int:page>')
This converter does not support negative values.
:param fixed_digits: the number of fixed digits in the URL. If you set
this to ``4`` for example, the application will
only match if the url looks like ``/0001/``. The
default is variable length.
:param min: the minimal value.
:param max: the maximal value.
"""
regex = r'\d+'
num_convert = int
class FloatConverter(NumberConverter):
"""This converter only accepts floating point values::
Rule('/probability/<float:probability>')
This converter does not support negative values.
:param min: the minimal value.
:param max: the maximal value.
"""
regex = r'\d+\.\d+'
num_convert = float
def __init__(self, min=None, max=None):
super().__init__(0, min, max)
def parse_converter_args(argstr):
argstr += ','
args = []
kwargs = {}
for item in _converter_args_re.finditer(argstr):
value = item.group('stringval')
if value is None:
value = item.group('value')
value = _pythonize(value)
if not item.group('name'):
args.append(value)
else:
name = item.group('name')
kwargs[name] = value
return tuple(args), kwargs
def get_converter(name, parameters):
c = _CONVERTERS.get(name)
if not c:
raise LookupError('Route converter {0} not available'.format(name))
if parameters:
args, kwargs = parse_converter_args(parameters)
return c(*args, **kwargs)
else:
return c()
#: the default converter mapping for the map.
_CONVERTERS = {
'default': StringConverter,
'string': StringConverter,
'any': AnyConverter,
'path': PathConverter,
'int': IntegerConverter,
'float': FloatConverter
}