Skip to content

Commit

Permalink
Objects can now be loaded from both mappings, sequences and scalars. F…
Browse files Browse the repository at this point in the history
…ixes #12
  • Loading branch information
Sylvain MARIE committed Feb 2, 2022
1 parent c726f5c commit cd48359
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 19 deletions.
136 changes: 133 additions & 3 deletions yamlable/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from abc import ABCMeta
from collections import OrderedDict

from yaml import ScalarNode, SequenceNode, MappingNode

try:
# Python 2 only:
from StringIO import StringIO as _StringIO # type: ignore # noqa
Expand All @@ -27,7 +29,7 @@ def __exit__(self, exception_type, exception_value, traceback):
import six

try: # python 3.5+
from typing import Union, TypeVar, Dict, Any
from typing import Union, TypeVar, Dict, Any, Tuple

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

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

# def __to_yaml_scalar__(self):
# # type: (...) -> Any
# """
# Implementors should transform the object into a scalar containing all information necessary to decode the
# object as a YAML scalar in the future.
#
# Default implementation raises an error.
# :return:
# """
# raise NotImplementedError("Please override `__to_yaml_scalar__` if you wish to dump instances of `%s`"
# " as yaml scalars." % type(self).__name__)
#
# def __to_yaml_sequence__(self):
# # type: (...) -> Tuple[Any]
# """
# Implementors should transform the object into a tuple containing all information necessary to decode the
# object as a YAML sequence in the future.
#
# Default implementation raises an error.
# :return:
# """
# raise NotImplementedError("Please override `__to_yaml_sequence__` if you wish to dump instances of `%s`"
# " as yaml sequences." % type(self).__name__)

