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

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 

8 

9try: 

10 from inspect import signature, Parameter, Signature 

11except ImportError: 

12 from funcsigs import signature, Parameter, Signature 

13 

14 

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 

20 

21 

22from makefun import wraps, with_signature 

23 

24from pyfields.core import PY36, USE_FACTORY, EMPTY, Field 

25from pyfields.helpers import get_fields 

26 

27 

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. 

33 

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: 

37 

38 >>> import sys, pytest 

39 >>> if sys.version_info < (3, 7): pytest.skip('doctest skipped') # 3.6 help() is different on travis 

40 

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 

60 

61 

62 The list of fields can be explicitly provided in `fields`. 

63 

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

67 

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) 

81 

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 

87 

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) 

91 

92 def apply_decorator(init_fun): 

93 # @init_fields(...) 

94 

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) 

99 

100 return apply_decorator 

101 

102 

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

108 

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

111 

112 >>> import sys, pytest 

113 >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped') 

114 

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'> 

135 

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 

146 

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) 

149 

150 def apply_decorator(init_fun): 

151 # @inject_fields(...) 

152 

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) 

156 

157 return apply_decorator 

158 

159 

160def make_init(*fields, # type: Union[Field, Any] 

161 **kwargs 

162 ): 

163 # type: (...) -> InitDescriptor 

164 """ 

165 Creates a constructor based on the provided fields. 

166 

167 If `fields` is empty, all fields from the class will be used in order of appearance, then the ancestors (following 

168 the mro) 

169 

170 >>> import sys, pytest 

171 >>> if sys.version_info < (3, 6): pytest.skip('doctest skipped') 

172 

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} 

181 

182 If `fields` is not empty, only the listed fields will appear in the constructor and will be initialized upon init. 

183 

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' 

192 

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. 

195 

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} 

206 

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` 

212 

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

227 

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) 

245 

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) 

248 

249 

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. 

255 

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. 

258 

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' 

263 

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 

278 

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 

286 

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__') 

314 

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) 

326 

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) 

330 

331 # replace it forever in the class 

332 # setattr(objtype, '__init__', new_init) 

333 objtype.__init__ = new_init 

334 

335 # return the new init 

336 return new_init.__get__(obj, objtype) 

337 

338 

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

343 

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' 

348 

349 def __init__(self, **init_field_values): 

350 self.field_values = init_field_values 

351 

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) 

365 

366 

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. 

376 

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

385 

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) 

390 

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] 

403 

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) 

413 

414 return init_fun 

415 

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) 

420 

421 # then get the function signature 

422 user_init_sig = signature(user_init_fun) 

423 

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) 

429 

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 

445 

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

454 

455 # replace the signature with the newly created one 

456 new_sig = user_init_sig.replace(parameters=params) 

457 

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) 

469 

470 # 2. inject our special variable 

471 kwargs['fields'] = InjectedInitFieldsArg(**_fields) 

472 

473 # 3. call your __init__ method 

474 return user_init_fun(self, *args, **kwargs) 

475 

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) 

492 

493 # 2. call your post-init method 

494 return user_init_fun(self, *args, **kwargs) 

495 

496 return __init__ 

497 

498 

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. 

507 

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

516 

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 

534 

535 # Are there annotations on the field ? 

536 annotation = _field.type_hint if _field.type_hint is not EMPTY else Parameter.empty 

537 

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) 

540 

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) 

544 

545 return field_names, last_mandatory_idx 

546 

547 

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. 

554 

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) 

567 

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) 

570 

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