diff --git a/CHANGES.rst b/CHANGES.rst index 737e0956..88d87dfa 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,7 +1,8 @@ 0.18.1 (unreleased) =================== -- +- Allow assignment to or creation of node attributes using dot notation of object instances + with validation. [#284] 0.18.0 (2023-11-06) =================== diff --git a/src/roman_datamodels/stnode/_node.py b/src/roman_datamodels/stnode/_node.py index 17fc75e2..bb4d718c 100644 --- a/src/roman_datamodels/stnode/_node.py +++ b/src/roman_datamodels/stnode/_node.py @@ -94,6 +94,33 @@ def _get_schema_for_property(schema, attr): return {} +def _get_attributes_from_schema(schema): + explicit_properties = schema.get("properties", {}).keys() + patterns = schema.get("patternProperties", {}) + return SchemaProperties(explicit_properties, patterns) + + +class SchemaProperties: + """ + This class provides the capability for using the "contains" machinery + so that an attribute can be tested against patternProperties as well + as whether the attribute is explicitly a property of the schema. + """ + + def __init__(self, explicit_properties, patterns): + self.explicit_properties = explicit_properties + self.patterns = patterns + + def __contains__(self, attr): + if attr in self.explicit_properties: + return True + else: + for key in self.patterns.keys(): + if re.match(key, attr): + return True + return False + + class DNode(MutableMapping): """ Base class describing all "object" (dict-like) data nodes for STNode classes. @@ -113,6 +140,7 @@ def __init__(self, node=None, parent=None, name=None): self._schema_uri = None self._parent = parent self._name = name + self._x_schema_attributes = None @property def ctx(self): @@ -153,7 +181,7 @@ def __setattr__(self, key, value): if key[0] != "_": value = self._convert_to_scalar(key, value) - if key in self._data: + if key in self._data or key in self._schema_attributes(): if will_validate(): schema = _get_schema_for_property(self._schema(), key) if schema: @@ -164,6 +192,17 @@ def __setattr__(self, key, value): else: self.__dict__[key] = value + def _schema_attributes(self): + if self._x_schema_attributes is None: + self._x_schema_attributes = self._get_schema_attributes() + return self._x_schema_attributes + + def _get_schema_attributes(self): + """ + Extract all schema attributes for this node. + """ + return _get_attributes_from_schema(self._schema()) + def to_flat_dict(self, include_arrays=True): """ Returns a dictionary of all of the schema items as a flat dictionary. diff --git a/tests/test_stnode.py b/tests/test_stnode.py index 16f8f68f..c480d3ea 100644 --- a/tests/test_stnode.py +++ b/tests/test_stnode.py @@ -4,8 +4,12 @@ import asdf import astropy.units as u import pytest +from asdf.exceptions import ValidationError -from roman_datamodels import datamodels, maker_utils, stnode, validate +from roman_datamodels import datamodels +from roman_datamodels import maker_utils +from roman_datamodels import maker_utils as utils +from roman_datamodels import stnode, validate from roman_datamodels.testing import assert_node_equal, assert_node_is_copy, wraps_hashable from .conftest import MANIFEST @@ -181,6 +185,26 @@ def test_set_pattern_properties(): mdl.phot_table.F062.pixelareasr = None +# Test that a currently undefined attribute can be assigned using dot notation +# so long as the attribute is defined in the corresponding schema. +def test_node_new_attribute_assignment(): + exp = stnode.Exposure() + with pytest.raises(AttributeError): + exp.bozo = 0 + exp.ngroups = 5 + assert exp.ngroups == 5 + # Test patternProperties attribute case + photmod = utils.mk_wfi_img_photom() + phottab = photmod.phot_table + newphottab = {"F062": phottab["F062"]} + photmod.phot_table = newphottab + photmod.phot_table.F213 = phottab["F213"] + with pytest.raises(AttributeError): + photmod.phot_table.F214 = phottab["F213"] + with pytest.raises(ValidationError): + photmod.phot_table.F106 = 0 + + VALIDATION_CASES = ("true", "yes", "1", "True", "Yes", "TrUe", "YeS", "foo", "Bar", "BaZ")