Coverage for pyfields/helpers.py: 77%
120 statements
« prev ^ index » next coverage.py v7.2.7, created at 2023-11-06 16:35 +0000
« 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
9try:
10 from typing import Union, Type, TypeVar
11 T = TypeVar('T')
12except ImportError:
13 pass
15from pyfields.core import Field, ClassFieldAccessError, PY36, get_type_hints
18class NotAFieldError(TypeError):
19 """ Raised by `get_field` when the class member with that name is not a field """
20 __slots__ = 'name', 'cls'
22 def __init__(self, cls, name):
23 self.name = name
24 self.cls = cls
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.
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)
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.
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,)
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
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)
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
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
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)
104 if public_only and member_name.startswith('_'):
105 continue
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)
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
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))
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
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.
143 :param cls:
144 :param include_inherited:
145 :return:
146 """
147 return any(yield_fields(cls, include_inherited=include_inherited))
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
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.
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.
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.
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)
188 return container_type((f.name, getattr(obj, f.name)) for f in fields_gen)
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
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.
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.
216 If an object is provided, `get_fields` will be executed on its class.
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__
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))
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
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=...)`.
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.
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))
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))
286 def create_default(obj):
287 return copy(val)
289 # attach a method to easily get a new factory with a new value
290 def get_copied_value():
291 return val
293 def clone_with_new_val(newval):
294 return copy_value(newval, deep)
296 create_default.get_copied_value = get_copied_value
297 create_default.clone_with_new_val = clone_with_new_val
298 return create_default
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=...)`.
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`.
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))
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)
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=...)`.
339 That factory will create a copy of the value in the given attribute.
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))
352 return create_default