Repository: wtforms/wtforms (python)
Issue: [feature request/question] Form wise errors
On form validation I want to raise errors such as: "at least a field must be filled" which do not belong to one field, but to the form as a whole.

Would it be possible to raise have them on the form? (I'm currently using a hidden field and validating these kind of errors on it).

Pull request description: Form-level errors
Fixes #55 

Adds a `Form.form_errors` list attribute to store form level errors. The content of `form_errors` is available in `Form.errors[None]`.

I have no strong ideas about `Form.errors[None]`, or `Form.errors["__all__"]`, even if I prefer a keyword such as `None` for such a special key. `__all__` is fine too if you prefer.

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index ad5b983..5a4f498 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -25,6 +25,7 @@ Unreleased
     :pr:`608`
 -   Choices shortcut for :class:`~fields.core.SelectMultipleField`.
     :issue:`603` :pr:`605`
+-   Forms can have form-level errors. :issue:`55` :pr:`595`
 
 
 Version 2.3.1
diff --git a/docs/forms.rst b/docs/forms.rst
index e6b32f8..7de7d65 100644
--- a/docs/forms.rst
+++ b/docs/forms.rst
@@ -47,6 +47,15 @@ The Form class
         A dict containing a list of errors for each field. Empty if the form
         hasn't been validated, or there were no errors.
 
+        If present, the key ``None`` contains the content of
+        :attr:`~wtforms.forms.Form.form_errors`.
+
+    .. attribute:: form_errors
+
+        A list of form-level errors. Those are errors that does not concern a
+        particuliar field, but the whole form consistency. Those errors are
+        often set when overriding :meth:`~wtforms.forms.Form.validate`.
+
     .. attribute:: meta
 
         This is an object which contains various configuration options and also
diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py
index 0fefcad..8415ca9 100644
--- a/src/wtforms/fields/core.py
+++ b/src/wtforms/fields/core.py
@@ -398,7 +398,7 @@ class UnboundField:
 
     def __repr__(self):
         return "<UnboundField({}, {!r}, {!r})>".format(
-            self.field_class.__name__, self.args, self.kwargs,
+            self.field_class.__name__, self.args, self.kwargs
         )
 
 
diff --git a/src/wtforms/form.py b/src/wtforms/form.py
index 29def6d..26e8331 100644
--- a/src/wtforms/form.py
+++ b/src/wtforms/form.py
@@ -46,6 +46,8 @@ class BaseForm:
             field = meta.bind_field(self, unbound_field, options)
             self._fields[name] = field
 
+        self.form_errors = []
+
     def __iter__(self):
         """Iterate form fields in creation order."""
         return iter(self._fields.values())
@@ -140,7 +142,10 @@ class BaseForm:
 
     @property
     def errors(self):
-        return {name: f.errors for name, f in self._fields.items() if f.errors}
+        errors = {name: f.errors for name, f in self._fields.items() if f.errors}
+        if self.form_errors:
+            errors[None] = self.form_errors
+        return errors
 
 
 class FormMeta(type):


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 15fb5e1..cde7945 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -1154,7 +1154,7 @@ class TestHTML5Fields:
             data = form_input
         if expected_html.startswith("type="):
             expected_html = '<input id="{}" name="{}" {} value="{}">'.format(
-                key, key, expected_html, form_input,
+                key, key, expected_html, form_input
             )
         return {
             "key": key,
diff --git a/tests/test_form.py b/tests/test_form.py
index 6192673..27ad948 100644
--- a/tests/test_form.py
+++ b/tests/test_form.py
@@ -174,6 +174,31 @@ class TestForm:
         form = F2(test="nope", other="extra")
         assert not form.validate(extra_validators={"other": [extra]})
 
+    def test_form_level_errors(self):
+        class F(Form):
+            a = IntegerField()
+            b = IntegerField()
+
+            def validate(self):
+                if not super().validate():
+                    return False
+
+                if (self.a.data + self.b.data) % 2 != 0:
+                    self.form_errors.append("a + b should be even")
+                    return False
+
+                return True
+
+        f = F(a=1, b=1)
+        assert f.validate()
+        assert not f.form_errors
+        assert not f.errors
+
+        f = F(a=0, b=1)
+        assert not f.validate()
+        assert ["a + b should be even"] == f.form_errors
+        assert ["a + b should be even"] == f.errors[None]
+
     def test_field_adding_disabled(self):
         form = self.F()
         with pytest.raises(TypeError):