Skip to content

Commit cd48359

Browse files
author
Sylvain MARIE
committed
Objects can now be loaded from both mappings, sequences and scalars. Fixes #12
1 parent c726f5c commit cd48359

6 files changed

Lines changed: 300 additions & 19 deletions

File tree

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, Tuple
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: (...) -> Tuple[Any]
68+
# """
69+
# Implementors should transform the object into a tuple 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: Tuple[Any]
122+
yaml_tag # type: str
123+
):
124+
# type: (...) -> Y
125+
"""
126+
Implementors should transform the given tuple (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: (...) -> Tuple
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))

yamlable/main.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import six
1010

1111
try: # python 3.5+
12-
from typing import TypeVar, Callable, Iterable, Any, Tuple, Dict, Set, List
12+
from typing import TypeVar, Callable, Iterable, Any, Tuple, Dict, Set, List, Sequence
13+
1314
YA = TypeVar('YA', bound='YamlAble')
1415
T = TypeVar('T')
1516
except ImportError:
@@ -20,9 +21,10 @@
2021
except ImportError:
2122
pass # normal for old versions of typing
2223

23-
from yaml import Loader, SafeLoader, Dumper, SafeDumper, MappingNode
24+
from yaml import Loader, SafeLoader, Dumper, SafeDumper, MappingNode, ScalarNode, SequenceNode
2425

25-
from yamlable.base import AbstractYamlObject, read_yaml_node_as_dict
26+
from yamlable.base import AbstractYamlObject, read_yaml_node_as_yamlobject, read_yaml_node_as_dict, \
27+
read_yaml_node_as_sequence, read_yaml_node_as_scalar
2628
from yamlable.yaml_objects import YamlObject2
2729

2830

@@ -231,12 +233,13 @@ def decode_yamlable(loader,
231233
for clazz in candidates:
232234
try:
233235
if clazz.is_yaml_tag_supported(yaml_tag):
234-
constructor_args = read_yaml_node_as_dict(loader, node)
235-
return clazz.__from_yaml_dict__(constructor_args, yaml_tag=yaml_tag)
236+
return read_yaml_node_as_yamlobject(cls=clazz, loader=loader, node=node, yaml_tag=yaml_tag) # type: ignore
237+
else:
238+
errors[clazz.__name__] = "yaml tag %r is not supported." % yaml_tag
236239
except Exception as e:
237240
errors[clazz.__name__] = e
238241

239-
raise TypeError("No YamlAble subclass found able to decode object !yamlable/" + yaml_tag + ". Tried classes: "
242+
raise TypeError("No YamlAble subclass found able to decode object '!yamlable/" + yaml_tag + "'. Tried classes: "
240243
+ str(candidates) + ". Caught errors: " + str(errors) + ". "
241244
"Please check the value of <cls>.__yaml_tag_suffix__ on these classes. Note that this value may be "
242245
"set using @yaml_info() so help(yaml_info) might help too.")
@@ -403,8 +406,22 @@ def decode(cls, loader,
403406
:return:
404407
"""
405408
if cls.is_yaml_tag_supported(yaml_tag_suffix):
406-
constructor_args = read_yaml_node_as_dict(loader, node)
407-
return cls.from_yaml_dict(yaml_tag_suffix, constructor_args, **kwargs)
409+
# Note: same as in read_yaml_node_as_yamlobject but different yaml tag handling so code copy
410+
411+
if isinstance(node, ScalarNode):
412+
constructor_args = read_yaml_node_as_scalar(loader, node)
413+
return cls.from_yaml_scalar(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore
414+
415+
elif isinstance(node, SequenceNode):
416+
constructor_args = read_yaml_node_as_sequence(loader, node)
417+
return cls.from_yaml_sequence(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore
418+
419+
elif isinstance(node, MappingNode):
420+
constructor_args = read_yaml_node_as_dict(loader, node)
421+
return cls.from_yaml_dict(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore
422+
423+
else:
424+
raise TypeError("Unknown type of yaml node: %r. Please report this to `yamlable` project." % type(node))
408425

409426
@classmethod
410427
@abstractmethod
@@ -419,6 +436,40 @@ def is_yaml_tag_supported(cls,
419436
:return:
420437
"""
421438

439+
@classmethod
440+
def from_yaml_scalar(cls,
441+
yaml_tag_suffix, # type: str
442+
scalar, # type: Any
443+
**kwargs):
444+
# type: (...) -> Any
445+
"""
446+
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML scalar.
447+
448+
:param scalar:
449+
:param yaml_tag_suffix:
450+
:param kwargs: keyword arguments coming from pyyaml, not sure what you will find here.
451+
:return:
452+
"""
453+
raise NotImplementedError("This codec does not support loading objects from scalar. Please override "
454+
"`from_yaml_scalar` to support this feature.")
455+
456+
@classmethod
457+
def from_yaml_sequence(cls,
458+
yaml_tag_suffix, # type: str
459+
seq, # type: Sequence[Any]
460+
**kwargs):
461+
# type: (...) -> Any
462+
"""
463+
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML sequence.
464+
465+
:param seq:
466+
:param yaml_tag_suffix:
467+
:param kwargs: keyword arguments coming from pyyaml, not sure what you will find here.
468+
:return:
469+
"""
470+
raise NotImplementedError("This codec does not support loading objects from sequence. Please override "
471+
"`from_yaml_sequence` to support this feature.")
472+
422473
@classmethod
423474
@abstractmethod
424475
def from_yaml_dict(cls,
@@ -427,8 +478,7 @@ def from_yaml_dict(cls,
427478
**kwargs):
428479
# type: (...) -> Any
429480
"""
430-
Implementing classes should create an object corresponding to the given yaml tag, using the given constructor
431-
arguments.
481+
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML mapping.
432482
433483
:param dct:
434484
:param yaml_tag_suffix:

yamlable/tests/test_yamlable.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,13 @@ def test_yamlable():
5858
class Foo(YamlAble):
5959
# __yaml_tag_suffix__ = 'foo' not needed: we used @yaml_info
6060

61-
def __init__(self, a, b):
61+
def __init__(self, a, b="hey"):
6262
self.a = a
6363
self.b = b
6464

65+
def __repr__(self):
66+
return "Foo(a=%r,b=%r)" % (self.a, self.b)
67+
6568
def __eq__(self, other):
6669
return vars(self) == vars(other)
6770

@@ -111,6 +114,25 @@ def close(self):
111114
# load pyyaml
112115
assert f == load(y)
113116

117+
# mapping, sequences and scalar
118+
y_map = """
119+
!yamlable/yaml.tests.Foo
120+
a: 1
121+
"""
122+
y_seq = """
123+
!yamlable/yaml.tests.Foo
124+
- 1
125+
"""
126+
y_scalar = """
127+
!yamlable/yaml.tests.Foo
128+
1
129+
"""
130+
assert Foo.loads_yaml(y_map) == Foo(a=1)
131+
assert Foo.loads_yaml(y_seq) == Foo(a=1)
132+
133+
# Important: if we provide a scalar, there will not be any auto-resolver
134+
assert Foo.loads_yaml(y_scalar) == Foo(a="1")
135+
114136

115137
def test_yamlable_legacy_method_names():
116138
""" Tests that YamlAbleMixIn works correctly """

yamlable/tests/test_yamlcodec.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import pytest
2+
13
try: # python 3.5+
24
from typing import Tuple, Any, Iterable, Dict
35
except ImportError:
@@ -86,3 +88,40 @@ def to_yaml_dict(cls, obj):
8688
# load pyyaml
8789
assert f == load(fy)
8890
assert b == load(by)
91+
92+
# load from sequence / scalar
93+
by_seq = """!mycodec/yaml.tests.Bar
94+
- what?
95+
"""
96+
by_scalar = "!mycodec/yaml.tests.Bar what?"
97+
98+
with pytest.raises(NotImplementedError):
99+
load(by_seq)
100+
101+
with pytest.raises(NotImplementedError):
102+
load(by_scalar)
103+
104+
class MyCodec2(MyCodec):
105+
@classmethod
106+
def from_yaml_sequence(cls,
107+
yaml_tag_suffix, # type: str
108+
seq, # type: Sequence[Any]
109+
**kwargs):
110+
# type: (...) -> Any
111+
typ = yaml_tags_to_types[yaml_tag_suffix]
112+
return typ(*seq)
113+
114+
@classmethod
115+
def from_yaml_scalar(cls,
116+
yaml_tag_suffix, # type: str
117+
scalar, # type: Any
118+
**kwargs):
119+
# type: (...) -> Any
120+
typ = yaml_tags_to_types[yaml_tag_suffix]
121+
return typ(scalar)
122+
123+
# register the codec
124+
MyCodec2.register_with_pyyaml()
125+
126+
assert b == load(by_seq)
127+
assert b == load(by_scalar)

0 commit comments

Comments
 (0)