Skip to content

Commit 87d88c6

Browse files
committed
Add utilities for registering custom YAML tags
1 parent 758416f commit 87d88c6

File tree

5 files changed

+269
-37
lines changed

5 files changed

+269
-37
lines changed

docs/guides/defining-template-context.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,77 @@ def add_total(context, request):
203203
third_num = context.get('third_number', 0)
204204
context['total'] = first_num + second_num + third_num
205205
```
206+
207+
## Extending the YAML syntax
208+
209+
You can also take advantage of YAML's local tags in order to insert full-fledged Python objects into your mocked contexts.
210+
211+
To do so, decorate a function that returns the object you want with `@register_yaml_tag` like so:
212+
213+
```python
214+
# myproject/core/pattern_contexts.py
215+
216+
from pattern_library.yaml import register_yaml_tag
217+
from wagtail.images import get_image_model
218+
219+
@register_yaml_tag
220+
def testimage():
221+
"""
222+
Return a random Image instance.
223+
"""
224+
Image = get_image_model()
225+
return Image.objects.order_by("?").first()
226+
```
227+
228+
Once the custom YAML tag is registered, you can use it by adding the `!` prefix:
229+
230+
```yaml
231+
context:
232+
object_list:
233+
- title: First item
234+
image: !testimage
235+
- title: Second item
236+
image: !testimage
237+
```
238+
239+
### Registering a tag under a different name
240+
241+
The `@register_yaml_tag` decorator will use the name of the decorated function as the tag name automatically.
242+
243+
You can specify a different name by passing `name=...` when registering the function:
244+
245+
```python
246+
@register_yaml_tag("testimage")
247+
def get_random_image():
248+
...
249+
```
250+
251+
252+
### Passing arguments to custom tags
253+
254+
It's possible to create custom tags that take arguments.
255+
256+
```python
257+
@register_yaml_tag
258+
def testimage(collection):
259+
"""
260+
Return a random Image instance from the given collection.
261+
"""
262+
Image = get_image_model()
263+
images = Image.objects.filter(collection__name=collection)
264+
return images.order_by("?").first()
265+
```
266+
267+
You can then specify arguments positionally using YAML's list syntax:
268+
```yaml
269+
context:
270+
test_image: !testimage
271+
- pattern_library
272+
```
273+
274+
Alternatively you can specify keyword arguments using YAML's dictionary syntax:
275+
```yaml
276+
context:
277+
test_image: !testimage
278+
collection: pattern_library
279+
```

pattern_library/utils.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@
2121
)
2222
from pattern_library.context_modifiers import registry
2323
from pattern_library.exceptions import TemplateIsNotPattern
24-
25-
# Define our own yaml loader so we can register constructors on it without
26-
# polluting the original loader from the library.
27-
class PatternLibraryLoader(yaml.FullLoader):
28-
pass
24+
from pattern_library.yaml import PatternLibraryLoader
2925

3026

3127
def path_to_section():

pattern_library/yaml.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
from functools import partial, wraps
2+
3+
from yaml.loader import FullLoader
4+
from yaml.nodes import MappingNode, SequenceNode
5+
6+
# Define our own yaml loader so we can register constructors on it without
7+
# polluting the original loader from the library.
8+
class PatternLibraryLoader(FullLoader):
9+
pass
10+
11+
12+
def _yaml_tag_constructor(fn):
13+
"""
14+
Convert the given function into a PyYAML-compatible constructor that
15+
correctly parses it args/kwargs.
16+
"""
17+
@wraps(fn)
18+
def constructor(loader, node):
19+
args, kwargs = (), {}
20+
if isinstance(node, SequenceNode):
21+
args = loader.construct_sequence(node, deep=True)
22+
elif isinstance(node, MappingNode):
23+
kwargs = loader.construct_mapping(node, deep=True)
24+
else:
25+
pass # No arguments given
26+
return fn(*args, **kwargs)
27+
28+
return constructor
29+
30+
31+
def register_yaml_tag(fn=None, name=None):
32+
"""
33+
Register the given function as a custom (local) YAML tag under the given name.
34+
"""
35+
36+
# This set of if statements is fairly complex so we can support a variety
37+
# of ways to call the decorator:
38+
39+
# @register_yaml_tag()
40+
if fn is None and name is None: # @register_yaml_tag()
41+
return partial(register_yaml_tag, name=None)
42+
43+
# @register_yaml_tag(name="asdf")
44+
elif fn is None and name is not None:
45+
return partial(register_yaml_tag, name=name)
46+
47+
# @register_yaml_tag("asdf")
48+
elif isinstance(fn, str) and name is None:
49+
return partial(register_yaml_tag, name=fn)
50+
51+
# @register_yaml_tag
52+
elif fn is not None and name is None:
53+
return register_yaml_tag(fn, name=fn.__name__)
54+
55+
# At this point, both `fn` and `name` are defined
56+
PatternLibraryLoader.add_constructor(f"!{name}", _yaml_tag_constructor(fn))
57+
return fn
58+
59+
60+
def unregister_yaml_tag(name):
61+
"""
62+
Unregister the custom tag with the given name.
63+
"""
64+
# PyYAML doesn't provide an inverse operation for add_constructor(), so
65+
# we need to do it manually.
66+
del PatternLibraryLoader.yaml_constructors[f"!{name}"]

tests/tests/test_utils.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
import os
22
from unittest import mock
33

4-
import yaml
54
from django.conf import settings
65
from django.test import SimpleTestCase, override_settings
7-
from yaml.constructor import ConstructorError
86

97
from pattern_library.utils import (
10-
get_pattern_config,
118
get_pattern_config_str,
129
get_renderer,
1310
get_template_dirs,
14-
PatternLibraryLoader,
1511
)
1612

1713

@@ -126,31 +122,3 @@ def test_atom_yml(self):
126122

127123
self.assertNotEqual(result, "")
128124
self.assertIn("atom_var value from test_atom.yml", result)
129-
130-
@mock.patch("pattern_library.utils.get_pattern_config_str")
131-
def test_custom_yaml_tag_error_if_unregistered(self, get_pattern_config_str):
132-
get_pattern_config_str.return_value = "context:\n atom_var: !customtag"
133-
134-
self.assertRaises(
135-
ConstructorError,
136-
get_pattern_config,
137-
"patterns/atoms/test_custom_yaml_tag/test_custom_yaml_tag.html",
138-
)
139-
140-
@mock.patch("pattern_library.utils.get_pattern_config_str")
141-
def test_custom_yaml_tag(self, get_pattern_config_str):
142-
get_pattern_config_str.return_value = "context:\n atom_var: !customtag"
143-
yaml.add_constructor(
144-
"!customtag",
145-
lambda loader, node: 42,
146-
Loader=PatternLibraryLoader,
147-
)
148-
# PyYAML's API doesn't have a remove_constructor() function so we do
149-
# that manually to avoid leaving things on the loader after the test
150-
# is finished.
151-
self.addCleanup(PatternLibraryLoader.yaml_constructors.pop, "!customtag")
152-
153-
self.assertEqual(
154-
get_pattern_config("mocked.html"),
155-
{"context": {"atom_var": 42}},
156-
)

tests/tests/test_yaml.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
from unittest import mock
2+
3+
import yaml
4+
from django.test import SimpleTestCase
5+
from yaml.constructor import ConstructorError
6+
7+
from pattern_library.utils import get_pattern_context
8+
from pattern_library.yaml import (
9+
register_yaml_tag,
10+
unregister_yaml_tag,
11+
)
12+
13+
14+
class PatternLibraryLoaderTestCase(SimpleTestCase):
15+
def tearDown(self):
16+
try:
17+
unregister_yaml_tag("customtag")
18+
except KeyError:
19+
pass
20+
21+
def _get_context(self, yaml_str):
22+
# Use mock.patch to avoid having to create actual files on disk
23+
with mock.patch("pattern_library.utils.get_pattern_config_str", return_value=yaml_str):
24+
return get_pattern_context("mocked.html")
25+
26+
def assertContextEqual(self, yaml_str, expected, msg=None):
27+
"""
28+
Check that the given yaml string can be loaded and results in the given context.
29+
"""
30+
context = self._get_context(yaml_str)
31+
self.assertEqual(context, expected, msg=msg)
32+
33+
def test_unknown_tag_throws_error(self):
34+
self.assertRaises(
35+
ConstructorError,
36+
self._get_context,
37+
"context:\n test: !customtag"
38+
)
39+
40+
def test_custom_tag_can_be_registered(self):
41+
register_yaml_tag(lambda: 42, "customtag")
42+
self.assertContextEqual(
43+
"context:\n test: !customtag",
44+
{"test": 42},
45+
)
46+
47+
def test_custom_tag_can_be_unregistered(self):
48+
register_yaml_tag(lambda: 42, "customtag")
49+
unregister_yaml_tag("customtag")
50+
self.assertRaises(
51+
ConstructorError,
52+
self._get_context,
53+
"context:\n test: !customtag"
54+
)
55+
56+
def test_custom_tag_registering_doesnt_pollute_parent_loader(self):
57+
register_yaml_tag(lambda: 42, "customtag")
58+
self.assertRaises(
59+
ConstructorError,
60+
yaml.load,
61+
"context:\n test: !customtag",
62+
Loader=yaml.FullLoader,
63+
)
64+
65+
def test_registering_plain_decorator(self):
66+
@register_yaml_tag
67+
def customtag():
68+
return 42
69+
70+
self.assertContextEqual(
71+
"context:\n test: !customtag",
72+
{"test": 42},
73+
)
74+
75+
def test_registering_plain_decorator_called(self):
76+
@register_yaml_tag()
77+
def customtag():
78+
return 42
79+
80+
self.assertContextEqual(
81+
"context:\n test: !customtag",
82+
{"test": 42},
83+
)
84+
85+
def test_registering_decorator_specify_name(self):
86+
@register_yaml_tag("customtag")
87+
def function_with_different_name():
88+
return 42
89+
90+
self.assertContextEqual(
91+
"context:\n test: !customtag",
92+
{"test": 42},
93+
)
94+
95+
def test_registering_decorator_specify_name_kwarg(self):
96+
@register_yaml_tag(name="customtag")
97+
def function_with_different_name():
98+
return 42
99+
100+
self.assertContextEqual(
101+
"context:\n test: !customtag",
102+
{"test": 42},
103+
)
104+
105+
def test_custom_tag_with_args(self):
106+
register_yaml_tag(lambda *a: sum(a), "customtag")
107+
108+
yaml_str = """
109+
context:
110+
test: !customtag
111+
- 1
112+
- 2
113+
- 3
114+
""".strip()
115+
116+
self.assertContextEqual(yaml_str, {"test": 6})
117+
118+
def test_custom_tag_with_kwargs(self):
119+
register_yaml_tag(lambda **kw: {k.upper(): v for k, v in kw.items()}, "customtag")
120+
121+
yaml_str = """
122+
context:
123+
test: !customtag
124+
key1: 1
125+
key2: 2
126+
""".strip()
127+
128+
self.assertContextEqual(yaml_str, {"test": {"KEY1": 1, "KEY2": 2}})

0 commit comments

Comments
 (0)