def __to_yaml_dict__(self):
# type: (...) -> Dict[str, Any]
"""
Expand All @@ -67,6 +93,52 @@ def __to_yaml_dict__(self):
# Default: return vars(self) (Note: no need to make a copy, pyyaml does not modify it)
return vars(self)

@classmethod
def __from_yaml_scalar__(cls, # type: Type[Y]
scalar, # type: Any
yaml_tag # type: str
):
# type: (...) -> Y
"""
Implementors should transform the given scalar (read from yaml by the pyYaml stack) into an object instance.
The yaml tag associated to this object, read in the yaml document, is provided in parameter.
Note that for YamlAble and YamlObject2 subclasses, if this method is called the yaml tag will already have
been checked so implementors do not have to validate it.
Default implementation returns cls(scalar)
:param scalar: the yaml scalar
:param yaml_tag: the yaml schema id that was used for encoding the object (it has already been checked
against is_json_schema_id_supported)
:return:
"""
# Default: call constructor with positional arguments
return cls(scalar) # type: ignore

@classmethod
def __from_yaml_sequence__(cls, # type: Type[Y]
seq, # type: Tuple[Any]
yaml_tag # type: str
):
# type: (...) -> Y
"""
Implementors should transform the given tuple (read from yaml by the pyYaml stack) into an object instance.
The yaml tag associated to this object, read in the yaml document, is provided in parameter.
Note that for YamlAble and YamlObject2 subclasses, if this method is called the yaml tag will already have
been checked so implementors do not have to validate it.
Default implementation returns cls(*seq)
:param seq: the yaml sequence
:param yaml_tag: the yaml schema id that was used for encoding the object (it has already been checked
against is_json_schema_id_supported)
:return:
"""
# Default: call constructor with positional arguments
return cls(*seq) # type: ignore

@classmethod
def __from_yaml_dict__(cls, # type: Type[Y]
dct, # type: Dict[str, Any]
Expand Down Expand Up @@ -200,14 +272,72 @@ def load_yaml(cls, # type: Type[Y]


def read_yaml_node_as_dict(loader, node):
# type: (...) -> OrderedDict
"""
Utility method to read a yaml node into a dictionary
:param loader:
:param node:
:return:
"""
loader.flatten_mapping(node)
pairs = loader.construct_pairs(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
# loader.flatten_mapping(node)
# pairs = loader.construct_pairs(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
pairs = loader.construct_mapping(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
constructor_args = OrderedDict(pairs)
return constructor_args


def read_yaml_node_as_sequence(loader, node):
# type: (...) -> Tuple
"""
Utility method to read a yaml node into a sequence
:param loader:
:param node:
:return:
"""
seq = loader.construct_sequence(node, deep=True) # 'deep' allows the construction to be complete (inner seq...)
return seq


def read_yaml_node_as_scalar(loader, node):
# type: (...) -> Any
"""
Utility method to read a yaml node into a sequence
:param loader:
:param node:
:return:
"""
value = loader.construct_scalar(node)
return value


def read_yaml_node_as_yamlobject(
cls, # type: Type[AbstractYamlObject]
loader,
node, # type: MappingNode
yaml_tag # type: str
):
# type: (...) -> AbstractYamlObject
"""
Default implementation: loads the node as a dictionary and calls __from_yaml_dict__ with this dictionary
:param loader:
:param node:
:return:
"""
if isinstance(node, ScalarNode):
constructor_args = read_yaml_node_as_scalar(loader, node)
return cls.__from_yaml_scalar__(constructor_args, yaml_tag=yaml_tag) # type: ignore

elif isinstance(node, SequenceNode):
constructor_args = read_yaml_node_as_sequence(loader, node)
return cls.__from_yaml_sequence__(constructor_args, yaml_tag=yaml_tag) # type: ignore

elif isinstance(node, MappingNode):
constructor_args = read_yaml_node_as_dict(loader, node)
return cls.__from_yaml_dict__(constructor_args, yaml_tag=yaml_tag) # type: ignore

else:
raise TypeError("Unknown type of yaml node: %r. Please report this to `yamlable` project." % type(node))
70 changes: 60 additions & 10 deletions yamlable/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import six

try: # python 3.5+
from typing import TypeVar, Callable, Iterable, Any, Tuple, Dict, Set, List
from typing import TypeVar, Callable, Iterable, Any, Tuple, Dict, Set, List, Sequence

YA = TypeVar('YA', bound='YamlAble')
T = TypeVar('T')
except ImportError:
Expand All @@ -20,9 +21,10 @@
except ImportError:
pass # normal for old versions of typing

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

from yamlable.base import AbstractYamlObject, read_yaml_node_as_dict
from yamlable.base import AbstractYamlObject, read_yaml_node_as_yamlobject, read_yaml_node_as_dict, \
read_yaml_node_as_sequence, read_yaml_node_as_scalar
from yamlable.yaml_objects import YamlObject2


Expand Down Expand Up @@ -231,12 +233,13 @@ def decode_yamlable(loader,
for clazz in candidates:
try:
if clazz.is_yaml_tag_supported(yaml_tag):
constructor_args = read_yaml_node_as_dict(loader, node)
return clazz.__from_yaml_dict__(constructor_args, yaml_tag=yaml_tag)
return read_yaml_node_as_yamlobject(cls=clazz, loader=loader, node=node, yaml_tag=yaml_tag) # type: ignore
else:
errors[clazz.__name__] = "yaml tag %r is not supported." % yaml_tag
except Exception as e:
errors[clazz.__name__] = e

raise TypeError("No YamlAble subclass found able to decode object !yamlable/" + yaml_tag + ". Tried classes: "
raise TypeError("No YamlAble subclass found able to decode object '!yamlable/" + yaml_tag + "'. Tried classes: "
+ str(candidates) + ". Caught errors: " + str(errors) + ". "
"Please check the value of <cls>.__yaml_tag_suffix__ on these classes. Note that this value may be "
"set using @yaml_info() so help(yaml_info) might help too.")
Expand Down Expand Up @@ -403,8 +406,22 @@ def decode(cls, loader,
:return:
"""
if cls.is_yaml_tag_supported(yaml_tag_suffix):
constructor_args = read_yaml_node_as_dict(loader, node)
return cls.from_yaml_dict(yaml_tag_suffix, constructor_args, **kwargs)
# Note: same as in read_yaml_node_as_yamlobject but different yaml tag handling so code copy

