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._separator = separator
61 self._field_separator = unbound_field.kwargs.get("separator", "-")
63 def process(self, formdata, data=unset_value, extra_filters=None):
64 if extra_filters:
65 raise TypeError(
66 "FieldList does not accept any filters. Instead, define"
67 " them on the enclosed field."
68 )
70 self.last_index = -1
71 self.entries = []
72 if data is unset_value or not data:
73 try:
74 data = self.default()
75 except TypeError:
76 data = self.default
78 self.object_data = data
80 if formdata:
81 indices = sorted(set(self._extract_indices(self.name, formdata)))
82 if self.max_entries:
83 indices = indices[: self.max_entries]
85 data_list = list(data) if data else []
86 for index in indices:
87 if index < len(data_list):
88 obj_data = data_list[index]
89 else:
90 obj_data = unset_value
91 self._add_entry(formdata, obj_data, index=index)
93 self._compact_indices()
94 else:
95 for obj_data in data:
96 self._add_entry(formdata, obj_data)
98 while len(self.entries) < self.min_entries:
99 self._add_entry(formdata)
101 def _extract_indices(self, prefix, formdata):
102 """
103 Yield indices of any keys with given prefix.
105 formdata must be an object which will produce keys when iterated. For
106 example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then
107 the numbers 0 and 1 will be yielded, but not necessarily in order.
108 """
109 offset = len(prefix) + 1
110 for k in formdata:
111 if k.startswith(prefix):
112 k = k[offset:].split(self._field_separator, 1)[0]
113 if k.isdigit():
114 yield int(k)
116 def post_process(self, formdata=None):
117 for entry in self.entries:
118 entry.post_process(formdata)
120 def validate(self, form, extra_validators=()):
121 """
122 Validate this FieldList.
124 Note that FieldList validation differs from normal field validation in
125 that FieldList validates all its enclosed fields first before running any
126 of its own validators.
127 """
128 self.errors = []
130 # Run validators on all entries within
131 for subfield in self.entries:
132 subfield.validate(form)
133 self.errors.append(subfield.errors)
135 if not any(x for x in self.errors):
136 self.errors = []
138 chain = itertools.chain(self.validators, extra_validators)
139 self._run_validation_chain(form, chain)
141 return len(self.errors) == 0
143 def populate_obj(self, obj, name):
144 values = getattr(obj, name, None)
145 try:
146 ivalues = iter(values)
147 except TypeError:
148 ivalues = iter([])
150 candidates = itertools.chain(ivalues, itertools.repeat(None))
151 _fake = type("_fake", (object,), {})
152 output = []
153 for field, fallback in zip(self.entries, candidates, strict=False):
154 fake_obj = _fake()
155 bound = field.object_data
156 if bound is unset_value or bound is None or isinstance(bound, dict):
157 fake_obj.data = fallback
158 else:
159 fake_obj.data = bound
160 field.populate_obj(fake_obj, "data")
161 output.append(fake_obj.data)
163 setattr(obj, name, output)
165 def _add_entry(self, formdata=None, data=unset_value, index=None):
166 assert not self.max_entries or len(self.entries) < self.max_entries, (
167 "You cannot have more than max_entries entries in this FieldList"
168 )
169 if index is None:
170 index = self.last_index + 1
171 self.last_index = index
172 name = f"{self.name}{self._separator}{index}"
173 id = f"{self.id}{self._separator}{index}"
174 options = dict(
175 name=name,
176 id=id,
177 _meta=self.meta,
178 translations=self._translations,
179 )
180 field = self.meta.bind_field(self._form, self.unbound_field, options)
181 field.index = index
182 field.short_name = str(index)
183 field.process(formdata, data)
184 self.entries.append(field)
185 return field
187 def _compact_indices(self):
188 """Renumber all entries so indices form a consecutive ``[0..N-1]``."""
189 for new_index, entry in enumerate(self.entries):
190 self._rename_entry(entry, new_index)
191 self.last_index = len(self.entries) - 1
193 def _rename_entry(self, entry, new_index):
194 """Rename ``entry`` to ``new_index`` and propagate to its descendants."""
195 old_name = entry.name
196 old_id = entry.id
197 new_name = f"{self.name}{self._separator}{new_index}"
198 new_id = f"{self.id}{self._separator}{new_index}"
200 if old_name == new_name and old_id == new_id and entry.index == new_index:
201 return
203 entry.index = new_index
204 entry.name = new_name
205 entry.short_name = str(new_index)
206 entry.id = new_id
207 self._sync_label(entry, old_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 old_descendant_id = descendant.id
214 descendant.id = new_id + descendant.id[len(old_id) :]
215 self._sync_label(descendant, old_descendant_id)
217 @staticmethod
218 def _sync_label(field, old_id):
219 label = getattr(field, "label", None)
220 if label is not None and label.field_id == old_id:
221 label.field_id = field.id
223 def _iter_descendants(self, field):
224 """Yield all fields below ``field`` recursively (form sub-fields and
225 FieldList entries)."""
226 if hasattr(field, "form") and hasattr(field.form, "_fields"):
227 for subfield in field.form._fields.values():
228 yield subfield
229 yield from self._iter_descendants(subfield)
230 if hasattr(field, "entries"):
231 for subfield in field.entries:
232 yield subfield
233 yield from self._iter_descendants(subfield)
235 def append_entry(self, data=unset_value):
236 """
237 Create a new entry with optional default data.
239 Entries added in this way will *not* receive formdata however, and can
240 only receive object data.
241 """
242 field = self._add_entry(data=data)
243 field.post_process()
244 return field
246 def insert_entry(self, index, data=unset_value):
247 """
248 Create a new entry with optional default data and insert it at the
249 given position in :attr:`entries`.
251 After insertion, all entries are renumbered so indices form a
252 consecutive ``[0..N-1]`` range. ``index`` follows :meth:`list.insert`
253 semantics (negative or out-of-range values are clamped). Like
254 :meth:`append_entry`, the new entry only receives object data, not
255 formdata.
256 """
257 field = self._add_entry(data=data)
258 self.entries.insert(index, self.entries.pop())
259 self._compact_indices()
260 field.post_process()
261 return field
263 def pop_entry(self, index=-1):
264 """Remove the entry at ``index`` from :attr:`entries` and return it.
266 After removal, remaining entries are renumbered so indices form a
267 consecutive ``[0..N-1]`` range.
268 """
269 entry = self.entries.pop(index)
270 self._compact_indices()
271 return entry
273 def __iter__(self):
274 return iter(self.entries)
276 def __len__(self):
277 return len(self.entries)
279 def __getitem__(self, index):
280 return self.entries[index]
282 @property
283 def data(self):
284 return [f.data for f in self.entries]