From a4991fd422a7eefefee1a9b4481cf3c058148bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Thu, 28 Oct 2021 16:54:40 +0200 Subject: [PATCH 1/2] Split the field files --- src/wtforms/fields/__init__.py | 8 +- src/wtforms/fields/choices.py | 215 ++++++++ src/wtforms/fields/core.py | 897 --------------------------------- src/wtforms/fields/datetime.py | 112 ++++ src/wtforms/fields/form.py | 97 ++++ src/wtforms/fields/list.py | 201 ++++++++ src/wtforms/fields/numeric.py | 212 ++++++++ src/wtforms/fields/simple.py | 90 +++- 8 files changed, 931 insertions(+), 901 deletions(-) create mode 100644 src/wtforms/fields/choices.py create mode 100644 src/wtforms/fields/datetime.py create mode 100644 src/wtforms/fields/form.py create mode 100644 src/wtforms/fields/list.py create mode 100644 src/wtforms/fields/numeric.py diff --git a/src/wtforms/fields/__init__.py b/src/wtforms/fields/__init__.py index e4296317c..f72189c34 100644 --- a/src/wtforms/fields/__init__.py +++ b/src/wtforms/fields/__init__.py @@ -1,7 +1,11 @@ -from wtforms.fields.core import * +from wtforms.fields.choices import * +from wtforms.fields.choices import SelectFieldBase from wtforms.fields.core import Field from wtforms.fields.core import Flags from wtforms.fields.core import Label -from wtforms.fields.core import SelectFieldBase +from wtforms.fields.datetime import * +from wtforms.fields.form import * +from wtforms.fields.list import * +from wtforms.fields.numeric import * from wtforms.fields.simple import * from wtforms.utils import unset_value as _unset_value diff --git a/src/wtforms/fields/choices.py b/src/wtforms/fields/choices.py new file mode 100644 index 000000000..d24f1a02d --- /dev/null +++ b/src/wtforms/fields/choices.py @@ -0,0 +1,215 @@ +import itertools + +from wtforms import widgets +from wtforms.fields.core import Field +from wtforms.validators import ValidationError + +__all__ = ( + "SelectField", + "SelectMultipleField", + "RadioField", +) + + +class SelectFieldBase(Field): + option_widget = widgets.Option() + + """ + Base class for fields which can be iterated to produce options. + + This isn't a field, but an abstract base class for fields which want to + provide this functionality. + """ + + def __init__(self, label=None, validators=None, option_widget=None, **kwargs): + super().__init__(label, validators, **kwargs) + + if option_widget is not None: + self.option_widget = option_widget + + def iter_choices(self): + """ + Provides data for choice widget rendering. Must return a sequence or + iterable of (value, label, selected) tuples. + """ + raise NotImplementedError() + + def has_groups(self): + return False + + def iter_groups(self): + raise NotImplementedError() + + def __iter__(self): + opts = dict( + widget=self.option_widget, + validators=self.validators, + name=self.name, + render_kw=self.render_kw, + _form=None, + _meta=self.meta, + ) + for i, (value, label, checked) in enumerate(self.iter_choices()): + opt = self._Option(label=label, id="%s-%d" % (self.id, i), **opts) + opt.process(None, value) + opt.checked = checked + yield opt + + class _Option(Field): + checked = False + + def _value(self): + return str(self.data) + + +class SelectField(SelectFieldBase): + widget = widgets.Select() + + def __init__( + self, + label=None, + validators=None, + coerce=str, + choices=None, + validate_choice=True, + **kwargs, + ): + super().__init__(label, validators, **kwargs) + self.coerce = coerce + if callable(choices): + choices = choices() + if choices is not None: + self.choices = choices if isinstance(choices, dict) else list(choices) + else: + self.choices = None + self.validate_choice = validate_choice + + def iter_choices(self): + if not self.choices: + choices = [] + elif isinstance(self.choices, dict): + choices = list(itertools.chain.from_iterable(self.choices.values())) + else: + choices = self.choices + + return self._choices_generator(choices) + + def has_groups(self): + return isinstance(self.choices, dict) + + def iter_groups(self): + if isinstance(self.choices, dict): + for label, choices in self.choices.items(): + yield (label, self._choices_generator(choices)) + + def _choices_generator(self, choices): + if not choices: + _choices = [] + + elif isinstance(choices[0], (list, tuple)): + _choices = choices + + else: + _choices = zip(choices, choices) + + for value, label in _choices: + yield (value, label, self.coerce(value) == self.data) + + def process_data(self, value): + try: + # If value is None, don't coerce to a value + self.data = self.coerce(value) if value is not None else None + except (ValueError, TypeError): + self.data = None + + def process_formdata(self, valuelist): + if not valuelist: + return + + try: + self.data = self.coerce(valuelist[0]) + except ValueError as exc: + raise ValueError(self.gettext("Invalid Choice: could not coerce.")) from exc + + def pre_validate(self, form): + if self.choices is None: + raise TypeError(self.gettext("Choices cannot be None.")) + + if not self.validate_choice: + return + + for _, _, match in self.iter_choices(): + if match: + break + else: + raise ValidationError(self.gettext("Not a valid choice.")) + + +class SelectMultipleField(SelectField): + """ + No different from a normal select field, except this one can take (and + validate) multiple choices. You'll need to specify the HTML `size` + attribute to the select field when rendering. + """ + + widget = widgets.Select(multiple=True) + + def _choices_generator(self, choices): + if choices: + if isinstance(choices[0], (list, tuple)): + _choices = choices + else: + _choices = zip(choices, choices) + else: + _choices = [] + + for value, label in _choices: + selected = self.data is not None and self.coerce(value) in self.data + yield (value, label, selected) + + def process_data(self, value): + try: + self.data = list(self.coerce(v) for v in value) + except (ValueError, TypeError): + self.data = None + + def process_formdata(self, valuelist): + try: + self.data = list(self.coerce(x) for x in valuelist) + except ValueError as exc: + raise ValueError( + self.gettext( + "Invalid choice(s): one or more data inputs could not be coerced." + ) + ) from exc + + def pre_validate(self, form): + if self.choices is None: + raise TypeError(self.gettext("Choices cannot be None.")) + + if not self.validate_choice or not self.data: + return + + acceptable = {c[0] for c in self.iter_choices()} + if any(d not in acceptable for d in self.data): + unacceptable = [str(d) for d in set(self.data) - acceptable] + raise ValidationError( + self.ngettext( + "'%(value)s' is not a valid choice for this field.", + "'%(value)s' are not valid choices for this field.", + len(unacceptable), + ) + % dict(value="', '".join(unacceptable)) + ) + + +class RadioField(SelectField): + """ + Like a SelectField, except displays a list of radio buttons. + + Iterating the field will produce subfields (each containing a label as + well) in order to allow custom rendering of the individual radio fields. + """ + + widget = widgets.ListWidget(prefix_label=False) + option_widget = widgets.RadioInput() diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py index 915944dbe..59c4e9552 100644 --- a/src/wtforms/fields/core.py +++ b/src/wtforms/fields/core.py @@ -1,5 +1,3 @@ -import datetime -import decimal import inspect import itertools import warnings @@ -9,35 +7,10 @@ from wtforms import widgets from wtforms.i18n import DummyTranslations -from wtforms.utils import clean_datetime_format_for_strptime from wtforms.utils import unset_value from wtforms.validators import StopValidation from wtforms.validators import ValidationError -__all__ = ( - "BooleanField", - "DecimalField", - "DecimalRangeField", - "DateField", - "DateTimeField", - "DateTimeLocalField", - "EmailField", - "FieldList", - "FloatField", - "FormField", - "IntegerField", - "IntegerRangeField", - "RadioField", - "SearchField", - "SelectField", - "SelectMultipleField", - "StringField", - "TelField", - "TimeField", - "MonthField", - "URLField", -) - class Field: """ @@ -474,873 +447,3 @@ def __call__(self, text=None, **kwargs): def __repr__(self): return f"Label({self.field_id!r}, {self.text!r})" - - -class SelectFieldBase(Field): - option_widget = widgets.Option() - - """ - Base class for fields which can be iterated to produce options. - - This isn't a field, but an abstract base class for fields which want to - provide this functionality. - """ - - def __init__(self, label=None, validators=None, option_widget=None, **kwargs): - super().__init__(label, validators, **kwargs) - - if option_widget is not None: - self.option_widget = option_widget - - def iter_choices(self): - """ - Provides data for choice widget rendering. Must return a sequence or - iterable of (value, label, selected) tuples. - """ - raise NotImplementedError() - - def has_groups(self): - return False - - def iter_groups(self): - raise NotImplementedError() - - def __iter__(self): - opts = dict( - widget=self.option_widget, - validators=self.validators, - name=self.name, - render_kw=self.render_kw, - _form=None, - _meta=self.meta, - ) - for i, (value, label, checked) in enumerate(self.iter_choices()): - opt = self._Option(label=label, id="%s-%d" % (self.id, i), **opts) - opt.process(None, value) - opt.checked = checked - yield opt - - class _Option(Field): - checked = False - - def _value(self): - return str(self.data) - - -class SelectField(SelectFieldBase): - widget = widgets.Select() - - def __init__( - self, - label=None, - validators=None, - coerce=str, - choices=None, - validate_choice=True, - **kwargs, - ): - super().__init__(label, validators, **kwargs) - self.coerce = coerce - if callable(choices): - choices = choices() - if choices is not None: - self.choices = choices if isinstance(choices, dict) else list(choices) - else: - self.choices = None - self.validate_choice = validate_choice - - def iter_choices(self): - if not self.choices: - choices = [] - elif isinstance(self.choices, dict): - choices = list(itertools.chain.from_iterable(self.choices.values())) - else: - choices = self.choices - - return self._choices_generator(choices) - - def has_groups(self): - return isinstance(self.choices, dict) - - def iter_groups(self): - if isinstance(self.choices, dict): - for label, choices in self.choices.items(): - yield (label, self._choices_generator(choices)) - - def _choices_generator(self, choices): - if not choices: - _choices = [] - - elif isinstance(choices[0], (list, tuple)): - _choices = choices - - else: - _choices = zip(choices, choices) - - for value, label in _choices: - yield (value, label, self.coerce(value) == self.data) - - def process_data(self, value): - try: - # If value is None, don't coerce to a value - self.data = self.coerce(value) if value is not None else None - except (ValueError, TypeError): - self.data = None - - def process_formdata(self, valuelist): - if not valuelist: - return - - try: - self.data = self.coerce(valuelist[0]) - except ValueError as exc: - raise ValueError(self.gettext("Invalid Choice: could not coerce.")) from exc - - def pre_validate(self, form): - if self.choices is None: - raise TypeError(self.gettext("Choices cannot be None.")) - - if not self.validate_choice: - return - - for _, _, match in self.iter_choices(): - if match: - break - else: - raise ValidationError(self.gettext("Not a valid choice.")) - - -class SelectMultipleField(SelectField): - """ - No different from a normal select field, except this one can take (and - validate) multiple choices. You'll need to specify the HTML `size` - attribute to the select field when rendering. - """ - - widget = widgets.Select(multiple=True) - - def _choices_generator(self, choices): - if choices: - if isinstance(choices[0], (list, tuple)): - _choices = choices - else: - _choices = zip(choices, choices) - else: - _choices = [] - - for value, label in _choices: - selected = self.data is not None and self.coerce(value) in self.data - yield (value, label, selected) - - def process_data(self, value): - try: - self.data = list(self.coerce(v) for v in value) - except (ValueError, TypeError): - self.data = None - - def process_formdata(self, valuelist): - try: - self.data = list(self.coerce(x) for x in valuelist) - except ValueError as exc: - raise ValueError( - self.gettext( - "Invalid choice(s): one or more data inputs could not be coerced." - ) - ) from exc - - def pre_validate(self, form): - if self.choices is None: - raise TypeError(self.gettext("Choices cannot be None.")) - - if not self.validate_choice or not self.data: - return - - acceptable = {c[0] for c in self.iter_choices()} - if any(d not in acceptable for d in self.data): - unacceptable = [str(d) for d in set(self.data) - acceptable] - raise ValidationError( - self.ngettext( - "'%(value)s' is not a valid choice for this field.", - "'%(value)s' are not valid choices for this field.", - len(unacceptable), - ) - % dict(value="', '".join(unacceptable)) - ) - - -class RadioField(SelectField): - """ - Like a SelectField, except displays a list of radio buttons. - - Iterating the field will produce subfields (each containing a label as - well) in order to allow custom rendering of the individual radio fields. - """ - - widget = widgets.ListWidget(prefix_label=False) - option_widget = widgets.RadioInput() - - -class StringField(Field): - """ - This field is the base for most of the more complicated fields, and - represents an ````. - """ - - widget = widgets.TextInput() - - def process_formdata(self, valuelist): - if valuelist: - self.data = valuelist[0] - - def _value(self): - return str(self.data) if self.data is not None else "" - - -class LocaleAwareNumberField(Field): - """ - Base class for implementing locale-aware number parsing. - - Locale-aware numbers require the 'babel' package to be present. - """ - - def __init__( - self, - label=None, - validators=None, - use_locale=False, - number_format=None, - **kwargs, - ): - super().__init__(label, validators, **kwargs) - self.use_locale = use_locale - if use_locale: - self.number_format = number_format - self.locale = kwargs["_form"].meta.locales[0] - self._init_babel() - - def _init_babel(self): - try: - from babel import numbers - - self.babel_numbers = numbers - except ImportError as exc: - raise ImportError( - "Using locale-aware decimals requires the babel library." - ) from exc - - def _parse_decimal(self, value): - return self.babel_numbers.parse_decimal(value, self.locale) - - def _format_decimal(self, value): - return self.babel_numbers.format_decimal(value, self.number_format, self.locale) - - -class IntegerField(Field): - """ - A text field, except all input is coerced to an integer. Erroneous input - is ignored and will not be accepted as a value. - """ - - widget = widgets.NumberInput() - - def __init__(self, label=None, validators=None, **kwargs): - super().__init__(label, validators, **kwargs) - - def _value(self): - if self.raw_data: - return self.raw_data[0] - if self.data is not None: - return str(self.data) - return "" - - def process_data(self, value): - if value is None or value is unset_value: - self.data = None - return - - try: - self.data = int(value) - except (ValueError, TypeError) as exc: - self.data = None - raise ValueError(self.gettext("Not a valid integer value.")) from exc - - def process_formdata(self, valuelist): - if not valuelist: - return - - try: - self.data = int(valuelist[0]) - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid integer value.")) from exc - - -class DecimalField(LocaleAwareNumberField): - """ - A text field which displays and coerces data of the `decimal.Decimal` type. - - :param places: - How many decimal places to quantize the value to for display on form. - If None, does not quantize value. - :param rounding: - How to round the value during quantize, for example - `decimal.ROUND_UP`. If unset, uses the rounding value from the - current thread's context. - :param use_locale: - If True, use locale-based number formatting. Locale-based number - formatting requires the 'babel' package. - :param number_format: - Optional number format for locale. If omitted, use the default decimal - format for the locale. - """ - - widget = widgets.NumberInput(step="any") - - def __init__( - self, label=None, validators=None, places=unset_value, rounding=None, **kwargs - ): - super().__init__(label, validators, **kwargs) - if self.use_locale and (places is not unset_value or rounding is not None): - raise TypeError( - "When using locale-aware numbers, 'places' and 'rounding' are ignored." - ) - - if places is unset_value: - places = 2 - self.places = places - self.rounding = rounding - - def _value(self): - if self.raw_data: - return self.raw_data[0] - - if self.data is None: - return "" - - if self.use_locale: - return str(self._format_decimal(self.data)) - - if self.places is None: - return str(self.data) - - if not hasattr(self.data, "quantize"): - # If for some reason, data is a float or int, then format - # as we would for floats using string formatting. - format = "%%0.%df" % self.places - return format % self.data - - exp = decimal.Decimal(".1") ** self.places - if self.rounding is None: - quantized = self.data.quantize(exp) - else: - quantized = self.data.quantize(exp, rounding=self.rounding) - return str(quantized) - - def process_formdata(self, valuelist): - if not valuelist: - return - - try: - if self.use_locale: - self.data = self._parse_decimal(valuelist[0]) - else: - self.data = decimal.Decimal(valuelist[0]) - except (decimal.InvalidOperation, ValueError) as exc: - self.data = None - raise ValueError(self.gettext("Not a valid decimal value.")) from exc - - -class FloatField(Field): - """ - A text field, except all input is coerced to an float. Erroneous input - is ignored and will not be accepted as a value. - """ - - widget = widgets.TextInput() - - def __init__(self, label=None, validators=None, **kwargs): - super().__init__(label, validators, **kwargs) - - def _value(self): - if self.raw_data: - return self.raw_data[0] - if self.data is not None: - return str(self.data) - return "" - - def process_formdata(self, valuelist): - if not valuelist: - return - - try: - self.data = float(valuelist[0]) - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid float value.")) from exc - - -class BooleanField(Field): - """ - Represents an ````. Set the ``checked``-status by using the - ``default``-option. Any value for ``default``, e.g. ``default="checked"`` puts - ``checked`` into the html-element and sets the ``data`` to ``True`` - - :param false_values: - If provided, a sequence of strings each of which is an exact match - string of what is considered a "false" value. Defaults to the tuple - ``(False, "false", "")`` - """ - - widget = widgets.CheckboxInput() - false_values = (False, "false", "") - - def __init__(self, label=None, validators=None, false_values=None, **kwargs): - super().__init__(label, validators, **kwargs) - if false_values is not None: - self.false_values = false_values - - def process_data(self, value): - self.data = bool(value) - - def process_formdata(self, valuelist): - if not valuelist or valuelist[0] in self.false_values: - self.data = False - else: - self.data = True - - def _value(self): - if self.raw_data: - return str(self.raw_data[0]) - return "y" - - -class DateTimeField(Field): - """ - A text field which stores a `datetime.datetime` matching a format. - """ - - widget = widgets.DateTimeInput() - - def __init__( - self, label=None, validators=None, format="%Y-%m-%d %H:%M:%S", **kwargs - ): - super().__init__(label, validators, **kwargs) - self.format = format - self.strptime_format = clean_datetime_format_for_strptime(format) - - def _value(self): - if self.raw_data: - return " ".join(self.raw_data) - return self.data and self.data.strftime(self.format) or "" - - def process_formdata(self, valuelist): - if not valuelist: - return - - date_str = " ".join(valuelist) - try: - self.data = datetime.datetime.strptime(date_str, self.strptime_format) - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid datetime value.")) from exc - - -class DateField(DateTimeField): - """ - Same as DateTimeField, except stores a `datetime.date`. - """ - - widget = widgets.DateInput() - - def __init__(self, label=None, validators=None, format="%Y-%m-%d", **kwargs): - super().__init__(label, validators, format, **kwargs) - - def process_formdata(self, valuelist): - if not valuelist: - return - - date_str = " ".join(valuelist) - try: - self.data = datetime.datetime.strptime( - date_str, self.strptime_format - ).date() - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid date value.")) from exc - - -class TimeField(DateTimeField): - """ - Same as DateTimeField, except stores a `time`. - """ - - widget = widgets.TimeInput() - - def __init__(self, label=None, validators=None, format="%H:%M", **kwargs): - super().__init__(label, validators, format, **kwargs) - - def process_formdata(self, valuelist): - if not valuelist: - return - - time_str = " ".join(valuelist) - try: - self.data = datetime.datetime.strptime( - time_str, self.strptime_format - ).time() - except ValueError as exc: - self.data = None - raise ValueError(self.gettext("Not a valid time value.")) from exc - - -class MonthField(DateField): - """ - Same as DateField, except represents a month, stores a `datetime.date` - with `day = 1`. - """ - - widget = widgets.MonthInput() - - def __init__(self, label=None, validators=None, format="%Y-%m", **kwargs): - super().__init__(label, validators, format, **kwargs) - - -class FormField(Field): - """ - Encapsulate a form as a field in another form. - - :param form_class: - A subclass of Form that will be encapsulated. - :param separator: - A string which will be suffixed to this field's name to create the - prefix to enclosed fields. The default is fine for most uses. - """ - - widget = widgets.TableWidget() - - def __init__( - self, form_class, label=None, validators=None, separator="-", **kwargs - ): - super().__init__(label, validators, **kwargs) - self.form_class = form_class - self.separator = separator - self._obj = None - if self.filters: - raise TypeError( - "FormField cannot take filters, as the encapsulated" - " data is not mutable." - ) - if validators: - raise TypeError( - "FormField does not accept any validators. Instead," - " define them on the enclosed form." - ) - - def process(self, formdata, data=unset_value, extra_filters=None): - if extra_filters: - raise TypeError( - "FormField cannot take filters, as the encapsulated" - "data is not mutable." - ) - - if data is unset_value: - try: - data = self.default() - except TypeError: - data = self.default - self._obj = data - - self.object_data = data - - prefix = self.name + self.separator - if isinstance(data, dict): - self.form = self.form_class(formdata=formdata, prefix=prefix, **data) - else: - self.form = self.form_class(formdata=formdata, obj=data, prefix=prefix) - - def validate(self, form, extra_validators=()): - if extra_validators: - raise TypeError( - "FormField does not accept in-line validators, as it" - " gets errors from the enclosed form." - ) - return self.form.validate() - - def populate_obj(self, obj, name): - candidate = getattr(obj, name, None) - if candidate is None: - if self._obj is None: - raise TypeError( - "populate_obj: cannot find a value to populate from" - " the provided obj or input data/defaults" - ) - candidate = self._obj - - self.form.populate_obj(candidate) - setattr(obj, name, candidate) - - def __iter__(self): - return iter(self.form) - - def __getitem__(self, name): - return self.form[name] - - def __getattr__(self, name): - return getattr(self.form, name) - - @property - def data(self): - return self.form.data - - @property - def errors(self): - return self.form.errors - - -class FieldList(Field): - """ - Encapsulate an ordered list of multiple instances of the same field type, - keeping data as a list. - - >>> authors = FieldList(StringField('Name', [validators.DataRequired()])) - - :param unbound_field: - A partially-instantiated field definition, just like that would be - defined on a form directly. - :param min_entries: - if provided, always have at least this many entries on the field, - creating blank ones if the provided input does not specify a sufficient - amount. - :param max_entries: - accept no more than this many entries as input, even if more exist in - formdata. - :param separator: - A string which will be suffixed to this field's name to create the - prefix to enclosed list entries. The default is fine for most uses. - """ - - widget = widgets.ListWidget() - - def __init__( - self, - unbound_field, - label=None, - validators=None, - min_entries=0, - max_entries=None, - separator="-", - default=(), - **kwargs, - ): - super().__init__(label, validators, default=default, **kwargs) - if self.filters: - raise TypeError( - "FieldList does not accept any filters. Instead, define" - " them on the enclosed field." - ) - assert isinstance( - unbound_field, UnboundField - ), "Field must be unbound, not a field class" - self.unbound_field = unbound_field - self.min_entries = min_entries - self.max_entries = max_entries - self.last_index = -1 - self._prefix = kwargs.get("_prefix", "") - self._separator = separator - self._field_separator = unbound_field.kwargs.get("separator", "-") - - def process(self, formdata, data=unset_value, extra_filters=None): - if extra_filters: - raise TypeError( - "FieldList does not accept any filters. Instead, define" - " them on the enclosed field." - ) - - self.entries = [] - if data is unset_value or not data: - try: - data = self.default() - except TypeError: - data = self.default - - self.object_data = data - - if formdata: - indices = sorted(set(self._extract_indices(self.name, formdata))) - if self.max_entries: - indices = indices[: self.max_entries] - - idata = iter(data) - for index in indices: - try: - obj_data = next(idata) - except StopIteration: - obj_data = unset_value - self._add_entry(formdata, obj_data, index=index) - else: - for obj_data in data: - self._add_entry(formdata, obj_data) - - while len(self.entries) < self.min_entries: - self._add_entry(formdata) - - def _extract_indices(self, prefix, formdata): - """ - Yield indices of any keys with given prefix. - - formdata must be an object which will produce keys when iterated. For - example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then - the numbers 0 and 1 will be yielded, but not necessarily in order. - """ - offset = len(prefix) + 1 - for k in formdata: - if k.startswith(prefix): - k = k[offset:].split(self._field_separator, 1)[0] - if k.isdigit(): - yield int(k) - - def validate(self, form, extra_validators=()): - """ - Validate this FieldList. - - Note that FieldList validation differs from normal field validation in - that FieldList validates all its enclosed fields first before running any - of its own validators. - """ - self.errors = [] - - # Run validators on all entries within - for subfield in self.entries: - subfield.validate(form) - self.errors.append(subfield.errors) - - if not any(x for x in self.errors): - self.errors = [] - - chain = itertools.chain(self.validators, extra_validators) - self._run_validation_chain(form, chain) - - return len(self.errors) == 0 - - def populate_obj(self, obj, name): - values = getattr(obj, name, None) - try: - ivalues = iter(values) - except TypeError: - ivalues = iter([]) - - candidates = itertools.chain(ivalues, itertools.repeat(None)) - _fake = type("_fake", (object,), {}) - output = [] - for field, data in zip(self.entries, candidates): - fake_obj = _fake() - fake_obj.data = data - field.populate_obj(fake_obj, "data") - output.append(fake_obj.data) - - setattr(obj, name, output) - - def _add_entry(self, formdata=None, data=unset_value, index=None): - assert ( - not self.max_entries or len(self.entries) < self.max_entries - ), "You cannot have more than max_entries entries in this FieldList" - if index is None: - index = self.last_index + 1 - self.last_index = index - name = f"{self.short_name}{self._separator}{index}" - id = f"{self.id}{self._separator}{index}" - field = self.unbound_field.bind( - form=None, - name=name, - prefix=self._prefix, - id=id, - _meta=self.meta, - translations=self._translations, - ) - field.process(formdata, data) - self.entries.append(field) - return field - - def append_entry(self, data=unset_value): - """ - Create a new entry with optional default data. - - Entries added in this way will *not* receive formdata however, and can - only receive object data. - """ - return self._add_entry(data=data) - - def pop_entry(self): - """Removes the last entry from the list and returns it.""" - entry = self.entries.pop() - self.last_index -= 1 - return entry - - def __iter__(self): - return iter(self.entries) - - def __len__(self): - return len(self.entries) - - def __getitem__(self, index): - return self.entries[index] - - @property - def data(self): - return [f.data for f in self.entries] - - -class SearchField(StringField): - """ - Represents an ````. - """ - - widget = widgets.SearchInput() - - -class TelField(StringField): - """ - Represents an ````. - """ - - widget = widgets.TelInput() - - -class URLField(StringField): - """ - Represents an ````. - """ - - widget = widgets.URLInput() - - -class EmailField(StringField): - """ - Represents an ````. - """ - - widget = widgets.EmailInput() - - -class DateTimeLocalField(DateTimeField): - """ - Represents an ````. - """ - - widget = widgets.DateTimeLocalInput() - - -class IntegerRangeField(IntegerField): - """ - Represents an ````. - """ - - widget = widgets.RangeInput() - - -class DecimalRangeField(DecimalField): - """ - Represents an ````. - """ - - widget = widgets.RangeInput(step="any") diff --git a/src/wtforms/fields/datetime.py b/src/wtforms/fields/datetime.py new file mode 100644 index 000000000..1fc8df0a3 --- /dev/null +++ b/src/wtforms/fields/datetime.py @@ -0,0 +1,112 @@ +import datetime + +from wtforms import widgets +from wtforms.fields.core import Field +from wtforms.utils import clean_datetime_format_for_strptime + +__all__ = ( + "DateTimeField", + "DateField", + "TimeField", + "MonthField", + "DateTimeLocalField", +) + + +class DateTimeField(Field): + """ + A text field which stores a `datetime.datetime` matching a format. + """ + + widget = widgets.DateTimeInput() + + def __init__( + self, label=None, validators=None, format="%Y-%m-%d %H:%M:%S", **kwargs + ): + super().__init__(label, validators, **kwargs) + self.format = format + self.strptime_format = clean_datetime_format_for_strptime(format) + + def _value(self): + if self.raw_data: + return " ".join(self.raw_data) + return self.data and self.data.strftime(self.format) or "" + + def process_formdata(self, valuelist): + if not valuelist: + return + + date_str = " ".join(valuelist) + try: + self.data = datetime.datetime.strptime(date_str, self.strptime_format) + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid datetime value.")) from exc + + +class DateField(DateTimeField): + """ + Same as DateTimeField, except stores a `datetime.date`. + """ + + widget = widgets.DateInput() + + def __init__(self, label=None, validators=None, format="%Y-%m-%d", **kwargs): + super().__init__(label, validators, format, **kwargs) + + def process_formdata(self, valuelist): + if not valuelist: + return + + date_str = " ".join(valuelist) + try: + self.data = datetime.datetime.strptime( + date_str, self.strptime_format + ).date() + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid date value.")) from exc + + +class TimeField(DateTimeField): + """ + Same as DateTimeField, except stores a `time`. + """ + + widget = widgets.TimeInput() + + def __init__(self, label=None, validators=None, format="%H:%M", **kwargs): + super().__init__(label, validators, format, **kwargs) + + def process_formdata(self, valuelist): + if not valuelist: + return + + time_str = " ".join(valuelist) + try: + self.data = datetime.datetime.strptime( + time_str, self.strptime_format + ).time() + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid time value.")) from exc + + +class MonthField(DateField): + """ + Same as DateField, except represents a month, stores a `datetime.date` + with `day = 1`. + """ + + widget = widgets.MonthInput() + + def __init__(self, label=None, validators=None, format="%Y-%m", **kwargs): + super().__init__(label, validators, format, **kwargs) + + +class DateTimeLocalField(DateTimeField): + """ + Represents an ````. + """ + + widget = widgets.DateTimeLocalInput() diff --git a/src/wtforms/fields/form.py b/src/wtforms/fields/form.py new file mode 100644 index 000000000..83f5abeae --- /dev/null +++ b/src/wtforms/fields/form.py @@ -0,0 +1,97 @@ +from .. import widgets +from .core import Field +from wtforms.utils import unset_value + +__all__ = ("FormField",) + + +class FormField(Field): + """ + Encapsulate a form as a field in another form. + + :param form_class: + A subclass of Form that will be encapsulated. + :param separator: + A string which will be suffixed to this field's name to create the + prefix to enclosed fields. The default is fine for most uses. + """ + + widget = widgets.TableWidget() + + def __init__( + self, form_class, label=None, validators=None, separator="-", **kwargs + ): + super().__init__(label, validators, **kwargs) + self.form_class = form_class + self.separator = separator + self._obj = None + if self.filters: + raise TypeError( + "FormField cannot take filters, as the encapsulated" + " data is not mutable." + ) + if validators: + raise TypeError( + "FormField does not accept any validators. Instead," + " define them on the enclosed form." + ) + + def process(self, formdata, data=unset_value, extra_filters=None): + if extra_filters: + raise TypeError( + "FormField cannot take filters, as the encapsulated" + "data is not mutable." + ) + + if data is unset_value: + try: + data = self.default() + except TypeError: + data = self.default + self._obj = data + + self.object_data = data + + prefix = self.name + self.separator + if isinstance(data, dict): + self.form = self.form_class(formdata=formdata, prefix=prefix, **data) + else: + self.form = self.form_class(formdata=formdata, obj=data, prefix=prefix) + + def validate(self, form, extra_validators=()): + if extra_validators: + raise TypeError( + "FormField does not accept in-line validators, as it" + " gets errors from the enclosed form." + ) + return self.form.validate() + + def populate_obj(self, obj, name): + candidate = getattr(obj, name, None) + if candidate is None: + if self._obj is None: + raise TypeError( + "populate_obj: cannot find a value to populate from" + " the provided obj or input data/defaults" + ) + candidate = self._obj + + self.form.populate_obj(candidate) + setattr(obj, name, candidate) + + def __iter__(self): + return iter(self.form) + + def __getitem__(self, name): + return self.form[name] + + def __getattr__(self, name): + return getattr(self.form, name) + + @property + def data(self): + return self.form.data + + @property + def errors(self): + return self.form.errors diff --git a/src/wtforms/fields/list.py b/src/wtforms/fields/list.py new file mode 100644 index 000000000..db52bc046 --- /dev/null +++ b/src/wtforms/fields/list.py @@ -0,0 +1,201 @@ +import itertools + +from .. import widgets +from .core import Field +from .core import UnboundField +from wtforms.utils import unset_value + +__all__ = ("FieldList",) + + +class FieldList(Field): + """ + Encapsulate an ordered list of multiple instances of the same field type, + keeping data as a list. + + >>> authors = FieldList(StringField('Name', [validators.DataRequired()])) + + :param unbound_field: + A partially-instantiated field definition, just like that would be + defined on a form directly. + :param min_entries: + if provided, always have at least this many entries on the field, + creating blank ones if the provided input does not specify a sufficient + amount. + :param max_entries: + accept no more than this many entries as input, even if more exist in + formdata. + :param separator: + A string which will be suffixed to this field's name to create the + prefix to enclosed list entries. The default is fine for most uses. + """ + + widget = widgets.ListWidget() + + def __init__( + self, + unbound_field, + label=None, + validators=None, + min_entries=0, + max_entries=None, + separator="-", + default=(), + **kwargs, + ): + super().__init__(label, validators, default=default, **kwargs) + if self.filters: + raise TypeError( + "FieldList does not accept any filters. Instead, define" + " them on the enclosed field." + ) + assert isinstance( + unbound_field, UnboundField + ), "Field must be unbound, not a field class" + self.unbound_field = unbound_field + self.min_entries = min_entries + self.max_entries = max_entries + self.last_index = -1 + self._prefix = kwargs.get("_prefix", "") + self._separator = separator + self._field_separator = unbound_field.kwargs.get("separator", "-") + + def process(self, formdata, data=unset_value, extra_filters=None): + if extra_filters: + raise TypeError( + "FieldList does not accept any filters. Instead, define" + " them on the enclosed field." + ) + + self.entries = [] + if data is unset_value or not data: + try: + data = self.default() + except TypeError: + data = self.default + + self.object_data = data + + if formdata: + indices = sorted(set(self._extract_indices(self.name, formdata))) + if self.max_entries: + indices = indices[: self.max_entries] + + idata = iter(data) + for index in indices: + try: + obj_data = next(idata) + except StopIteration: + obj_data = unset_value + self._add_entry(formdata, obj_data, index=index) + else: + for obj_data in data: + self._add_entry(formdata, obj_data) + + while len(self.entries) < self.min_entries: + self._add_entry(formdata) + + def _extract_indices(self, prefix, formdata): + """ + Yield indices of any keys with given prefix. + + formdata must be an object which will produce keys when iterated. For + example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then + the numbers 0 and 1 will be yielded, but not necessarily in order. + """ + offset = len(prefix) + 1 + for k in formdata: + if k.startswith(prefix): + k = k[offset:].split(self._field_separator, 1)[0] + if k.isdigit(): + yield int(k) + + def validate(self, form, extra_validators=()): + """ + Validate this FieldList. + + Note that FieldList validation differs from normal field validation in + that FieldList validates all its enclosed fields first before running any + of its own validators. + """ + self.errors = [] + + # Run validators on all entries within + for subfield in self.entries: + subfield.validate(form) + self.errors.append(subfield.errors) + + if not any(x for x in self.errors): + self.errors = [] + + chain = itertools.chain(self.validators, extra_validators) + self._run_validation_chain(form, chain) + + return len(self.errors) == 0 + + def populate_obj(self, obj, name): + values = getattr(obj, name, None) + try: + ivalues = iter(values) + except TypeError: + ivalues = iter([]) + + candidates = itertools.chain(ivalues, itertools.repeat(None)) + _fake = type("_fake", (object,), {}) + output = [] + for field, data in zip(self.entries, candidates): + fake_obj = _fake() + fake_obj.data = data + field.populate_obj(fake_obj, "data") + output.append(fake_obj.data) + + setattr(obj, name, output) + + def _add_entry(self, formdata=None, data=unset_value, index=None): + assert ( + not self.max_entries or len(self.entries) < self.max_entries + ), "You cannot have more than max_entries entries in this FieldList" + if index is None: + index = self.last_index + 1 + self.last_index = index + name = f"{self.short_name}{self._separator}{index}" + id = f"{self.id}{self._separator}{index}" + field = self.unbound_field.bind( + form=None, + name=name, + prefix=self._prefix, + id=id, + _meta=self.meta, + translations=self._translations, + ) + field.process(formdata, data) + self.entries.append(field) + return field + + def append_entry(self, data=unset_value): + """ + Create a new entry with optional default data. + + Entries added in this way will *not* receive formdata however, and can + only receive object data. + """ + return self._add_entry(data=data) + + def pop_entry(self): + """Removes the last entry from the list and returns it.""" + entry = self.entries.pop() + self.last_index -= 1 + return entry + + def __iter__(self): + return iter(self.entries) + + def __len__(self): + return len(self.entries) + + def __getitem__(self, index): + return self.entries[index] + + @property + def data(self): + return [f.data for f in self.entries] diff --git a/src/wtforms/fields/numeric.py b/src/wtforms/fields/numeric.py new file mode 100644 index 000000000..2ee7ef437 --- /dev/null +++ b/src/wtforms/fields/numeric.py @@ -0,0 +1,212 @@ +import decimal + +from wtforms import widgets +from wtforms.fields.core import Field +from wtforms.utils import unset_value + +__all__ = ( + "IntegerField", + "DecimalField", + "FloatField", + "IntegerRangeField", + "DecimalRangeField", +) + + +class LocaleAwareNumberField(Field): + """ + Base class for implementing locale-aware number parsing. + + Locale-aware numbers require the 'babel' package to be present. + """ + + def __init__( + self, + label=None, + validators=None, + use_locale=False, + number_format=None, + **kwargs, + ): + super().__init__(label, validators, **kwargs) + self.use_locale = use_locale + if use_locale: + self.number_format = number_format + self.locale = kwargs["_form"].meta.locales[0] + self._init_babel() + + def _init_babel(self): + try: + from babel import numbers + + self.babel_numbers = numbers + except ImportError as exc: + raise ImportError( + "Using locale-aware decimals requires the babel library." + ) from exc + + def _parse_decimal(self, value): + return self.babel_numbers.parse_decimal(value, self.locale) + + def _format_decimal(self, value): + return self.babel_numbers.format_decimal(value, self.number_format, self.locale) + + +class IntegerField(Field): + """ + A text field, except all input is coerced to an integer. Erroneous input + is ignored and will not be accepted as a value. + """ + + widget = widgets.NumberInput() + + def __init__(self, label=None, validators=None, **kwargs): + super().__init__(label, validators, **kwargs) + + def _value(self): + if self.raw_data: + return self.raw_data[0] + if self.data is not None: + return str(self.data) + return "" + + def process_data(self, value): + if value is None or value is unset_value: + self.data = None + return + + try: + self.data = int(value) + except (ValueError, TypeError) as exc: + self.data = None + raise ValueError(self.gettext("Not a valid integer value.")) from exc + + def process_formdata(self, valuelist): + if not valuelist: + return + + try: + self.data = int(valuelist[0]) + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid integer value.")) from exc + + +class DecimalField(LocaleAwareNumberField): + """ + A text field which displays and coerces data of the `decimal.Decimal` type. + + :param places: + How many decimal places to quantize the value to for display on form. + If None, does not quantize value. + :param rounding: + How to round the value during quantize, for example + `decimal.ROUND_UP`. If unset, uses the rounding value from the + current thread's context. + :param use_locale: + If True, use locale-based number formatting. Locale-based number + formatting requires the 'babel' package. + :param number_format: + Optional number format for locale. If omitted, use the default decimal + format for the locale. + """ + + widget = widgets.NumberInput(step="any") + + def __init__( + self, label=None, validators=None, places=unset_value, rounding=None, **kwargs + ): + super().__init__(label, validators, **kwargs) + if self.use_locale and (places is not unset_value or rounding is not None): + raise TypeError( + "When using locale-aware numbers, 'places' and 'rounding' are ignored." + ) + + if places is unset_value: + places = 2 + self.places = places + self.rounding = rounding + + def _value(self): + if self.raw_data: + return self.raw_data[0] + + if self.data is None: + return "" + + if self.use_locale: + return str(self._format_decimal(self.data)) + + if self.places is None: + return str(self.data) + + if not hasattr(self.data, "quantize"): + # If for some reason, data is a float or int, then format + # as we would for floats using string formatting. + format = "%%0.%df" % self.places + return format % self.data + + exp = decimal.Decimal(".1") ** self.places + if self.rounding is None: + quantized = self.data.quantize(exp) + else: + quantized = self.data.quantize(exp, rounding=self.rounding) + return str(quantized) + + def process_formdata(self, valuelist): + if not valuelist: + return + + try: + if self.use_locale: + self.data = self._parse_decimal(valuelist[0]) + else: + self.data = decimal.Decimal(valuelist[0]) + except (decimal.InvalidOperation, ValueError) as exc: + self.data = None + raise ValueError(self.gettext("Not a valid decimal value.")) from exc + + +class FloatField(Field): + """ + A text field, except all input is coerced to an float. Erroneous input + is ignored and will not be accepted as a value. + """ + + widget = widgets.TextInput() + + def __init__(self, label=None, validators=None, **kwargs): + super().__init__(label, validators, **kwargs) + + def _value(self): + if self.raw_data: + return self.raw_data[0] + if self.data is not None: + return str(self.data) + return "" + + def process_formdata(self, valuelist): + if not valuelist: + return + + try: + self.data = float(valuelist[0]) + except ValueError as exc: + self.data = None + raise ValueError(self.gettext("Not a valid float value.")) from exc + + +class IntegerRangeField(IntegerField): + """ + Represents an ````. + """ + + widget = widgets.RangeInput() + + +class DecimalRangeField(DecimalField): + """ + Represents an ````. + """ + + widget = widgets.RangeInput(step="any") diff --git a/src/wtforms/fields/simple.py b/src/wtforms/fields/simple.py index 5d26413e9..8feff4f56 100644 --- a/src/wtforms/fields/simple.py +++ b/src/wtforms/fields/simple.py @@ -1,7 +1,5 @@ from .. import widgets -from .core import BooleanField from .core import Field -from .core import StringField __all__ = ( "BooleanField", @@ -10,10 +8,66 @@ "FileField", "MultipleFileField", "HiddenField", + "SearchField", "SubmitField", + "StringField", + "TelField", + "URLField", + "EmailField", ) +class BooleanField(Field): + """ + Represents an ````. Set the ``checked``-status by using the + ``default``-option. Any value for ``default``, e.g. ``default="checked"`` puts + ``checked`` into the html-element and sets the ``data`` to ``True`` + + :param false_values: + If provided, a sequence of strings each of which is an exact match + string of what is considered a "false" value. Defaults to the tuple + ``(False, "false", "")`` + """ + + widget = widgets.CheckboxInput() + false_values = (False, "false", "") + + def __init__(self, label=None, validators=None, false_values=None, **kwargs): + super().__init__(label, validators, **kwargs) + if false_values is not None: + self.false_values = false_values + + def process_data(self, value): + self.data = bool(value) + + def process_formdata(self, valuelist): + if not valuelist or valuelist[0] in self.false_values: + self.data = False + else: + self.data = True + + def _value(self): + if self.raw_data: + return str(self.raw_data[0]) + return "y" + + +class StringField(Field): + """ + This field is the base for most of the more complicated fields, and + represents an ````. + """ + + widget = widgets.TextInput() + + def process_formdata(self, valuelist): + if valuelist: + self.data = valuelist[0] + + def _value(self): + return str(self.data) if self.data is not None else "" + + class TextAreaField(StringField): """ This field represents an HTML ``