Coverage for pyfields/helpers.py: 77%

120 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-11-06 16:35 +0000

1# Authors: Sylvain MARIE <sylvain.marie@se.com> 

2# + All contributors to <https://github.com/smarie/python-pyfields> 

3# 

4# License: 3-clause BSD, <https://github.com/smarie/python-pyfields/blob/master/LICENSE> 

5import sys 

6from copy import copy, deepcopy 

7from inspect import getmro, isclass 

8 

9try: 

10 from typing import Union, Type, TypeVar 

11 T = TypeVar('T') 

12except ImportError: 

13 pass 

14 

15from pyfields.core import Field, ClassFieldAccessError, PY36, get_type_hints 

16 

17 

18class NotAFieldError(TypeError): 

19 """ Raised by `get_field` when the class member with that name is not a field """ 

20 __slots__ = 'name', 'cls' 

21 

22 def __init__(self, cls, name): 

23 self.name = name 

24 self.cls = cls 

25 

26 

27def get_field(cls, name): 

28 """ 

29 Utility method to return the field member with name `name` in class `cls`. 

30 If the member is not a field, a `NotAFieldError` is raised. 

31 

32 :param cls: 

33 :param name: 

34 :return: 

35 """ 

36 try: 

37 member = getattr(cls, name) 

38 except ClassFieldAccessError as e: 38 ↛ 40line 38 didn't jump to line 40, because the exception caught by line 38 didn't happen

39 # we know it is a field :) 

40 return e.field 

41 except Exception: 

42 # any other exception that can happen with a descriptor for example 

43 raise NotAFieldError(cls, name) 

44 else: 

45 # it is a field if is it an instance of Field 

46 if isinstance(member, Field): 

47 return member 

48 else: 

49 raise NotAFieldError(cls, name) 

50 

51 

52def yield_fields(cls, 

53 include_inherited=True, # type: bool 

54 remove_duplicates=True, # type: bool 

55 ancestors_first=True, # type: bool 

56 public_only=False, # type: bool 

57 _auto_fix_fields=False # type: bool 

58 ): 

59 """ 

60 Similar to `get_fields` but as a generator. 

61 

62 :param cls: 

63 :param include_inherited: 

64 :param remove_duplicates: 

65 :param ancestors_first: 

66 :param public_only: 

67 :param _auto_fix_fields: 

68 :return: 

69 """ 

70 # List the classes where we should be looking for fields 

71 if include_inherited: 71 ↛ 74line 71 didn't jump to line 74, because the condition on line 71 was never false

72 where_cls = reversed(getmro(cls)) if ancestors_first else getmro(cls) 

73 else: 

74 where_cls = (cls,) 

75 

76 # Init 

77 _already_found_names = set() if remove_duplicates else None # a reference set of already yielded field names 

78 _cls_pep484_member_type_hints = None # where to hold found type hints if needed 

79 _all_fields_for_cls = None # temporary list when we have to reorder 

80 

81 # finally for each class, gather all fields in order 

82 for _cls in where_cls: 

83 if not PY36: 83 ↛ 85line 83 didn't jump to line 85, because the condition on line 83 was never true

84 # in python < 3.6 we'll need to sort the fields at the end as class member order is not preserved 

85 _all_fields_for_cls = [] 

86 elif _auto_fix_fields: 86 ↛ 88line 86 didn't jump to line 88, because the condition on line 86 was never true

87 # in python >= 3.6, pep484 type hints can be available as member annotation, grab them 

88 _cls_pep484_member_type_hints = get_type_hints(_cls) 

89 

90 for member_name in vars(_cls): 

91 # if not member_name.startswith('__'): not stated in the doc: too dangerous to have such implicit filter 

92 

93 # avoid infinite recursion as this method is called in the descriptor for __init__ 

94 if not member_name == '__init__': 

95 try: 

96 field = get_field(_cls, member_name) 

97 except NotAFieldError: 

98 continue 

99 

100 if _auto_fix_fields: 100 ↛ 102line 100 didn't jump to line 102, because the condition on line 100 was never true

101 # take this opportunity to set the name and type hints 

102 field.set_as_cls_member(_cls, member_name, owner_cls_type_hints=_cls_pep484_member_type_hints) 

103 

104 if public_only and member_name.startswith('_'): 

105 continue 

106 

107 if remove_duplicates: 107 ↛ 114line 107 didn't jump to line 114, because the condition on line 107 was never false

108 if member_name in _already_found_names: 

109 continue 

110 else: 

111 _already_found_names.add(member_name) 

112 

