|
| 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 | +""" |
| 6 | +Yaml-able classes |
| 7 | +================= |
| 8 | +
|
| 9 | +Let's make a class yaml-aware so that instances can be loaded from YAML and dumped to |
| 10 | +YAML. |
| 11 | +
|
| 12 | +1. Basics |
| 13 | +--------- |
| 14 | +
|
| 15 | +To make a class yaml-able, we have to |
| 16 | +
|
| 17 | + - inherit from `YamlAble` |
| 18 | + - decorate it with the `@yaml_info` annotation to declare the associated yaml tag |
| 19 | +""" |
| 20 | + |
| 21 | +from yamlable import yaml_info, YamlAble |
| 22 | + |
| 23 | +@yaml_info(yaml_tag_ns='com.yamlable.example') |
| 24 | +class Foo(YamlAble): |
| 25 | + |
| 26 | + def __init__(self, a, b="hey"): |
| 27 | + """ Constructor """ |
| 28 | + self.a = a |
| 29 | + self.b = b |
| 30 | + |
| 31 | + def __repr__(self): |
| 32 | + """ String representation for prints """ |
| 33 | + return f"{type(self).__name__} - {dict(a=self.a, b=self.b)}" |
| 34 | + |
| 35 | +# %% |
| 36 | +# That's it! Let's check that our class is correct and allows us to create instances: |
| 37 | + |
| 38 | +f = Foo(1, 'hello') |
| 39 | +f |
| 40 | + |
| 41 | + |
| 42 | +# %% |
| 43 | +# Now let's dump it to a YAML document using `pyyaml`: |
| 44 | + |
| 45 | +import yaml |
| 46 | + |
| 47 | +print(yaml.dump(f)) |
| 48 | + |
| 49 | +# %% |
| 50 | +# we can also load an instance from a document: |
| 51 | + |
| 52 | +f2 = yaml.safe_load(""" |
| 53 | +!yamlable/com.yamlable.example.Foo |
| 54 | +a: 0 |
| 55 | +b: hey |
| 56 | +""") |
| 57 | +print(f2) |
| 58 | + |
| 59 | +# %% |
| 60 | +# For more general cases where your object is embedded in a more complex structure for example, it will work as expected: |
| 61 | + |
| 62 | +d = {'foo': f, 'foo2': 12} |
| 63 | +print(yaml.dump(d)) |
| 64 | + |
| 65 | +# %% |
| 66 | +# In addition, the object directly offers the `dump_yaml` (dumping to file) / `dumps_yaml` (dumping to string) |
| 67 | +# convenience methods, and the class directly offers the `load_yaml` (load from file) / `loads_yaml` (load from string) |
| 68 | +# convenience methods. |
| 69 | +# |
| 70 | +# See [PyYaml documentation](http://pyyaml.org/wiki/PyYAMLDocumentation) for the various formatting arguments that you |
| 71 | +# can use, they are the same than in the `yaml.dump` method. For example: |
| 72 | + |
| 73 | + |
| 74 | +print(f.dumps_yaml(default_flow_style=False)) |
| 75 | + |
| 76 | +# %% |
| 77 | +# 2. Customization |
| 78 | +# ---------------- |
| 79 | +# |
| 80 | +# ### a. dumper/loader |
| 81 | +# |
| 82 | +# As could be seen above, `YamlAble` comes with a default implementation of the yaml formatting and parsing |
| 83 | +# associated with the classes. This is controlled by two methods that you may wish to override: |
| 84 | +# |
| 85 | +# - `__to_yaml_dict__` is an instance method that controls what to dump. By default it returns `vars(self)` |
| 86 | +# - `__from_yaml_dict__` is a class method that controls the loading process. By default it returns `cls(**dct)`. |
| 87 | +# |
| 88 | +# You may wish to override one, or both of these methods. |
| 89 | +# For example if you do not wish to dump all of the object attributes: |
| 90 | + |
| 91 | +@yaml_info(yaml_tag_ns='com.yamlable.example') |
| 92 | +class CustomFoo(YamlAble): |
| 93 | + |
| 94 | + def __init__(self, a, b): |
| 95 | + """ Constructor """ |
| 96 | + self.a = a |
| 97 | + self.b = b |
| 98 | + self.irrelevant = 37 |
| 99 | + |
| 100 | + def __repr__(self): |
| 101 | + """ String representation for prints """ |
| 102 | + return f"{type(self).__name__} - {dict(a=self.a, b=self.b, irrelevant=self.irrelevant)}" |
| 103 | + |
| 104 | + def __to_yaml_dict__(self): |
| 105 | + # Do not dump 'irrelevant' |
| 106 | + return {'a': self.a, 'b': self.b} |
| 107 | + |
| 108 | + @classmethod |
| 109 | + def __from_yaml_dict__(cls, dct, yaml_tag): |
| 110 | + # Accept a default value for b |
| 111 | + return cls(dct['a'], dct.get('b', "default")) |
| 112 | + |
| 113 | +# %% |
| 114 | +# Let's test it: loading... |
| 115 | + |
| 116 | +f3 = yaml.safe_load(""" |
| 117 | +!yamlable/com.yamlable.example.CustomFoo |
| 118 | +a: 0 |
| 119 | +""") |
| 120 | +print(f3) |
| 121 | + |
| 122 | +# %% |
| 123 | +# ...and dumping again |
| 124 | + |
| 125 | +print(f3.dumps_yaml()) |
| 126 | + |
| 127 | +# %% |
| 128 | +# ### b. YAML tag |
| 129 | +# |
| 130 | +# You probably noticed in the above examples that the dumped YAML document contains a tag such as |
| 131 | +# `!yamlable/com.yamlable.example.CustomFoo`. |
| 132 | +# |
| 133 | +# When you dump a `YamlAble` object `o` to yaml, the corresponding tag is `f!yamlable/{o.__yaml_tag_suffix__}`. |
| 134 | +# Note that you can override the class attribute, or even the instance attribute: |
| 135 | + |
| 136 | +f3.__yaml_tag_suffix__ = "wow_you_changed_me" |
| 137 | +print(f3.dumps_yaml()) |
| 138 | + |
| 139 | +# %% |
| 140 | +# The `@yaml_info` decorator is just a convenient way to fill the `__yaml_tag_suffix__` attribute on a class, nothing |
| 141 | +# more. |
| 142 | +# You can either provide a full yaml tag suffix: |
| 143 | + |
| 144 | +@yaml_info("com.example.WowObject") |
| 145 | +class MyFoo(YamlAble): |
| 146 | + pass |
| 147 | + |
| 148 | +print(MyFoo.__yaml_tag_suffix__) |
| 149 | +print(MyFoo().dumps_yaml()) |
| 150 | + |
| 151 | +# %% |
| 152 | +# Notice that this is great for retrocompatiblity: you can change your class name or module without changing the |
| 153 | +# YAML serialization. |
| 154 | +# |
| 155 | +# Otherwise you can simply provide a namespace, that will be appended with `.{cls.__name__}`: |
| 156 | + |
| 157 | +@yaml_info(yaml_tag_ns="com.example") |
| 158 | +class MyFoo(YamlAble): |
| 159 | + pass |
| 160 | + |
| 161 | +print(MyFoo.__yaml_tag_suffix__) |
| 162 | +print(MyFoo().dumps_yaml()) |
| 163 | + |
| 164 | +# %% |
| 165 | +# In that case, you'll have to be sure that your class name does not change over time. |
| 166 | +# If you do not like the `!yamlable` prefix, you should use the |
| 167 | +# [alternate `YamlObject2` class](#3-alternate-way-yamlobject2) - in that case the decorator should not be used. |
| 168 | +# |
| 169 | +# 3. Alternate way: `YamlObject2` |
| 170 | +# ------------------------------- |
| 171 | +# If you absolutely wish to use PyYaml's `YamlObject` for some reason, you can use `YamlObject2` as an alternative to |
| 172 | +# `YamlAble`. But it comes with the metaclass, like `YamlObject`. |
| 173 | +# |
| 174 | +# Nevertheless, the way to work is very similar: simply override the optional methods. However you must specify the |
| 175 | +# **entire** yaml tag directly using the `yaml_tag` class variable. The `@yaml_info` decorator |
| 176 | +# can not be used with classes subclassing `YamlObject2`. |
| 177 | + |
| 178 | +from yamlable import YamlObject2 |
| 179 | + |
| 180 | +class CustomFoo2(YamlObject2): |
| 181 | + yaml_tag = '!foo' |
| 182 | + |
| 183 | + def __init__(self, a, b): |
| 184 | + """ Constructor """ |
| 185 | + self.a = a |
| 186 | + self.b = b |
| 187 | + self.irrelevant = 37 |
| 188 | + |
| 189 | + def __repr__(self): |
| 190 | + """ String representation for prints """ |
| 191 | + return f"{type(self).__name__} - {dict(a=self.a, b=self.b, irrelevant=self.irrelevant)}" |
| 192 | + |
| 193 | + def __to_yaml_dict__(self): |
| 194 | + # Do not dump 'irrelevant' |
| 195 | + return {'a': self.a, 'b': self.b} |
| 196 | + |
| 197 | + @classmethod |
| 198 | + def __from_yaml_dict__(cls, dct, yaml_tag): |
| 199 | + # Accept a default value for b |
| 200 | + return cls(dct['a'], dct.get('b', "default")) |
| 201 | + |
| 202 | +# instantiate |
| 203 | +f = CustomFoo2(1, 'hello') |
| 204 | + |
| 205 | +# dump to yaml |
| 206 | +o = yaml.dump(f) |
| 207 | +print(o) |
| 208 | + |
| 209 | +print(yaml.safe_load(o)) |
| 210 | + |
| 211 | + |
| 212 | +# %% |
| 213 | +# 4. Support for sequences and scalars |
| 214 | +# ------------------------------------ |
| 215 | +# |
| 216 | +# Objects can also be loaded from YAML sequences: |
| 217 | + |
| 218 | +yaml.safe_load(""" |
| 219 | +!yamlable/com.yamlable.example.Foo |
| 220 | +- 0 |
| 221 | +- hey |
| 222 | +""") |
| 223 | + |
| 224 | +# %% |
| 225 | +# The default implementation of `__from_yaml_list__` (that you may wish to override in your subclass), is to call |
| 226 | +# the constructor with the sequence contents as positional arguments. |
| 227 | +# |
| 228 | +# The same also works for scalars: |
| 229 | + |
| 230 | +yaml.safe_load(""" |
| 231 | +!yamlable/com.yamlable.example.Foo 0 |
| 232 | +""") |
| 233 | + |
| 234 | +# %% |
| 235 | +# The default implementation of `__from_yaml_scalar__` (that you may wish to override in your subclass), is to call |
| 236 | +# the constructor with the scalar as first positional argument. |
| 237 | +# |
| 238 | +# !!! warning "Scalars are not resolved" |
| 239 | +# As can be seen in the above example, scalars are not auto-resolved when constructing an object from a scalar. So an |
| 240 | +# integer `0` is actually received as a string `"0"` by `from_yaml_scalar`. |
0 commit comments