Coverage for yamlable/main.py: 86%

139 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-07-06 08:57 +0000

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

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

3# 

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

5 

6try: 

7 from collections.abc import Mapping 

8except ImportError: 

9 from collections import Mapping 

10 

11from abc import abstractmethod, ABCMeta 

12 

13import six 

14 

15try: # python 3.5+ 

16 from typing import TypeVar, Callable, Iterable, Any, Tuple, Dict, Set, List, Sequence 

17 

18 YA = TypeVar('YA', bound='YamlAble') 

19 T = TypeVar('T') 

20except ImportError: 

21 pass 

22 

23try: # python 3.5.4+ 

24 from typing import Type 

25except ImportError: 

26 pass # normal for old versions of typing 

27 

28from yaml import Loader, SafeLoader, Dumper, SafeDumper, MappingNode, ScalarNode, SequenceNode 

29 

30from yamlable.base import AbstractYamlObject, read_yaml_node_as_yamlobject, read_yaml_node_as_dict, \ 

31 read_yaml_node_as_sequence, read_yaml_node_as_scalar 

32from yamlable.yaml_objects import YamlObject2 

33 

34 

35YAMLABLE_PREFIX = '!yamlable/' 

36 

37 

38class AbstractYamlAble(AbstractYamlObject): 

39 """ 

40 The abstract part of YamlAble. It might be useful to inherit if you want to create a super class for several 

41 classes, with the same YamlAble behaviour. 

42 """ 

43 

44 @classmethod 

45 @abstractmethod 

46 def is_yaml_tag_supported(cls, 

47 yaml_tag # type: str 

48 ): 

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

50 """ 

51 Implementing classes should return True if they are able to decode yaml objects with this tag. 

52 Note that the associated yaml object tag will be 

53 

54 !yamlable/<yaml_tag> 

55 

56 :param yaml_tag: 

57 :return: 

58 """ 

59 

60 

61class YamlAble(AbstractYamlAble): 

62 """ 

63 A helper class to register a class as able to dump instances to yaml and to load them back from yaml. 

64 

65 This class does not rely on the `YAMLObject` class provided in pyyaml, so it provides a bit more flexibility (no 

66 metaclass magic). 

67 

68 The behaviour is very similar though: 

69 - inherit from `YamlAble` or virtually inherit from it using YamlAble.register(cls) 

70 - fill the `__yaml_tag_suffix__` either directly or using the `@yaml_info()` decorator 

71 - optionally override `__from_yaml_dict__` (class method called during decoding) and/or `__to_yaml_dict__` 

72 (instance method called during encoding) if you wish to have control on the process, for example to only dump part 

73 of the attributes or perform some custom instance creation. Note that default implementation relies on `vars(self)` 

74 for dumping and on `cls(**dct)` for loading. 

75 """ 

76 __yaml_tag_suffix__ = None 

77 """ placeholder for a class-wide yaml tag. It will be prefixed with '!yamlable/', stored in `YAMLABLE_PREFIX` """ 

78 

79 @classmethod 

80 def is_yaml_tag_supported(cls, 

81 yaml_tag # type: str 

82 ): 

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

84 """ 

85 Implementing classes should return True if they are able to decode yaml objects with this yaml tag. 

86 Default implementation relies on class attribute `__yaml_tag_suffix__` if provided, either manually or through 

87 the `@yaml_info` decorator. 

88 

89 :param yaml_tag: 

90 :return: 

91 """ 

92 if hasattr(cls, '__yaml_tag_suffix__') and cls.__yaml_tag_suffix__ is not None: 

93 if '__yaml_tag_suffix__' in cls.__dict__: 93 ↛ 98line 93 didn't jump to line 98, because the condition on line 93 was never false

94 # this is an explicitly configured class (__yaml_tag_suffix__ is set on it), ok 

95 return cls.__yaml_tag_suffix__ == yaml_tag 

96 else: 

97 # this class inherits from the __yaml_tag_suffix__ and does not redefine it, not ok 

98 raise TypeError("`__yaml_tag_suffix__` field is not redefined by class {}, cannot inherit from YamlAble" 

99 "properly.".format(cls)) 