if isinstance(node, ScalarNode):
constructor_args = read_yaml_node_as_scalar(loader, node)
return cls.from_yaml_scalar(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore

elif isinstance(node, SequenceNode):
constructor_args = read_yaml_node_as_sequence(loader, node)
return cls.from_yaml_sequence(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore

elif isinstance(node, MappingNode):
constructor_args = read_yaml_node_as_dict(loader, node)
return cls.from_yaml_dict(yaml_tag_suffix, constructor_args, **kwargs) # type: ignore

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

@classmethod
@abstractmethod
Expand All @@ -419,6 +436,40 @@ def is_yaml_tag_supported(cls,
:return:
"""

@classmethod
def from_yaml_scalar(cls,
yaml_tag_suffix, # type: str
scalar, # type: Any
**kwargs):
# type: (...) -> Any
"""
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML scalar.
:param scalar:
:param yaml_tag_suffix:
:param kwargs: keyword arguments coming from pyyaml, not sure what you will find here.
:return:
"""
raise NotImplementedError("This codec does not support loading objects from scalar. Please override "
"`from_yaml_scalar` to support this feature.")

@classmethod
def from_yaml_sequence(cls,
yaml_tag_suffix, # type: str
seq, # type: Sequence[Any]
**kwargs):
# type: (...) -> Any
"""
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML sequence.
:param seq:
:param yaml_tag_suffix:
:param kwargs: keyword arguments coming from pyyaml, not sure what you will find here.
:return:
"""
raise NotImplementedError("This codec does not support loading objects from sequence. Please override "
"`from_yaml_sequence` to support this feature.")

@classmethod
@abstractmethod
def from_yaml_dict(cls,
Expand All @@ -427,8 +478,7 @@ def from_yaml_dict(cls,
**kwargs):
# type: (...) -> Any
"""
Implementing classes should create an object corresponding to the given yaml tag, using the given constructor
arguments.
Implementing classes should create an object corresponding to the given yaml tag, using the given YAML mapping.
:param dct:
:param yaml_tag_suffix:
Expand Down
24 changes: 23 additions & 1 deletion yamlable/tests/test_yamlable.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,13 @@ def test_yamlable():
class Foo(YamlAble):
# __yaml_tag_suffix__ = 'foo' not needed: we used @yaml_info

def __init__(self, a, b):
def __init__(self, a, b="hey"):
self.a = a
self.b = b

def __repr__(self):
return "Foo(a=%r,b=%r)" % (self.a, self.b)

def __eq__(self, other):
return vars(self) == vars(other)

Expand Down Expand Up @@ -111,6 +114,25 @@ def close(self):
# load pyyaml
assert f == load(y)

# mapping, sequences and scalar
y_map = """
!yamlable/yaml.tests.Foo
a: 1
"""
y_seq = """
!yamlable/yaml.tests.Foo
- 1
"""
y_scalar = """
!yamlable/yaml.tests.Foo
1
"""
assert Foo.loads_yaml(y_map) == Foo(a=1)
assert Foo.loads_yaml(y_seq) == Foo(a=1)

# Important: if we provide a scalar, there will not be any auto-resolver
assert Foo.loads_yaml(y_scalar) == Foo(a="1")


def test_yamlable_legacy_method_names():
""" Tests that YamlAbleMixIn works correctly """
Expand Down
39 changes: 39 additions & 0 deletions yamlable/tests/test_yamlcodec.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

try: # python 3.5+
from typing import Tuple, Any, Iterable, Dict
except ImportError:
Expand Down Expand Up @@ -86,3 +88,40 @@ def to_yaml_dict(cls, obj):
# load pyyaml
assert f == load(fy)
assert b == load(by)

# load from sequence / scalar
by_seq = """!mycodec/yaml.tests.Bar
- what?
"""
by_scalar = "!mycodec/yaml.tests.Bar what?"

with pytest.raises(NotImplementedError):
load(by_seq)

with pytest.raises(NotImplementedError):
load(by_scalar)

class MyCodec2(MyCodec):
@classmethod
def from_yaml_sequence(cls,
yaml_tag_suffix, # type: str
seq, # type: Sequence[Any]
**kwargs):
# type: (...) -> Any
typ = yaml_tags_to_types[yaml_tag_suffix]
return typ(*seq)

@classmethod
def from_yaml_scalar(cls,
yaml_tag_suffix, # type: str
scalar, # type: Any
**kwargs):
# type: (...) -> Any
typ = yaml_tags_to_types[yaml_tag_suffix]
return typ(scalar)

# register the codec
MyCodec2.register_with_pyyaml()

assert b == load(by_seq)
assert b == load(by_scalar)
Loading

0 comments on commit cd48359

Please sign in to comment.