diff --git a/docs/filters_list.rst b/docs/filters_list.rst index a66e0a1..f3ee34c 100644 --- a/docs/filters_list.rst +++ b/docs/filters_list.rst @@ -10,7 +10,7 @@ This is in contrast to :ref:`Complex Filters `, which operate on collections of values. String Filters --------------- +^^^^^^^^^^^^^^ These filters are designed to operate on (or convert to) string values. *Important:* to ensure consistent behavior between Python 2 and Python 3, @@ -129,7 +129,7 @@ string filters only accept unicode strings, unless otherwise noted. version in the filter initializer. Number Filters --------------- +^^^^^^^^^^^^^^ These filters are designed to operate on (or convert to) numeric types. :py:class:`filters.Decimal` @@ -172,7 +172,7 @@ These filters are designed to operate on (or convert to) numeric types. `floating-point precision `_. Collection Filters ------------------- +^^^^^^^^^^^^^^^^^^ These filters are designed to operate on collections of values. Most of these filters can also operate on strings, except where noted. @@ -227,7 +227,7 @@ Most of these filters can also operate on strings, except where noted. Miscellaneous Filters ---------------------- +^^^^^^^^^^^^^^^^^^^^^ These filters do various things that defy categorization. :py:class:`filters.Array` @@ -236,6 +236,18 @@ These filters do various things that defy categorization. For example, ``list`` or any class that extends ``typing.Sequence`` will pass, but any string type (or subclass thereof) will fail. +:py:class:`filters.Call` + Calls an arbitrary function on the incoming value. + + This filter is almost always inferior to + :doc:`creating a custom filter `, but it can be a useful + way to quickly inject a function into a filter workflow to see if it will + work. + + .. important:: + The function must raise a :py:class:`filters.FilterError` to indicate that + the incoming value is not valid. + :py:class:`filters.NoOp` This filter returns the incoming value unmodified. @@ -309,6 +321,51 @@ These filters are covered in more detail in :doc:`/complex_filters`. ``FilterRepeater`` can also process mappings (e.g., ``dict``); it will apply the filters to every value in the mapping, preserving the keys. +:py:class:`filters.FilterSwitch` + Conditionally invokes a filter based on the output of a function. + + ``FilterSwitch`` takes 2-3 parameters: + + - ``getter: Callable[[Any], Hashable]`` - a function that extracts the + comparison value from the incoming value. Whatever this function returns + will be matched against the keys in ``cases``. + - ``cases: Mapping[Hashable, FilterCompatible]`` - a mapping of valid + comparison values and their corresponding filters. + - ``default: Optional[FilterCompatible]`` - if specified, this is the filter + that will be used if the comparison value doesn't match any cases. If not + specified, then the incoming value will be considered invalid if the + comparison value doesn't match any cases. + + Example of a ``FilterSwitch`` that selects the correct filter to use based + upon the incoming value's ``name`` value: + + .. code-block:: py + + switch = f.FilterSwitch( + # This function will extract the comparison value. + getter=lambda value: value['name'], + + # These are the cases that the comparison value might + # match. + cases={ + 'price': f.FilterMapper({'value': f.Int | f.Min(0)}), + 'color': f.FilterMapper({'value': f.Choice({'r', 'g', 'b'})}), + # etc. + }, + + # This is the filter that will be used if none of the cases match. + default=f.FilterMapper({'value': f.Unicode}), + ) + + # Applies the 'price' filter: + switch.apply({'name': price, 'value': 42}) + + # Applies the 'color' filter: + switch.apply({'name': color, 'value': 'b'}) + + # Applies the default filter: + switch.apply({'name': 'mfg', 'value': 'Acme Widget Co.'}) + Extensions ========== The following filters are provided by the diff --git a/docs/writing_filters.rst b/docs/writing_filters.rst index 89b8449..4f84560 100644 --- a/docs/writing_filters.rst +++ b/docs/writing_filters.rst @@ -104,7 +104,7 @@ To create a new filter, write a class that extends Validation ----------- +^^^^^^^^^^ To implement validation in your filter, add the following: - Define a unique code for each validation error. @@ -136,7 +136,7 @@ Here's the ``Pkcs7Pad`` filter with a little bit of validation logic: Unit Tests ----------- +^^^^^^^^^^ To help you unit test your custom filters, the Filters library provides a helper class called :py:class:`test.BaseFilterTestCase`. @@ -181,7 +181,7 @@ Here's a starter test case for ``Pkcs7Pad``: Registering Your Filters (Optional) ------------------------------------ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Once you've packaged up your filters, you can register them with the Extensions framework to add them to the (nearly) top-level ``filters.ext`` namespace. diff --git a/filters/aliases.py b/filters/aliases.py new file mode 100644 index 0000000..b9f9d64 --- /dev/null +++ b/filters/aliases.py @@ -0,0 +1,26 @@ +from collections import OrderedDict +from typing import Mapping, Sequence + +__all__ = [ + 'JSON_ALIASES', +] + +# Used by e.g. :py:class:`Type` and :py:class:`Array` to mask +# python-specific names in error messages. +JSON_ALIASES = { + # Builtins + bool: 'Boolean', + bytes: 'String', + dict: 'Object', + float: 'Number', + int: 'Number', + list: 'Array', + str: 'String', + + # Collections + OrderedDict: 'Object', + + # Typing + Mapping: 'Object', + Sequence: 'Array', +} diff --git a/filters/base.py b/filters/base.py index b87418f..06b26a0 100644 --- a/filters/base.py +++ b/filters/base.py @@ -1,7 +1,7 @@ from abc import ABCMeta, abstractmethod as abstract_method from copy import copy from typing import Any, Callable, Iterable, List, Mapping, \ - MutableMapping, Optional as OptionalType, Sequence, Tuple, Union + MutableMapping, Optional as OptionalType, Tuple, Union from weakref import ProxyTypes, proxy __all__ = [ @@ -31,7 +31,7 @@ class FilterMeta(ABCMeta): # noinspection PyShadowingBuiltins def __init__(cls, what, bases=None, dict=None, **kwargs): # noinspection PyArgumentList - super(FilterMeta, cls).__init__(what, bases, dict, **kwargs) + super().__init__(what, bases, dict, **kwargs) if not hasattr(cls, 'templates'): cls.templates = {} @@ -75,11 +75,11 @@ class BaseFilter(metaclass=FilterMeta): } def __init__(self): - super(BaseFilter, self).__init__() + super().__init__() - self._parent = None # type: Optional[BaseFilter] - self._handler = None # type: Optional[BaseInvalidValueHandler] - self._key = None # type: Optional[str] + self._parent = None # type: OptionalType[BaseFilter] + self._handler = None # type: OptionalType[BaseInvalidValueHandler] + self._key = None # type: OptionalType[str] # # Indicates whether the Filter detected any invalid values. @@ -465,7 +465,7 @@ class FilterChain(BaseFilter): """ def __init__(self, start_filter: FilterCompatible = None) -> None: - super(FilterChain, self).__init__() + super().__init__() self._filters = [] # type: List[BaseFilter] @@ -498,7 +498,7 @@ def __copy__(cls, the_filter: 'FilterChain') -> 'FilterChain': """ Creates a shallow copy of the object. """ - new_filter = super(FilterChain, cls).__copy__(the_filter) + new_filter = super().__copy__(the_filter) new_filter._filters = the_filter._filters[:] # noinspection PyTypeChecker return new_filter @@ -583,7 +583,7 @@ def __init__(self, *args, **kwargs): # Exception kwargs are deprecated in Python 3, but keeping them # around for compatibility with Python 2. # noinspection PyArgumentList - super(FilterError, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.context = {} @@ -603,24 +603,6 @@ def handle_invalid_value( raise error -# Used by :py:meth:`Type.__init__` to inject JSON data types in place of -# Python type names in error messages. -JSON_ALIASES = { - # Builtins - bool: 'Boolean', - bytes: 'String', - dict: 'Object', - float: 'Number', - int: 'Number', - list: 'Array', - str: 'String', - - # Typing - Mapping: 'Array', - Sequence: 'Array', -} - - # This filter is used extensively by other filters. # To avoid lots of needless "circular import" hacks, we'll put it in # the base module. @@ -655,7 +637,7 @@ def __init__( This is useful for providing more context- appropriate names to end users and/or masking native Python type names. """ - super(Type, self).__init__() + super().__init__() # A pinch of syntactic sugar. self.allowed_types = ( diff --git a/filters/complex.py b/filters/complex.py index d0096ba..3de8f03 100644 --- a/filters/complex.py +++ b/filters/complex.py @@ -2,12 +2,13 @@ from collections import OrderedDict from filters.base import BaseFilter, FilterCompatible, FilterError, Type -from filters.simple import Length +from filters.simple import Choice, Length from filters.string import Unicode __all__ = [ 'FilterMapper', 'FilterRepeater', + 'FilterSwitch', 'NamedTuple', ] @@ -51,7 +52,7 @@ def __init__( Set to ``None`` (default) to allow any key/index. """ - super(FilterRepeater, self).__init__() + super().__init__() self._filter_chain = self.resolve_filter(filter_chain, parent=self) @@ -72,7 +73,7 @@ def __copy__(cls, the_filter: 'FilterRepeater') -> 'FilterRepeater': """ Creates a shallow copy of the object. """ - new_filter = super(FilterRepeater, cls).__copy__(the_filter) + new_filter = super().__copy__(the_filter) new_filter._filter_chain = the_filter._filter_chain new_filter.restrict_keys = the_filter.restrict_keys @@ -225,7 +226,7 @@ def __init__( omitted from the filtered value. - : Only the specified extra keys are allowed. """ - super(FilterMapper, self).__init__() + super().__init__() self._filters = OrderedDict() @@ -390,6 +391,54 @@ def unicodify_key(key: typing.Any) -> str: return repr(key) +class FilterSwitch(BaseFilter): + """ + Chooses the next filter to apply based on the output of a callable. + """ + + def __init__( + self, + getter: typing.Callable[[typing.Any], typing.Hashable], + cases: typing.Mapping[typing.Hashable, FilterCompatible], + default: typing.Optional[FilterCompatible] = None, + ) -> None: + """ + :param getter: + Callable used to extract the value to match against switch + cases. + + :param cases: + Mapping of possible values to the corresponding filters. + + :param default: + Default filter to use, if none of the cases are matched. + + If null (default) then the value will be considered invalid + if it doesn't match any cases. + """ + super().__init__() + + self.getter = getter + self.cases = cases + self.default = default + + def _apply(self, value): + gotten = self.getter(value) # type: typing.Hashable + + if not self.default: + gotten = self._filter(gotten, Choice(self.cases.keys())) + + if self._has_errors: + return None + + if gotten in self.cases: + return self._filter(value, self.cases[gotten]) + + # If we get here, then we have set a default filter. + return self._filter(value, self.default) + + + class NamedTuple(BaseFilter): """ Attempts to convert the incoming value into a namedtuple. @@ -426,7 +475,7 @@ def __init__( >>> filter_chain.apply(['64', '128', '192']) Color(r=64, g=128, b=192) """ - super(NamedTuple, self).__init__() + super().__init__() self.type = type_ diff --git a/filters/extensions.py b/filters/extensions.py index 0aad377..87b602a 100644 --- a/filters/extensions.py +++ b/filters/extensions.py @@ -46,7 +46,7 @@ class FilterExtensionRegistry(EntryPointClassRegistry): """ def __init__(self, group: str = GROUP_NAME) -> None: - super(FilterExtensionRegistry, self).__init__(group) + super().__init__(group) def __getattr__(self, item: str) -> typing.Type[BaseFilter]: return self[item] diff --git a/filters/handlers.py b/filters/handlers.py index e6658fe..73b54d1 100644 --- a/filters/handlers.py +++ b/filters/handlers.py @@ -29,7 +29,7 @@ def __init__( :param logger: The logger that log messages will get sent to. :param level: Level of the logged messages. """ - super(LogHandler, self).__init__() + super().__init__() self.logger = logger self.level = level @@ -62,7 +62,7 @@ def __init__( """ :param exc_info: Exception traceback (if applicable). """ - super(FilterMessage, self).__init__() + super().__init__() self.message = message self.context = context @@ -117,7 +117,7 @@ def __init__(self, capture_exc_info: bool = False) -> None: Regardless, you can still check ``self.has_exceptions`` to see if an exception occurred. """ - super(MemoryHandler, self).__init__() + super().__init__() self.messages = OrderedDict() # type: typing.Union[OrderedDict, typing.Dict[str, typing.List[FilterMessage]]] self.has_exceptions = False @@ -148,7 +148,7 @@ def handle_exception(self, message: str, exc: Exception) -> typing.Any: if self.capture_exc_info: self.exc_info.append(sys.exc_info()) - return super(MemoryHandler, self).handle_exception(message, exc) + return super().handle_exception(message, exc) class FilterRunner(object): @@ -182,7 +182,7 @@ def __init__( Regardless, you can still check ``self.has_exceptions`` to see if an exception occurred. """ - super(FilterRunner, self).__init__() + super().__init__() self.filter_chain = BaseFilter.resolve_filter(starting_filter) self.data = incoming_data diff --git a/filters/macros.py b/filters/macros.py index 00c3c09..e22420e 100644 --- a/filters/macros.py +++ b/filters/macros.py @@ -64,7 +64,7 @@ def __new__(mcs, name, bases, attrs): # Note that we ignore the ``name`` argument, passing in # ``func.__name__`` instead. - return super(FilterMacroMeta, mcs) \ + return super() \ .__new__(mcs, func.__name__, bases, attrs) def __call__(cls, *runtime_args, **runtime_kwargs): diff --git a/filters/number.py b/filters/number.py index d1edf82..4ae1572 100644 --- a/filters/number.py +++ b/filters/number.py @@ -50,7 +50,7 @@ def __init__( tightly to Python's Decimal type, so you have the option to disallow it. """ - super(Decimal, self).__init__() + super().__init__() # Convert e.g., 3 => DecimalType('.001'). if not ( @@ -163,7 +163,7 @@ def __init__(self, max_value: typing.Any, exclusive: bool = False) -> None: - False (default): The incoming value must be _less than or equal to_ the max value. """ - super(Max, self).__init__() + super().__init__() self.max_value = max_value self.exclusive = exclusive @@ -230,7 +230,7 @@ def __init__(self, min_value: typing.Any, exclusive: bool = False) -> None: - False (default): The incoming value must be _greater than or equal to_ the min value. """ - super(Min, self).__init__() + super().__init__() self.min_value = min_value self.exclusive = exclusive @@ -296,7 +296,7 @@ def __init__(self, :param result_type: The type of result to return. """ - super(Round, self).__init__() + super().__init__() self.to_nearest = DecimalType(to_nearest) diff --git a/filters/simple.py b/filters/simple.py index 502a1c9..024636b 100644 --- a/filters/simple.py +++ b/filters/simple.py @@ -12,6 +12,7 @@ __all__ = [ 'Array', 'ByteArray', + 'Call', 'Choice', 'Date', 'Datetime', @@ -35,10 +36,10 @@ def __init__( self, aliases: typing.Optional[typing.Mapping[type, str]] = None, ) -> None: - super(Array, self).__init__(typing.Sequence, True, aliases) + super().__init__(typing.Sequence, True, aliases) def _apply(self, value): - value = super(Array, self)._apply(value) # type: typing.Sequence + value = super()._apply(value) # type: typing.Sequence if self._has_errors: return None @@ -73,7 +74,7 @@ def __init__(self, encoding: str = 'utf-8') -> None: :param encoding: The encoding to use when decoding strings into bytes. """ - super(ByteArray, self).__init__() + super().__init__() self.encoding = encoding @@ -121,6 +122,50 @@ def _apply(self, value): return bytearray(filtered) +class Call(BaseFilter): + """ + Runs the value through a callable. + + Usually, creating a custom filter type works better, as you have + more control over how invalid values are handled, you can specify + custom error codes, it's easier to write tests for, etc. + + But, in a pinch, this is a handy way to quickly integrate a custom + function into a filter chain. + """ + + def __init__(self, + callable_: typing.Callable[..., typing.Any], + *extra_args, + **extra_kwargs + ) -> None: + """ + :param callable_: + The callable that will be applied to incoming values. + + :param extra_args: + Extra positional arguments to pass to the callable. + + :param extra_kwargs: + Extra keyword arguments to pass to the callable. + """ + super().__init__() + + self.callable = callable_ + self.extra_args = extra_args + self.extra_kwargs = extra_kwargs + + def _apply(self, value): + try: + return self.callable(value, *self.extra_args, **self.extra_kwargs) + except Exception as e: + return self._invalid_value( + value, + reason=e, + exc_info=True, + ) + + class Choice(BaseFilter): """ Expects the value to match one of the items in a set. @@ -136,7 +181,7 @@ class Choice(BaseFilter): } def __init__(self, choices: typing.Iterable[typing.Hashable]) -> None: - super(Choice, self).__init__() + super().__init__() self.choices = set(choices) @@ -198,7 +243,7 @@ def __init__( IMPORTANT: Incoming values are still converted to UTC before stripping tzinfo! """ - super(Datetime, self).__init__() + super().__init__() if not isinstance(timezone, tzinfo): if timezone in [0, None]: @@ -276,7 +321,7 @@ def _apply(self, value): if isinstance(value, date) and not isinstance(value, datetime): return value - filtered = super(Date, self)._apply(value) # type: datetime + filtered = super()._apply(value) # type: datetime # Normally we return `None` if we get any errors, but in this # case, we'll let the superclass method decide. @@ -326,7 +371,7 @@ class Length(BaseFilter): } def __init__(self, length: int) -> None: - super(Length, self).__init__() + super().__init__() self.length = length @@ -375,7 +420,7 @@ class MaxLength(BaseFilter): } def __init__(self, max_length: int) -> None: - super(MaxLength, self).__init__() + super().__init__() self.max_length = max_length @@ -416,7 +461,7 @@ class MinLength(BaseFilter): } def __init__(self, min_length: int) -> None: - super(MinLength, self).__init__() + super().__init__() self.min_length = min_length @@ -483,7 +528,7 @@ def __init__(self, allow_none: bool = True) -> None: :param allow_none: Whether to allow ``None``. """ - super(NotEmpty, self).__init__() + super().__init__() self.allow_none = allow_none @@ -520,7 +565,7 @@ class Required(NotEmpty): } def __init__(self): - super(Required, self).__init__(allow_none=False) + super().__init__(allow_none=False) class Optional(BaseFilter): @@ -538,7 +583,7 @@ def __init__(self, default=None): :param default: The default value used to replace empty values. """ - super(Optional, self).__init__() + super().__init__() self.default = default diff --git a/filters/string.py b/filters/string.py index d088fd7..ec1dd87 100644 --- a/filters/string.py +++ b/filters/string.py @@ -40,7 +40,7 @@ class Base64Decode(BaseFilter): } def __init__(self): - super(Base64Decode, self).__init__() + super().__init__() self.whitespace_re = regex.compile(b'[ \t\r\n]+', regex.ASCII) self.base64_re = regex.compile(b'^[-+_/A-Za-z0-9=]+$', regex.ASCII) @@ -134,7 +134,7 @@ class IpAddress(BaseFilter): } def __init__(self, ipv4: bool = True, ipv6: bool = False) -> None: - super(IpAddress, self).__init__() + super().__init__() self.ipv4 = ipv4 self.ipv6 = ipv6 @@ -209,7 +209,7 @@ class JsonDecode(BaseFilter): } def __init__(self, decoder: typing.Callable = json.loads) -> None: - super(JsonDecode, self).__init__() + super().__init__() self.decoder = decoder @@ -269,7 +269,7 @@ def __init__( Note: This filter is optimized for UTF-8. """ - super(MaxBytes, self).__init__() + super().__init__() self.encoding = encoding self.max_bytes = max_bytes @@ -467,7 +467,7 @@ def __init__(self, pattern: typing.Union[str, typing.Pattern]) -> None: IMPORTANT: If you specify your own compiled regex, be sure to add the ``UNICODE`` flag for Unicode support! """ - super(Regex, self).__init__() + super().__init__() self.regex = ( pattern @@ -533,7 +533,7 @@ def __init__( IMPORTANT: If ``keys`` is set, the split value's length must be less than or equal to ``len(keys)``. """ - super(Split, self).__init__() + super().__init__() self.regex = ( pattern @@ -595,7 +595,7 @@ def __init__( :param trailing: Regex to match at the end of the string. """ - super(Strip, self).__init__() + super().__init__() if leading: self.leading = regex.compile( @@ -667,7 +667,7 @@ def __init__( - Remove non-printable characters. - Convert all line endings to unix-style ('\n'). """ - super(Unicode, self).__init__() + super().__init__() self.encoding = encoding self.normalize = normalize @@ -775,11 +775,11 @@ def __init__( :py:class:`ByteString`, but ``True`` by default for :py:class:`Unicode`. """ - super(ByteString, self).__init__(encoding, normalize) + super().__init__(encoding, normalize) # noinspection SpellCheckingInspection def _apply(self, value): - decoded = super(ByteString, self)._apply(value) # type: str + decoded = super()._apply(value) # type: str # # No need to catch UnicodeEncodeErrors here; UTF-8 can handle @@ -834,7 +834,7 @@ def __init__(self, version: typing.Optional[int] = None) -> None: References: - https://en.wikipedia.org/wiki/Uuid#RFC_4122_Variant """ - super(Uuid, self).__init__() + super().__init__() self.version = version diff --git a/setup.py b/setup.py index 1641ec8..d35c7e4 100644 --- a/setup.py +++ b/setup.py @@ -19,13 +19,12 @@ ## # Off we go! -# noinspection SpellCheckingInspection setup( name = 'phx-filters', description = 'Validation and data pipelines made easy!', url = 'https://filters.readthedocs.io/', - version = '2.0.0', + version = '2.0.1', packages = ['filters'], diff --git a/test/complex_test.py b/test/complex_test.py index 074caf2..8cc4632 100644 --- a/test/complex_test.py +++ b/test/complex_test.py @@ -1192,3 +1192,93 @@ def test_fail_filter_map(self): 'b': [f.Decimal.CODE_INVALID], }, ) + + +class FilterSwitchTestCase(BaseFilterTestCase): + filter_type = f.FilterSwitch + + def test_pass_none(self): + """ + ``None`` always passes this filter. + + Use ``f.Required | f.FilterSwitch`` to reject null values. + """ + self.assertFilterPasses( + self._filter( + None, + + getter=lambda value: value['anything'], + cases={}, + ), + ) + + def test_pass_match_case(self): + """ + The incoming value matches one of the switch cases. + """ + self.assertFilterPasses( + self._filter( + {'name': 'positive', 'value': 42}, + + getter=lambda value: value['name'], + cases={ + 'positive': f.FilterMapper({'value': f.Int | f.Min(0)}), + }, + ), + ) + + def test_fail_match_case(self): + """ + The incoming value matches one of the switch cases, but it is + not valid, according to the corresponding filter. + """ + self.assertFilterErrors( + self._filter( + {'name': 'positive', 'value': -1}, + + getter=lambda value: value['name'], + cases={ + 'positive': f.FilterMapper({'value': f.Int | f.Min(0)}), + }, + ), + {'value': [f.Min.CODE_TOO_SMALL]}, + + # The result is the exact same as if the value were passed + # directly to the corresponding filter. + expected_value={'name': 'positive', 'value': None}, + ) + + def test_pass_default(self): + """ + The incoming value does not match any of the switch cases, but + we defined a default filter. + """ + self.assertFilterPasses( + self._filter( + {'name': 'negative', 'value': -42}, + + getter=lambda value: value['name'], + cases={ + 'positive': f.FilterMapper({'value': f.Int | f.Min(0)}), + }, + default=f.FilterMapper({'value': f.Int | f.Max(0)}), + ), + ) + + def test_fail_no_default(self): + """ + The incoming value does not match any of the switch cases, and + we did not define a default filter. + """ + self.assertFilterErrors( + self._filter( + {'name': 'negative', 'value': -42}, + + getter=lambda value: value['name'], + cases={ + 'positive': f.FilterMapper({'value': f.Int | f.Min(0)}), + }, + ), + + [f.Choice.CODE_INVALID], + ) diff --git a/test/simple_test.py b/test/simple_test.py index 0f23726..387591b 100644 --- a/test/simple_test.py +++ b/test/simple_test.py @@ -15,7 +15,7 @@ class Lengthy(typing.Sized): """ def __init__(self, length): - super(Lengthy, self).__init__() + super().__init__() self.length = length def __len__(self): @@ -30,7 +30,7 @@ class Bytesy(object): """ def __init__(self, value): - super(Bytesy, self).__init__() + super().__init__() self.value = value def __bytes__(self): @@ -45,7 +45,7 @@ class Unicody(object): """ def __init__(self, value): - super(Unicody, self).__init__() + super().__init__() self.value = value def __str__(self): @@ -297,6 +297,89 @@ def test_fail_unencodable_unicode(self): ) +class CallTestCase(BaseFilterTestCase): + filter_type = f.Call + + def test_pass_none(self): + """ + ``None`` always passes this Filter. + + Use ``Required | Call`` if you want to reject null values. + """ + def always_fail(value): + raise ValueError('{value} is not valid!'.format(value=value)) + + self.assertFilterPasses( + self._filter(None, always_fail) + ) + + def test_pass_successful_execution(self): + """ + The callable runs successfully. + """ + def is_odd(value): + return value % 2 + + self.assertFilterPasses( + self._filter(6, is_odd), + + # Note that ANY value returned by the callable is considered + # valid; if you want custom handling of some values, you're + # better off creating a custom Filter type (it's super + # easy!). + False, + ) + + def test_fail_filter_error(self): + """ + The callable raises a :py:class:`FilterError`. + """ + def even_only(value): + if value % 2: + raise f.FilterError('value is not even!') + return value + + self.assertFilterErrors( + self._filter(5, even_only), + ['value is not even!'] + ) + + def test_fail_filter_error_custom_code(self): + """ + The callable raises a :py:class:`FilterError` with a custom + error code. + """ + def even_only(value): + if value % 2: + # If you find yourself doing this, you would probably be + # better served by creating a custom filter instead. + error = f.FilterError('value is not even!') + error.context = {'code': 'not_even'} + raise error + return value + + self.assertFilterErrors( + self._filter(5, even_only), + ['not_even'], + ) + + def test_error_exception(self): + """ + The callable raises an exception other than a + :py:class:`FilterError`. + """ + def even_only(value): + if value % 2: + raise ValueError('{value} is not even!') + return value + + filter_ = self._filter(5, even_only) + + # :py:class:`Call` assumes that any exception other than a + # :py:class:`FilterError` represents an error in the code. + self.assertTrue(filter_.has_exceptions) + + class ChoiceTestCase(BaseFilterTestCase): filter_type = f.Choice