"""This module contains some utility modules for handling URL
building, and the aspect of handling several parts of the URL, each
separated by different separators, that may be provided or not (thus,
handling separators becomes a bit more complicated).
"""
from copy import copy
from itertools import chain
from functools import partial, wraps
[docs]def pass_tuple(count=1):
"""Returns a decorator that wraps a function to make it run witout the
first part of a tuple in its original arguments and return the omitted
arguments contatenated with its original return value.
:argument count: Number of arguments to omit, default 1.
:type count: int
:returns: Decorator
:rtype: function
.. warning::
This function is not the actual decorator, but a function that
returns that decorator (with the given tuple slice index). If
it is used as a decorator, it should be written
``@pass_tuple()`` instead of ``@pass_tuple``.
>>> pack_with_42 = lambda x: (42, x)
>>> pack_with_42(8)
(42, 8)
>>> add_2 = pass_tuple()(lambda x: x + 2)
>>> add_2(pack_with_42(8))
(42, 10)
>>> mul_2 = pass_tuple()(lambda x: x*2)
>>> mul_2(add_2(pack_with_42(8)))
(42, 20)
>>> unpack = lambda x: x[0] + x[1]
>>> unpack(mul_2(add_2(pack_with_42(8))))
62
"""
def decorator(func):
"""Wrap a function to make it run witout the first part of a tuple in
its original arguments and return the omitted items
concatenated with its original return value.
"""
@wraps(func)
def decorated(args_tuple, *args, **kwargs):
"""Return concatenation of omitted items (``args_tuple[:count]``) and
result of original function called without omitted items
(``args_tuple[count:]``).
"""
return args_tuple[:count] + (
func(
*(tuple(args_tuple[count:]) + tuple(args)),
**kwargs
),
)
return decorated
return decorator
[docs]def compose(functions, *args, **kwargs):
"""Compose functions together
:argument functions: Functions to compose
:type functions: list of callables
:returns: Composed function
:rtype: function
.. note::
This function will pass all other arguments and keyword
arguments to the composed functions.
>>> compose([lambda x: x*2, lambda x: x+2])(5)
12
"""
if args is None: # pragma: no cover
args = []
if kwargs is None: # pragma: no cover
kwargs = {}
funcs = map(
partial(partial, *args, **kwargs),
functions
)
def composed(x):
"""Composed function"""
for func in funcs:
x = func(x)
return x
return composed
[docs]class Separated:
"""Accepts separator options in :func:`__init__`, and provide
:func:`get_separator`, that returns the corresponding separator
for required and optional parts, based on the separator passed to
:func:`__init__` (or set at class-level).
.. inheritance-diagram:: Separated
"""
separator = "/"
"""
:attribute separator: Separator to use in front of a required item
:type separator: str
"""
opt_separator = "/?"
"""
:attribute opt_separator: Separator to use in front of an optional item
:type opt_separator: str
"""
required_default = True
"""
:attribute required_default: If True, items required by default
(when None)
:type required_default: bool
"""
[docs] def __init__(self,
*args,
separator=None,
opt_separator=None,
required_default=None,
**kwargs):
"""Initialize, set separator options
:argument separator: See :attr:`separator`
:argument opt_separator: See :attr:`opt_separator`
:argument required_default: See :attr:`required_default`
"""
self.required = False
if separator:
self.separator = separator
if opt_separator:
self.opt_separator = opt_separator
if required_default: # pragma: no cover
self.required_default = required_default
super().__init__(*args, **kwargs)
[docs] def get_separator(self, required=None):
"""Get the argument separator to use according to the :attr:`required`
argument
:argument required: If False, will return the optional
argument separator instead of the regular
one. Default is True.
:type required: bool
:returns: Separator
:rtype: str
>>> Separated().get_separator()
'/'
>>> Separated().get_separator(True)
'/'
>>> Separated().get_separator(False)
'/?'
"""
if required is None:
required = self.required_default
if required:
return self.separator
else:
return self.opt_separator
[docs]class Parsable:
"""Class whose instances may be called, to return a "parsed" version,
obtained by passing the original version in the parsers returned
by :func:`get_parsers`.
.. inheritance-diagram:: Parsable
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return [lambda x: x*2, lambda x: x+2]
>>>
>>> TestParsable(5)()
12
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return []
>>>
>>> TestParsable(5)()
5
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return [lambda x: None]
>>>
>>> TestParsable(5)()
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return [None]
>>>
>>> TestParsable(5)()
Traceback (most recent call last):
...
TypeError: the first argument must be callable
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return [lambda x, y: None]
>>>
>>> TestParsable(5)()
Traceback (most recent call last):
...
TypeError: <lambda>() missing 1 required positional argument: 'y'
>>> class TestParsable(Parsable, int):
... def get_parsers(self):
... return [lambda: None]
>>>
>>> TestParsable(5)()
Traceback (most recent call last):
...
TypeError: <lambda>() takes 0 positional arguments but 1 was given
"""
[docs] def get_parsers(self):
"""Return parsers list. Base implementation returns an empty list. To
add new parsers, override this function and append/prepend the
functions to use as parsers.
:returns: List of parser functions
:rtype: list
"""
return []
[docs] def __call__(self):
"""Compose the parsers in :func:`get_parsers` using :func:`compose`,
and use the composed function to get the parsed version from the
original version.
:returns: output of parsers
.. seealso::
For doctests that use this member, see
:class:`Parsable`
"""
items = self
parsers = self.get_parsers()
composed_parsers = compose(parsers)
# tuple (bool, str or list (str))
# or str or list (tuple (bool, str or list (str)) or str)
# ->
# iterable(str)
return composed_parsers(items)
[docs]class OptionalPartList(Separated, Parsable, list):
"""Implement Separated and Parsable into a list, to make a separated,
parsable URL part list, that handles optional parts and that uses
registered parsers (from :func:`get_parsers`) when the instance is
called.
Provide two base parsers, that convert, if needed, original
items in 2-tuples (:func:`transform_to_tuple`), and provide a
default value for the first item of the tuple if it's None
(:func:`apply_required_default`).
.. inheritance-diagram:: OptionalPartList
>>> builder = OptionalPartList(
... ["<1>", (None, "<2>"), (False, "<3>")]
... )
>>>
>>> list(builder())
[(True, '<1>'), (True, '<2>'), (False, '<3>')]
>>> failing_builder = OptionalPartList(
... ["<1>", (None, "<2>"), (False, "<3>", "fail")]
... )
>>>
>>> list(failing_builder())
Traceback (most recent call last):
...
ValueError: too many values to unpack (expected 2)
"""
[docs] def __add__(self, other):
"""Concatenate with other iterable, creating a new object..
We override :func:`list.__add__` to return a new
:class:`OptionalPartList` instance, instead of a list
instance.
:argument other: Iterable to concatenate with
:type other: iterable
:return: Concatenated object
:rtype: type(self)
>>> a = OptionalPartList(['foo'])
>>> b = OptionalPartList(['bar'])
>>>
>>> a + b
['foo', 'bar']
>>>
>>> type(a + b)
<class 'django_crucrudile.urlutils.OptionalPartList'>
>>>
>>> (a + b) is a
False
>>>
>>> (a + b) is b
False
>>> (a + None) is a
True
"""
if not other:
return self
new = copy(self)
new.extend(other)
return new
[docs] def __init__(self, iterable=None,
separator=None, opt_separator=None, required_default=None):
"""Initialize, use empty list as iterable if None provided.
:argument iterable: Raw URL part list
:type iterable: iterable
:argument separator: See :func:`Separated.__init__`
:type separator: str
:argument opt_separator: See :func:`Separated.__init__`
:type opt_separator: str
:argument required_default: See :func:`Separated.__init__`
:type required_default: bool
"""
if iterable is None:
iterable = []
super().__init__(
iterable,
separator=separator,
opt_separator=opt_separator,
required_default=required_default
)
[docs] def get_parsers(self):
"""Complement :class:`OptionalPartList` parsers (from
:func:`OptionalPartList.get_parsers`) with
:func:`transform_to_tuple` and :func:`apply_required_default`.
:returns: List of parser functions
:rtype: list
"""
return super().get_parsers() + [
self.transform_to_tuple,
partial(
self.apply_required_default,
default=self.required_default
),
list
]
@staticmethod
@staticmethod
[docs] def apply_required_default(items, default):
"""Apply default value to first element of item if it's None.
:argument items: List of tuples
:type items: iterable
:argument default: Value to use if none provided
:type default: boolean
:returns: List of tuples, with required default value applied
:rtype: iterable of tuple
>>> list(
... OptionalPartList.apply_required_default(
... [
... ('<provided>', '<1>'),
... (None, '<2>')
... ],
... default='<default>'
... )
... )
[('<provided>', '<1>'), ('<default>', '<2>')]
"""
for required, args in items:
if required is None:
required = default
yield required, args
[docs]class URLBuilder(OptionalPartList):
"""Allows building URLs from a list of URL parts. The parts can be
required or optional, this information will be used to determine
which separator to use.
We subclass :class:`OptionalPartList`, and add our parsers in
:func:`get_parsers`, so that they are used when the instance gets
called :
- :func:`filter_empty_items`
- :func:`add_first_item_required_flag`
- :func:`flatten`
- :func:`join`
.. inheritance-diagram:: URLBuilder
>>> builder = URLBuilder(
... ["<1>", (False, "<2>"), (True, "<3>")]
... )
>>>
>>> builder()
(True, '<1>/?<2>/<3>')
>>> builder = URLBuilder(
... ["<1>", "<2>", (False, "<3>")]
... )
>>>
>>> builder()
(True, '<1>/<2>/?<3>')
>>> builder = URLBuilder(
... [(False, "<1>"), "<2>", (False, "<3>")]
... )
>>>
>>> builder()
(False, '<1>/<2>/?<3>')
>>> builder = URLBuilder(
... [(False, "<1>"), None, (True, None)]
... )
>>>
>>> builder()
(False, '<1>')
>>> builder = URLBuilder(
... [(False, "<1>"), 1]
... )
>>>
>>> builder()
Traceback (most recent call last):
...
TypeError: sequence item 2: expected str instance, int found
"""
[docs] def get_parsers(self):
"""Complement :class:`OptionalPartList` parsers (from
:func:`OptionalPartList.get_parsers`) with :func:`filter_empty_items`,
:func:`add_first_item_required_flag`, :func:`flatten` and
:func:`join`.
:returns: List of parser functions
:rtype: list
"""
return super().get_parsers() + [
self.filter_empty_items,
partial(
self.add_first_item_required_flag,
),
partial(
self.flatten,
get_separator=self.get_separator
),
self.join,
]
@staticmethod
[docs] def filter_empty_items(items):
"""
Filter out items that give False when casted to boolean.
:argument items: List of tuples
:type items: iterable
:returns: List of URL part specs (with empty items cleared out)
:rtype: list of tuple
>>> list(URLBuilder.filter_empty_items([
... (None, ''),
... (None, '<not empty>'),
... (None, []),
... (None, None),
... (None, '<not empty 2>'),
... ]))
[(None, '<not empty>'), (None, '<not empty 2>')]
>>> list(URLBuilder.filter_empty_items([
... (None, '<not empty>'),
... None
... ]))
Traceback (most recent call last):
...
TypeError: 'NoneType' object is not iterable
"""
for required, item in items:
if item:
yield required, item
@staticmethod
[docs] def add_first_item_required_flag(items):
"""Return a boolean indicating whether the first item is required,
and the list of items.
:argument items: List of tuples
:type items: iterable
:returns: Tuple with "first item required" flag, and item list
:rtype: tuple : (boolean, list)
>>> output = URLBuilder.add_first_item_required_flag(
... [(False, '<opt>'), (True, '<req>')]
... )
>>>
>>> output[0], list(output[1])
(False, [(False, '<opt>'), (True, '<req>')])
>>> output = URLBuilder.add_first_item_required_flag(
... []
... )
>>>
>>> output[0], list(output[1])
(False, [])
>>> output = URLBuilder.add_first_item_required_flag(
... [(None, )*3]
... )
Traceback (most recent call last):
...
ValueError: too many values to unpack (expected 2)
"""
items = iter(items)
try:
required, item = next(items)
except StopIteration:
return False, items
else:
return required, chain(
((required, item),),
items
)
@staticmethod
@pass_tuple(1)
[docs] def flatten(items, get_separator):
"""Flatten items, adding the separator where required.
:argument items: List of tuples
:type items: iterable
:returns: List of URL parts with separators
:rtype: iterable of str
.. warning::
This function is decorated using :func:`pass_tuple`, the
first part of the tuple in its arguments will be omitted,
and inserted at the beginning of the return value,
automatically. See the documentation of :func:`pass_tuple`
for more information.
>>> get_separator = lambda x: '/'
>>> output = URLBuilder.flatten(
... (None, [(True, '<1>'), (True, '<2>')]),
... get_separator
... )
>>>
>>> output[0], list(output[1])
(None, ['<1>', '/', '<2>'])
>>> from mock import Mock
>>>
>>> get_separator = Mock()
>>> get_separator.side_effect = ['/']
>>> output = URLBuilder.flatten(
... (None, [(True, '<1>'), (True, '<2>')]),
... get_separator
... )
>>>
>>> output[0], list(output[1])
(None, ['<1>', '/', '<2>'])
>>> get_separator.assert_called_once_with(True)
"""
items = iter(items)
required, item = next(items)
if item:
yield item
for required, item in items:
if item:
yield get_separator(required)
yield item
@staticmethod
@pass_tuple(1)
[docs] def join(items):
"""
Concatenate items into a string
:argument items: List of URL parts, with separators
:type items: list of str
:returns: Joined URL parts
:rtype: str
.. warning::
This function is decorated using :func:`pass_tuple`, the
first part of the tuple in its arguments will be passed
automatically. See the documentation of :func:`pass_tuple`
for more information.
>>> URLBuilder.join((None, ['a', 'b']))
(None, 'ab')
>>> URLBuilder.join((None, [['a'], 'b']))
Traceback (most recent call last):
...
TypeError: sequence item 0: expected str instance, list found
>>> URLBuilder.join((None, ['a', None]))
Traceback (most recent call last):
...
TypeError: sequence item 1: expected str instance, NoneType found
"""
return ''.join(items)