Source code for cascade_at.core.form.abstract_form
""" This module defines general tools for building validators for messy
hierarchical parameter data. It provides a declarative API for creating form
validators. It tries to follow conventions from form validation systems in the
web application world since that is a very similar problem.
Example:
Validators are defined as classes with attributes which correspond to the
values they expect to receive. For example, consider this JSON blob:
{"field_a": "10", "field_b": "22.4", "nested": {"field_c": "Some Text"}}
A validator for that document would look like this:
class NestedValidator(Form):
field_c = SimpleTypeField(str)
class BlobValidator(Form):
field_a = SimpleTypeField(int)
field_b = SimpleTypeField(int)
nested = NestedValidator()
And could be used as follows:
>>> form = BlobValidator(json.loads(document))
>>> form.validate_and_normalize()
>>> form.field_a
10
>>> form.nested.field_c
"Some Text"
"""
from cascade_at.core.log import get_loggers
LOG = get_loggers(__name__)
[docs]class NoValue:
"""Represents an unset value, which is distinct from None because None may
actually appear in input data.
"""
def __repr__(self):
return "NO_VALUE"
def __eq__(self, other):
return isinstance(other, NoValue)
NO_VALUE = NoValue()
[docs]class FormComponent:
""" Base class for all form components. It bundles up behavior shared by
both (sub)Forms and Fields.
Note:
FormComponent, Form and Field all make heavy use of the descriptor
protocol (https://docs.python.org/3/howto/descriptor.html). That means
that the relationship between objects and the data they operate on is
more complex than usual. Read up on descriptors, if you aren't familiar,
and pay close attention to how __set__ and __get__ access data.
Args:
nullable (bool): If False then missing data for this node is considered
an error. Defaults to False.
default: Default value to return if unset
display (str): The name used in the EpiViz interface.
validation_priority (int): Sort order for validation.
"""
_children = None
def __init__(self, nullable=False, default=None, display=None, validation_priority=100):
self._nullable = nullable
self._default = default
self._name = None
self._display_name = display
self._component_id = id(self)
if self._children:
self._validation_priority = min([c._validation_priority for c in self._children] + [validation_priority])
self._child_instances = {c: NO_VALUE for c in self._children}
else:
self._validation_priority = validation_priority
self._child_instances = {}
def __get__(self, instance, owner=None):
if isinstance(instance, FormComponent):
value = instance._child_instances[self]
if value == NO_VALUE and self._nullable:
return self._default
return value
else:
return None
def _to_dict_value(self, instance=None):
raise NotImplementedError
@property
def display_name(self):
return self._display_name if self._display_name else self._name
def is_unset(self, instance):
value = instance._child_instances[self]
if value == NO_VALUE:
return True
return False
def __set__(self, instance, value):
if isinstance(instance, FormComponent):
instance._child_instances[self] = value
def __set_name__(self, owner, name):
if issubclass(owner, FormComponent):
if owner._children is None:
owner._children = {self}
else:
owner._children.add(self)
self._name = name
def __hash__(self):
# The use of self._component_id here allows dictionaries where this
# type is the key to be serialized and deserialized correctly.
return self._component_id
def __eq__(self, other):
if isinstance(other, FormComponent):
return self._component_id == other._component_id
else:
return NotImplemented
@property
def unset(self):
value = self.__class__.__get__(self, self.__class__)
if value == NO_VALUE:
return True
return False
[docs]class Field(FormComponent):
""" A field within a form. Fields are responsible for validating the data
they contain (without respect to data in other fields) and transforming
it into a normalized form.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
[docs] def validate_and_normalize(self, instance, root=None):
""" Validates the data for this field on the given parent instance and
transforms the data into it's normalized form. The actual details of
validating and transforming are delegated to subclasses except for
checking for missing data which is handled here.
Args:
instance (Form): the instance of the form for which this field
should be validated.
root (Form): pointer back to the base of the form hierarchy.
Returns:
[(str, str, str)]: a list of error messages with path strings
showing where in this object they occurred. For most
fields the path will always be empty.
"""
if self.is_unset(instance):
if not self._nullable:
return [("", "", "Missing data")]
return []
value = self.__get__(instance, type(instance))
new_value, error = self._validate_and_normalize(instance, value)
if error is not None:
return [("", "", error)]
self.__set__(instance, new_value)
return []
def _validate_and_normalize(self, instance, value):
""" Validation and normalization details to be handled in overridden
methods in subclasses.
Args:
instance (Form): the instance of the form for which this field
should be validated.
value: The value of this field on the parent instance.
Returns:
[(str, str)]: a list of error messages with path strings
showing where in this object they occurred. For most
fields the path will always be empty.
"""
return value, None
def _to_dict_value(self, instance=None):
return self.__get__(instance, self.__class__)
[docs]class SimpleTypeField(Field):
"""A field which transforms input data using a constructor function and
emits errors if that transformation fails.
In general this is used to convert to simple types like int or float.
Because it emits only very simple messages it is not appropriate for
cases where the cause of any error isn't obvious from knowing the
name of the constructor function and a string representation of the
input value.
Args:
constructor: a function which takes one argument and returns a
normalized version of that argument. It must raise
ValueError, TypeError or OverflowError if transformation
is not possible.
"""
def __init__(self, constructor, *args, **kwargs):
super().__init__(*args, **kwargs)
self.constructor = constructor
def _validate_and_normalize(self, instance, value):
try:
new_value = self.constructor(value)
except (ValueError, TypeError, OverflowError):
return None, f"Invalid {self.constructor.__name__} value '{value}'"
return new_value, None
[docs]class Form(FormComponent):
""" The parent class of all forms.
Validation for forms happens in two stages. First all the form's fields and
sub forms are validated. If none of those have errors, then the form is
known to be in a consistent state and it's `_full_form_validation` method
is run to finalize validation. If any field or sub form is invalid then
this form's `_full_form_validation` method will not be run because the form
may be in an inconsistent state.
Simple forms will be valid if all their fields are valid but more complex
forms will require additional checks across multiple fields which are
handled by `_full_form_validation`.
Note:
A nested form may be marked nullable. It is considered null if all of
it's children are null. If a nullable form is null then it is not an
error for non-nullable fields in it to be null. If any of the form's
fields are non-null then the whole form is considered non-null at which
point missing data for non-nullable fields becomes an error again.
Args:
source (dict): The input data to parse. If None, it can be supplied
later by calling process_source
name_field (str): If supplied then a field of the same name must be
present on the subclass. That field will always have
the name of the attribute this class is assigned to
in it's parent rather than the value, if any, that
the field had in the input data.
"""
def __init__(self, source=None, name_field=None, nullable=False, display=None, **kwargs):
super().__init__(nullable=nullable, display=display, **kwargs)
self._args = []
self._kwargs = {"name_field": name_field, "nullable": nullable}
self._name_field = name_field
if source is not None:
self.process_source(source)
def __set__(self, instance, value):
if isinstance(instance, FormComponent):
form = self.new_instance()
form._name = self._name
form._display_name = self._display_name
form.process_source(value)
instance._child_instances[self] = form
@property
def children(self):
for child, child_value in self._child_instances.items():
if isinstance(child_value, FormComponent):
child = child_value
yield child
def items(self):
for c in self.children:
yield (c._name, getattr(self, c._name))
def is_field_unset(self, field_name):
child = type(self).__dict__[field_name]
child_value = self._child_instances[child]
if isinstance(child_value, FormComponent):
child = child_value
return child.is_unset(self)
def is_unset(self, instance=None):
return all([c.is_unset(self) for c in self.children])
def new_instance(self):
return type(self)(*self._args, validation_priority=self._validation_priority, **self._kwargs)
def process_source(self, source):
for c in self._children:
v = source.get(str(c._name), NO_VALUE)
if v != NO_VALUE:
setattr(self, c._name, v)
elif isinstance(c, Form) and not c._nullable:
# Make sure sub-forms which contain default values get a chance to setup
setattr(self, c._name, {})
if self._name_field:
setattr(self, self._name_field, self._name)
def validate_and_normalize(self, instance=None, root=None):
if self.is_unset() and self._nullable:
return []
errors = []
if root is None:
root = self
for child in sorted(self.children, key=lambda c: c._validation_priority):
c_errors = child.validate_and_normalize(self, root=root)
if c_errors:
if child._name:
# TODO: This replace is ugly and probably means that I'm
# not thinking clearly about how these error paths
# get constructed.
errors.extend(
[(
(f"{child._name}." + p).replace(".[", "[") if p else child._name,
(f"{child.display_name}." + h).replace(".[", "[") if h else child.display_name,
e
)
for p, h, e in c_errors]
)
else:
errors.extend(c_errors)
if not errors:
errors = [("", "", e) for e in self._full_form_validation(root)]
return errors
@property
def unset(self):
return all([c.unset for c in self.children])
def _full_form_validation(self, root):
""" Can be overridden by subclasses to do any validation that requires
multiple fields. This method will only execute if all the form's fields
are themselves valid and have been normalize. If not overridden it does
no additional checks.
"""
return []
def _to_dict_value(self, instance=None):
return self.to_dict(instance)
def to_dict(self, instance=None):
values = {}
for c in self.children:
if not self.is_field_unset(c._name):
values[c._name] = c._to_dict_value(self)
return values