Coverage for pyfields/init_makers.py: 82%
164 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 inspect import isfunction, getmro
7from itertools import islice
9try:
10 from inspect import signature, Parameter, Signature
11except ImportError:
12 from funcsigs import signature, Parameter, Signature
15try: # python 3.5+
16 from typing import List, Callable, Any, Union, Iterable, Tuple
17 use_type_hints = sys.version_info > (3, 0)
18except ImportError:
19 use_type_hints = False
22from makefun import wraps, with_signature
24from pyfields.core import PY36, USE_FACTORY, EMPTY, Field
25from pyfields.helpers import get_fields
28def init_fields(*fields, # type: Union[Field, Any]
29 **kwargs
30 ):
31 """
32 Decorator for an init method, so that fields are initialized before entering the method.
34 By default, when the decorator is used without arguments or when `fields` is empty, all fields defined in the class
35 are initialized. Fields inherited from parent classes are included, following the mro. The signature of the init
36 method is modified so that it can receive values for these fields:
38 >>> import sys, pytest
39 >>> if sys.version_info < (3, 7): pytest.skip('doctest skipped') # 3.6 help() is different on travis
41 >>> from pyfields import field, init_fields
42 >>> class Wall:
43 ... height: int = field(doc="Height of the wall in mm.")
44 ... color: str = field(default='white', doc="Color of the wall.")
45 ...
46 ... @init_fields
47 ... def __init__(self, msg: str = 'hello'):
48 ... print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg))
49 ... self.non_field_attr = msg
50 ...
51 >>> help(Wall.__init__)
52 Help on function __init__ in module pyfields.init_makers:
53 <BLANKLINE>
54 __init__(self, height: int, msg: str = 'hello', color: str = 'white')
55 The `__init__` method generated for you when you use `@init_fields`
56 or `make_init` with a non-None `post_init_fun` method.
57 <BLANKLINE>
58 >>> w = Wall(2)
59 post init ! height=2, color=white, msg=hello
62 The list of fields can be explicitly provided in `fields`.
64 By default the init arguments will appear before the fields in the signature, wherever possible (mandatory args
65 before mandatory fields, optional args before optional fields). You can change this behaviour by setting
66 `init_args_before` to `False`.
68 :param fields: list of fields to initialize before entering the decorated `__init__` method. For each of these
69 fields a corresponding argument will be added in the method's signature. If an empty list is provided, all
70 fields from the class will be used including inherited fields following the mro. In case of inherited fields,
71 they will appear before the class fields if `ancestor_fields_first` is `True` (default), after otherwise.
72 :param init_args_before: If set to `True` (default), arguments from the decorated init method will appear before
73 the fields when possible. If set to `False` the contrary will happen.
74 :param ancestor_fields_first: If set to `True` (default behaviour), when the provided list of fields is empty,
75 ancestor-inherited fields will appear before the class fields when possible (even for fields overridden in the
76 subclass). If set to `False` the contrary will happen.
77 :return:
78 """
79 init_args_before, ancestor_fields_first = pop_kwargs(kwargs, [('init_args_before', True),
80 ('ancestor_fields_first', None)], allow_others=False)
82 if len(fields) == 1:
83 # used without argument ?
84 f = fields[0]
85 if isfunction(f) and not isinstance(f, Field) and init_args_before: 85 ↛ 92line 85 didn't jump to line 92, because the condition on line 85 was never false
86 # @init_fields decorator used without parenthesis
88 # The list of fields is NOT explicit: we have no way to gather this list without creating a descriptor
89 return InitDescriptor(user_init_fun=f, user_init_is_injected=False,
90 ancestor_fields_first=ancestor_fields_first)
92 def apply_decorator(init_fun):
93 # @init_fields(...)
95 # The list of fields is explicit AND names/type hints have been set already:
96 # it is not easy to be sure of this (names yes, but annotations?) > prefer the descriptor anyway
97 return InitDescriptor(fields=fields, user_init_fun=init_fun, user_init_args_before=init_args_before,
98 user_init_is_injected=False, ancestor_fields_first=ancestor_fields_first)
100 return apply_decorator
103def inject_fields(*fields # type: Union[Field, Any]
104 ):
105 """
106 A decorator for `__init__` methods, to make them automatically expose arguments corresponding to all `*fields`.
107 It can be used with or without arguments. If the list of fields is empty, it means "all fields from the class".
109 The decorated `__init__` method should have an argument named `'fields'`. This argument will be injected with an
110 object so that users can manually execute the fields initialization. This is done with `fields.init()`.
112 >>> import sys, pytest
113 >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped')
115 >>> from pyfields import field, inject_fields
116 ...
117 >>> class Wall(object):
118 ... height = field(doc="Height of the wall in mm.")
119 ... color = field(default='white', doc="Color of the wall.")
120 ...
121 ... @inject_fields(height, color)
122 ... def __init__(self, fields):
123 ... # initialize all fields received
124 ... fields.init(self)
125 ...
126 ... def __repr__(self):
127 ... return "Wall<height=%r, color=%r>" % (self.height, self.color)
128 ...
129 >>> Wall()
130 Traceback (most recent call last):
131 ...
132 TypeError: __init__() missing 1 required positional argument: 'height'
133 >>> Wall(1)
134 Wall<height=1, color='white'>
136 :param fields: list of fields to initialize before entering the decorated `__init__` method. For each of these
137 fields a corresponding argument will be added in the method's signature. If an empty list is provided, all
138 fields from the class will be used including inherited fields following the mro.
139 :return:
140 """
141 if len(fields) == 1:
142 # used without argument ?
143 f = fields[0]
144 if isfunction(f) and not isinstance(f, Field): 144 ↛ 150line 144 didn't jump to line 150, because the condition on line 144 was never false
145 # @inject_fields decorator used without parenthesis
147 # The list of fields is NOT explicit: we have no way to gather this list without creating a descriptor
148 return InitDescriptor(user_init_fun=f, user_init_is_injected=True)
150 def apply_decorator(init_fun):
151 # @inject_fields(...)
153 # The list of fields is explicit AND names/type hints have been set already:
154 # it is not easy to be sure of this (names yes, but annotations?) > prefer the descriptor anyway
155 return InitDescriptor(user_init_fun=init_fun, fields=fields, user_init_is_injected=True)
157 return apply_decorator
160def make_init(*fields, # type: Union[Field, Any]
161 **kwargs
162 ):
163 # type: (...) -> InitDescriptor
164 """
165 Creates a constructor based on the provided fields.
167 If `fields` is empty, all fields from the class will be used in order of appearance, then the ancestors (following
168 the mro)
170 >>> import sys, pytest
171 >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped')
173 >>> from pyfields import field, make_init
174 ...
175 >>> class Wall:
176 ... height = field(doc="Height of the wall in mm.")
177 ... color = field(default='white', doc="Color of the wall.")
178 ... __init__ = make_init()
179 >>> w = Wall(1, color='blue')
180 >>> assert vars(w) == {'color': 'blue', 'height': 1}
182 If `fields` is not empty, only the listed fields will appear in the constructor and will be initialized upon init.
184 >>> class Wall:
185 ... height = field(doc="Height of the wall in mm.")
186 ... color = field(default='white', doc="Color of the wall.")
187 ... __init__ = make_init(height)
188 >>> w = Wall(1, color='blue')
189 Traceback (most recent call last):
190 ...
191 TypeError: __init__() got an unexpected keyword argument 'color'
193 `fields` can contain fields that do not belong to this class: typically they can be fields defined in a parent
194 class. Note however that any field can be used, it is not mandatory to use class or inherited fields.
196 >>> class Wall:
197 ... height: int = field(doc="Height of the wall in mm.")
198 ...
199 >>> class ColoredWall(Wall):
200 ... color: str = field(default='white', doc="Color of the wall.")
201 ... __init__ = make_init(Wall.__dict__['height'])
202 ...
203 >>> w = ColoredWall(1)
204 >>> vars(w)
205 {'height': 1}
207 If a `post_init_fun` is provided, it should be a function with `self` as first argument. This function will be
208 executed after all declared fields have been initialized. The signature of the resulting `__init__` function
209 created will be constructed by blending all mandatory/optional fields with the mandatory/optional args in the
210 `post_init_fun` signature. The ones from the `post_init_fun` will appear first except if `post_init_args_before`
211 is set to `False`
213 >>> class Wall:
214 ... height: int = field(doc="Height of the wall in mm.")
215 ... color: str = field(default='white', doc="Color of the wall.")
216 ...
217 ... def post_init(self, msg='hello'):
218 ... print("post init ! height=%s, color=%s, msg=%s" % (self.height, self.color, msg))
219 ... self.non_field_attr = msg
220 ...
221 ... # only `height` and `foo` will be in the constructor
222 ... __init__ = make_init(height, post_init_fun=post_init)
223 ...
224 >>> w = Wall(1, 'hey')
225 post init ! height=1, color=white, msg=hey
226 >>> assert vars(w) == {'height': 1, 'color': 'white', 'non_field_attr': 'hey'}
228 :param fields: the fields to include in the generated constructor signature. If no field is provided, all fields
229 defined in the class will be included, as well as inherited ones following the mro.
230 :param post_init_fun: (default: `None`) an optional function to call once all fields have been initialized. This
231 function should have `self` as first argument. The rest of its signature will be blended with the fields in the
232 generated constructor signature.
233 :param post_init_args_before: boolean. Defines if the arguments from the `post_init_fun` should appear before
234 (default: `True`) or after (`False`) the fields in the generated signature. Of course in all cases, mandatory
235 arguments will appear after optional arguments, so as to ensure that the created signature is valid.
236 :param ancestor_fields_first: If set to `True` (default behaviour), when the provided list of fields is empty,
237 ancestor-inherited fields will appear before the class fields when possible (even for fields overridden in the
238 subclass). If set to `False` the contrary will happen.
239 :return: a constructor method to be used as `__init__`
240 """
241 # python <3.5 compliance: pop the kwargs following the varargs
242 post_init_fun, post_init_args_before, ancestor_fields_first = pop_kwargs(kwargs, [
243 ('post_init_fun', None), ('post_init_args_before', True), ('ancestor_fields_first', None)
244 ], allow_others=False)
246 return InitDescriptor(fields=fields, user_init_fun=post_init_fun, user_init_args_before=post_init_args_before,
247 user_init_is_injected=False, ancestor_fields_first=ancestor_fields_first)
250class InitDescriptor(object):
251 """
252 A class member descriptor for the `__init__` method that we create with `make_init`.
253 The first time people access `cls.__init__`, the actual method will be created and injected in the class.
254 This descriptor will then disappear and the class will behave normally.
256 The reason why we do not create the init method directly is that we require all fields to be attached to the class
257 so that they have names and type hints.
259 Inspired by https://stackoverflow.com/a/3412743/7262247
260 """
261 __slots__ = 'fields', 'user_init_is_injected', 'user_init_fun', 'user_init_args_before', 'ancestor_fields_first', \
262 'ownercls'
264 def __init__(self, fields=None, user_init_is_injected=False, user_init_fun=None, user_init_args_before=True,
265 ancestor_fields_first=None):
266 if fields is not None and len(fields) == 0:
267 fields = None
268 self.fields = fields
269 self.user_init_is_injected = user_init_is_injected
270 self.user_init_fun = user_init_fun
271 self.user_init_args_before = user_init_args_before
272 if ancestor_fields_first is None:
273 ancestor_fields_first = True
274 elif fields is not None: 274 ↛ 275line 274 didn't jump to line 275, because the condition on line 274 was never true
275 raise ValueError("`ancestor_fields_first` is only applicable when `fields` is empty")
276 self.ancestor_fields_first = ancestor_fields_first
277 self.ownercls = None
279 def __set_name__(self, owner, name):
280 """
281 There is a python issue with init descriptors with super() access. To fix it we need to
282 remember the owner class type separately as we cant' trust the one received in __get__.
283 See https://github.com/smarie/python-pyfields/issues/53
284 """
285 self.ownercls = owner
287 def __get__(self, obj, objtype):
288 # type: (...) -> Callable
289 """
290 THIS IS NOT THE INIT METHOD ! THIS IS THE CREATOR OF THE INIT METHOD (first time only)
291 Python Descriptor protocol - this is called when the __init__ method is required for the first time,
292 it creates the `__init__` method, replaces itself with it, and returns it. Subsequent calls will directly
293 be routed to the new init method and not here.
294 """
295 # objtype is not reliable: when called through super() it does not contain the right class.
296 # see https://github.com/smarie/python-pyfields/issues/53
297 if self.ownercls is not None: 297 ↛ 299line 297 didn't jump to line 299, because the condition on line 297 was never false
298 objtype = self.ownercls
299 elif objtype is not None:
300 # workaround in case of python < 3.6: at least, when a subclass init is created, make sure that all super
301 # classes init have their owner class properly set, .
302 # That way, when the subclass __init__ will be called, containing potential calls to super(), the parents'
303 # __init__ method descriptors will be correctly configured.
304 for _c in reversed(getmro(objtype)[1:-1]):
305 try:
306 _init_member = _c.__dict__['__init__']
307 except KeyError:
308 continue
309 else:
310 if isinstance(_init_member, InitDescriptor):
311 if _init_member.ownercls is None:
312 # call __set_name__ explicitly (python < 3.6) to register the descriptor with the class
313 _init_member.__set_name__(_c, '__init__')
315 # <objtype>.__init__ has been accessed. Create the modified init
316 fields = self.fields
317 if fields is None:
318 # fields have not been provided explicitly, collect them all.
319 fields = get_fields(objtype, include_inherited=True, ancestors_first=self.ancestor_fields_first,
320 _auto_fix_fields=not PY36)
321 elif not PY36: 321 ↛ 324line 321 didn't jump to line 324, because the condition on line 321 was never true
322 # take this opportunity to apply all field names including inherited
323 # TODO set back inherited = False when the bug with class-level access is solved -> make_init will be ok
324 get_fields(objtype, include_inherited=True, ancestors_first=self.ancestor_fields_first,
325 _auto_fix_fields=True)
327 # create the init method
328 new_init = create_init(fields=fields, inject_fields=self.user_init_is_injected,
329 user_init_fun=self.user_init_fun, user_init_args_before=self.user_init_args_before)
331 # replace it forever in the class
332 # setattr(objtype, '__init__', new_init)
333 objtype.__init__ = new_init
335 # return the new init
336 return new_init.__get__(obj, objtype)
339class InjectedInitFieldsArg(object):
340 """
341 The object that is injected in the users' `__init__` method as the `fields` argument,
342 when it has been decorated with `@inject_fields`.
344 All field values received from the generated `__init__` are available in `self.field_values`, and
345 a `init()` method allows users to perform the initialization per se.
346 """
347 __slots__ = 'field_values'
349 def __init__(self, **init_field_values):
350 self.field_values = init_field_values
352 def init(self, obj):
353 """
354 Initializes all fields on the provided object
355 :param obj:
356 :return:
357 """
358 for field_name, field_value in self.field_values.items():
359 if field_value is not USE_FACTORY: 359 ↛ 364line 359 didn't jump to line 364, because the condition on line 359 was never false
360 # init the field with the provided value or the injected default value
361 setattr(obj, field_name, field_value)
362 else:
363 # init the field with its factory
364 getattr(obj, field_name)
367def create_init(fields, # type: Iterable[Field]
368 user_init_fun=None, # type: Callable[[...], Any]
369 inject_fields=False, # type: bool
370 user_init_args_before=True # type: bool
371 ):
372 """
373 Creates the new init function that will replace `init_fun`.
374 It requires that all fields have correct names and type hints so we usually execute it from within a __init__
375 descriptor.
377 :param fields:
378 :param user_init_fun:
379 :param inject_fields:
380 :param user_init_args_before:
381 :return:
382 """
383 # the list of parameters that should be exposed
384 params = [Parameter('self', kind=Parameter.POSITIONAL_OR_KEYWORD)]
386 if user_init_fun is None:
387 # A - no function provided: expose a signature containing 'self' + fields
388 field_names, _ = _insert_fields_at_position(fields, params, 1)
389 new_sig = Signature(parameters=params)
391 # and create the new init method
392 @with_signature(new_sig, func_name='__init__')
393 def init_fun(*args, **kwargs):
394 """
395 The `__init__` method generated for you when you use `make_init`
396 """
397 # 1. get 'self'
398 try:
399 # most of the time 'self' will be received like that
400 self = kwargs['self']
401 except IndexError:
402 self = args[0]
404 # 2. self-assign all fields
405 for field_name in field_names:
406 field_value = kwargs[field_name]
407 if field_value is not USE_FACTORY:
408 # init the field with the provided value or the injected default value
409 setattr(self, field_name, field_value)
410 else:
411 # init the field with its factory, by just getting it
412 getattr(self, field_name)
414 return init_fun
416 else:
417 # B - function provided - expose a signature containing 'self' + the function params + fields
418 # start by inserting all fields
419 field_names, _idx = _insert_fields_at_position(fields, params, 1)
421 # then get the function signature
422 user_init_sig = signature(user_init_fun)
424 # Insert all parameters from the function except 'self'
425 if user_init_args_before: 425 ↛ 428line 425 didn't jump to line 428, because the condition on line 425 was never false
426 mandatory_insert_idx, optional_insert_idx = 1, _idx
427 else:
428 mandatory_insert_idx, optional_insert_idx = _idx, len(params)
430 fields_arg_found = False
431 for p in islice(user_init_sig.parameters.values(), 1, None): # remove the 'self' argument
432 if inject_fields and p.name == 'fields':
433 # injected argument
434 fields_arg_found = True
435 continue
436 if p.default is p.empty:
437 # mandatory
438 params.insert(mandatory_insert_idx, p)
439 mandatory_insert_idx += 1
440 optional_insert_idx += 1
441 else:
442 # optional
443 params.insert(optional_insert_idx, p)
444 optional_insert_idx += 1
446 if inject_fields and not fields_arg_found: 446 ↛ 448line 446 didn't jump to line 448, because the condition on line 446 was never true
447 # 'fields' argument not found in __init__ signature: impossible to inject, raise an error
448 try:
449 name = user_init_fun.__qualname__
450 except AttributeError:
451 name = user_init_fun.__name__
452 raise ValueError("Error applying `@inject_fields` on `%s%s`: "
453 "no 'fields' argument is available in the signature." % (name, user_init_sig))
455 # replace the signature with the newly created one
456 new_sig = user_init_sig.replace(parameters=params)
458 # and create the new init method
459 if inject_fields:
460 @wraps(user_init_fun, new_sig=new_sig)
461 def __init__(self, *args, **kwargs):
462 """
463 The `__init__` method generated for you when you use `@inject_fields` on your `__init__`
464 """
465 # 1. remove all field values received from the outer signature
466 _fields = dict()
467 for f_name in field_names:
468 _fields[f_name] = kwargs.pop(f_name)
470 # 2. inject our special variable
471 kwargs['fields'] = InjectedInitFieldsArg(**_fields)
473 # 3. call your __init__ method
474 return user_init_fun(self, *args, **kwargs)
476 else:
477 @wraps(user_init_fun, new_sig=new_sig)
478 def __init__(self, *args, **kwargs):
479 """
480 The `__init__` method generated for you when you use `@init_fields`
481 or `make_init` with a non-None `post_init_fun` method.
482 """
483 # 1. self-assign all fields
484 for field_name in field_names:
485 field_value = kwargs.pop(field_name)
486 if field_value is not USE_FACTORY:
487 # init the field with the provided value or the injected default value
488 setattr(self, field_name, field_value)
489 else:
490 # init the field with its factory, by just getting it
491 getattr(self, field_name)
493 # 2. call your post-init method
494 return user_init_fun(self, *args, **kwargs)
496 return __init__
499def _insert_fields_at_position(fields_to_insert,
500 params,
501 i,
502 field_names=None
503 ):
504 """
505 Note: preserve order as much as possible, but automatically place all mandatory fields first so that the
506 signature is valid.
508 :param fields_to_insert:
509 :param field_names:
510 :param i:
511 :param params:
512 :return:
513 """
514 if field_names is None: 514 ↛ 517line 514 didn't jump to line 517, because the condition on line 514 was never false
515 field_names = []
517 initial_i = i
518 last_mandatory_idx = i
519 for _field in reversed(fields_to_insert):
520 # Is this field optional ?
521 if _field.is_mandatory:
522 # mandatory
523 where_to_insert = i
524 last_mandatory_idx += 1
525 default = Parameter.empty
526 elif _field.is_default_factory:
527 # optional with a default value factory: place a specific symbol in the signature to indicate it
528 default = USE_FACTORY
529 where_to_insert = last_mandatory_idx
530 else:
531 # optional with a default value
532 default = _field.default
533 where_to_insert = last_mandatory_idx
535 # Are there annotations on the field ?
536 annotation = _field.type_hint if _field.type_hint is not EMPTY else Parameter.empty
538 # remember the list of field names for later use - but in the right order
539 field_names.insert(where_to_insert - initial_i, _field.name)
541 # finally inject the new parameter in the signature
542 new_param = Parameter(_field.name, kind=Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation)
543 params.insert(where_to_insert, new_param)
545 return field_names, last_mandatory_idx
548def pop_kwargs(kwargs,
549 names_with_defaults, # type: List[Tuple[str, Any]]
550 allow_others=False
551 ):
552 """
553 Internal utility method to extract optional arguments from kwargs.
555 :param kwargs:
556 :param names_with_defaults:
557 :param allow_others: if False (default) then an error will be raised if kwargs still contains something at the end.
558 :return:
559 """
560 all_arguments = []
561 for name, default_ in names_with_defaults:
562 try:
563 val = kwargs.pop(name)
564 except KeyError:
565 val = default_
566 all_arguments.append(val)
568 if not allow_others and len(kwargs) > 0: 568 ↛ 569line 568 didn't jump to line 569, because the condition on line 568 was never true
569 raise ValueError("Unsupported arguments: %s" % kwargs)
571 if len(names_with_defaults) == 1: 571 ↛ 572line 571 didn't jump to line 572, because the condition on line 571 was never true
572 return all_arguments[0]
573 else:
574 return all_arguments