100 

101 else: 

102 raise NotImplementedError("class {} does not seem to have a non-None '__yaml_tag_suffix__' field. You can " 

103 "either create one manually or by decorating your class with @yaml_info. " 

104 "Alternately you should override the 'is_yaml_tag_supported' method " 

105 "from YamlAble.".format(cls)) 

106 

107 

108def yaml_info(yaml_tag=None, # type: str 

109 yaml_tag_ns=None # type: str 

110 ): 

111 # type: (...) -> Callable[[Type[YA]], Type[YA]] 

112 """ 

113 A simple class decorator to tag a class with a global yaml tag - that way you do not have to call `YamlAble` super 

114 constructor. 

115 

116 You can either provide a full yaml tag suffix: 

117 

118 ```python 

119 @yaml_info("com.example.MyFoo") 

120 class Foo(YamlAble): 

121 pass 

122 

123 print(Foo.__yaml_tag_suffix__) # yields "com.example.MyFoo" 

124 ``` 

125 

126 or simply provide a namespace, that will be appended with '.<class name>' : 

127 

128 ```python 

129 @yaml_info(yaml_tag_ns="com.example") 

130 class Foo(YamlAble): 

131 pass 

132 

133 print(MyFoo.__yaml_tag_suffix__) # yields "com.example.Foo" 

134 ``` 

135 

136 In both cases, the suffix is appended at the end of the common yamlable prefix: 

137 

138 ```python 

139 print(Foo().dumps_yaml()) # yields "!yamlable/com.example.Foo {}" 

140 ``` 

141 

142 :param yaml_tag: the complete yaml suffix. 

143 :param yaml_tag_ns: the yaml namespace. It will be appended with '.<cls.__name__>' 

144 :return: 

145 """ 

146 def f(cls # type: Type[YA] 

147 ): 

148 # type: (...) -> Type[YA] 

149 return yaml_info_decorate(cls, yaml_tag=yaml_tag, yaml_tag_ns=yaml_tag_ns) 

150 return f 

151 

152 

153def yaml_info_decorate(cls, # type: Type[YA] 

154 yaml_tag=None, # type: str 

155 yaml_tag_ns=None # type: str 

156 ): 

157 # type: (...) -> Type[YA] 

158 """ 

159 A simple class decorator to tag a class with a global yaml tag - that way you do not have to call `YamlAble` super 

160 constructor. 

161 

162 You can either provide a full yaml tag suffix: 

163 

164 ```python 

165 @yaml_info("com.example.MyFoo") 

166 class Foo(YamlAble): 

167 pass 

168 

169 print(Foo.__yaml_tag_suffix__) # yields "com.example.MyFoo" 

170 ``` 

171 

172 or simply provide a namespace, that will be appended with '.<class name>' : 

173 

174 ```python 

175 @yaml_info(yaml_tag_ns="com.example") 

176 class Foo(YamlAble): 

177 pass 

178 

179 print(MyFoo.__yaml_tag_suffix__) # yields "com.example.Foo" 

180 ``` 

181 

182 In both cases, the suffix is appended at the end of the common yamlable prefix: 

183 

184 ```python 

185 print(Foo().dumps_yaml()) # yields "!yamlable/com.example.Foo {}" 

186 ``` 

187 

188 :param cls: 

189 :param yaml_tag: the complete yaml suffix. 

190 :param yaml_tag_ns: the yaml namespace. It will be appended with '.<cls.__name__>' 

191 :return: 

192 """ 

193 if yaml_tag_ns is not None: 

194 if yaml_tag is not None: 194 ↛ 195line 194 didn't jump to line 195, because the condition on line 194 was never true

195 raise ValueError("Only one of 'yaml_tag' and 'yaml_tag_ns' should be provided") 

196 

197 # create yaml_tag by appending the class name to the namespace 

198 yaml_tag = yaml_tag_ns + '.' + cls.__name__ 

199 

200 elif yaml_tag is None: 200 ↛ 201line 200 didn't jump to line 201, because the condition on line 200 was never true

201 raise ValueError("One non-None `yaml_tag` or `yaml_tag_ns` must be provided.") 

