Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wtforms/fields/list.py: 18%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1import itertools
3from wtforms.utils import unset_value
5from .. import widgets
6from .core import Field
7from .core import UnboundField
9__all__ = ("FieldList",)
12class FieldList(Field):
13 """
14 Encapsulate an ordered list of multiple instances of the same field type,
15 keeping data as a list.
17 >>> authors = FieldList(StringField('Name', [validators.DataRequired()]))
19 :param unbound_field:
20 A partially-instantiated field definition, just like that would be
21 defined on a form directly.
22 :param min_entries:
23 if provided, always have at least this many entries on the field,
24 creating blank ones if the provided input does not specify a sufficient
25 amount.
26 :param max_entries:
27 accept no more than this many entries as input, even if more exist in
28 formdata.
29 :param separator:
30 A string which will be suffixed to this field's name to create the
31 prefix to enclosed list entries. The default is fine for most uses.
32 """
34 widget = widgets.ListWidget()
36 def __init__(
37 self,
38 unbound_field,
39 label=None,
40 validators=None,
41 min_entries=0,
42 max_entries=None,
43 separator="-",
44 default=(),
45 **kwargs,
46 ):
47 super().__init__(label, validators, default=default, **kwargs)
48 if self.filters:
49 raise TypeError(
50 "FieldList does not accept any filters. Instead, define"
51 " them on the enclosed field."
52 )
53 assert isinstance(unbound_field, UnboundField), (
54 "Field must be unbound, not a field class"
55 )
56 self.unbound_field = unbound_field
57 self.min_entries = min_entries
58 self.max_entries = max_entries
59 self.last_index = -1
60 self._prefix = kwargs.get("_prefix", "")
61 self._separator = separator
62 self._field_separator = unbound_field.kwargs.get("separator", "-")
64 def process(self, formdata, data=unset_value, extra_filters=None):
65 if extra_filters:
66 raise TypeError(
67 "FieldList does not accept any filters. Instead, define"
68 " them on the enclosed field."
69 )
71 self.last_index = -1
72 self.entries = []
73 if data is unset_value or not data:
74 try:
75 data = self.default()
76 except TypeError:
77 data = self.default
79 self.object_data = data
81 if formdata:
82 indices = sorted(set(self._extract_indices(self.name, formdata)))
83 if self.max_entries:
84 indices = indices[: self.max_entries]
86 data_list = list(data) if data else []
87 for index in indices:
88 if index < len(data_list):
89 obj_data = data_list[index]
90 else:
91 obj_data = unset_value
92 self._add_entry(formdata, obj_data, index=index)
94 self._compact_indices()
95 else:
96 for obj_data in data:
97 self._add_entry(formdata, obj_data)
99 while len(self.entries) < self.min_entries:
100 self._add_entry(formdata)
102 def _extract_indices(self, prefix, formdata):
103 """
104 Yield indices of any keys with given prefix.
106 formdata must be an object which will produce keys when iterated. For
107 example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then
108 the numbers 0 and 1 will be yielded, but not necessarily in order.
109 """
110 offset = len(prefix) + 1
111 for k in formdata:
112 if k.startswith(prefix):
113 k = k[offset:].split(self._field_separator, 1)[0]
114 if k.isdigit():
115 yield int(k)
117 def post_process(self):
118 for entry in self.entries:
119 entry.post_process()
121 def validate(self, form, extra_validators=()):
122 """
123 Validate this FieldList.
125 Note that FieldList validation differs from normal field validation in
126 that FieldList validates all its enclosed fields first before running any
127 of its own validators.
128 """
129 self.errors = []
131 # Run validators on all entries within
132 for subfield in self.entries:
133 subfield.validate(form)
134 self.errors.append(subfield.errors)
136 if not any(x for x in self.errors):
137 self.errors = []
139 chain = itertools.chain(self.validators, extra_validators)
140 self._run_validation_chain(form, chain)
142 return len(self.errors) == 0
144 def populate_obj(self, obj, name):
145 values = getattr(obj, name, None)
146 try:
147 ivalues = iter(values)
148 except TypeError:
149 ivalues = iter([])
151 candidates = itertools.chain(ivalues, itertools.repeat(None))
152 _fake = type("_fake", (object,), {})
153 output = []
154 for field, fallback in zip(self.entries, candidates, strict=False):
155 fake_obj = _fake()
156 bound = field.object_data
157 if bound is unset_value or bound is None or isinstance(bound, dict):
158 fake_obj.data = fallback
159 else:
160 fake_obj.data = bound
161 field.populate_obj(fake_obj, "data")
162 output.append(fake_obj.data)
164 setattr(obj, name, output)
166 def _add_entry(self, formdata=None, data=unset_value, index=None):
167 assert not self.max_entries or len(self.entries) < self.max_entries, (
168 "You cannot have more than max_entries entries in this FieldList"
169 )
170 if index is None:
171 index = self.last_index + 1
172 self.last_index = index
173 name = f"{self.short_name}{self._separator}{index}"
174 id = f"{self.id}{self._separator}{index}"
175 options = dict(
176 name=name,
177 prefix=self._prefix,
178 id=id,
179 _meta=self.meta,
180 translations=self._translations,
181 )
182 field = self.meta.bind_field(self._form, self.unbound_field, options)
183 field.index = index
184 field.process(formdata, data)
185 self.entries.append(field)
186 return field
188 def _compact_indices(self):
189 """Renumber all entries so indices form a consecutive ``[0..N-1]``."""
190 for new_index, entry in enumerate(self.entries):
191 self._rename_entry(entry, new_index)
192 self.last_index = len(self.entries) - 1
194 def _rename_entry(self, entry, new_index):
195 """Rename ``entry`` to ``new_index`` and propagate to its descendants."""
196 old_name = entry.name
197 old_id = entry.id
198 new_name = f"{self.short_name}{self._separator}{new_index}"
199 new_id = f"{self.id}{self._separator}{new_index}"
201 if old_name == new_name and old_id == new_id and entry.index == new_index:
202 return
204 entry.index = new_index
205 entry.name = new_name
206 entry.short_name = str(new_index)
207 entry.id = new_id
209 for descendant in self._iter_descendants(entry):
210 if descendant.name and descendant.name.startswith(old_name):
211 descendant.name = new_name + descendant.name[len(old_name) :]
212 if descendant.id and descendant.id.startswith(old_id):
213 descendant.id = new_id + descendant.id[len(old_id) :]
215 def _iter_descendants(self, field):
216 """Yield all fields below ``field`` recursively (form sub-fields and
217 FieldList entries)."""
218 if hasattr(field, "form") and hasattr(field.form, "_fields"):
219 for subfield in field.form._fields.values():
220 yield subfield
221 yield from self._iter_descendants(subfield)
222 if hasattr(field, "entries"):
223 for subfield in field.entries:
224 yield subfield
225 yield from self._iter_descendants(subfield)
227 def append_entry(self, data=unset_value):
228 """
229 Create a new entry with optional default data.
231 Entries added in this way will *not* receive formdata however, and can
232 only receive object data.
233 """
234 return self._add_entry(data=data)
236 def insert_entry(self, index, data=unset_value):
237 """
238 Create a new entry with optional default data and insert it at the
239 given position in :attr:`entries`.
241 After insertion, all entries are renumbered so indices form a
242 consecutive ``[0..N-1]`` range. ``index`` follows :meth:`list.insert`
243 semantics (negative or out-of-range values are clamped). Like
244 :meth:`append_entry`, the new entry only receives object data, not
245 formdata.
246 """
247 field = self._add_entry(data=data)
248 self.entries.insert(index, self.entries.pop())
249 self._compact_indices()
250 return field
252 def pop_entry(self, index=-1):
253 """Remove the entry at ``index`` from :attr:`entries` and return it.
255 After removal, remaining entries are renumbered so indices form a
256 consecutive ``[0..N-1]`` range.
257 """
258 entry = self.entries.pop(index)
259 self._compact_indices()
260 return entry
262 def __iter__(self):
263 return iter(self.entries)
265 def __len__(self):
266 return len(self.entries)
268 def __getitem__(self, index):
269 return self.entries[index]
271 @property
272 def data(self):
273 return [f.data for f in self.entries]