Skip to content

Commit 79f3aaf

Browse files
Lazy/Eager evaluation of YAML tag nodes (#100)
* unittest for #99 * added config section and YAML tag for tests * YAML constructors default to eager evaluation * added test for lazy YAML evaluation * YAML tag plugin marker * documented yaml_tag
1 parent a7617c7 commit 79f3aaf

File tree

7 files changed

+250
-15
lines changed

7 files changed

+250
-15
lines changed

cobald_tests/daemon/core/test_config.py

Lines changed: 156 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from tempfile import NamedTemporaryFile
22

33
import pytest
4+
import copy
45

56
from cobald.daemon.config.mapping import ConfigurationError
67
from cobald.daemon.core.config import load, COBalDLoader, yaml_constructor
@@ -13,6 +14,35 @@
1314
COBalDLoader.add_constructor(tag="!MockPool", constructor=yaml_constructor(MockPool))
1415

1516

17+
# Helpers for testing lazy/eager YAML evaluation
18+
# Since YAML defaults to lazy evaluation, the arguments available during evaluation
19+
# are not necessarily complete.
20+
class TagTracker:
21+
"""Helper to track the arguments supplied to YAML !Tags"""
22+
23+
def __init__(self, *args, **kwargs):
24+
# the state of arguments *during* YAML evaluation
25+
self.orig_args = copy.deepcopy(args)
26+
self.orig_kwargs = copy.deepcopy(kwargs)
27+
# the state of arguments *after* YAML evaluation
28+
self.final_args = args
29+
self.final_kwargs = kwargs
30+
31+
32+
COBalDLoader.add_constructor(
33+
tag="!TagTrackerEager", constructor=yaml_constructor(TagTracker, eager=True)
34+
)
35+
COBalDLoader.add_constructor(
36+
tag="!TagTrackerLazy", constructor=yaml_constructor(TagTracker, eager=False)
37+
)
38+
39+
40+
def get_config_section(config: dict, section: str):
41+
return next(
42+
content for plugin, content in config.items() if plugin.section == section
43+
)
44+
45+
1646
class TestYamlConfig:
1747
def test_load(self):
1848
"""Load a valid YAML config"""
@@ -95,10 +125,131 @@ def test_load_mixed_creation(self):
95125
"""
96126
)
97127
with load(config.name) as config:
98-
pipeline = next(
99-
content
100-
for plugin, content in config.items()
101-
if plugin.section == "pipeline"
102-
)
128+
pipeline = get_config_section(config, "pipeline")
103129
assert isinstance(pipeline[0], LinearController)
104130
assert isinstance(pipeline[0].target, MockPool)
131+
132+
def test_load_tags_substructure(self):
133+
"""Load !Tags with substructure"""
134+
with NamedTemporaryFile(suffix=".yaml") as config:
135+
with open(config.name, "w") as write_stream:
136+
write_stream.write(
137+
"""
138+
pipeline:
139+
- !MockPool
140+
__config_test:
141+
tagged: !TagTrackerEager
142+
host: 127.0.0.1
143+
port: 1234
144+
algorithm: HS256
145+
users:
146+
- user_name: tardis
147+
scopes:
148+
- user:read
149+
"""
150+
)
151+
with load(config.name) as config:
152+
tagged = get_config_section(config, "__config_test")["tagged"]
153+
assert isinstance(tagged, TagTracker)
154+
assert tagged.final_kwargs["host"] == "127.0.0.1"
155+
assert tagged.final_kwargs["port"] == 1234
156+
assert tagged.final_kwargs["algorithm"] == "HS256"
157+
assert tagged.final_kwargs["users"][0]["user_name"] == "tardis"
158+
assert tagged.final_kwargs["users"][0]["scopes"] == ["user:read"]
159+
160+
def test_load_tags_eager(self):
161+
"""Load !Tags with substructure, immediately using them"""
162+
with NamedTemporaryFile(suffix=".yaml") as config:
163+
with open(config.name, "w") as write_stream:
164+
write_stream.write(
165+
"""
166+
pipeline:
167+
- !MockPool
168+
__config_test:
169+
tagged: !TagTrackerEager
170+
top: "top level value"
171+
nested:
172+
- leaf: "leaf level value"
173+
"""
174+
)
175+
with load(config.name) as config:
176+
tagged = get_config_section(config, "__config_test")["tagged"]
177+
assert isinstance(tagged, TagTracker)
178+
# eager loading => all data should exist immediately
179+
assert tagged.orig_kwargs["top"] == "top level value"
180+
assert tagged.orig_kwargs["nested"] == [{"leaf": "leaf level value"}]
181+
assert tagged.orig_kwargs == tagged.final_kwargs
182+
183+
def test_load_tags_lazy(self):
184+
"""Load !Tags with substructure, lazily using them"""
185+
with NamedTemporaryFile(suffix=".yaml") as config:
186+
with open(config.name, "w") as write_stream:
187+
write_stream.write(
188+
"""
189+
pipeline:
190+
- !MockPool
191+
__config_test:
192+
tagged: !TagTrackerLazy
193+
top: "top level value"
194+
nested:
195+
- leaf: "leaf level value"
196+
"""
197+
)
198+
with load(config.name) as config:
199+
tagged = get_config_section(config, "__config_test")["tagged"]
200+
assert isinstance(tagged, TagTracker)
201+
# eager loading => only some data should exist immediately...
202+
assert tagged.orig_kwargs["top"] == "top level value"
203+
assert tagged.orig_kwargs["nested"] == []
204+
# ...but should be there in the end
205+
assert tagged.final_kwargs["nested"] == [{"leaf": "leaf level value"}]
206+
207+
def test_load_tags_nested(self):
208+
"""Load !Tags with nested !Tags"""
209+
with NamedTemporaryFile(suffix=".yaml") as config:
210+
with open(config.name, "w") as write_stream:
211+
write_stream.write(
212+
"""
213+
pipeline:
214+
- !MockPool
215+
__config_test:
216+
top_eager: !TagTrackerEager
217+
nested:
218+
- leaf: "leaf level value"
219+
- leaf_lazy: !TagTrackerLazy
220+
nested:
221+
- leaf: "leaf level value"
222+
"""
223+
)
224+
with load(config.name) as config:
225+
top_eager = get_config_section(config, "__config_test")["top_eager"]
226+
# eager tags are evaluated eagerly
227+
assert top_eager.orig_kwargs["nested"][0] == {
228+
"leaf": "leaf level value"
229+
}
230+
leaf_lazy = top_eager.orig_kwargs["nested"][1]["leaf_lazy"]
231+
# eagerness overrides laziness
232+
assert leaf_lazy.orig_kwargs["nested"] == [{"leaf": "leaf level value"}]
233+
234+
def test_load_tag_settings(self):
235+
"""Load !Tags with decorator settings"""
236+
# __yaml_tag_test is provided by the cobald package
237+
with NamedTemporaryFile(suffix=".yaml") as config:
238+
with open(config.name, "w") as write_stream:
239+
write_stream.write(
240+
"""
241+
pipeline:
242+
- !MockPool
243+
__config_test:
244+
settings_tag: !__yaml_tag_test
245+
top: "top level value"
246+
nested:
247+
- leaf: "leaf level value"
248+
"""
249+
)
250+
with load(config.name) as config:
251+
section = get_config_section(config, "__config_test")
252+
args, kwargs = section["settings_tag"]
253+
assert args == ()
254+
assert kwargs["top"] == "top level value"
255+
assert kwargs["nested"] == [{"leaf": "leaf level value"}]
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
category: added
2+
summary: "YAML ``!tags`` may be eagerly evaluated"
3+
description: |
4+
The YAML configuration is usually evaluated lazily, as YAML documents can be
5+
recursive. YAML ``!tag`` plugins that require their part of the configuration
6+
promptly can now be decorated with `:py:func:`cobald.daemon.plugins.yaml_tag`
7+
to enforce eager evaluation.
8+
issues:
9+
- 99
10+
pull requests:
11+
- 100

docs/source/custom/package.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,15 @@ can be made available as ``MyExtension`` in this way:
157157
If a plugin implements a :py:meth:`~cobald.interfaces.Controller.s` method,
158158
this is used automatically.
159159

160+
.. note::
161+
162+
If a plugin requires eager loading of its YAML configuration,
163+
decorate it with :py:func:`cobald.daemon.plugins.yaml_tag`.
164+
165+
.. versionadded:: 0.12.3
166+
167+
The :py:func:`cobald.daemon.plugins.yaml_tag` and eager evaluation.
168+
160169
Section Plugins
161170
---------------
162171

setup.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@
4545
("Limiter", "cobald.decorator.limiter"),
4646
("Logger", "cobald.decorator.logger"),
4747
("Standardiser", "cobald.decorator.standardiser"),
48+
("__yaml_tag_test", "cobald.daemon.plugins"),
4849
)
4950
],
5051
"cobald.config.sections": [
51-
"pipeline = cobald.daemon.core.config:load_pipeline"
52+
"pipeline = cobald.daemon.core.config:load_pipeline",
53+
"__config_test = builtins:dict",
5254
],
5355
},
5456
# >>> Dependencies

src/cobald/daemon/config/yaml.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Type, Tuple
1+
from typing import Type, Tuple, Callable, TypeVar
22

33
from yaml import SafeLoader, BaseLoader, nodes
44

@@ -9,6 +9,9 @@
99
)
1010

1111

12+
R = TypeVar("R")
13+
14+
1215
def load_configuration(
1316
path: str, loader: Type[BaseLoader] = SafeLoader, plugins: Tuple[SectionPlugin] = ()
1417
):
@@ -21,18 +24,20 @@ def load_configuration(
2124
return load_mapping_configuration(config_data=config_data, plugins=plugins)
2225

2326

24-
def yaml_constructor(factory):
27+
def yaml_constructor(
28+
factory: Callable[..., R], *, eager: bool = False
29+
) -> Callable[..., R]:
2530
"""
2631
Convert a factory function/class to a YAML constructor
2732
2833
:param factory: the factory function/class
34+
:param eager: whether the YAML must be evaluated eagerly
2935
:return: factory constructor
3036
3137
Applying this helper to a factory allows it to be used as a YAML constructor,
3238
without it knowing about YAML itself.
3339
It properly constructs nodes and converts
34-
mapping nodes to ``factory(**node)``,
35-
sequence nodes to ``factory(*node)``, and
40+
mapping nodes to ``factory(**node)``, sequence nodes to ``factory(*node)``, and
3641
scalar nodes to ``factory()``.
3742
3843
For example, registering the constructor ``yaml_constructor(factory)`` as
@@ -43,16 +48,20 @@ def yaml_constructor(factory):
4348
- !factory
4449
a: 0.3
4550
b: 0.7
51+
52+
Since YAML can express recursive data, nested data structures are evaluated lazily
53+
by default. Set ``eager=True`` to enforce eager evaluation before calling the
54+
constructor.
4655
"""
4756

4857
def factory_constructor(loader: BaseLoader, node: nodes.Node):
4958
if isinstance(node, nodes.MappingNode):
50-
kwargs = loader.construct_mapping(node)
59+
kwargs = loader.construct_mapping(node, deep=eager)
5160
return factory(**kwargs)
5261
elif isinstance(node, nodes.ScalarNode):
5362
return factory()
5463
elif isinstance(node, nodes.SequenceNode):
55-
args = loader.construct_sequence(node)
64+
args = loader.construct_sequence(node, deep=eager)
5665
return factory(*args)
5766
else:
5867
raise ConfigurationError(

src/cobald/daemon/core/config.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from entrypoints import get_group_all as get_entrypoints
77
from toposort import toposort_flatten
88

9-
from ..plugins import constraints as plugin_constraints
9+
from ..plugins import constraints as plugin_constraints, YAMLTagSettings
1010
from ..config.yaml import (
1111
load_configuration as load_yaml_configuration,
1212
yaml_constructor,
@@ -42,8 +42,10 @@ def add_constructor_plugins(entry_point_group: str, loader: Type[BaseLoader]) ->
4242
pipeline_factory = entry.load().s
4343
except AttributeError:
4444
pipeline_factory = entry.load()
45+
settings = YAMLTagSettings.fetch(pipeline_factory)
4546
loader.add_constructor(
46-
tag="!" + entry.name, constructor=yaml_constructor(pipeline_factory)
47+
tag="!" + entry.name,
48+
constructor=yaml_constructor(pipeline_factory, eager=settings.eager),
4749
)
4850

4951

src/cobald/daemon/plugins.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""
22
Tools and helpers to declare plugins
33
"""
4-
from typing import Iterable, FrozenSet, TypeVar
4+
from typing import Iterable, FrozenSet, TypeVar, NamedTuple
55

66

77
T = TypeVar("T")
@@ -54,3 +54,54 @@ def section_wrapper(plugin: T) -> T:
5454
return plugin
5555

5656
return section_wrapper
57+
58+
59+
class YAMLTagSettings(NamedTuple):
60+
"""Settings for interpreting a YAML tag"""
61+
62+
eager: bool = False
63+
64+
@classmethod
65+
def fetch(cls, plugin):
66+
"""Provide the settings for `plugin`"""
67+
try:
68+
return plugin.__cobald_yaml_tag__
69+
except AttributeError:
70+
return cls()
71+
72+
def mark(self, plugin):
73+
"""Mark `plugin` to use the current settings"""
74+
plugin.__cobald_yaml_tag__ = self
75+
76+
77+
def yaml_tag(*, eager: bool = False):
78+
"""
79+
Mark a callable as a YAML tag constructor with specific settings
80+
81+
:param eager: whether the YAML content must be evaluated eagerly
82+
83+
Since YAML can express recursive data, nested data structures are evaluated lazily
84+
by default. This means a constructor receives nested data structures
85+
(e.g. a ``dict`` of ``dict``s) upfront but nested content is added later on.
86+
If a constructor requires the entire data at once, set ``eager=True`` to enforce
87+
eager evaluation before calling the constructor.
88+
89+
.. note::
90+
91+
This decorator only serves to apply non-default settings for a plugin.
92+
A plugin must still be registered using ``entry_points``.
93+
"""
94+
95+
def mark_settings(plugin: T) -> T:
96+
YAMLTagSettings(eager=eager).mark(plugin)
97+
return plugin
98+
99+
return mark_settings
100+
101+
102+
@yaml_tag(eager=True)
103+
def __yaml_tag_test(*args, **kwargs):
104+
"""YAML tag constructor for testing only"""
105+
import copy
106+
107+
return copy.deepcopy(args), copy.deepcopy(kwargs)

0 commit comments

Comments
 (0)