Skip to content

Commit 07e3cd4

Browse files
committed
Merge branch 'main' of https://github.com/spacetelescope/roman_datamodels into RCAL-701_L3Metadata
2 parents e5befdd + 70f05e8 commit 07e3cd4

File tree

8 files changed

+148
-32
lines changed

8 files changed

+148
-32
lines changed

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,23 @@ repos:
4242
args: ["--py38-plus"]
4343

4444
- repo: https://github.com/astral-sh/ruff-pre-commit
45-
rev: 'v0.1.3'
45+
rev: 'v0.1.8'
4646
hooks:
4747
- id: ruff
4848
args: ["--fix"]
4949

5050
- repo: https://github.com/pycqa/isort
51-
rev: 5.12.0
51+
rev: 5.13.2
5252
hooks:
5353
- id: isort
5454

5555
- repo: https://github.com/psf/black
56-
rev: 23.10.1
56+
rev: 23.12.0
5757
hooks:
5858
- id: black
5959

6060
- repo: https://github.com/PyCQA/bandit
61-
rev: 1.7.5
61+
rev: 1.7.6
6262
hooks:
6363
- id: bandit
6464
args: ["-r", "-ll"]

CHANGES.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
0.18.1 (unreleased)
22
===================
33

4-
-
4+
- Allow assignment to or creation of node attributes using dot notation of object instances
5+
with validation. [#284]
6+
7+
- Bugfix for ``model.meta.filename`` not matching the filename of the file on disk. [#295]
8+
9+
- Bugfix for ``meta.model_type`` not being set to match the model writing the file. [#296]
510

611
0.18.0 (2023-11-06)
712
===================

src/roman_datamodels/datamodels/_core.py

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,9 @@
1111
import copy
1212
import datetime
1313
import functools
14-
import os
15-
import os.path
1614
import sys
17-
from pathlib import PurePath
15+
from contextlib import contextmanager
16+
from pathlib import Path, PurePath
1817

1918
import asdf
2019
import numpy as np
@@ -48,6 +47,26 @@ def wrapper(self, *args, **kwargs):
4847
return wrapper
4948

5049

50+
@contextmanager
51+
def _temporary_update_filename(datamodel, filename):
52+
"""
53+
Context manager to temporarily update the filename of a datamodel so that it
54+
can be saved with that new file name without changing the current model's filename
55+
"""
56+
from roman_datamodels.stnode import Filename
57+
58+
if "meta" in datamodel._instance and "filename" in datamodel._instance.meta:
59+
old_filename = datamodel._instance.meta.filename
60+
datamodel._instance.meta.filename = Filename(filename)
61+
62+
yield
63+
datamodel._instance.meta.filename = old_filename
64+
return
65+
66+
yield
67+
return
68+
69+
5170
class DataModel(abc.ABC):
5271
"""Base class for all top level datamodels"""
5372

@@ -181,17 +200,9 @@ def clone(target, source, deepcopy=False, memo=None):
181200
target._ctx = target
182201

183202
def save(self, path, dir_path=None, *args, **kwargs):
184-
if callable(path):
185-
path_head, path_tail = os.path.split(path(self.meta.filename))
186-
else:
187-
path_head, path_tail = os.path.split(path)
188-
base, ext = os.path.splitext(path_tail)
189-
if isinstance(ext, bytes):
190-
ext = ext.decode(sys.getfilesystemencoding())
191-
192-
if dir_path:
193-
path_head = dir_path
194-
output_path = os.path.join(path_head, path_tail)
203+
path = Path(path(self.meta.filename) if callable(path) else path)
204+
output_path = Path(dir_path) / path.name if dir_path else path
205+
ext = path.suffix.decode(sys.getfilesystemencoding()) if isinstance(path.suffix, bytes) else path.suffix
195206

196207
# TODO: Support gzip-compressed fits
197208
if ext == ".asdf":
@@ -206,10 +217,10 @@ def open_asdf(self, init=None, **kwargs):
206217
return asdf.open(init, **kwargs) if isinstance(init, str) else asdf.AsdfFile(init, **kwargs)
207218

208219
def to_asdf(self, init, *args, **kwargs):
209-
with validate.nuke_validation():
210-
asdffile = self.open_asdf(**kwargs)
211-
asdffile.tree = {"roman": self._instance}
212-
asdffile.write_to(init, *args, **kwargs)
220+
with validate.nuke_validation(), _temporary_update_filename(self, Path(init).name):
221+
asdf_file = self.open_asdf(**kwargs)
222+
asdf_file.tree = {"roman": self._instance}
223+
asdf_file.write_to(init, *args, **kwargs)
213224

214225
def get_primary_array_name(self):
215226
"""

src/roman_datamodels/datamodels/_datamodels.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,26 @@ class _DataModel(DataModel):
2525
def __init_subclass__(cls, **kwargs):
2626
"""Register each subclass in the __all__ for this module"""
2727
super().__init_subclass__(**kwargs)
28+
29+
# Don't register private classes
30+
if cls.__name__.startswith("_"):
31+
return
32+
2833
if cls.__name__ in __all__:
2934
raise ValueError(f"Duplicate model type {cls.__name__}")
3035

3136
__all__.append(cls.__name__)
3237

3338

34-
class MosaicModel(_DataModel):
39+
class _RomanDataModel(_DataModel):
40+
def __init__(self, init=None, **kwargs):
41+
super().__init__(init, **kwargs)
42+
43+
if init is not None:
44+
self.meta.model_type = self.__class__.__name__
45+
46+
47+
class MosaicModel(_RomanDataModel):
3548
_node_type = stnode.WfiMosaic
3649

3750
def append_individual_image_meta(self, meta):
@@ -50,19 +63,19 @@ def append_individual_image_meta(self, meta):
5063
self.meta.individual_image_meta.all_meta.append(meta.to_flat_dict())
5164

5265

53-
class ImageModel(_DataModel):
66+
class ImageModel(_RomanDataModel):
5467
_node_type = stnode.WfiImage
5568

5669

57-
class ScienceRawModel(_DataModel):
70+
class ScienceRawModel(_RomanDataModel):
5871
_node_type = stnode.WfiScienceRaw
5972

6073

61-
class MsosStackModel(_DataModel):
74+
class MsosStackModel(_RomanDataModel):
6275
_node_type = stnode.MsosStack
6376

6477

65-
class RampModel(_DataModel):
78+
class RampModel(_RomanDataModel):
6679
_node_type = stnode.Ramp
6780

6881
@classmethod
@@ -101,7 +114,7 @@ def from_science_raw(cls, model):
101114
raise ValueError("Input model must be a ScienceRawModel or RampModel")
102115

103116

104-
class RampFitOutputModel(_DataModel):
117+
class RampFitOutputModel(_RomanDataModel):
105118
_node_type = stnode.RampFitOutput
106119

107120

@@ -122,7 +135,7 @@ def is_association(cls, asn_data):
122135
return isinstance(asn_data, dict) and "asn_id" in asn_data and "asn_pool" in asn_data
123136

124137

125-
class GuidewindowModel(_DataModel):
138+
class GuidewindowModel(_RomanDataModel):
126139
_node_type = stnode.Guidewindow
127140

128141

src/roman_datamodels/stnode/_node.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,33 @@ def _get_schema_for_property(schema, attr):
9494
return {}
9595

9696

97+
def _get_attributes_from_schema(schema):
98+
explicit_properties = schema.get("properties", {}).keys()
99+
patterns = schema.get("patternProperties", {})
100+
return SchemaProperties(explicit_properties, patterns)
101+
102+
103+
class SchemaProperties:
104+
"""
105+
This class provides the capability for using the "contains" machinery
106+
so that an attribute can be tested against patternProperties as well
107+
as whether the attribute is explicitly a property of the schema.
108+
"""
109+
110+
def __init__(self, explicit_properties, patterns):
111+
self.explicit_properties = explicit_properties
112+
self.patterns = patterns
113+
114+
def __contains__(self, attr):
115+
if attr in self.explicit_properties:
116+
return True
117+
else:
118+
for key in self.patterns.keys():
119+
if re.match(key, attr):
120+
return True
121+
return False
122+
123+
97124
class DNode(MutableMapping):
98125
"""
99126
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):
113140
self._schema_uri = None
114141
self._parent = parent
115142
self._name = name
143+
self._x_schema_attributes = None
116144

117145
@property
118146
def ctx(self):
@@ -153,7 +181,7 @@ def __setattr__(self, key, value):
153181

154182
if key[0] != "_":
155183
value = self._convert_to_scalar(key, value)
156-
if key in self._data:
184+
if key in self._data or key in self._schema_attributes():
157185
if will_validate():
158186
schema = _get_schema_for_property(self._schema(), key)
159187
if schema:
@@ -164,6 +192,17 @@ def __setattr__(self, key, value):
164192
else:
165193
self.__dict__[key] = value
166194

195+
def _schema_attributes(self):
196+
if self._x_schema_attributes is None:
197+
self._x_schema_attributes = self._get_schema_attributes()
198+
return self._x_schema_attributes
199+
200+
def _get_schema_attributes(self):
201+
"""
202+
Extract all schema attributes for this node.
203+
"""
204+
return _get_attributes_from_schema(self._schema())
205+
167206
def to_flat_dict(self, include_arrays=True):
168207
"""
169208
Returns a dictionary of all of the schema items as a flat dictionary.

tests/test_maker_utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from astropy.time import Time
88

99
from roman_datamodels import datamodels, maker_utils, stnode
10+
from roman_datamodels.datamodels._datamodels import _RomanDataModel
1011
from roman_datamodels.maker_utils import _ref_files as ref_files
1112
from roman_datamodels.testing import assert_node_equal
1213

@@ -109,6 +110,9 @@ def test_datamodel_maker(model_class):
109110
assert isinstance(model, model_class)
110111
model.validate()
111112

113+
if issubclass(model_class, _RomanDataModel):
114+
assert model.meta.model_type == model_class.__name__
115+
112116

113117
@pytest.mark.parametrize("node_class", [node for node in datamodels.MODEL_REGISTRY])
114118
@pytest.mark.filterwarnings("ignore:This function assumes shape is 2D")

tests/test_models.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,14 @@ def test_ramp_from_science_raw():
807807
if isinstance(ramp_value, np.ndarray):
808808
assert_array_equal(ramp_value, raw_value.astype(ramp_value.dtype))
809809

810+
elif key == "meta":
811+
for meta_key in ramp_value:
812+
if meta_key == "model_type":
813+
ramp_value[meta_key] = ramp.__class__.__name__
814+
raw_value[meta_key] = raw.__class__.__name__
815+
continue
816+
assert_node_equal(ramp_value[meta_key], raw_value[meta_key])
817+
810818
elif isinstance(ramp_value, stnode.DNode):
811819
assert_node_equal(ramp_value, raw_value)
812820

@@ -839,3 +847,15 @@ def test_datamodel_construct_like_from_like(model):
839847
new_mdl = model(mdl)
840848
assert new_mdl is mdl
841849
assert new_mdl._iscopy == "foo" # Verify that the constructor didn't override stuff
850+
851+
852+
def test_datamodel_save_filename(tmp_path):
853+
filename = tmp_path / "fancy_filename.asdf"
854+
ramp = utils.mk_datamodel(datamodels.RampModel, shape=(2, 8, 8))
855+
assert ramp.meta.filename != filename.name
856+
857+
ramp.save(filename)
858+
assert ramp.meta.filename != filename.name
859+
860+
with datamodels.open(filename) as new_ramp:
861+
assert new_ramp.meta.filename == filename.name

tests/test_stnode.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44
import asdf
55
import astropy.units as u
66
import pytest
7+
from asdf.exceptions import ValidationError
78

8-
from roman_datamodels import datamodels, maker_utils, stnode, validate
9+
from roman_datamodels import datamodels
10+
from roman_datamodels import maker_utils
11+
from roman_datamodels import maker_utils as utils
12+
from roman_datamodels import stnode, validate
913
from roman_datamodels.testing import assert_node_equal, assert_node_is_copy, wraps_hashable
1014

1115
from .conftest import MANIFEST
@@ -181,6 +185,26 @@ def test_set_pattern_properties():
181185
mdl.phot_table.F062.pixelareasr = None
182186

183187

188+
# Test that a currently undefined attribute can be assigned using dot notation
189+
# so long as the attribute is defined in the corresponding schema.
190+
def test_node_new_attribute_assignment():
191+
exp = stnode.Exposure()
192+
with pytest.raises(AttributeError):
193+
exp.bozo = 0
194+
exp.ngroups = 5
195+
assert exp.ngroups == 5
196+
# Test patternProperties attribute case
197+
photmod = utils.mk_wfi_img_photom()
198+
phottab = photmod.phot_table
199+
newphottab = {"F062": phottab["F062"]}
200+
photmod.phot_table = newphottab
201+
photmod.phot_table.F213 = phottab["F213"]
202+
with pytest.raises(AttributeError):
203+
photmod.phot_table.F214 = phottab["F213"]
204+
with pytest.raises(ValidationError):
205+
photmod.phot_table.F106 = 0
206+
207+
184208
VALIDATION_CASES = ("true", "yes", "1", "True", "Yes", "TrUe", "YeS", "foo", "Bar", "BaZ")
185209

186210

0 commit comments

Comments
 (0)