Skip to content

Commit

Permalink
[#35] Implemented NamedTuple filter.
Browse files Browse the repository at this point in the history
  • Loading branch information
todofixthis committed Mar 8, 2018
1 parent 38c2f95 commit cbe343f
Show file tree
Hide file tree
Showing 2 changed files with 170 additions and 2 deletions.
57 changes: 57 additions & 0 deletions filters/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
unicode_literals

import json
from collections import namedtuple
from datetime import date, datetime, time, tzinfo
from typing import Hashable, Iterable, Mapping, Optional, Sequence, Sized, Text, \
Union
Expand All @@ -25,6 +26,7 @@
'Length',
'MaxLength',
'MinLength',
'NamedTuple',
'NoOp',
'NotEmpty',
'Optional',
Expand Down Expand Up @@ -462,6 +464,61 @@ def _apply(self, value):
return value


class NamedTuple(BaseFilter):
"""
Attempts to convert the incoming value into a namedtuple.
"""

CODE_WRONG_NAMEDTUPLE = "wrong_namedtuple"

templates = {
CODE_WRONG_NAMEDTUPLE:
"{incoming_type} is not valid (expected {expected_type})",
}

def __init__(self, type_):
# type: (namedtuple) -> None
super(NamedTuple, self).__init__()

self.type = type_

def _apply(self, value):
# noinspection PyTypeChecker
value = self._filter(value, Type((Iterable, Mapping)))

if self._has_errors:
return None

if isinstance(value, self.type):
return value

if isinstance(value, Mapping):
# Prevent circular import.
from filters.complex import FilterMapper

# noinspection PyProtectedMember
value = self._filter(value, FilterMapper(
{key: None for key in self.type._fields},

allow_extra_keys=False,
allow_missing_keys=False,
))

if self._has_errors:
return None

return self.type(**value)

else:
# noinspection PyProtectedMember
value = self._filter(value, Length(len(self.type._fields)))

if self._has_errors:
return None

return self.type(*value)


class NoOp(BaseFilter):
"""
Filter that does nothing, used when you need a placeholder Filter
Expand Down
115 changes: 113 additions & 2 deletions test/simple_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
from __future__ import absolute_import, division, print_function, \
unicode_literals

from collections import Sequence
from collections import namedtuple
from datetime import date, datetime
from typing import Sized
from typing import Sequence, Sized

from dateutil.tz import tzoffset
from pytz import utc
Expand Down Expand Up @@ -906,6 +906,117 @@ def test_fail_short_collection(self):
# etc.


Constraint = namedtuple("Constraint", ("type", "parameters"))


class NamedTupleTestCase(BaseFilterTestCase):
@staticmethod
def filter_type():
return f.NamedTuple(Constraint)

def test_success_none(self):
"""
``None`` always passes this filter.
Chain with :py:class:`f.Required` if you want to disallow null
values.
"""
self.assertFilterPasses(None)

def test_success_namedtuple_correct_type(self):
"""
Incoming value is already a namedtuple of the expected type.
"""
self.assertFilterPasses(
Constraint("temperature", {"min": -1, "max": 4}),
)

def test_success_namedtuple_different_type(self):
"""
Incoming value is a namedtuple instance, but of a different
type.
Since namedtuples are still tuples, this has the same result as
for any other incoming iterable.
"""
# Just to be tricky, we'll make it look very close to the
# expected type.
AltConstraint = namedtuple("AltConstraint", ("type", "parameters"))

self.assertFilterPasses(
AltConstraint("temperature", {"min": -1, "max": 4}),
Constraint("temperature", {"min": -1, "max": 4}),
)

def test_success_iterable(self):
"""
Incoming value is an iterable with correct values.
"""
value = ["temperature", {"min": -1, "max": 4}]

self.assertFilterPasses(value, Constraint(*value))

def test_success_mapping(self):
"""
Incoming value is a mapping with correct keys.
"""
value = {"type": "temperature", "parameters": {"min": -1, "max": 4}}

self.assertFilterPasses(value, Constraint(**value))

def test_fail_incompatible_type(self):
"""
Incoming value has a type that we cannot work with.
"""
self.assertFilterErrors(42, [f.Type.CODE_WRONG_TYPE])

def test_fail_iterable_too_short(self):
"""
Incoming value is an iterable that is missing one or more
values.
"""
self.assertFilterErrors(("temperature",), [f.MinLength.CODE_TOO_SHORT])

def test_fail_iterable_too_long(self):
"""
Incoming value is an iterable that has too many values.
"""
self.assertFilterErrors(
("temperature", {"min": -1, "max": 4}, None),
[f.MaxLength.CODE_TOO_LONG],
)

def test_fail_mapping_missing_keys(self):
"""
Incoming value is a mapping that is missing one or more keys.
"""
self.assertFilterErrors(
{},

{
"type": [f.FilterMapper.CODE_MISSING_KEY],
"parameters": [f.FilterMapper.CODE_MISSING_KEY],
},
)

def test_fail_mapping_extra_keys(self):
"""
Incoming value is a mapping that has extra keys that we don't
know what to do with.
"""
self.assertFilterErrors(
{
"type": "temperature",
"parameters": {"min": -1, "max": 4},
"notes": None,
},

{
"notes": [f.FilterMapper.CODE_EXTRA_KEY],
},
)


class NoOpTestCase(BaseFilterTestCase):
filter_type = f.NoOp

Expand Down

0 comments on commit cbe343f

Please sign in to comment.