-
Notifications
You must be signed in to change notification settings - Fork 51
Registering custom YAML tags #260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -100,9 +100,9 @@ def add_page_images(context, request): | |||||
Image = get_image_model() | ||||||
if "page" in context: | ||||||
if "hero_image" in context["page"]: | ||||||
context["hero_image"] = Image.objects.all().order("?").first() | ||||||
context["hero_image"] = Image.objects.all().order_by("?").first() | ||||||
if "main_image" in context["page"]: | ||||||
context["main_image"] = Image.objects.all().order("?").first() | ||||||
context["main_image"] = Image.objects.all().order_by("?").first() | ||||||
|
||||||
|
||||||
@register_context_modifier | ||||||
|
@@ -203,3 +203,77 @@ def add_total(context, request): | |||||
third_num = context.get('third_number', 0) | ||||||
context['total'] = first_num + second_num + third_num | ||||||
``` | ||||||
|
||||||
## Extending the YAML syntax | ||||||
|
||||||
You can also take advantage of YAML's local tags in order to insert full-fledged Python objects into your mocked contexts. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
I think an external explainer link would be good here? Have added one but it’s a bit dry. If you have a better one please add it? |
||||||
|
||||||
To do so, decorate a function that returns the object you want with `@register_yaml_tag` like so: | ||||||
|
||||||
```python | ||||||
# myproject/core/pattern_contexts.py | ||||||
|
||||||
from pattern_library.yaml import register_yaml_tag | ||||||
from wagtail.images import get_image_model | ||||||
|
||||||
@register_yaml_tag | ||||||
def testimage(): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Also I think this will be a lot of people’s first contact with YAML tags, so well worth it to polish this example more if there are ways? Though "test image" is a reasonable start. |
||||||
""" | ||||||
Return a random Image instance. | ||||||
""" | ||||||
Image = get_image_model() | ||||||
return Image.objects.order_by("?").first() | ||||||
``` | ||||||
|
||||||
Once the custom YAML tag is registered, you can use it by adding the `!` prefix: | ||||||
|
||||||
```yaml | ||||||
context: | ||||||
object_list: | ||||||
- title: First item | ||||||
image: !testimage | ||||||
- title: Second item | ||||||
image: !testimage | ||||||
``` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. GitHub’s syntax highlighting seems to fail from here onwards. Not a dealbreaker but just wanted to check if we should mention compatibility of local tags in YAML with different IDEs / linters? For example some projects will use Prettier’s auto-formatting for YAML, not sure if local tags in there are a compatibility problem that warrants mentioning. |
||||||
|
||||||
### Registering a tag under a different name | ||||||
|
||||||
The `@register_yaml_tag` decorator will use the name of the decorated function as the tag name automatically. | ||||||
|
||||||
You can specify a different name by passing `name=...` when registering the function: | ||||||
|
||||||
```python | ||||||
@register_yaml_tag("testimage") | ||||||
def get_random_image(): | ||||||
... | ||||||
Comment on lines
+247
to
+248
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this not missing the |
||||||
``` | ||||||
|
||||||
|
||||||
### Passing arguments to custom tags | ||||||
|
||||||
It's possible to create custom tags that take arguments. | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Say more about why this is useful? Something like:
Suggested change
Even better to have a more complete example |
||||||
|
||||||
```python | ||||||
@register_yaml_tag | ||||||
def testimage(collection): | ||||||
""" | ||||||
Return a random Image instance from the given collection. | ||||||
""" | ||||||
Image = get_image_model() | ||||||
images = Image.objects.filter(collection__name=collection) | ||||||
return images.order_by("?").first() | ||||||
``` | ||||||
|
||||||
You can then specify arguments positionally using YAML's list syntax: | ||||||
```yaml | ||||||
context: | ||||||
test_image: !testimage | ||||||
- pattern_library | ||||||
``` | ||||||
|
||||||
Alternatively you can specify keyword arguments using YAML's dictionary syntax: | ||||||
```yaml | ||||||
context: | ||||||
test_image: !testimage | ||||||
collection: pattern_library | ||||||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
from functools import partial, wraps | ||
|
||
from yaml.loader import FullLoader | ||
from yaml.nodes import MappingNode, SequenceNode | ||
|
||
# Define our own yaml loader so we can register constructors on it without | ||
# polluting the original loader from the library. | ||
class PatternLibraryLoader(FullLoader): | ||
pass | ||
|
||
|
||
def _yaml_tag_constructor(fn): | ||
""" | ||
Convert the given function into a PyYAML-compatible constructor that | ||
correctly parses it args/kwargs. | ||
""" | ||
@wraps(fn) | ||
def constructor(loader, node): | ||
args, kwargs = (), {} | ||
if isinstance(node, SequenceNode): | ||
args = loader.construct_sequence(node, deep=True) | ||
elif isinstance(node, MappingNode): | ||
kwargs = loader.construct_mapping(node, deep=True) | ||
else: | ||
pass # No arguments given | ||
return fn(*args, **kwargs) | ||
|
||
return constructor | ||
|
||
|
||
def register_yaml_tag(fn=None, name=None): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The package’s public API uses type hints, so this will need adding here. Same for |
||
""" | ||
Register the given function as a custom (local) YAML tag under the given name. | ||
""" | ||
|
||
# This set of if statements is fairly complex so we can support a variety | ||
# of ways to call the decorator: | ||
|
||
# @register_yaml_tag() | ||
if fn is None and name is None: # @register_yaml_tag() | ||
return partial(register_yaml_tag, name=None) | ||
|
||
# @register_yaml_tag(name="asdf") | ||
elif fn is None and name is not None: | ||
return partial(register_yaml_tag, name=name) | ||
|
||
# @register_yaml_tag("asdf") | ||
elif isinstance(fn, str) and name is None: | ||
return partial(register_yaml_tag, name=fn) | ||
|
||
# @register_yaml_tag | ||
elif fn is not None and name is None: | ||
return register_yaml_tag(fn, name=fn.__name__) | ||
|
||
# At this point, both `fn` and `name` are defined | ||
PatternLibraryLoader.add_constructor(f"!{name}", _yaml_tag_constructor(fn)) | ||
return fn | ||
|
||
|
||
def unregister_yaml_tag(name): | ||
""" | ||
Unregister the custom tag with the given name. | ||
""" | ||
# PyYAML doesn't provide an inverse operation for add_constructor(), so | ||
# we need to do it manually. | ||
del PatternLibraryLoader.yaml_constructors[f"!{name}"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not familiar with the pyyaml internals, but won't this mutate the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great question! I believe it won't, because of the But I should probably add a test for that behavior to be certain. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ohhh that makes sense. Thanks for checking. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,130 @@ | ||
from unittest import mock | ||
|
||
import yaml | ||
from django.test import SimpleTestCase | ||
from yaml.constructor import ConstructorError | ||
|
||
from pattern_library.utils import get_pattern_context | ||
from pattern_library.yaml import ( | ||
register_yaml_tag, | ||
unregister_yaml_tag, | ||
) | ||
|
||
|
||
class PatternLibraryLoaderTestCase(SimpleTestCase): | ||
def tearDown(self): | ||
super().tearDown() | ||
# Make sure any custom tag is unregistered after every test | ||
try: | ||
unregister_yaml_tag("customtag") | ||
except KeyError: | ||
pass | ||
|
||
def _get_context(self, yaml_str): | ||
# Use mock.patch to avoid having to create actual files on disk | ||
with mock.patch("pattern_library.utils.get_pattern_config_str", return_value=yaml_str): | ||
return get_pattern_context("mocked.html") | ||
|
||
def assertContextEqual(self, yaml_str, expected, msg=None): | ||
""" | ||
Check that the given yaml string can be loaded and results in the given context. | ||
""" | ||
context = self._get_context(yaml_str) | ||
self.assertEqual(context, expected, msg=msg) | ||
|
||
def test_unknown_tag_throws_error(self): | ||
self.assertRaises( | ||
ConstructorError, | ||
self._get_context, | ||
"context:\n test: !customtag" | ||
) | ||
|
||
def test_custom_tag_can_be_registered(self): | ||
register_yaml_tag(lambda: 42, "customtag") | ||
self.assertContextEqual( | ||
"context:\n test: !customtag", | ||
{"test": 42}, | ||
) | ||
|
||
def test_custom_tag_can_be_unregistered(self): | ||
register_yaml_tag(lambda: 42, "customtag") | ||
unregister_yaml_tag("customtag") | ||
self.assertRaises( | ||
ConstructorError, | ||
self._get_context, | ||
"context:\n test: !customtag" | ||
) | ||
|
||
def test_custom_tag_registering_doesnt_pollute_parent_loader(self): | ||
register_yaml_tag(lambda: 42, "customtag") | ||
self.assertRaises( | ||
ConstructorError, | ||
yaml.load, | ||
"context:\n test: !customtag", | ||
Loader=yaml.FullLoader, | ||
) | ||
|
||
def test_registering_plain_decorator(self): | ||
@register_yaml_tag | ||
def customtag(): | ||
return 42 | ||
|
||
self.assertContextEqual( | ||
"context:\n test: !customtag", | ||
{"test": 42}, | ||
) | ||
|
||
def test_registering_plain_decorator_called(self): | ||
@register_yaml_tag() | ||
def customtag(): | ||
return 42 | ||
|
||
self.assertContextEqual( | ||
"context:\n test: !customtag", | ||
{"test": 42}, | ||
) | ||
|
||
def test_registering_decorator_specify_name(self): | ||
@register_yaml_tag("customtag") | ||
def function_with_different_name(): | ||
return 42 | ||
|
||
self.assertContextEqual( | ||
"context:\n test: !customtag", | ||
{"test": 42}, | ||
) | ||
|
||
def test_registering_decorator_specify_name_kwarg(self): | ||
@register_yaml_tag(name="customtag") | ||
def function_with_different_name(): | ||
return 42 | ||
|
||
self.assertContextEqual( | ||
"context:\n test: !customtag", | ||
{"test": 42}, | ||
) | ||
|
||
def test_custom_tag_with_args(self): | ||
register_yaml_tag(lambda *a: sum(a), "customtag") | ||
|
||
yaml_str = """ | ||
context: | ||
test: !customtag | ||
- 1 | ||
- 2 | ||
- 3 | ||
""".strip() | ||
|
||
self.assertContextEqual(yaml_str, {"test": 6}) | ||
|
||
def test_custom_tag_with_kwargs(self): | ||
register_yaml_tag(lambda **kw: {k.upper(): v for k, v in kw.items()}, "customtag") | ||
|
||
yaml_str = """ | ||
context: | ||
test: !customtag | ||
key1: 1 | ||
key2: 2 | ||
""".strip() | ||
|
||
self.assertContextEqual(yaml_str, {"test": {"KEY1": 1, "KEY2": 2}}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!