202 

203 if issubclass(cls, YamlObject2): 203 ↛ 207line 203 didn't jump to line 207, because the condition on line 203 was never true

204 # Do not support this, because the `YamlObject` metaclass needs the tag to be present BEFORE this decorator is 

205 # even called. So if we are here, it means that we are trying to override a yaml_tag that was already registered 

206 # with pyyaml. Too late! 

207 raise TypeError("This is not supported") 

208 # if not yaml_tag.startswith('!'): 

209 # raise ValueError("When extending YamlObject2, the `yaml_tag` field should contain the full yaml tag, " 

210 # "and should therefore start with !") 

211 # cls.yaml_tag = yaml_tag 

212 

213 elif issubclass(cls, YamlAble): 213 ↛ 219line 213 didn't jump to line 219, because the condition on line 213 was never false

214 if yaml_tag.startswith('!'): 214 ↛ 215line 214 didn't jump to line 215, because the condition on line 214 was never true

215 raise ValueError("When extending YamlAble, the `yaml_tag` field should only contain the yaml tag suffix, " 

216 "and should therefore NOT start with !") 

217 cls.__yaml_tag_suffix__ = yaml_tag # type: ignore 

218 else: 

219 raise TypeError("classes tagged with @yaml_info should be subclasses of YamlAble or YamlObject2") 

220 

221 return cls 

222 

223 

224# --------------------------------------Codecs----------------------------------------------------------- 

225def decode_yamlable(loader, 

226 yaml_tag, # type: str 

227 node, # type: MappingNode 

228 **kwargs): 

229 # type: (...) -> YamlAble 

230 """ 

231 The method used to decode YamlAble object instances 

232 

233 :param loader: 

234 :param yaml_tag: 

235 :param node: 

236 :param kwargs: 

237 :return: 

238 """ 

239 candidates = _get_all_subclasses(YamlAble) 

240 errors = dict() 

241 for clazz in candidates: 

242 try: 

243 if clazz.is_yaml_tag_supported(yaml_tag): 

244 return read_yaml_node_as_yamlobject( 

245 cls=clazz, loader=loader, node=node, yaml_tag=yaml_tag 

246 ) # type: ignore 

247 else: 

248 errors[clazz.__name__] = "yaml tag %r is not supported." % yaml_tag 

249 except Exception as e: 

250 errors[clazz.__name__] = e 

251 

252 raise TypeError("No YamlAble subclass found able to decode object '!yamlable/" + yaml_tag + "'. Tried classes: " 

253 + str(candidates) + ". Caught errors: " + str(errors) + ". " 

254 "Please check the value of <cls>.__yaml_tag_suffix__ on these classes. Note that this value may be " 

255 "set using @yaml_info() so help(yaml_info) might help too.") 

256 

257 

258def encode_yamlable(dumper, 

259 obj, # type: YamlAble 

260 without_custom_tag=False, # type: bool 

261 **kwargs): 

262 # type: (...) -> MappingNode 

263 """ 

264 The method used to encode YamlAble object instances 

265 

266 :param dumper: 

267 :param obj: 

268 :param without_custom_tag: if this is set to True, the yaml tag !yamlable/<yaml_tag_suffix> will not be written to 

269 the document. Warning: if you do so, decoding the object will not be easy! 

270 :param kwargs: 

271 :return: 

272 """ 

273 # Convert objects to a dictionary of their representation 

274 new_data = obj.__to_yaml_dict__() 

275 

276 if without_custom_tag: 276 ↛ 278line 276 didn't jump to line 278, because the condition on line 276 was never true

277 # TODO check that it works 

278 return dumper.represent_mapping(None, new_data, flow_style=None) 

279 else: 

280 # Add the tag information 

281 if not hasattr(obj, '__yaml_tag_suffix__') or obj.__yaml_tag_suffix__ is None: 

282 raise NotImplementedError("object {} does not seem to have a non-None '__yaml_tag_suffix__' field. You " 

283 "can either create one manually or by decorating your class with @yaml_info." 

284 "".format(obj)) 

285 yaml_tag = YAMLABLE_PREFIX + obj.__yaml_tag_suffix__ 

