import abc
import re
import numbers
from .compat import SimpleNamespace
__all__ = ['BaseField', 'String', 'Number', 'Float', 'Integer',
'Boolean', 'coerce_boolean', 'Choice']
ALWAYS_SUCCESSFUL_RE = SimpleNamespace()
ALWAYS_SUCCESSFUL_RE.search = lambda *args, **kwargs: True
NULL_SENTINEL = object()
[docs]class BaseField(metaclass=abc.ABCMeta):
"""Abstract Base Class for field types.
Cannot be instantiated, but should be inherited to provide all the
useful information that a field might need.
:param any default: A default value to provide if the input is ever
None. If not provided, and nullable is False, a field will
not accept None as an argument.
:param bool nullable: True if this field can be None/null, False
otherwise. Defaults to True.
:param coerce: A function that can coerce any input into
input of a valid type. If it cannot coerce, it should either
return "False" or raise a ValueError. Defaults to a no-op.
Example: ``coerce=int`` would convert values to int where possible.
"""
def __init__(self, **kwargs):
# pass-through by default
self.coerce_func = kwargs.pop('coerce', lambda x: x)
self.default = kwargs.pop('default', NULL_SENTINEL)
self._has_default = self.default is not NULL_SENTINEL
self.nullable = kwargs.pop('nullable', not self._has_default)
self._accept_none = self.nullable
if len(kwargs) > 0:
msg = "Unrecognised parameter(s) passed to field: {d}"
raise TypeError(msg.format(d=kwargs))
@abc.abstractmethod
[docs] def check(self, val):
"""Base case method to check if a value is allowed by this field.
Must be overriden. Currently only returns True, but may do its
own checking in future, and so should probably be checked before
any overriden method.
:param any val: Value to check
:return: Whether that value is allowed by the parameters given to this
field.
"""
return True
def get_value(self, val):
if not self.check(val) and not self._has_default:
raise ValueError("Invalid value {d} with no default".format(d=val))
if self.check(val):
return val
else:
return self.default
[docs] def coerce(self, val):
"""Attempt to coerce a value using the pre-defined function.
If no function was passed in, the default operation is to
return the value straight through. If the function fails to
coerce (i.e. raises ValueError), the value is returned
unchanged. (`type_check` should therefore always be used to
check the type of a coerced value.)
:param any val: Value to coerce
:return: Coerced value
"""
try:
return self.coerce_func(val)
except ValueError:
return val
[docs] def type_check(self, val, typ=None):
"""Check if value is of a certain type (using nullability).
If this field instance can be nulled, checks if the val is
either of type ``typ`` or of the None type. Otherwise, it just
checks if the val is of type ``typ``. Note that ``typ`` is passed
straight through to ``isinstance``, so it can be any value allowed
by the second parameter of ``isinstance``.
:param any val: Value to check
:param typ: Type(s) to check against
:return: Whether val is of type ``typ``.
"""
if typ is None: # just check nullability
return self._accept_none or val is not None
if self._accept_none:
return isinstance(val, typ) or val is None
else:
return isinstance(val, typ)
[docs]class String(BaseField):
"""A field representing string types.
Parameters:
regex (str or regex): Regular expression that this string must match.
If not present, any string will match. Can be either a regular
expression object, or a string.
maxlen (int): Maximum length of the string. 'None' (default) for no
length restrictions.
"""
def __init__(self, **kwargs):
self.max_len = kwargs.pop('maxlen', None)
regex = kwargs.pop('regex', None)
super().__init__(**kwargs)
if regex is None:
self.regex = ALWAYS_SUCCESSFUL_RE
elif isinstance(regex, str):
self.regex = re.compile(regex)
else:
self.regex = regex
def check(self, val):
if not super().check(val): # pragma: no cover
return False
val = self.coerce(val)
if not self.type_check(val, str):
return False
if self.regex is not None and val is not None:
if self.regex.search(val) is None:
return False
if self.max_len is not None and val is not None:
if self.max_len < len(val):
return False
return True
[docs]class Number(BaseField):
"""A field representing real numeric types.
:param numeric min: The minimum (inclusive) value that this field can
contain. If not specified, there is no minimum.
:param numeric max: The maximum (inclusive) value that this field can
contain. If not specified, there is no maximum.
"""
def __init__(self, **kwargs):
self.min = kwargs.pop('min', None)
self.max = kwargs.pop('max', None)
super().__init__(**kwargs)
def check(self, val):
if not super().check(val): # pragma: no cover
return False
val = self.coerce(val)
if not self.type_check(val, numbers.Real):
return False
if self.min is not None and val < self.min:
return False
if self.max is not None and val > self.max:
return False
return True
[docs]class Integer(Number):
"""A field representing integers.
:param numeric min: See :py:class:`.Number`
:param numeric max: See :py:class:`.Number`
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
def check(self, val):
if not super().check(val):
return False
val = self.coerce(val)
# NOTE: booleans are ints. This is bad, but there's nothing really
# that can be done about it. :(
if not self.type_check(val, int) or isinstance(val, bool):
return False
return True
[docs]class Float(Number):
"""A field representing floating point numbers.
:param numeric min: See :py:class:`.Number`
:param numeric max: See :py:class:`.Number`
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
def check(self, val):
if not super().check(val):
return False
val = self.coerce(val)
if not self.type_check(val, float):
return False
return True
BOOLEAN_TRUE = ("yes", "y", "true", "t", "on")
BOOLEAN_FALSE = ("no", "n", "false", "f", "off")
[docs]def coerce_boolean(val):
"""A useful function for coercing various types to boolean.
Unlike the usual Python :py:func:`bool` function which simply tests if a
value is empty, this matches boolean ``True``, strings in the set
``{'yes', 'y', 'true', 't', 'on'}`` and the integer ``1`` for ``True``,
or boolean ``False``, strings in the set
``{'no', 'n', 'false', 'f', 'off'}`` and the integer ``0`` for ``False``.
This is done in a case-insensitive manner. If the value is a string not in
the described sets, a number that doesn't equal ``1`` or ``0``, or any
other type (excepting :py:class:`boolean` of course), this function will
raise :py:class:`ValueError`.
"""
if isinstance(val, str):
if val.lower() in BOOLEAN_TRUE:
return True
elif val.lower() in BOOLEAN_FALSE:
return False
ival = int(val) # will raise ValueError if impossible
if ival == 1:
return True
elif ival == 0:
return False
else:
raise ValueError("Could not coerce {val}".format(val=val))
[docs]class Boolean(BaseField):
"""A field representing boolean values
See :py:func:`.coerce_boolean` for a useful coercion function for this
field."""
def __init__(self, **kwargs):
super().__init__(**kwargs)
def check(self, val):
if not super().check(val): # pragma: no cover
return False
val = self.coerce(val)
if not self.type_check(val, (bool, int)):
return False
if val is not None and int(val) not in (0, 1):
return False
return True
[docs]class Choice(BaseField):
"""A field representing a single item from a set of items.
:param collection choices: A required collection of items. The check
method will then ensure that the value must be in this collection.
"""
def __init__(self, choices=None, **kwargs):
if choices is None:
try:
self.choices = kwargs.pop('choices')
except KeyError:
raise TypeError("Choice type requires 'choices' parameter")
else:
self.choices = choices
super().__init__(**kwargs)
for item in self.choices:
if self.coerce(item) != item:
msg = "Coercion func prevents selecting item {i} from choices"
raise TypeError(msg.format(i=item))
def check(self, val):
if not super().check(val): # pragma: no cover
return False
val = self.coerce(val)
if not self.type_check(val):
return False
if val not in self.choices:
if not (self.nullable and val is None):
return False
return True