diff --git a/CHANGES.rst b/CHANGES.rst index e8a859846..813e124eb 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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) ------------------- diff --git a/asdf/extension/_extension.py b/asdf/extension/_extension.py index bf09fd6c3..395f39f7f 100644 --- a/asdf/extension/_extension.py +++ b/asdf/extension/_extension.py @@ -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): """ @@ -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 diff --git a/asdf/extension/_legacy.py b/asdf/extension/_legacy.py index 38094a339..e05bf4b41 100644 --- a/asdf/extension/_legacy.py +++ b/asdf/extension/_legacy.py @@ -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") diff --git a/asdf/tests/test_extension.py b/asdf/tests/test_extension.py index d99e54114..2378eb4e3 100644 --- a/asdf/tests/test_extension.py +++ b/asdf/tests/test_extension.py @@ -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 ( @@ -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): @@ -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): @@ -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()