Plain is headed towards 1.0! Subscribe for development updates →

Forms

HTML form handling and validation.

The Form and Field classes help output, parse, and validate form data from an HTTP request. Unlike other frameworks, the HTML inputs are not rendered automatically, though there are some helpers for you to do your own rendering.

With forms, you will typically use one of the built-in view classes to tie everything together.

from plain import forms
from plain.views import FormView


class ContactForm(forms.Form):
    email = forms.EmailField()
    message = forms.CharField()


class ContactView(FormView):
    form_class = ContactForm
    template_name = "contact.html"

Then in your template, you can render the form fields.

{% extends "base.html" %}

{% block content %}

<form method="post">
    {{ csrf_input }}

    <!-- Render general form errors -->
    {% for error in form.non_field_errors %}
    <div>{{ error }}</div>
    {% endfor %}

    <div>
        <label for="{{ form.email.html_id }}">Email</label>
        <input
            required
            type="email"
            name="{{ form.email.html_name }}"
            id="{{ form.email.html_id }}"
            value="{{ form.email.value }}">

        {% if form.email.errors %}
        <div>{{ form.email.errors|join(', ') }}</div>
        {% endif %}
    </div>

    <div>
        <label for="{{ form.message.html_id }}">Message</label>
        <textarea
            required
            rows="10"
            name="{{ form.message.html_name }}"
            id="{{ form.message.html_id }}">{{ form.message.value }}</textarea>

        {% if form.message.errors %}
        <div>{{ form.message.errors|join(', ') }}</div>
        {% endif %}
    </div>

    <button type="submit">Submit</button>
</form>

{% endblock %}

With manual form rendering, you have full control over the HTML classes, attributes, and JS behavior. But in large applications the form rendering can become repetitive. You will often end up re-using certain patterns in your HTML which can be abstracted away using Jinja includes, macros, or plain.elements.

  1"""
  2Form classes
  3"""
  4
  5import copy
  6
  7from plain.exceptions import NON_FIELD_ERRORS
  8from plain.utils.datastructures import MultiValueDict
  9from plain.utils.functional import cached_property
 10
 11from .exceptions import ValidationError
 12from .fields import Field, FileField
 13
 14__all__ = ("BaseForm", "Form")
 15
 16
 17class DeclarativeFieldsMetaclass(type):
 18    """Collect Fields declared on the base classes."""
 19
 20    def __new__(mcs, name, bases, attrs):
 21        # Collect fields from current class and remove them from attrs.
 22        attrs["declared_fields"] = {
 23            key: attrs.pop(key)
 24            for key, value in list(attrs.items())
 25            if isinstance(value, Field)
 26        }
 27
 28        new_class = super().__new__(mcs, name, bases, attrs)
 29
 30        # Walk through the MRO.
 31        declared_fields = {}
 32        for base in reversed(new_class.__mro__):
 33            # Collect fields from base class.
 34            if hasattr(base, "declared_fields"):
 35                declared_fields.update(base.declared_fields)
 36
 37            # Field shadowing.
 38            for attr, value in base.__dict__.items():
 39                if value is None and attr in declared_fields:
 40                    declared_fields.pop(attr)
 41
 42        new_class.base_fields = declared_fields
 43        new_class.declared_fields = declared_fields
 44
 45        return new_class
 46
 47
 48class BaseForm:
 49    """
 50    The main implementation of all the Form logic. Note that this class is
 51    different than Form. See the comments by the Form class for more info. Any
 52    improvements to the form API should be made to this class, not to the Form
 53    class.
 54    """
 55
 56    prefix = None
 57
 58    def __init__(
 59        self,
 60        data=None,
 61        files=None,
 62        auto_id="id_%s",
 63        prefix=None,
 64        initial=None,
 65    ):
 66        self.is_bound = data is not None or files is not None
 67        self.data = MultiValueDict() if data is None else data
 68        self.files = MultiValueDict() if files is None else files
 69        self._auto_id = auto_id
 70        if prefix is not None:
 71            self.prefix = prefix
 72        self.initial = initial or {}
 73        self._errors = None  # Stores the errors after clean() has been called.
 74
 75        # The base_fields class attribute is the *class-wide* definition of
 76        # fields. Because a particular *instance* of the class might want to
 77        # alter self.fields, we create self.fields here by copying base_fields.
 78        # Instances should always modify self.fields; they should not modify
 79        # self.base_fields.
 80        self.fields = copy.deepcopy(self.base_fields)
 81        self._bound_fields_cache = {}
 82
 83    def __repr__(self):
 84        if self._errors is None:
 85            is_valid = "Unknown"
 86        else:
 87            is_valid = self.is_bound and not self._errors
 88        return "<{cls} bound={bound}, valid={valid}, fields=({fields})>".format(
 89            cls=self.__class__.__name__,
 90            bound=self.is_bound,
 91            valid=is_valid,
 92            fields=";".join(self.fields),
 93        )
 94
 95    def _bound_items(self):
 96        """Yield (name, bf) pairs, where bf is a BoundField object."""
 97        for name in self.fields:
 98            yield name, self[name]
 99
