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

157 statements  

1import itertools 

2 

3from wtforms.utils import unset_value 

4 

5from .. import widgets 

6from .core import Field 

7from .core import UnboundField 

8 

9__all__ = ("FieldList",) 

10 

11 

12class FieldList(Field): 

13 """ 

14 Encapsulate an ordered list of multiple instances of the same field type, 

15 keeping data as a list. 

16 

17 >>> authors = FieldList(StringField('Name', [validators.DataRequired()])) 

18 

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 """ 

33 

34 widget = widgets.ListWidget() 

35 

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", "-") 

62 

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 ) 

69 

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 

77 

78 self.object_data = data 

79 

80 if formdata: 

81 indices = sorted(set(self._extract_indices(self.name, formdata))) 

82 if self.max_entries: 

83 indices = indices[: self.max_entries] 

84 

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) 

92 

93 self._compact_indices() 

94 else: 

95 for obj_data in data: 

96 self._add_entry(formdata, obj_data) 

97 

98 while len(self.entries) < self.min_entries: 

99 self._add_entry(formdata) 

100 

101 def _extract_indices(self, prefix, formdata): 

102 """ 

103 Yield indices of any keys with given prefix. 

104 

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) 

115 

116 def post_process(self, formdata=None): 

117 for entry in self.entries: 

118 entry.post_process(formdata) 

119 

120 def validate(self, form, extra_validators=()): 

121 """ 

122 Validate this FieldList. 

123 

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 = [] 

129 

130 # Run validators on all entries within 

131 for subfield in self.entries: 

132 subfield.validate(form) 

133 self.errors.append(subfield.errors) 

134 

135 if not any(x for x in self.errors): 

136 self.errors = [] 

137 

138 chain = itertools.chain(self.validators, extra_validators) 

139 self._run_validation_chain(form, chain) 

140 

141 return len(self.errors) == 0 

142 

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([]) 

149 

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) 

162 

163 setattr(obj, name, output) 

164 

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 

186 

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 

192 

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}" 

199 

200 if old_name == new_name and old_id == new_id and entry.index == new_index: 

201 return 

202 

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) 

208 

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) 

216 

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 

222 

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) 

234 

235 def append_entry(self, data=unset_value): 

236 """ 

237 Create a new entry with optional default data. 

238 

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 

245 

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`. 

250 

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 

262 

263 def pop_entry(self, index=-1): 

264 """Remove the entry at ``index`` from :attr:`entries` and return it. 

265 

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 

272 

273 def __iter__(self): 

274 return iter(self.entries) 

275 

276 def __len__(self): 

277 return len(self.entries) 

278 

279 def __getitem__(self, index): 

280 return self.entries[index] 

281 

282 @property 

283 def data(self): 

284 return [f.data for f in self.entries]