113 # maybe the field is overridden, in that case we should directly yield the new one 

114 if _cls is not cls: 

115 try: 

116 overridden_field = get_field(cls, member_name) 

117 except NotAFieldError: 

118 overridden_field = None 

119 else: 

120 overridden_field = None 

121 

122 # finally yield it... 

123 if PY36: # ...immediately in recent python versions because order is correct already 123 ↛ 126line 123 didn't jump to line 126, because the condition on line 123 was never false

124 yield field if overridden_field is None else overridden_field 

125 else: # ...or wait for this class to be collected, because the order needs to be fixed 

126 _all_fields_for_cls.append((field, overridden_field)) 

127 

128 if not PY36: 128 ↛ 130line 128 didn't jump to line 130, because the condition on line 128 was never true

129 # order is random in python < 3.6 - we need to explicitly sort according to instance creation number 

130 _all_fields_for_cls.sort(key=lambda f: f[0].__fieldinstcount__) 

131 for field, overridden_field in _all_fields_for_cls: 

132 yield field if overridden_field is None else overridden_field 

133 

134 

135def has_fields(cls, 

136 include_inherited=True # type: bool 

137 ): 

138 """ 

139 Returns True if class `cls` defines at least one `pyfields` field. 

140 If `include_inherited` is `True` (default), the method will return `True` if at least a field is defined in the 

141 class or one of its ancestors. If `False`, the fields need to be defined on the class itself. 

142 

143 :param cls: 

144 :param include_inherited: 

145 :return: 

146 """ 

147 return any(yield_fields(cls, include_inherited=include_inherited)) 

148 

149 

150if sys.version_info >= (3, 7): 150 ↛ 153line 150 didn't jump to line 153, because the condition on line 150 was never false

151 ODict = dict 

152else: 

153 from collections import OrderedDict 

154 ODict = OrderedDict 

155 

156 

157def get_field_values(obj, 

158 include_inherited=True, # type: bool 

159 remove_duplicates=True, # type: bool 

160 ancestors_first=True, # type: bool 

161 public_only=False, # type: bool 

162 container_type=ODict, # type: Type[T] 

163 _auto_fix_fields=False # type: bool 

164 ): 

165 """ 

166 Utility method to collect all field names and values defined on an object, including all inherited or not. 

167 

168 By default duplicates are removed and ancestor fields are included and appear first. If a field is overridden, 

169 it will appear at the position of the overridden field in the order. 

170 

171 The result is an ordered dictionary (a `dict` in python 3.7, an `OrderedDict` otherwise) of {name: value} pairs. 

172 One can change the container type with the `container_type` attribute though, that will receive an iterable of 

173 (key, value) pairs. 

174 

175 :param obj: 

176 :param include_inherited: 

177 :param remove_duplicates: 

178 :param ancestors_first: 

179 :param public_only: 

180 :param container_type: 

181 :param _auto_fix_fields: 

182 :return: 

183 """ 

184 fields_gen = yield_fields(obj.__class__, include_inherited=include_inherited, public_only=public_only, 

185 remove_duplicates=remove_duplicates, ancestors_first=ancestors_first, 

186 _auto_fix_fields=_auto_fix_fields) 

187 

188 return container_type((f.name, getattr(obj, f.name)) for f in fields_gen) 

189 

190 

191def safe_isclass(obj # type: object 

192 ): 

193 # type: (...) -> bool 

194 """Ignore any exception via isinstance on Python 3.""" 

195 try: 

196 return isclass(obj) 

197 except Exception: 

198 return False 

199 

200 

201def get_fields(cls_or_obj, 

202 include_inherited=True, # type: bool 

203 remove_duplicates=True, # type: bool 

204 ancestors_first=True, # type: bool 

205 public_only=False, # type: bool 

206 container_type=tuple, # type: Type[T] 

207 _auto_fix_fields=False # type: bool 

208 ): 

209 # type: (...) -> T 

210 """ 

211 Utility method to collect all fields defined in a class, including all inherited or not, in definition order. 

212 

213 By default duplicates are removed and ancestor fields are included and appear first. If a field is overridden, 

214 it will appear at the position of the overridden field in the order. 

215 

216 If an object is provided, `get_fields` will be executed on its class. 

217 

218 :param cls_or_obj: 

219 :param include_inherited: 

220 :param remove_duplicates: 

221 :param ancestors_first: 

222 :param public_only: 

223 :param container_type: 

224 :param _auto_fix_fields: 

225 :return: the fields (by default, as a tuple) 

226 """ 

227 if not safe_isclass(cls_or_obj): 227 ↛ 228line 227 didn't jump to line 228, because the condition on line 227 was never true

