import copy
import datetime
import re
from decimal import Decimal
from gettext import gettext as _
import aniso8601
from .base import ErrorMessageMixin
from .exceptions import BaseValidationException, InvalidDataException, NoData
[docs]class Field(ErrorMessageMixin):
"""
The base class for all fields.
By itself, :class:`~Field` only enforces the :attr:`Field.required` behaviour.
Subclasses of :class:`~Field` add more validation rules to add more useful behaviours.
**Attributes**
.. autoattribute:: required
.. autoattribute:: default
:annotation:
.. autoattribute:: default_error_messages
:annotation:
**Methods**
.. automethod:: clean
.. automethod:: error
"""
#: Is this field required to be present in the data.
#:
#: If a field is not required, and is not present in the data,
#: it will not be present in the cleaned data either.
#: If a field is required, and is not present in the data,
#: a :exc:`~valedictory.exceptions.ValidationException` will be thrown.
required = True
#: The default for this field if no value is supplied. Can be ``None``. If
#: not set, there is no default and the field will not be present in the
#: cleaned data.
default = NoData
#: A dictionary of messages for each error this field can raise.
#: The default error messages can be overridden by passing an
#: ``error_messages`` dict to the constructor.
#:
#: required
#: Raised when the field is not in the input data,
#: but the field is required.
default_error_messages = {
'required': _("This field is required"),
}
def __init__(self, required=None, error_messages=None, **kwargs):
if 'default' in kwargs:
self.default = kwargs.pop('default')
super().__init__(error_messages=error_messages, **kwargs)
if required is not None:
self.required = required
if self.required and self.has_default:
raise ValueError("A field cannot have a default and be required")
@property
def has_default(self):
return self.default is not NoData
[docs] def clean(self, data):
"""
Clean and validate the given data.
If there is no data for the field,
pass in the :exc:`~valedictory.exceptions.NoData` class to signal this.
If the field is required, a
:exc:`~valedictory.exceptions.ValidationException` will be raised.
If the field is not required, :exc:`~valedictory.exceptions.NoData` is returned.
"""
if data is NoData:
if self.has_default:
data = self.default
elif self.required:
raise self.error('required')
else:
raise NoData
return data
[docs]class TypedField(Field):
"""
A :class:`Field` that requires a specific type of input, such as
strings, integers, or booleans.
.. autoattribute:: required_types
:annotation:
.. autoattribute:: excluded_types
:annotation:
.. autoattribute:: type_name
:annotation:
.. autoattribute:: default_error_messages
:annotation:
"""
#: A tuple of acceptable classes for the data.
#: For example, ``(int, float)`` would accept any number type.
required_types = ()
#: A tuple of unacceptable classes for the data.
#: For example, ``bool``\s are subclasses of ``int``\s,
#: but should not be accepted as valid data when a number is expected.
excluded_types = ()
#: The friendly name of the type for error messages.
type_name = u''
#:
#: invalid_type
#: Raised when the incoming data is not an instance of :attr:`required_types`,
#: or is a subclass of :attr:`excluded_types`.
default_error_messages = {
'invalid_type': _("Expected a value of type '{type}'"),
}
def __init__(self, *, required_types=None, excluded_types=None,
type_name=None, **kwargs):
super().__init__(**kwargs)
if required_types is not None:
self.required_types = required_types
if required_types is not None:
self.required_types = required_types
if type_name is not None:
self.type_name = type_name
def clean(self, data):
value = super(TypedField, self).clean(data)
if (not isinstance(value, self.required_types) or
isinstance(value, self.excluded_types)):
raise self.error('invalid_type', {'type': self.type_name})
return value
[docs]class StringField(TypedField):
"""
Accepts a string, and only strings.
.. autoattribute:: min_length
.. autoattribute:: max_length
.. autoattribute:: default_error_messages
:annotation:
"""
required_types = (str,)
type_name = u'string'
#: The minimum acceptable length of the string.
#: Defaults to no minimum length.
min_length = 0
#: The maximum acceptable length of the string.
#: Defaults to no maximum length.
max_length = float('inf')
#:
#: non_empty
#: Raised when the input is an empty string, but :attr:`min_length` is 1.
#:
#: min_length
#: Raised when the input is shorter than :attr:`min_length`.
#:
#: max_length
#: Raised when the input is longer than :attr:`max_length`.
default_error_messages = {
'non_empty': _("This field can not be empty"),
'min_length': _("Minimum length {min}"),
'max_length': _("Maximum length {max}"),
}
def __init__(self, min_length=None, max_length=None, **kwargs):
"""
Construct a field that accepts only strings.
In addition to the arguments accepted by the ``Field`` class, the
following arguments are accepted:
* The ``min_length`` and ``max_length`` keyword arguments set the
minimum and maximum length of string the field will accept.
"""
super(StringField, self).__init__(**kwargs)
if min_length is not None:
self.min_length = min_length
if max_length is not None:
self.max_length = max_length
def clean(self, data):
value = super(StringField, self).clean(data)
if value == u'' and self.required:
raise self.error('required')
if len(value) < self.min_length:
if self.min_length == 1:
raise self.error('non_empty')
else:
raise self.error('min_length', {'min': self.min_length})
if len(value) > self.max_length:
raise self.error('max_length', {'max': self.max_length})
return value
[docs]class BooleanField(TypedField):
"""
A field that only accepts True and False values.
"""
required_types = bool
type_name = u'boolean'
[docs]class NumberField(TypedField):
"""
A field that only accepts numbers, either floats or integers.
.. autoattribute:: min
.. autoattribute:: max
.. autoattribute:: default_error_messages
:annotation:
"""
required_types = (int, float, Decimal, complex)
excluded_types = bool # bools subclass ints :(
type_name = u'number'
#: The minimum allowable value. Values lower than this will raise an exception.
#: Defaults to no minimum value.
min = None
#: The maximum allowable value. Values higher than this will raise an exception.
#: Defaults to no maximum value.
max = None
#:
#: min_value
#: Raised when the value is lower than :attr:`min`.
#:
#: max_value
#: Raised when the value is higher than :attr:`max`.
default_error_messages = {
'min_value': _("This must be equal to or greater than the minimum of {min}"),
'max_value': _("This must be equal to or less than the maximum of {max}"),
}
def __init__(self, min=None, max=None, **kwargs):
"""
In addition to the arguments accepted by the ``Field`` class, the
following arguments are accepted:
* The ``min`` and ``max`` keyword arguments set the minimum and maximum
value the field will accept. The range is inclusive.
"""
super().__init__(**kwargs)
if min is not None:
self.min = min
if max is not None:
self.max = max
def clean(self, data):
value = super().clean(data)
if self.min is not None and value < self.min:
raise self.error('min_value', {'min': self.min})
if self.max is not None and value > self.max:
raise self.error('max_value', {'max': self.max})
return value
[docs]class IntegerField(NumberField):
"""
A :class:`NumberField` that only accepts integers.
"""
required_types = int
excluded_types = bool # bools subclass ints :(
type_name = u'integer'
[docs]class FloatField(NumberField):
"""
A :class:`NumberField` that only accepts floating point numbers.
"""
required_types = float
type_name = u'float'
[docs]class EmailField(StringField):
"""
A :class:`StringField` that only accepts email address strings. The email matching regular
expression only checks for basic conformance: the string must have at least
one character, then an '@' symbol, then more characters with at least one
dot.
.. autoattribute:: default_error_messages
:annotation:
"""
email_re = re.compile(r"[^@]+@[^@]+\.[^@]+")
#:
#: invalid_email
#: Raised when the data is not a valid email address.
default_error_messages = {
'invalid_email': _("Not a valid email address"),
}
def clean(self, data):
value = super(EmailField, self).clean(data)
if not self.email_re.match(value):
raise self.error('invalid_email')
return value
[docs]class DateTimeField(StringField):
"""
A field that only accepts ISO 8601 date time strings.
After cleaning, a ``datetime.datetime`` instance is returned.
.. autoattribute:: timezone_required
:annotation:
.. autoattribute:: default_error_messages
:annotation:
"""
# YYYY-MM-DDTHH:MM:SS.ssssss+00:00 = 32 chars.
max_length = 32
#: If a timezone is required. If this is ``False``, naive datetimes will be
#: allowed.
timezone_required = True
#:
#: invalid_format
#: Raised when the input is not a valid ISO8601-formatted date time
#: no_timezone
#: Raised when the input does not have a timezone specified, but
#: :attr:`timezone_required` is ``True``
default_error_messages = {
'invalid_format': _("Not a valid date time"),
'no_timezone': _("A timezone must be specified"),
}
def __init__(self, *, timezone_required=None, **kwargs):
super().__init__(**kwargs)
if timezone_required is not None:
self.timezone_required = timezone_required
def clean(self, data):
date_string = super(DateTimeField, self).clean(data)
try:
value = aniso8601.parse_datetime(date_string)
except (ValueError, NotImplementedError):
raise self.error('invalid_format')
if self.timezone_required and value.tzinfo is None:
raise self.error('no_timezone')
return value
[docs]class DateField(StringField):
"""
A field that only accepts ISO 8601 date strings.
After cleaning, a ``datetime.date`` instance is returned.
.. autoattribute:: default_error_messages
:annotation:
"""
# YYYY-MM-DD = 10 chars.
max_length = 10
#:
#: invalid_format
#: Raised when the input is not a valid date
default_error_messages = {
'invalid_format': _("Not a valid date"),
}
def clean(self, data):
date_string = super(DateField, self).clean(data)
try:
return aniso8601.parse_date(date_string)
except ValueError:
raise self.error('invalid_format')
[docs]class TimeField(StringField):
"""
A field that only accepts ISO 8601 time strings.
After cleaning, a ``datetime.time`` instance is returned.
.. autoattribute:: timezone_required
:annotation:
.. autoattribute:: default_error_messages
:annotation:
"""
# HH:MM:SS.ssssss+00:00 = 11 chars.
max_length = 21
#: If a timezone is required. If this is ``False``, naive times will be
#: allowed.
timezone_required = True
#:
#: invalid_format
#: Raised when the input is not a valid ISO8601-formatted date time
#: no_timezone
#: Raised when the input does not have a timezone specified, but
#: :attr:`timezone_required` is ``True``
default_error_messages = {
'invalid_format': _("Not a valid time"),
'no_timezone': _("A timezone must be specified"),
}
def __init__(self, *, timezone_required=None, **kwargs):
super().__init__(**kwargs)
if timezone_required is not None:
self.timezone_required = timezone_required
def clean(self, data):
time_string = super(DateTimeField, self).clean(data)
try:
value = aniso8601.parse_time(time_string)
except (ValueError, NotImplementedError):
raise self.error('invalid_format')
if self.timezone_required and value.tzinfo is None:
raise self.error('no_timezone')
return value
[docs]class YearMonthField(StringField):
"""
A field that only accepts ``YYYY-MM`` date strings.
After cleaning, a tuple of ``(year, month)`` integers are returned.
.. autoattribute:: default_error_messages
:annotation:
"""
# YYYY-MM = 7 chars.
max_length = 7
#:
#: invalid_format
#: Raised when the input is not a valid year-month tuple
default_error_messages = {
'invalid_format': _("Not a valid date"),
}
def clean(self, data):
date_string = super(YearMonthField, self).clean(data)
try:
date = datetime.datetime.strptime(date_string, "%Y-%m").date()
except ValueError:
raise self.error('invalid_format')
return (date.year, date.month)
[docs]class ChoiceField(Field):
"""
A field that only accepts values from a predefined set of choices.
The values can be of any hashable type.
.. autoattribute:: choices
:annotation: = set()
.. autoattribute:: default_error_messages
:annotation:
"""
#: The field will only accept data if the value is in this set.
choices = None
#:
#: invalid_choice
#: Raised when the value is not one of the valid choices
default_error_messages = {
'invalid_choice': _("Not a valid choice"),
}
def __init__(self, choices=None, **kwargs):
super(ChoiceField, self).__init__(**kwargs)
if choices is not None:
self.choices = set(choices)
def clean(self, data):
value = super(ChoiceField, self).clean(data)
try:
if value not in self.choices:
raise self.error('invalid_choice')
except TypeError:
# ``{} in set()`` throws a TypeError: unhashable type: 'dict'
raise self.error('invalid_choice')
return value
def __deepcopy__(self, memo):
obj = super().__deepcopy__(memo)
obj.choices = copy.deepcopy(obj.choices, memo)
return obj
[docs]class ChoiceMapField(Field):
"""
A field that only accepts values from a predefined dictionary of choices.
The dictionary maps from valid input choices to the cleaned value returned.
For example:
.. code:: python
>>> field = ChoiceMapField({1: 'one', 2: 'two', 3: 'three'})
>>> field.clean(1)
'one'
>>> field.clean("one")
valedictory.exceptions.ValidationException: Not a valid choice
would only accept one of the numbers 1, 2 or 3 as input,
and would return one of the strings "one", "two", or "three".
.. autoattribute:: choices
:annotation: = set()
.. autoattribute:: default_error_messages
:annotation:
"""
#: The field will only accept data if the value is in this set.
choices = None
#:
#: invalid_choice
#: Raised when the value is not one of the valid choices
default_error_messages = {
'invalid_choice': _("Not a valid choice"),
}
def __init__(self, choices=None, **kwargs):
super(ChoiceMapField, self).__init__(**kwargs)
if choices is not None:
self.choices = dict(choices)
def clean(self, data):
value = super(ChoiceMapField, self).clean(data)
try:
return self.choices[value]
except (KeyError, TypeError):
# self.choices[{}] throws a TypeError: unhashable type: 'dict'
raise self.error('invalid_choice')
def __deepcopy__(self, memo):
obj = super().__deepcopy__(memo)
obj.choices = copy.deepcopy(obj.choices, memo)
return obj
[docs]class PunctuatedCharacterField(TypedField):
"""
A field that accepts characters only from an alphabet of allowed
characters. A set of allowed punctuation characters are allowed and
discarded when cleaned.
.. autoattribute:: alphabet
:annotation:
.. autoattribute:: punctuation
:annotation:
.. autoattribute:: min_length
.. autoattribute:: max_length
.. autoattribute:: default_error_messages
:annotation:
"""
required_types = (str)
type_name = u'string'
#: A string of all the allowed characters,
#: not including :attr:`punctuation` characters.
#: The cleaned output will consist only of characters from this string.
alphabet = None
#: A string of all the punctuation characters allowed.
#: Punctuation characters will be removed from the cleaned output.
punctuation = None
#: The minimum length of the cleaned output data,
#: not including punctuation characters.
#: There is no minimum length by default.
min_length = 0
#: The maximum length of the cleaned output data,
#: not including punctuation characters.
#: There is no maximum length by default.
max_length = float('inf')
#:
#: allowed_characters
#: Raised when characters not in
#: :attr:`alphabet` or :attr:`punctuation` are in the input.
#:
#: min_length
#: Raised when the cleaned string is shorter than :attr:`min_length`.
#:
#: max_length
#: Raised when the cleaned string is longer than :attr:`max_length`.
default_error_messages = {
'allowed_characters': _("Only the characters '{alphabet}{punctuation}' are allowed"),
'min_length': _("Minimum length {min}"),
'max_length': _("Maximum length {max}"),
}
def __init__(self, alphabet=None, punctuation=None,
min_length=None, max_length=None, **kwargs):
"""
Construct a new PunctuatedCharacterField.
In addition to the arguments accepted by the ``Field`` class, the
following arguments are accepted:
* ``alphabet`` is a string of allowed characters.
* ``puctuation`` is a string of allowed punctuation characters that
will be stripped as part of validation.
* ``min_length`` and ``max_length`` are the minimum and maximum allowed
number of characters. Length is calculated *after* punctuation
characters are removed.
"""
super(PunctuatedCharacterField, self).__init__(**kwargs)
if alphabet is not None:
self.alphabet = str(alphabet)
if punctuation is not None:
self.punctuation = str(punctuation)
if min_length is not None:
self.min_length = min_length
if max_length is not None:
self.max_length = max_length
def clean(self, data):
value = super(PunctuatedCharacterField, self).clean(data)
# Strip out punctuation
alphabet_dict = dict((ord(c), None) for c in self.alphabet)
punctuation_dict = dict((ord(c), None) for c in self.punctuation)
value = value.translate(punctuation_dict)
stripped_value = value.translate(alphabet_dict)
if len(stripped_value) != 0:
raise self.error('allowed_characters', {
'alphabet': self.alphabet,
'punctuation': self.punctuation})
if len(value) < self.min_length:
raise self.error('min_length', {'min': self.min_length})
if len(value) > self.max_length:
raise self.error('max_length', {'max': self.max_length})
return value
[docs]class RestrictedCharacterField(PunctuatedCharacterField):
"""
A field that only allows a defined alphabet of characters to be used.
This is just a :class:`PunctuatedCharacterField`,
with :attr:`~PunctuatedCharacterField.punctuation` set to the empty string.
.. attribute:: alphabet
A string of the characters allowed in the input.
If the input contains a character not in this string,
a :exc:`~valedictory.exceptions.ValidationException` is raised.
"""
punctuation = ''
[docs]class DigitField(RestrictedCharacterField):
"""
A field that only allows strings made up of digits.
It is not treated as a number, and leading zeros are preserved.
"""
alphabet = '0123456789'
[docs]class CreditCardField(PunctuatedCharacterField):
"""
Accepts credit card numbers.
The credit card numbers are checked using the Luhn checksum.
The credit card number can optionally be punctuated by `" -"` characters.
.. autoattribute:: default_error_messages
:annotation:
"""
punctuation = ' -'
alphabet = '0123456789'
min_length = 12
max_length = 20
#:
#: luhn_checksum
#: Raised when the credit card is not valid,
#: according to the Luhn checksum
default_error_messages = {
'luhn_checksum': _("The credit card number is not valid"),
}
def clean(self, data):
value = super(CreditCardField, self).clean(data)
if not self.luhn_checksum(value):
raise self.error('luhn_checksum')
return value
def luhn_checksum(self, card_number):
digits = list(map(int, reversed(card_number)))
evens = sum(digits[0::2])
odds = sum(sum(divmod(digit * 2, 10)) for digit in digits[1::2])
return (evens + odds) % 10 == 0
[docs]class ListField(TypedField):
"""
A list field validates all elements of a list against a field.
For example, to accept a list of integers,
you could declare a :class:`ListField` like:
.. code:: python
class MyValidator(Validator):
numbers = ListField(IntegerField())
.. autoattribute:: field
:annotation:
"""
#: The field to validate all elements of the input data against.
field = None
required_types = list
type_name = 'list'
def __init__(self, field=None, **kwargs):
"""
Construct a new ListField
In addition to the arguments accepted by the ``Field`` class, the
following arguments are accepted:
* ``field`` should be an instance of a Field subclass. Each item in the
submitted list will be validated and cleaned with Field.
"""
super(ListField, self).__init__(**kwargs)
if field is not None:
self.field = field
def clean(self, data):
value = super(ListField, self).clean(data)
errors = InvalidDataException()
cleaned_list = []
for i, datum in enumerate(value):
try:
cleaned_list.append(self.field.clean(datum))
except BaseValidationException as err:
errors.invalid_fields[i].append(err)
if errors:
raise errors
return cleaned_list
def __deepcopy__(self, memo):
obj = super().__deepcopy__(memo)
obj.field = copy.deepcopy(self.field, memo)
return obj
[docs]class NestedValidator(TypedField):
"""
Nested validators allow nesting dicts inside one another.
A validator is used to validate and clean the nested dict.
To validate a person with structured address data,
you could make a :class:`~valedictory.Validator` like:
.. code:: python
class AddressValidator(Validator):
street = StringField(min_length=1)
suburb = StringField(min_length=1)
postcode = DigitField(min_length=4, max_length=4)
state = ChoiceField('ACT NSW NT QLD SA TAS VIC WA'.split())
class Person(Validator):
name = StringField()
address = NestedValidator(AddressValidator())
This would accept data like:
.. code:: json
{
"name": "Alex Smith",
"address": {
"street": "123 Example Street",
"suburb": "Example Burb",
"postcode": "7123",
"state": "TAS"
}
}
"""
required_types = (dict, )
type_name = 'object'
def __init__(self, validator=None, **kwargs):
"""
Construct a new NestedValidator
In addition to the arguments accepted by the ``Field`` class, the
following arguments are accepted:
* ``validator`` should be an instance of a Validator class. The nested
dict is passed to this validator for validation and cleaning.
"""
super(NestedValidator, self).__init__(**kwargs)
if validator is not None:
self.validator = validator
def clean(self, data):
value = super(NestedValidator, self).clean(data)
return self.validator.clean(value)
def __deepcopy__(self, memo):
obj = super().__deepcopy__(memo)
obj.validator = copy.deepcopy(self.validator, memo)
return obj