Skip to content
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

Permit wildcard in tag validator URIs #858

Merged
merged 2 commits into from
Aug 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
- Add new extension API to support versioned extensions.
[#850, #851]

- Permit wildcard in tag validator URIs. [#858]

2.7.0 (2020-07-23)
------------------

Expand Down
6 changes: 4 additions & 2 deletions asdf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from . import versioning
from ._helpers import validate_version
from .extension import ExtensionProxy
from . import util


__all__ = ["AsdfConfig", "get_config", "config_context"]

Expand Down Expand Up @@ -165,7 +167,7 @@ def remove_extension(self, extension=None, *, package=None):
Parameters
----------
extension : asdf.extension.AsdfExtension or str, optional
An extension instance or URI to remove.
An extension instance or URI or URI pattern to remove.
package : str, optional
Remove only extensions provided by this package. If the `extension`
argument is omitted, then all extensions from this package will
Expand All @@ -181,7 +183,7 @@ def _remove_condition(e):
result = True

if isinstance(extension, str):
result = result and e.extension_uri == extension
result = result and util.uri_match(extension, e.extension_uri)
elif isinstance(extension, ExtensionProxy):
result = result and e == extension

Expand Down
19 changes: 15 additions & 4 deletions asdf/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,29 @@ def _type_to_tag(type_):
return None


def validate_tag(validator, tagname, instance, schema):

def validate_tag(validator, tag_pattern, instance, schema):
"""
Implements the tag validation directive, which checks the
tag against a pattern that may include wildcards. See
`asdf.util.uri_match` for details on the matching behavior.
"""
if hasattr(instance, '_tag'):
instance_tag = instance._tag
else:
# Try tags for known Python builtins
instance_tag = _type_to_tag(type(instance))

if instance_tag is not None and instance_tag != tagname:
if instance_tag is None:
yield ValidationError(
"mismatched tags, wanted '{}', got unhandled object type '{}'".format(
tag_pattern, util.get_class_name(instance)
)
)

if not util.uri_match(tag_pattern, instance_tag):
yield ValidationError(
"mismatched tags, wanted '{0}', got '{1}'".format(
tagname, instance_tag))
tag_pattern, instance_tag))


def validate_propertyOrder(validator, order, instance, schema):
Expand Down
13 changes: 13 additions & 0 deletions asdf/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from . import create_small_tree, create_large_tree

from asdf import config
from asdf import schema


@pytest.fixture
Expand All @@ -23,3 +24,15 @@ def restore_default_config():
yield
config._global_config = config.AsdfConfig()
config._local = config._ConfigLocal()


@pytest.fixture(autouse=True)
def clear_schema_cache():
"""
Fixture that clears schema caches to prevent issues
when tests use same URI for different schema content.
"""
yield
schema._load_schema.cache_clear()
schema._load_schema_cached.cache_clear()
schema.load_custom_schema.cache_clear()
5 changes: 5 additions & 0 deletions asdf/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,11 @@ class BarExtension:
config.remove_extension(uri_extension.extension_uri)
assert len(config.extensions) == len(original_extensions)

# And also by URI pattern:
config.add_extension(uri_extension)
config.remove_extension("asdf://somewhere.org/extensions/*")
assert len(config.extensions) == len(original_extensions)

# Remove by the name of the extension's package:
config.add_extension(ExtensionProxy(new_extension, package_name="foo"))
config.add_extension(ExtensionProxy(uri_extension, package_name="foo"))
Expand Down
34 changes: 34 additions & 0 deletions asdf/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,3 +1058,37 @@ def _test_validator(validator, value, instance, schema):
)
validator.validate(tree)
assert len(visited_nodes) == 3


def test_tag_validator():
content="""%YAML 1.1
---
$schema: http://stsci.edu/schemas/asdf/asdf-schema-1.0.0
id: asdf://somewhere.org/schemas/foo
tag: asdf://somewhere.org/tags/foo
...
"""
with asdf.config_context() as config:
config.add_resource_mapping({"asdf://somewhere.org/schemas/foo": content})

schema_tree = schema.load_schema("asdf://somewhere.org/schemas/foo")
instance = tagged.TaggedDict(tag="asdf://somewhere.org/tags/foo")
schema.validate(instance, schema=schema_tree)
with pytest.raises(ValidationError):
schema.validate(tagged.TaggedDict(tag="asdf://somewhere.org/tags/bar"), schema=schema_tree)