100    def __iter__(self):
101        """Yield the form's fields as BoundField objects."""
102        for name in self.fields:
103            yield self[name]
104
105    def __getitem__(self, name):
106        """Return a BoundField with the given name."""
107        try:
108            field = self.fields[name]
109        except KeyError:
110            raise KeyError(
111                "Key '{}' not found in '{}'. Choices are: {}.".format(
112                    name,
113                    self.__class__.__name__,
114                    ", ".join(sorted(self.fields)),
115                )
116            )
117        if name not in self._bound_fields_cache:
118            self._bound_fields_cache[name] = field.get_bound_field(self, name)
119        return self._bound_fields_cache[name]
120
121    @property
122    def errors(self):
123        """Return an error dict for the data provided for the form."""
124        if self._errors is None:
125            self.full_clean()
126        return self._errors
127
128    def is_valid(self):
129        """Return True if the form has no errors, or False otherwise."""
130        return self.is_bound and not self.errors
131
132    def add_prefix(self, field_name):
133        """
134        Return the field name with a prefix appended, if this Form has a
135        prefix set.
136
137        Subclasses may wish to override.
138        """
139        return f"{self.prefix}-{field_name}" if self.prefix else field_name
140
141    @property
142    def non_field_errors(self):
143        """
144        Return a list of errors that aren't associated with a particular
145        field -- i.e., from Form.clean(). Return an empty list if there
146        are none.
147        """
148        return self.errors.get(
149            NON_FIELD_ERRORS,
150            [],
151        )
152
153    def add_error(self, field, error):
154        """
155        Update the content of `self._errors`.
156
157        The `field` argument is the name of the field to which the errors
158        should be added. If it's None, treat the errors as NON_FIELD_ERRORS.
159
160        The `error` argument can be a single error, a list of errors, or a
161        dictionary that maps field names to lists of errors. An "error" can be
162        either a simple string or an instance of ValidationError with its
163        message attribute set and a "list or dictionary" can be an actual
164        `list` or `dict` or an instance of ValidationError with its
165        `error_list` or `error_dict` attribute set.
166
167        If `error` is a dictionary, the `field` argument *must* be None and
168        errors will be added to the fields that correspond to the keys of the
169        dictionary.
170        """
171        if not isinstance(error, ValidationError):
172            raise TypeError(
173                "The argument `error` must be an instance of "
174                f"`ValidationError`, not `{type(error).__name__}`."
175            )
176
177        if hasattr(error, "error_dict"):
178            if field is not None:
179                raise TypeError(
180                    "The argument `field` must be `None` when the `error` "
181                    "argument contains errors for multiple fields."
182                )
183            else:
184                error = error.error_dict
185        else:
186            error = {field or NON_FIELD_ERRORS: error.error_list}
187
188        class ValidationErrors(list):
189            def __iter__(self):
190                for err in super().__iter__():
191                    # TODO make sure this works...
192                    yield next(iter(err))
193
194        for field, error_list in error.items():
195            if field not in self.errors:
196                if field != NON_FIELD_ERRORS and field not in self.fields:
197                    raise ValueError(
198                        f"'{self.__class__.__name__}' has no field named '{field}'."
199                    )
200                self._errors[field] = ValidationErrors()
201
202            self._errors[field].extend(error_list)
203
204            # The field had an error, so removed it from the final data
205            # (we use getattr here so errors can be added to uncleaned forms)
206            if field in getattr(self, "cleaned_data", {}):
207                del self.cleaned_data[field]
208
209    def full_clean(self):
210        """
211        Clean all of self.data and populate self._errors and self.cleaned_data.
212        """
213        self._errors = {}
214        if not self.is_bound:  # Stop further processing.
215            return
216        self.cleaned_data = {}
217
218        self._clean_fields()
219        self._clean_form()
220        self._post_clean()
221
222    def _field_data_value(self, field, html_name):
223        if hasattr(self, f"parse_{html_name}"):
224            # Allow custom parsing from form data/files at the form level
225            return getattr(self, f"parse_{html_name}")()
226
227        return field.value_from_form_data(self.data, self.files, html_name)
228
229    def _clean_fields(self):
230        for name, bf in self._bound_items():
231            field = bf.field
232
233            if field.disabled:
234                value = bf.initial
235            else:
236                value = self._field_data_value(bf.field, bf.html_name)
237
238            try:
239                if isinstance(field, FileField):
240                    value = field.clean(value, bf.initial)
241                else:
242                    value = field.clean(value)
243                self.cleaned_data[name] = value
244                if hasattr(self, f"clean_{name}"):
245                    value = getattr(self, f"clean_{name}")()
246                    self.cleaned_data[name] = value
247            except ValidationError as e:
248                self.add_error(name, e)
249
250    def _clean_form(self):
251        try:
252            cleaned_data = self.clean()
253        except ValidationError as e:
254            self.add_error(None, e)
255        else:
256            if cleaned_data is not None:
257                self.cleaned_data = cleaned_data
258
259    def _post_clean(self):
260        """
261        An internal hook for performing additional cleaning after form cleaning
262        is complete. Used for model validation in model forms.
263        """
264        pass
265
266    def clean(self):
267        """
268        Hook for doing any extra form-wide cleaning after Field.clean() has been
269        called on every field. Any ValidationError raised by this method will
270        not be associated with a particular field; it will have a special-case
271        association with the field named '__all__'.
272        """
273        return self.cleaned_data
274
275    @cached_property
276    def changed_data(self):
277        return [name for name, bf in self._bound_items() if bf._has_changed()]
278
279    def get_initial_for_field(self, field, field_name):
280        """
281        Return initial data for field on form. Use initial data from the form
282        or the field, in that order. Evaluate callable values.
283        """
284        value = self.initial.get(field_name, field.initial)
285        if callable(value):
286            value = value()
287        return value
288
289
290class Form(BaseForm, metaclass=DeclarativeFieldsMetaclass):
291    "A collection of Fields, plus their associated data."
292
293    # This is a separate class from BaseForm in order to abstract the way
294    # self.fields is specified. This class (Form) is the one that does the
295    # fancy metaclass stuff purely for the semantic sugar -- it allows one
296    # to define a form using declarative syntax.
297    # BaseForm itself has no way of designating self.fields.