286 return dumper.represent_mapping(yaml_tag, new_data, flow_style=None) 

287 

288 

289try: # PyYaml 5.1+ 

290 from yaml import FullLoader 

291 ALL_PYYAML_LOADERS = (Loader, SafeLoader, FullLoader) 

292except ImportError: 

293 ALL_PYYAML_LOADERS = (Loader, SafeLoader) # type: ignore 

294 

295 

296ALL_PYYAML_DUMPERS = (Dumper, SafeDumper) 

297 

298 

299def register_yamlable_codec(loaders=ALL_PYYAML_LOADERS, dumpers=ALL_PYYAML_DUMPERS): 

300 # type: (...) -> None 

301 """ 

302 Registers the yamlable encoder and decoder with all pyYaml loaders and dumpers. 

303 

304 :param loaders: 

305 :param dumpers: 

306 :return: 

307 """ 

308 for loader in loaders: 

309 loader.add_multi_constructor(YAMLABLE_PREFIX, decode_yamlable) 

310 

311 for dumper in dumpers: 

312 dumper.add_multi_representer(YamlAble, encode_yamlable) 

313 

314 

315# Register the YamlAble encoding and decoding functions 

316register_yamlable_codec() 

317 

318 

319def _get_all_subclasses(typ, # type: Type[T] 

320 recursive=True, # type: bool 

321 _memo=None # type: Set[Type[Any]] 

322 ): 

323 # type: (...) -> Iterable[Type[T]] 

324 """ 

325 Returns all subclasses of `typ` 

326 Warning this does not support generic types. 

327 See parsyfiles.get_all_subclasses() if one day generic types are needed (commented lines below) 

328 

329 :param typ: 

330 :param recursive: a boolean indicating whether recursion is needed 

331 :param _memo: internal variable used in recursion to avoid exploring subclasses that were already explored 

332 :return: 

333 """ 

334 _memo = _memo or set() 

335 

336 # if we have collected the subclasses for this already, return 

337 if typ in _memo: 337 ↛ 338line 337 didn't jump to line 338, because the condition on line 337 was never true

338 return [] 

339 

340 # else remember that we have collected them, and collect them 

341 _memo.add(typ) 

342 # if is_generic_type(typ): 

343 # # We now use get_origin() to also find all the concrete subclasses in case the desired type is a generic 

344 # sub_list = get_origin(typ).__subclasses__() 

345 # else: 

346 sub_list = typ.__subclasses__() 

347 

348 # recurse 

349 result = [] # type: List[Type[T]] 

350 for t in sub_list: 

351 # only keep the origins in the list 

352 # to = get_origin(t) or t 

353 to = t 

354 # noinspection PyBroadException 

355 try: 

356 if to is not typ and to not in result and issubclass(to, typ): # is_subtype(to, typ, bound_typevars={}): 356 ↛ 350line 356 didn't jump to line 350, because the condition on line 356 was never false

357 result.append(to) 

358 except Exception: # noqa 

359 # catching an error with is_subtype(Dict, Dict[str, int], bound_typevars={}) 

360 pass 

361 

362 # recurse 

363 if recursive: 363 ↛ 371line 363 didn't jump to line 371, because the condition on line 363 was never false

364 for typpp in sub_list: 

365 for t in _get_all_subclasses(typpp, recursive=True, _memo=_memo): 

366 # unfortunately we have to check 't not in sub_list' because with generics strange things happen 

367 # also is_subtype returns false when the parent is a generic 

368 if t not in sub_list and issubclass(t, typ): # is_subtype(t, typ, bound_typevars={}): 368 ↛ 365line 368 didn't jump to line 365, because the condition on line 368 was never false

369 result.append(t) 

370 

371 return result 

372 

373 

374# ------------------------ Easy codecs --------------- 

375class YamlCodec(six.with_metaclass(ABCMeta, object)): 