content="""%YAML 1.1
---
$schema: http://stsci.edu/schemas/asdf/asdf-schema-1.0.0
id: asdf://somewhere.org/schemas/bar
tag: asdf://somewhere.org/tags/bar-*
...
"""
with asdf.config_context() as config:
config.add_resource_mapping({"asdf://somewhere.org/schemas/bar": content})

schema_tree = schema.load_schema("asdf://somewhere.org/schemas/bar")
instance = tagged.TaggedDict(tag="asdf://somewhere.org/tags/bar-2.5")
schema.validate(instance, schema=schema_tree)
with pytest.raises(ValidationError):
schema.validate(tagged.TaggedDict(tag="asdf://somewhere.org/tags/foo-1.0"), schema=schema_tree)
22 changes: 22 additions & 0 deletions asdf/tests/test_util.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import pytest

from asdf import util
from asdf.extension import BuiltinExtension

Expand Down Expand Up @@ -40,3 +42,23 @@ def test_patched_urllib_parse():
assert urllib.parse is not util.patched_urllib_parse
assert "asdf" not in urllib.parse.uses_relative
assert "asdf" not in urllib.parse.uses_netloc


@pytest.mark.parametrize("pattern, uri, result", [
("asdf://somewhere.org/tags/foo-1.0", "asdf://somewhere.org/tags/foo-1.0", True),
("asdf://somewhere.org/tags/foo-1.0", "asdf://somewhere.org/tags/bar-1.0", False),
("asdf://somewhere.org/tags/foo-*", "asdf://somewhere.org/tags/foo-1.0", True),
("asdf://somewhere.org/tags/foo-*", "asdf://somewhere.org/tags/bar-1.0", False),
("asdf://somewhere.org/tags/foo-*", "asdf://somewhere.org/tags/foo-extras/bar-1.0", False),
("asdf://*/tags/foo-*", "asdf://anywhere.org/tags/foo-4.9", True),
("asdf://*/tags/foo-*", "asdf://anywhere.org/tags/bar-4.9", False),
("asdf://*/tags/foo-*", "asdf://somewhere.org/tags/foo-extras/bar-4.9", False),
("asdf://**/*-1.0", "asdf://somewhere.org/tags/foo-1.0", True),
("asdf://**/*-1.0", "asdf://somewhere.org/tags/foo-2.0", False),
("asdf://**/*-1.0", "asdf://somewhere.org/tags/foo-extras/bar-1.0", True),
("asdf://**/*-1.0", "asdf://somewhere.org/tags/foo-extras/bar-2.0", False),
("asdf://somewhere.org/tags/foo-*", None, False),
("**", None, False),
])
def test_uri_match(pattern, uri, result):
assert util.uri_match(pattern, uri) is result
45 changes: 44 additions & 1 deletion asdf/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import struct
import types
import importlib.util
import re
from functools import lru_cache

from urllib.request import pathname2url

Expand All @@ -26,7 +28,7 @@

__all__ = ['human_list', 'get_array_base', 'get_base_uri', 'filepath_to_url',
'iter_subclasses', 'calculate_padding', 'resolve_name', 'NotSet',
'is_primitive']
'is_primitive', 'uri_match']


def human_list(l, separator="and"):
Expand Down Expand Up @@ -449,3 +451,44 @@ def is_primitive(value):
or isinstance(value, complex)
or isinstance(value, str)
)


def uri_match(pattern, uri):
"""
Determine if a URI matches a URI pattern with possible
wildcards. The two recognized wildcards:

"*": match any character except /

"**": match any character

Parameters
----------
pattern : str
URI pattern.
uri : str
URI to check against the pattern.

Returns
-------
bool
`True` if URI matches the pattern.
"""
if not isinstance(uri, str):
return False

if "*" in pattern:
return _compile_uri_match_pattern(pattern).fullmatch(uri) is not None
else:
return pattern == uri


@lru_cache(128)
def _compile_uri_match_pattern(pattern):
# Escape the pattern in case it contains regex special characters
# ('.' in particular is common in URIs) and then replace the
# escaped asterisks with the appropriate regex matchers.
pattern = re.escape(pattern)
pattern = pattern.replace(r"\*\*", r".*")
pattern = pattern.replace(r"\*", r"[^/]*")
return re.compile(pattern)