Skip to content

Commit

Permalink
add custom validator support to extensions
Browse files Browse the repository at this point in the history
old style (legacy) extensions allowed for custom validators,
functions called when a particular schema keyword was encountered
(see CustomType.validators).

This PR introduces a similar feature for new style Extensions
where custom validators can be defined in Extension.validators.

The scope of the validators is global (same as legacy validators).

fixes asdf-format#1012

update changelog

change raise to yield
  • Loading branch information
braingram committed Jan 3, 2023
1 parent e4e234f commit 19e0766
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 7 deletions.
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
2.15.0 (unreleased)
-------------------

The ASDF Standard is at v1.6.0

- Allow Extensions to define custom validators [#1287]

2.14.3 (2022-12-15)
-------------------

Expand Down
38 changes: 38 additions & 0 deletions asdf/extension/_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ def yaml_tag_handles(self):
"""
return {}

@property
def validators(self):
"""
Get a dictionary of custom schema keyword validators.
The dictionary key is the keyword and value a validation function that
accepts 4 arguments: validator, value, instance, schema. These functions
will be passed to jsonschema as validators during the call to validate.
See validators https://python-jsonschema.readthedocs.io/en/latest/creating/
These validators operate in a global scope so careful keyword naming
is required to avoid collisions between extensions and with builtin keywords.
"""
return {}


class ExtensionProxy(Extension, AsdfExtension):
"""
Expand Down Expand Up @@ -370,6 +386,28 @@ def yaml_tag_handles(self):
"""
return self._yaml_tag_handles

@property
def validators(self):
"""
Get a dictionary of custom schema keyword validators.
The dictionary key is the keyword and value a validation function that
accepts 4 arguments: validator, value, instance, schema. These functions
will be passed to jsonschema as validators during the call to validate.
See validators https://python-jsonschema.readthedocs.io/en/latest/creating/
These validators operate in a global scope so careful keyword naming
is required to avoid collisions between extensions and with builtin keywords.
For legacy extensions wrapped with this proxy, this property will not return
any custom validators defined by the types in the extension. Instead, the
validators must be accessed through the types.
"""
if hasattr(self.delegate, "validators"):
return self.delegate.validators
return {}

def __eq__(self, other):
if isinstance(other, ExtensionProxy):
return other.delegate is self.delegate
Expand Down
17 changes: 11 additions & 6 deletions asdf/extension/_legacy.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,17 @@ def __init__(self, extensions):
for extension in extensions:
tag_mapping.extend(extension.tag_mapping)
url_mapping.extend(extension.url_mapping)
for typ in extension.types:
self._type_index.add_type(typ, extension)
validators.update(typ.validators)
for sibling in typ.versioned_siblings:
self._type_index.add_type(sibling, extension)
validators.update(sibling.validators)
if extension.legacy:
for typ in extension.types:
self._type_index.add_type(typ, extension)
validators.update(typ.validators)
for sibling in typ.versioned_siblings:
self._type_index.add_type(sibling, extension)
validators.update(sibling.validators)
else:
# for non-legacy extensions, get validators from the extension
# not the types
validators.update(extension.validators)
self._extensions = extensions
self._tag_mapping = resolver.Resolver(tag_mapping, "tag")
self._url_mapping = resolver.Resolver(url_mapping, "url")
Expand Down
79 changes: 78 additions & 1 deletion asdf/tests/test_extension.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import pytest
from jsonschema import ValidationError
from packaging.specifiers import SpecifierSet

import asdf
from asdf import config_context
from asdf.exceptions import AsdfDeprecationWarning
from asdf.extension import (
Expand Down Expand Up @@ -56,12 +58,14 @@ def __init__(
asdf_standard_requirement=None,
tags=None,
legacy_class_names=None,
validators=None,
):
self._converters = [] if converters is None else converters
self._compressors = [] if compressors is None else compressors
self._asdf_standard_requirement = asdf_standard_requirement
self._tags = tags
self._tags = [] if tags is None else tags
self._legacy_class_names = [] if legacy_class_names is None else legacy_class_names
self._validators = [] if validators is None else validators

@property
def converters(self):
Expand All @@ -83,6 +87,10 @@ def tags(self):
def legacy_class_names(self):
return self._legacy_class_names

@property
def validators(self):
return self._validators


class MinimumConverter:
def __init__(self, tags=None, types=None):
Expand Down Expand Up @@ -686,3 +694,72 @@ def from_yaml_tree(self, *args):

proxy = ExtensionProxy(extension)
assert proxy.asdf_standard_requirement == SpecifierSet("==1.6.0")


def test_extension_validators():
"""
Adding an extension with custom validators should automatically
register these validators so calls to validate will use the custom
validators
"""

def multiple_of_validator(validator, value, instance, schema):
if instance % value != 0:
yield ValidationError(f"{instance} is not a multiple of {value}")

class MyClass:
def __init__(self, value):
self.value = value

class MyClassConverter(Converter):
tags = [
"asdf://example.com/tags/myclass-1.0.0",
]
types = [
MyClass,
]

def to_yaml_tree(self, obj, tag, ctx):
return {"value": obj.value}

def from_yaml_tree(self, obj, tag, ctx):
return MyClass(obj["value"])

class MyClassExtension(Extension):
extension_uri = "asdf://example.com/extensions/myclass-1.0.0"
converters = [
MyClassConverter(),
]
validators = {"multiple_of": multiple_of_validator}
tags = [
TagDefinition(
"asdf://example.com/tags/myclass-1.0.0", schema_uris="asdf://example.com/schemas/myclass-1.0.0"
),
]

schema = """
%YAML 1.1
---
$schema: "http://stsci.edu/schemas/asdf/asdf-schema-1.0.0"
id: "asdf://example.com/schemas/myclass-1.0.0"
type: object
properties:
value:
type: integer
multiple_of: 21
...
"""

cfg = asdf.get_config()
cfg.add_resource_mapping({"asdf://example.com/schemas/myclass-1.0.0": schema})
cfg.add_extension(MyClassExtension())

tree = {"obj": MyClass(42)}

af = asdf.AsdfFile(tree)
af.validate()

af["obj"].value = 43
with pytest.raises(ValidationError):
af.validate()

0 comments on commit 19e0766

Please sign in to comment.