376 """ 

377 Represents a codec class, able to encode several object types into/from yaml, with potentially different yaml tag 

378 ids. It assumes that the objects are written as yaml dictionaries, and that they all have the same yaml tag prefix 

379 

380 for example !mycodec/<yaml_tag_suffix>, where 'mycodec' is the yaml prefix associated with this codec. 

381 

382 This allows the code to be pre-wired so that it is very easy to implement. 

383 - Decoding: 

384 - fill get_yaml_prefix 

385 - fill is_yaml_tag_supported to declare if a given yaml tag is supported or not 

386 - fill from_yaml_dict to create new instances of objects from a dictionary, according to the yaml tag 

387 - Encoding: 

388 - fill get_known_types 

389 - fill the to_yaml_dict 

390 """ 

391 

392 # -------------- decoding 

393 

394 @classmethod 

395 @abstractmethod 

396 def get_yaml_prefix(cls): 

397 # type: (...) -> str 

398 """ 

399 Implementors should return the yaml prefix associated tto this codec. 

400 :return: 

401 """ 

402 

403 @classmethod 

404 def decode(cls, loader, 

405 yaml_tag_suffix, # type: str 

406 node, # type: MappingNode 

407 **kwargs): 

408 # type: (...) -> Any 

409 """ 

410 The method used to decode object instances 

411 

412 :param loader: 

413 :param yaml_tag_suffix: 

414 :param node: 

415 :param kwargs: keyword arguments coming from pyyaml, not sure what you will find here. 

416 :return: 

417 """ 

418 if cls.is_yaml_tag_supported(yaml_tag_suffix): 418 ↛ exitline 418 didn't return from function 'decode', because the condition on line 418 was never false

419 # Note: same as in read_yaml_node_as_yamlobject but different yaml tag handling so code copy 

420 

421 if isinstance(node, ScalarNode): 

422 constructor_args = read_yaml_node_as_scalar(loader, node) 

