Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/wtforms/fields/list.py: 17%

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

149 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._prefix = kwargs.get("_prefix", "") 

61 self._separator = separator 

62 self._field_separator = unbound_field.kwargs.get("separator", "-") 

63 

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 ) 

70 

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 

78 

79 self.object_data = data 

80 

81 if formdata: 

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

83 if self.max_entries: 

84 indices = indices[: self.max_entries] 

85 

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) 

93 

94 self._compact_indices() 

95 else: 

96 for obj_data in data: 

97 self._add_entry(formdata, obj_data) 

98 

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

100 self._add_entry(formdata) 

101 

102 def _extract_indices(self, prefix, formdata): 

103 """ 

104 Yield indices of any keys with given prefix. 

105 

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) 

116 

117 def post_process(self, formdata=None): 

118 for entry in self.entries: 

119 entry.post_process(formdata) 

120 

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

122 """ 

123 Validate this FieldList. 

124 

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

130 

131 # Run validators on all entries within 

132 for subfield in self.entries: 

133 subfield.validate(form) 

134 self.errors.append(subfield.errors) 

135 

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

137 self.errors = [] 

138 

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

140 self._run_validation_chain(form, chain) 

141 

142 return len(self.errors) == 0 

143 

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

150 

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) 

163 

164 setattr(obj, name, output) 

165 

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 

187 

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 

193 

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

200 

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

202 return 

203 

204 entry.index = new_index 

205 entry.name = new_name 

206 entry.short_name = str(new_index) 

207 entry.id = new_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 descendant.id = new_id + descendant.id[len(old_id) :] 

214 

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) 

226 

227 def append_entry(self, data=unset_value): 

228 """ 

229 Create a new entry with optional default data. 

230 

231 Entries added in this way will *not* receive formdata however, and can 

232 only receive object data. 

233 """ 

234 field = self._add_entry(data=data) 

235 field.post_process() 

236 return field 

237 

238 def insert_entry(self, index, data=unset_value): 

239 """ 

240 Create a new entry with optional default data and insert it at the 

241 given position in :attr:`entries`. 

242 

243 After insertion, all entries are renumbered so indices form a 

244 consecutive ``[0..N-1]`` range. ``index`` follows :meth:`list.insert` 

245 semantics (negative or out-of-range values are clamped). Like 

246 :meth:`append_entry`, the new entry only receives object data, not 

247 formdata. 

248 """ 

249 field = self._add_entry(data=data) 

250 self.entries.insert(index, self.entries.pop()) 

251 self._compact_indices() 

252 field.post_process() 

253 return field 

254 

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

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

257 

258 After removal, remaining entries are renumbered so indices form a 

259 consecutive ``[0..N-1]`` range. 

260 """ 

261 entry = self.entries.pop(index) 

262 self._compact_indices() 

263 return entry 

264 

265 def __iter__(self): 

266 return iter(self.entries) 

267 

268 def __len__(self): 

269 return len(self.entries) 

270 

271 def __getitem__(self, index): 

272 return self.entries[index] 

273 

274 @property 

275 def data(self): 

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