Skip to content

Commit 3068e10

Browse files
authored
Merge pull request #13 from smarie/feature/12_sequences_and_scalars
Feature/12 sequences and scalars
2 parents c726f5c + b780f0f commit 3068e10

13 files changed

Lines changed: 373 additions & 33 deletions

ci_tools/.gitignore

Lines changed: 0 additions & 5 deletions
This file was deleted.

ci_tools/flake8-requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ pydocstyle>=5.1.1,<6
1111
pycodestyle>=2.6.0,<3
1212
mccabe>=0.6.1,<1
1313
naming>=0.5.1,<1
14-
pyflakes>=2.2,<3
14+
pyflakes>=2.2,<3
15+
genbadge[flake8]

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
### 1.1.0 - Now load objects from sequences and scalars too
4+
5+
- Objects (subclasses of `YamlAble` or `YamlObject2`) can now be loaded from both mappings, sequences and scalars. Codecs (subclasses of `YamlCodec`) can also support this feature. Fixes [#12](https://github.com/smarie/python-yamlable/issues/12)
6+
37
### 1.0.4 - better type hinting (mypy)
48

59
- Most type hints have been fixed, in particular for `@yaml_info`. Fixes [#11](https://github.com/smarie/python-yamlable/issues/11).

docs/index.md

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ In addition `yamlable` provides a way to create Yaml codecs for several object t
2626

2727
## Usage
2828

29+
### 1. The (recommended) `YamlAble` way
30+
31+
#### a. Creating the class
32+
2933
Let's make a class yaml-able: we have to
3034

3135
- inherit from `YamlAble`
@@ -38,7 +42,7 @@ from yamlable import yaml_info, YamlAble
3842
@yaml_info(yaml_tag_ns='com.yamlable.example')
3943
class Foo(YamlAble):
4044

41-
def __init__(self, a, b):
45+
def __init__(self, a, b="hey"):
4246
""" Constructor """
4347
self.a = a
4448
self.b = b
@@ -67,6 +71,8 @@ That's it! Let's check that our class is correct and allows us to create instanc
6771
Foo - {'a': 1, 'b': 'hello'}
6872
```
6973

74+
#### b. Dumping and loading to/from YAML
75+
7076
Now let's dump and load it using `pyyaml`:
7177

7278
```python
@@ -105,7 +111,44 @@ a: 1
105111
b: hello
106112
```
107113

108-
See [Usage](./usage) for other possibilities of `yamlable`.
114+
#### c. Support for sequences and scalars
115+
116+
Objects can also be loaded from YAML sequences:
117+
118+
```python
119+
>>> print(yaml.safe_load("""
120+
!yamlable/com.yamlable.example.Foo
121+
- 0
122+
- hey
123+
"""))
124+
125+
Foo - {'a': 0, 'b': 'hey'}
126+
```
127+
128+
The default implementation of `__from_yaml_sequence__` (that you may wish to override in your subclass), is to call
129+
the constructor with the sequence contents as positional arguments.
130+
131+
The same also works for scalars:
132+
133+
```python
134+
>>> print(yaml.safe_load("""
135+
!yamlable/com.yamlable.example.Foo 0
136+
"""))
137+
138+
Foo - {'a': "0", 'b': 'hey'}
139+
```
140+
141+
The default implementation of `__from_yaml_scalar__` (that you may wish to override in your subclass), is to call
142+
the constructor with the scalar as first positional argument.
143+
144+
!!! warning "Scalars are not resolved"
145+
As can be seen in the above example, scalars are not auto-resolved when constructing an object from a scalar. So an
146+
integer `0` is actually received as a string `"0"` by `from_yaml_scalar`.
147+
148+
149+
#### d. What if you can not modify the class ?
150+
151+
See [Usage](./usage#yamlcodec) for another possibility offered by `yamlable`: creating a codec to handle YAML for several classes at once, typically classes that you cannot modify.
109152

110153

111154
## Main features / benefits

docs/usage.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ You have seen in the [main page](./index) a small example to understand the conc
44

55
## `YamlCodec`
66

7+
### 1. Writing a codec class
8+
79
Sometimes you do not have the possibility to change the classes of the objects that you wish to encode/decode. In this case the solution is to write an independent codec, inheriting from `YamlCodec`. Once again this feature leverages the `multi_constructor` and `multi_representer` concepts available in the `PyYaml` internals, but with `YamlCodec` it becomes a bit easier to do.
810

911
Let's assume that the following two classes are given and can not be modified:
@@ -84,13 +86,17 @@ class MyCodec(YamlCodec):
8486
return types_to_yaml_tags[type(obj)], vars(obj)
8587
```
8688

89+
### 2. Registering a codec
90+
8791
When you codec has been defined, it needs to be registerd before being usable. You can specify with which `PyYaml` Loaders/Dumpers it should be registered, or use the default (all):
8892

8993
```python
9094
# register the codec
9195
MyCodec.register_with_pyyaml()
9296
```
9397

98+
### 3. Using a codec
99+
94100
Finally let's test that the codec works:
95101

96102
```python
@@ -111,3 +117,10 @@ assert dump(b) == by
111117
assert f == load(fy)
112118
assert b == load(by)
113119
```
120+
121+
### 4. Sequences and scalars
122+
123+
Objects can be loaded from sequences and scalars, in addition to dictionaries. To support this possibility, you simply need to fill the class methods:
124+
125+
- `from_yaml_sequence` for sequences
126+
- `from_yaml_scalar` for scalars

docs/mkdocs.yml renamed to mkdocs.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
site_name: yamlable
22
# site_description: 'A short description of my project'
33
repo_url: https://github.com/smarie/python-yamlable
4-
docs_dir: .
5-
site_dir: ../site
4+
# docs_dir: .
5+
# site_dir: ../site
6+
# default branch is main instead of master now on github
7+
edit_uri : ./edit/main/docs
68
nav:
79
- Home: index.md
810
- Usage details: usage.md

noxfile.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from itertools import product
22
from json import dumps
3-
43
import logging
54

65
import nox # noqa
@@ -140,10 +139,10 @@ def flake8(session: PowerSession):
140139
"""Launch flake8 qualimetry."""
141140

142141
session.install("-r", str(Folders.ci_tools / "flake8-requirements.txt"))
143-
session.install("genbadge[flake8]")
144-
session.run2("pip install -e .[flake8]")
142+
session.run2("pip install .")
145143

146144
rm_folder(Folders.flake8_reports)
145+
Folders.flake8_reports.mkdir(parents=True, exist_ok=True)
147146
rm_file(Folders.flake8_intermediate_file)
148147

149148
# Options are set in `setup.cfg` file

yamlable/base.py

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from abc import ABCMeta
77
from collections import OrderedDict
88

9+
from yaml import ScalarNode, SequenceNode, MappingNode
10+
911
try:
1012
# Python 2 only:
1113
from StringIO import StringIO as _StringIO # type: ignore # noqa
@@ -27,7 +29,7 @@ def __exit__(self, exception_type, exception_value, traceback):
2729
import six
2830

2931
try: # python 3.5+
30-
from typing import Union, TypeVar, Dict, Any
32+
from typing import Union, TypeVar, Dict, Any, Sequence
3133

3234
Y = TypeVar('Y', bound='AbstractYamlObject')
3335

@@ -49,6 +51,30 @@ class AbstractYamlObject(six.with_metaclass(ABCMeta, object)):
4951
Default implementation uses vars(self) and cls(**dct), but subclasses can override.
5052
"""
5153

54+
# def __to_yaml_scalar__(self):
55+
# # type: (...) -> Any
56+
# """
57+
# Implementors should transform the object into a scalar containing all information necessary to decode the
58+
# object as a YAML scalar in the future.
59+
#
60+
# Default implementation raises an error.
61+
# :return:
62+
# """
63+
# raise NotImplementedError("Please override `__to_yaml_scalar__` if you wish to dump instances of `%s`"
64+
# " as yaml scalars." % type(self).__name__)
65+
#
66+
# def __to_yaml_sequence__(self):
67+
# # type: (...) -> Sequence[Any]
68+
# """
69+
# Implementors should transform the object into a Sequence containing all information necessary to decode the
70+
# object as a YAML sequence in the future.
71+
#
72+
# Default implementation raises an error.
73+
# :return:
74+
# """
75+
# raise NotImplementedError("Please override `__to_yaml_sequence__` if you wish to dump instances of `%s`"
76+
# " as yaml sequences." % type(self).__name__)
77+
5278
def __to_yaml_dict__(self):
5379
# type: (...) -> Dict[str, Any]
5480
"""
@@ -67,6 +93,52 @@ def __to_yaml_dict__(self):
6793
# Default: return vars(self) (Note: no need to make a copy, pyyaml does not modify it)
6894
return vars(self)
6995

96+
@classmethod
97+
def __from_yaml_scalar__(cls, # type: Type[Y]
98+
scalar, # type: Any
99+
yaml_tag # type: str
100+
):
101+
# type: (...) -> Y
102+
"""
103+
Implementors should transform the given scalar (read from yaml by the pyYaml stack) into an object instance.
104+
The yaml tag associated to this object, read in the yaml document, is provided in parameter.
105+
106+
Note that for YamlAble and YamlObject2 subclasses, if this method is called the yaml tag will already have
107+
been checked so implementors do not have to validate it.
108+
109+
Default implementation returns cls(scalar)
110+
111+
:param scalar: the yaml scalar
112+
:param yaml_tag: the yaml schema id that was used for encoding the object (it has already been checked
113+
against is_json_schema_id_supported)
114+
:return:
115+
"""
116+
# Default: call constructor with positional arguments
117+
return cls(scalar) # type: ignore
118+
119+
@classmethod
120+
def __from_yaml_sequence__(cls, # type: Type[Y]
121+
seq, # type: Sequence[Any]
122+
yaml_tag # type: str
123+
):
124+
# type: (...) -> Y
125+
"""
126+
Implementors should transform the given Sequence (read from yaml by the pyYaml stack) into an object instance.
127+
The yaml tag associated to this object, read in the yaml document, is provided in parameter.
128+
129+
Note that for YamlAble and YamlObject2 subclasses, if this method is called the yaml tag will already have
130+
been checked so implementors do not have to validate it.
131+
132+
Default implementation returns cls(*seq)
133+
134+
:param seq: the yaml sequence
135+
:param yaml_tag: the yaml schema id that was used for encoding the object (it has already been checked
136+
against is_json_schema_id_supported)
137+
:return:
138+
"""
139+
# Default: call constructor with positional arguments
140+
return cls(*seq) # type: ignore
141+
70142
@classmethod
71143
def __from_yaml_dict__(cls, # type: Type[Y]
72144
dct, # type: Dict[str, Any]
@@ -200,14 +272,72 @@ def load_yaml(cls, # type: Type[Y]
200272

201273

202274
def read_yaml_node_as_dict(loader, node):
275+
# type: (...) -> OrderedDict
203276
"""
204277
Utility method to read a yaml node into a dictionary
205278
206279
:param loader:
207280
:param node:
208281
:return:
209282
"""
210-
loader.flatten_mapping(node)
211-
pairs = loader.construct_pairs(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
283+
# loader.flatten_mapping(node)
284+
# pairs = loader.construct_pairs(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
285+
pairs = loader.construct_mapping(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
212286
constructor_args = OrderedDict(pairs)
213287
return constructor_args
288+
289+
290+
def read_yaml_node_as_sequence(loader, node):
291+
# type: (...) -> Sequence
292+
"""
293+
Utility method to read a yaml node into a sequence
294+
295+
:param loader:
296+
:param node:
297+
:return:
298+
"""
299+
seq = loader.construct_sequence(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
300+
return seq
301+
302+
303+
def read_yaml_node_as_scalar(loader, node):
304+
# type: (...) -> Any
305+
"""
306+
Utility method to read a yaml node into a sequence
307+
308+
:param loader:
309+
:param node:
310+
:return:
311+
"""
312+
value = loader.construct_scalar(node)
313+
return value
314+
315+
316+
def read_yaml_node_as_yamlobject(
317+
cls, # type: Type[AbstractYamlObject]
318+
loader,
319+
node, # type: MappingNode
320+
yaml_tag # type: str
321+
):
322+
# type: (...) -> AbstractYamlObject
323+
"""
324+
Default implementation: loads the node as a dictionary and calls __from_yaml_dict__ with this dictionary
325+
326+
:param loader:
327+
:param node:
328+
:return:
329+
"""
330+
if isinstance(node, ScalarNode):
331+
constructor_args = read_yaml_node_as_scalar(loader, node)
332+
return cls.__from_yaml_scalar__(constructor_args, yaml_tag=yaml_tag) # type: ignore
333+
334+
elif isinstance(node, SequenceNode):
335+
constructor_args = read_yaml_node_as_sequence(loader, node)
336+
return cls.__from_yaml_sequence__(constructor_args, yaml_tag=yaml_tag) # type: ignore
337+
338+
elif isinstance(node, MappingNode):
339+
constructor_args = read_yaml_node_as_dict(loader, node)
340+
return cls.__from_yaml_dict__(constructor_args, yaml_tag=yaml_tag) # type: ignore
341+
342+
else:
343+
raise TypeError("Unknown type of yaml node: %r. Please report this to `yamlable` project." % type(node))

0 commit comments

Comments
 (0)