Skip to content

Commit

Permalink
Merge pull request #1875 from braingram/combined_schema_info
Browse files Browse the repository at this point in the history
improve schema info collection combiner handling
  • Loading branch information
braingram authored Jan 9, 2025
2 parents a866c98 + e39d1e1 commit 8c0fa2f
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 27 deletions.
4 changes: 4 additions & 0 deletions asdf/_asdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -1378,6 +1378,10 @@ def schema_info(self, key="description", path=None, preserve_list=True, refresh_
"""
Get a nested dictionary of the schema information for a given key, relative to the path.
This method will only return unambiguous info. If a property is subject to multiple
subschemas or contains ambiguous entries (multiple titles) no result will be returned
for that property.
Parameters
----------
key : str
Expand Down
138 changes: 123 additions & 15 deletions asdf/_node_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,124 @@ def _filter_tree(info, filters):
return len(info.children) > 0 or all(f(info.node, info.identifier) for f in filters)


def _get_matching_schema_property(schema, property_name):
"""
Extract a property subschema for a given property_name.
This function does not descend into the schema (beyond
looking for a "properties" key) and does not support
schema combiners.
Parameters
----------
schema : dict
A dictionary containing a JSONSCHEMA
property_name : str
The name of the property to extract
Returns
-------
dict or None
The property subschema at the provided name or
``None`` if the property doesn't exist.
"""
if "properties" in schema:
props = schema["properties"]
if property_name in props:
return props[property_name]
if "patternProperties" in props:
patterns = props["patternProperties"]
for regex in patterns:
if re.search(regex, property_name):
return patterns[regex]
return None


def _get_subschema_for_property(schema, property_name):
"""
Extract a property subschema for a given property_name.
This function will attempt to consider schema combiners
and will return None on an ambiguous result.
Parameters
----------
schema : dict
A dictionary containing a JSONSCHEMA
property_name : str
The name of the property to extract
Returns
-------
dict or None
The property subschema at the provided name or
``None`` if the property doesn't exist or is
ambiguous (has more than one subschema or is nested in a not).
"""
# This does NOT handle $ref the expectation is that the schema
# is loaded with resolve_references=True
applicable = []

# first check properties and patternProperties
subschema = _get_matching_schema_property(schema, property_name)
if subschema is not None:
applicable.append(subschema)

# next handle schema combiners
if "not" in schema:
subschema = _get_subschema_for_property(schema["not"], property_name)
if subschema is not None:
# We can't resolve a valid subschema under a "not" since
# we'd have to know how to invert a schema
return None

for combiner in ("allOf", "oneOf", "anyOf"):
for combined_schema in schema.get(combiner, []):
subschema = _get_subschema_for_property(combined_schema, property_name)
if subschema is not None:
applicable.append(subschema)

# only return the subschema if we found exactly 1 applicable
if len(applicable) == 1:
return applicable[0]
return None


def _get_schema_key(schema, key):
"""
Extract a subschema at a given key.
This function will attempt to consider schema combiners
(allOf, oneOf, anyOf) and will return None on an
ambiguous result (where more than 1 match is found).
Parameters
----------
schema : dict
A dictionary containing a JSONSCHEMA
key : str
The key under which the subschema is stored
Returns
-------
dict or None
The subschema at the provided key or
``None`` if the key doesn't exist or is ambiguous.
"""
applicable = []
if key in schema:
applicable.append(schema[key])
# Here we don't consider any subschema under "not" to avoid
# false positives for keys like "type" etc.
for combiner in ("allOf", "oneOf", "anyOf"):
for combined_schema in schema.get(combiner, []):
possible = _get_schema_key(combined_schema, key)
if possible is not None:
applicable.append(possible)

# only return the property if we found exactly 1 applicable
if len(applicable) == 1:
return applicable[0]
return None


def create_tree(key, node, identifier="root", filters=None, refresh_extension_manager=False):
"""
Create a `NodeSchemaInfo` tree which can be filtered from a base node.
Expand Down Expand Up @@ -214,22 +332,12 @@ def parent_node(self):

@property
def info(self):
if self.schema is not None:
return self.schema.get(self.key, None)

return None
if self.schema is None:
return None
return _get_schema_key(self.schema, self.key)

def get_schema_for_property(self, identifier):
subschema = self.schema.get("properties", {}).get(identifier, None)
if subschema is not None:
return subschema

subschema = self.schema.get("properties", {}).get("patternProperties", None)
if subschema:
for key in subschema:
if re.search(key, identifier):
return subschema[key]
return {}
return _get_subschema_for_property(self.schema, identifier) or {}

def set_schema_for_property(self, parent, identifier):
"""Extract a subschema from the parent for the identified property"""
Expand All @@ -241,7 +349,7 @@ def set_schema_from_node(self, node, extension_manager):

tag_def = extension_manager.get_tag_definition(node._tag)
schema_uri = tag_def.schema_uris[0]
schema = load_schema(schema_uri)
schema = load_schema(schema_uri, resolve_references=True)

self.schema = schema

Expand Down
97 changes: 85 additions & 12 deletions asdf/_tests/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import tempfile

import numpy as np
import pytest

import asdf
from asdf.extension import ExtensionManager, ExtensionProxy, ManifestExtension
Expand Down Expand Up @@ -168,8 +169,8 @@ def manifest_extension(tmp_path):
description: Some silly description
type: integer
archive_catalog:
datatype: int
destination: [ScienceCommon.silly]
datatype: int
destination: [ScienceCommon.silly]
clown:
title: clown name
description: clown description
Expand Down Expand Up @@ -231,14 +232,14 @@ def manifest_extension(tmp_path):
title: Attribute1 Title
type: string
archive_catalog:
datatype: str
destination: [ScienceCommon.attribute1]
datatype: str
destination: [ScienceCommon.attribute1]
attribute2:
title: Attribute2 Title
type: string
archive_catalog:
datatype: str
destination: [ScienceCommon.attribute2]
datatype: str
destination: [ScienceCommon.attribute2]
...
"""

Expand All @@ -251,19 +252,29 @@ def manifest_extension(tmp_path):
type: object
title: object with info support 3 title
description: object description
allOf:
- $ref: drink_ref-1.0.0
...
"""
drink_ref_schema = """
%YAML 1.1
---
$schema: "asdf://stsci.edu/schemas/asdf/asdf-schema-1.1.0"
id: "asdf://somewhere.org/asdf/schemas/drink_ref-1.0.0"
properties:
attributeOne:
title: AttributeOne Title
description: AttributeOne description
type: string
archive_catalog:
datatype: str
destination: [ScienceCommon.attributeOne]
datatype: str
destination: [ScienceCommon.attributeOne]
attributeTwo:
title: AttributeTwo Title
description: AttributeTwo description
type: string
archive_catalog:
allOf:
- title: AttributeTwo Title
description: AttributeTwo description
type: string
archive_catalog:
datatype: str
destination: [ScienceCommon.attributeTwo]
...
Expand All @@ -278,6 +289,9 @@ def manifest_extension(tmp_path):
spath = tmp_path / "schemas" / "drink-1.0.0.yaml"
with open(spath, "w") as fschema:
fschema.write(drink_schema)
spath = tmp_path / "schemas" / "drink_ref-1.0.0.yaml"
with open(spath, "w") as fschema:
fschema.write(drink_ref_schema)
os.mkdir(tmp_path / "manifests")
mpath = str(tmp_path / "manifests" / "foo_manifest-1.0.yaml")
with open(mpath, "w") as fmanifest:
Expand Down Expand Up @@ -702,3 +716,62 @@ def __str__(self):
assert "(NewlineStr)\n" in captured.out
assert "(CarriageReturnStr)\n" in captured.out
assert "(NiceStr): nice\n" in captured.out


@pytest.mark.parametrize(
"schema, expected",
[
({"properties": {"foo": {"type": "object"}}}, {"type": "object"}),
({"allOf": [{"properties": {"foo": {"type": "object"}}}]}, {"type": "object"}),
({"oneOf": [{"properties": {"foo": {"type": "object"}}}]}, {"type": "object"}),
({"anyOf": [{"properties": {"foo": {"type": "object"}}}]}, {"type": "object"}),
],
)
def test_node_property(schema, expected):
ni = asdf._node_info.NodeSchemaInfo.from_root_node("title", "root", {}, schema)
assert ni.get_schema_for_property("foo") == expected


@pytest.mark.parametrize(
"schema",
[
{"not": {"properties": {"foo": {"type": "object"}}}},
{"properties": {"foo": {"type": "object"}}, "allOf": [{"properties": {"foo": {"type": "object"}}}]},
{"properties": {"foo": {"type": "object"}}, "anyOf": [{"properties": {"foo": {"type": "object"}}}]},
{"properties": {"foo": {"type": "object"}}, "oneOf": [{"properties": {"foo": {"type": "object"}}}]},
{
"allOf": [{"properties": {"foo": {"type": "object"}}}],
"anyOf": [{"properties": {"foo": {"type": "object"}}}],
},
{
"anyOf": [{"properties": {"foo": {"type": "object"}}}],
"oneOf": [{"properties": {"foo": {"type": "object"}}}],
},
{
"oneOf": [{"properties": {"foo": {"type": "object"}}}],
"allOf": [{"properties": {"foo": {"type": "object"}}}],
},
],
)
def test_node_property_error(schema):
ni = asdf._node_info.NodeSchemaInfo.from_root_node("title", "root", {}, schema)
assert ni.get_schema_for_property("foo") == {}


@pytest.mark.parametrize(
"schema, expected",
[
({"title": "foo"}, "foo"),
({"allOf": [{"title": "foo"}]}, "foo"),
({"oneOf": [{"title": "foo"}]}, "foo"),
({"anyOf": [{"title": "foo"}]}, "foo"),
({"not": {"title": "foo"}}, None),
({"allOf": [{"title": "foo"}, {"title": "bar"}]}, None),
({"oneOf": [{"title": "foo"}, {"title": "bar"}]}, None),
({"anyOf": [{"title": "foo"}, {"title": "bar"}]}, None),
({"allOf": [{"title": "foo"}, {"title": "bar"}]}, None),
],
)
def test_node_info(schema, expected):
ni = asdf._node_info.NodeSchemaInfo.from_root_node("title", "root", {}, schema)
assert ni.info == expected
1 change: 1 addition & 0 deletions changes/1875.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improve ``schema_info`` handling of schemas with combiners (allOf, anyOf, etc).

0 comments on commit 8c0fa2f

Please sign in to comment.