1import decimal
2
3from wtforms import widgets
4from wtforms.fields.core import Field
5from wtforms.utils import unset_value
6
7__all__ = (
8 "IntegerField",
9 "DecimalField",
10 "FloatField",
11 "IntegerRangeField",
12 "DecimalRangeField",
13)
14
15
16class LocaleAwareNumberField(Field):
17 """
18 Base class for implementing locale-aware number parsing.
19
20 Locale-aware numbers require the 'babel' package to be present.
21 """
22
23 def __init__(
24 self,
25 label=None,
26 validators=None,
27 use_locale=False,
28 number_format=None,
29 **kwargs,
30 ):
31 super().__init__(label, validators, **kwargs)
32 self.use_locale = use_locale
33 if use_locale:
34 self.number_format = number_format
35 self.locale = kwargs["_form"].meta.locales[0]
36 self._init_babel()
37
38 def _init_babel(self):
39 try:
40 from babel import numbers
41
42 self.babel_numbers = numbers
43 except ImportError as exc:
44 raise ImportError(
45 "Using locale-aware decimals requires the babel library."
46 ) from exc
47
48 def _parse_decimal(self, value):
49 return self.babel_numbers.parse_decimal(value, self.locale)
50
51 def _format_decimal(self, value):
52 return self.babel_numbers.format_decimal(value, self.number_format, self.locale)
53
54
55class IntegerField(Field):
56 """
57 A text field, except all input is coerced to an integer. Erroneous input
58 is ignored and will not be accepted as a value.
59 """
60
61 widget = widgets.NumberInput()
62
63 def __init__(
64 self, label=None, validators=None, invalid_value_message=None, **kwargs
65 ):
66 super().__init__(label, validators, **kwargs)
67 self.invalid_value_message = invalid_value_message or self.gettext(
68 "Not a valid integer value."
69 )
70
71 def _value(self):
72 if self.raw_data:
73 return self.raw_data[0]
74 if self.data is not None:
75 return str(self.data)
76 return ""
77
78 def process_data(self, value):
79 if value is None or value is unset_value:
80 self.data = None
81 return
82
83 try:
84 self.data = int(value)
85 except (ValueError, TypeError) as exc:
86 self.data = None
87 raise ValueError(self.invalid_value_message) from exc
88
89 def process_formdata(self, valuelist):
90 if not valuelist:
91 return
92
93 try:
94 self.data = int(valuelist[0])
95 except ValueError as exc:
96 self.data = None
97 raise ValueError(self.invalid_value_message) from exc
98
99
100class DecimalField(LocaleAwareNumberField):
101 """
102 A text field which displays and coerces data of the `decimal.Decimal` type.
103
104 :param places:
105 How many decimal places to quantize the value to for display on form.
106 If unset, use 2 decimal places.
107 If explicitely set to `None`, does not quantize value.
108 :param rounding:
109 How to round the value during quantize, for example
110 `decimal.ROUND_UP`. If unset, uses the rounding value from the
111 current thread's context.
112 :param use_locale:
113 If True, use locale-based number formatting. Locale-based number
114 formatting requires the 'babel' package.
115 :param number_format:
116 Optional number format for locale. If omitted, use the default decimal
117 format for the locale.
118 """
119
120 widget = widgets.NumberInput(step="any")
121
122 def __init__(
123 self,
124 label=None,
125 validators=None,
126 places=unset_value,
127 rounding=None,
128 invalid_value_message=None,
129 **kwargs,
130 ):
131 super().__init__(label, validators, **kwargs)
132 if self.use_locale and (places is not unset_value or rounding is not None):
133 raise TypeError(
134 "When using locale-aware numbers, 'places' and 'rounding' are ignored."
135 )
136
137 if places is unset_value:
138 places = 2
139 self.places = places
140 self.rounding = rounding
141 self.invalid_value_message = invalid_value_message or self.gettext(
142 "Not a valid decimal value."
143 )
144
145 def _value(self):
146 if self.raw_data:
147 return self.raw_data[0]
148
149 if self.data is None:
150 return ""
151
152 if self.use_locale:
153 return str(self._format_decimal(self.data))
154
155 if self.places is None:
156 return str(self.data)
157
158 if not hasattr(self.data, "quantize"):
159 # If for some reason, data is a float or int, then format
160 # as we would for floats using string formatting.
161 format = f"%.{self.places}f"
162 return format % self.data
163
164 exp = decimal.Decimal(".1") ** self.places
165 if self.rounding is None:
166 quantized = self.data.quantize(exp)
167 else:
168 quantized = self.data.quantize(exp, rounding=self.rounding)
169 return str(quantized)
170
171 def process_formdata(self, valuelist):
172 if not valuelist:
173 return
174
175 try:
176 if self.use_locale:
177 self.data = self._parse_decimal(valuelist[0])
178 else:
179 self.data = decimal.Decimal(valuelist[0])
180 except (decimal.InvalidOperation, ValueError) as exc:
181 self.data = None
182 raise ValueError(self.invalid_value_message) from exc
183
184
185class FloatField(Field):
186 """
187 A field that stores floating-point values.
188
189 By default, this renders as an ``<input type="number">`` with
190 ``step="any"`` to allow browser input of any floating-point value.
191 Erroneous input is ignored and will not be accepted as a value.
192 """
193
194 widget = widgets.NumberInput(step="any")
195
196 def __init__(
197 self, label=None, validators=None, invalid_value_message=None, **kwargs
198 ):
199 super().__init__(label, validators, **kwargs)
200 self.invalid_value_message = invalid_value_message or self.gettext(
201 "Not a valid float value."
202 )
203
204 def _value(self):
205 if self.raw_data:
206 return self.raw_data[0]
207 if self.data is not None:
208 return str(self.data)
209 return ""
210
211 def process_formdata(self, valuelist):
212 if not valuelist:
213 return
214
215 try:
216 self.data = float(valuelist[0])
217 except ValueError as exc:
218 self.data = None
219 raise ValueError(self.invalid_value_message) from exc
220
221
222class IntegerRangeField(IntegerField):
223 """
224 Represents an :mdn-input:`range`.
225 """
226
227 widget = widgets.RangeInput()
228
229
230class DecimalRangeField(DecimalField):
231 """
232 Represents an :mdn-input:`range`.
233 """
234
235 widget = widgets.RangeInput(step="any")