228 cls_or_obj = cls_or_obj.__class__ 

229 

230 return container_type(yield_fields(cls_or_obj, include_inherited=include_inherited, public_only=public_only, 

231 remove_duplicates=remove_duplicates, ancestors_first=ancestors_first, 

232 _auto_fix_fields=_auto_fix_fields)) 

233 

234 

235# def ordered_dir(cls, 

236# ancestors_first=False # type: bool 

237# ): 

238# """ 

239# since `dir` does not preserve order, lets have our own implementation 

240# 

241# :param cls: 

242# :param ancestors_first: 

243# :return: 

244# """ 

245# classes = reversed(getmro(cls)) if ancestors_first else getmro(cls) 

246# 

247# for _cls in classes: 

248# for k in vars(_cls): 

249# yield k 

250 

251 

252def copy_value(val, 

253 deep=True, # type: bool 

254 autocheck=True # type: bool 

255 ): 

256 """ 

257 Returns a default value factory to be used in a `field(default_factory=...)`. 

258 

259 That factory will create a copy of the provided `val` everytime it is called. Handy if you wish to use mutable 

260 objects as default values for your fields ; for example lists. 

261 

262 :param val: the (mutable) value to copy 

263 :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 

264 :param autocheck: if this is True (default), an initial copy will be created when the method is called, so as to 

265 alert the user early if this leads to errors. 

266 :return: 

267 """ 

268 if deep: 

269 if autocheck: 

270 try: 

271 # autocheck: make sure that we will be able to create copies later 

272 deepcopy(val) 

273 except Exception as e: 

274 raise ValueError("The provided default value %r can not be deep-copied: caught error %r" % (val, e)) 

275 

276 def create_default(obj): 

277 return deepcopy(val) 

278 else: 

279 if autocheck: 279 ↛ 286line 279 didn't jump to line 286, because the condition on line 279 was never false

280 try: 

281 # autocheck: make sure that we will be able to create copies later 

282 copy(val) 

283 except Exception as e: 

284 raise ValueError("The provided default value %r can not be copied: caught error %r" % (val, e)) 

285 

286 def create_default(obj): 

287 return copy(val) 

288 

289 # attach a method to easily get a new factory with a new value 

290 def get_copied_value(): 

291 return val 

292 

293 def clone_with_new_val(newval): 

294 return copy_value(newval, deep) 

295 

296 create_default.get_copied_value = get_copied_value 

297 create_default.clone_with_new_val = clone_with_new_val 

298 return create_default 

299 

300 

301def copy_field(field_or_name, # type: Union[str, Field] 

302 deep=True # type: bool 

303 ): 

304 """ 

305 Returns a default value factory to be used in a `field(default_factory=...)`. 

306 

307 That factory will create a copy of the value in the given field. You can provide a field or a field name, in which 

308 case this method is strictly equivalent to `copy_attr`. 

309 

310 :param field_or_name: the field or name of the field for which the value needs to be copied 

311 :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 

312 :return: 

313 """ 

314 if isinstance(field_or_name, Field): 

315 if field_or_name.name is None: 315 ↛ 327line 315 didn't jump to line 327, because the condition on line 315 was never false

316 # Name not yet available, we'll get it later 

317 if deep: 317 ↛ 321line 317 didn't jump to line 321, because the condition on line 317 was never false

318 def create_default(obj): 

319 return deepcopy(getattr(obj, field_or_name.name)) 

320 else: 

321 def create_default(obj): 

322 return copy(getattr(obj, field_or_name.name)) 

323 

324 return create_default 

325 else: 

326 # use the field name 

327 return copy_attr(field_or_name.name, deep=deep) 

328 else: 

329 # this is already a field name 

330 return copy_attr(field_or_name, deep=deep) 

331 

332 

333def copy_attr(attr_name, # type: str 

334 deep=True # type: bool 

335 ): 

336 """ 

337 Returns a default value factory to be used in a `field(default_factory=...)`. 

338 

339 That factory will create a copy of the value in the given attribute. 

340 

341 :param attr_name: the name of the attribute for which the value will be copied 

342 :param deep: by default deep copies will be created. You can change this behaviour by setting this to `False` 

343 :return: 

344 """ 

345 if deep: 345 ↛ 349line 345 didn't jump to line 349, because the condition on line 345 was never false

346 def create_default(obj): 

347 return deepcopy(getattr(obj, attr_name)) 

348 else: 

349 def create_default(obj): 

350 return copy(getattr(obj, attr_name)) 

351 

352 return create_default