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

146 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): 

118 for entry in self.entries: 

119 entry.post_process() 

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 return self._add_entry(data=data) 

235 

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

240 

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 

251 

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

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

254 

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 

261 

262 def __iter__(self): 

263 return iter(self.entries) 

264 

265 def __len__(self): 

266 return len(self.entries) 

267 

268 def __getitem__(self, index): 

269 return self.entries[index] 

270 

271 @property 

272 def data(self): 

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