423 return cls.from_yaml_scalar(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore 

424 

425 elif isinstance(node, SequenceNode): 

426 constructor_args = read_yaml_node_as_sequence(loader, node) 

427 return cls.from_yaml_list(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore 

428 

429 elif isinstance(node, MappingNode): 429 ↛ 434line 429 didn't jump to line 434, because the condition on line 429 was never false

430 constructor_args = read_yaml_node_as_dict(loader, node) 

431 return cls.from_yaml_dict(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore 

432 

433 else: 

434 raise TypeError("Unknown type of yaml node: %r. Please report this to `yamlable` project." % type(node)) 

435 

436 @classmethod 

437 @abstractmethod 

438 def is_yaml_tag_supported(cls, 

439 yaml_tag_suffix # type: str 

440 ): 

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

442 """ 

443 Implementing classes should return True if they are able to decode yaml objects with this yaml tag. 

444 

445 :param yaml_tag_suffix: 

446 :return: 

447 """ 

448 

449 @classmethod 

450 def from_yaml_scalar(cls, 

451 yaml_tag_suffix, # type: str 

452 scalar, # type: Any 

453 **kwargs): 

454 # type: (...) -> Any 

455 """ 

456 Implementing classes should create an object corresponding to the given yaml tag, using the given YAML scalar. 

457 

458 :param scalar: 

459 :param yaml_tag_suffix: 

460 :param kwargs: keyword arguments coming from pyyaml, not sure what you will find here. 

461 :return: 

462 """ 

463 raise NotImplementedError("This codec does not support loading objects from scalar. Please override " 

464 "`from_yaml_scalar` to support this feature.") 

465 

466 @classmethod 

467 def from_yaml_list(cls, 

468 yaml_tag_suffix, # type: str 

469 seq, # type: Sequence[Any] 

470 **kwargs): 

471 # type: (...) -> Any 

472 """ 

473 Implementing classes should create an object corresponding to the given yaml tag, using the given YAML sequence. 

474 

475 :param seq: 

476 :param yaml_tag_suffix: 

477 :param kwargs: keyword arguments coming from pyyaml, not sure what you will find here. 

478 :return: 

479 """ 

480 raise NotImplementedError("This codec does not support loading objects from sequence. Please override " 

481 "`from_yaml_list` to support this feature.") 

482 

483 @classmethod 

484 @abstractmethod 

485 def from_yaml_dict(cls, 

486 yaml_tag_suffix, # type: str 

487 dct, # type: Dict[str, Any] 

488 **kwargs): 

489 # type: (...) -> Any 

490 """ 

491 Implementing classes should create an object corresponding to the given yaml tag, using the given YAML mapping. 

492 

493 :param dct: 

494 :param yaml_tag_suffix: 

495 :param kwargs: keyword arguments coming from pyyaml, not sure what you will find here. 

496 :return: 

497 """ 

498 

499 # --------------- encoding 

500 

501 @classmethod 

502 @abstractmethod 

503 def get_known_types(cls): 

504 # type: (...) -> Iterable[Type[Any]] 

505 """ 

506 Implementing classes should return an iterable of known object types. 

507 :return: 

508 """ 

509 

510 @classmethod 

511 def encode(cls, dumper, obj, 

512 without_custom_tag=False, # type: bool 

513 **kwargs): 

514 # type: (...) -> MappingNode 

515 """ 

516 The method used to encode YamlAble object instances 

517 

518 :param dumper: 

519 :param obj: 

520 :param without_custom_tag: if this is set to True, the yaml tag !yamlable/<yaml_tag_suffix> will not be written 

521 to the document. Warning: if you do so, decoding the object will not be easy! 

522 :param kwargs: keyword arguments coming from pyyaml, not sure what you will find here. 

523 :return: 

524 """ 

525 # Convert objects to a dictionary of their representation 

526 yaml_tag_suffix, obj_as_dict = cls.to_yaml_dict(obj) 

527 if not isinstance(obj_as_dict, Mapping) or not isinstance(yaml_tag_suffix, str): 527 ↛ 528line 527 didn't jump to line 528, because the condition on line 527 was never true

528 raise TypeError("`to_yaml_dict` did not return correct results. It should return a tuple of " 

529 "`yaml_tag_suffix, obj_as_dict`") 

530 

531 if without_custom_tag: 531 ↛ 533line 531 didn't jump to line 533, because the condition on line 531 was never true

532 # TODO check that it works 

533 return dumper.represent_mapping(None, obj_as_dict, flow_style=None) 

534 else: 

535 # Add the tag information 

536 prefix = cls.get_yaml_prefix() 

537 if len(prefix) == 0 or prefix[-1] != '/': 537 ↛ 538line 537 didn't jump to line 538, because the condition on line 537 was never true

538 prefix = prefix + '/' 

539 yaml_tag = prefix + yaml_tag_suffix 

540 return dumper.represent_mapping(yaml_tag, obj_as_dict, flow_style=None) 

541 

542 @classmethod 

543 @abstractmethod 

544 def to_yaml_dict(cls, obj): 

545 # type: (...) -> Tuple[str, Dict[str, Any]] 

546 """ 

547 Implementors should encode the given object as a dictionary and also return the yaml tag that should be used to 

548 ensure correct decoding. 

549 

550 :param obj: 

551 :return: a tuple where the first element is the yaml tag suffix, and the second is the dictionary representing 

552 the object 

553 """ 

554 

555 @classmethod 

556 def register_with_pyyaml(cls, loaders=ALL_PYYAML_LOADERS, dumpers=ALL_PYYAML_DUMPERS): 

557 # type: (...) -> None 

558 """ 

559 Registers this codec with PyYaml, on the provided loaders and dumpers (default: all PyYaml loaders and dumpers). 

560 - The encoding part is registered for the object types listed in cls.get_known_types(), in order 

561 - The decoding part is registered for the yaml prefix in cls.get_yaml_prefix() 

562 

563 :param loaders: the PyYaml loaders to register this codec with. By default all pyyaml loaders are considered 

564 (Loader, SafeLoader...) 

565 :param dumpers: the PyYaml dumpers to register this codec with. By default all pyyaml loaders are considered 

566 (Dumper, SafeDumper...) 

567 :return: 

568 """ 

569 for loader in loaders: 

570 loader.add_multi_constructor(cls.get_yaml_prefix(), cls.decode) 

571 

572 for dumper in dumpers: 

573 for t in cls.get_known_types(): 

574 dumper.add_multi_representer(t, cls.encode)