Skip to content

Commit ddee79d

Browse files
authored
Merge pull request #32 from stonerlab/copilot/stable-make-dataarray-descriptor
Make DataArray a descriptor to enforce Data.data type invariant
2 parents ed77b21 + b4ed59a commit ddee79d

5 files changed

Lines changed: 1250 additions & 1203 deletions

File tree

Stoner/core/array.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,75 @@ def __array_wrap__(self, obj, context=None, return_scalar=None):
122122
ret = ma.MaskedArray.__array_wrap__(self, obj, context=context, return_scalar=return_scalar)
123123
return ret
124124

125+
# ==============================================================================================================
126+
############################ Descriptor Protocol ################################################
127+
# ==============================================================================================================
128+
129+
def __set_name__(self, owner, name):
130+
"""Record the attribute name when the descriptor is assigned as a class attribute.
131+
132+
Args:
133+
owner (type):
134+
The class that owns this descriptor.
135+
name (str):
136+
The attribute name used for this descriptor on the owner class.
137+
"""
138+
self._attr_name = name
139+
self._private_name = f"_{name}"
140+
141+
def __get__(self, obj, objtype=None):
142+
"""Return the DataArray stored on the owner instance (descriptor getter).
143+
144+
Args:
145+
obj: The owner instance, or ``None`` when accessed on the class itself.
146+
objtype: The owner class.
147+
148+
Returns:
149+
DataArray: The stored data array, guaranteed to be at least 2-dimensional,
150+
or this descriptor instance when accessed on the class.
151+
"""
152+
if obj is None:
153+
return self
154+
try:
155+
return np.atleast_2d(object.__getattribute__(obj, self._private_name))
156+
except AttributeError:
157+
return np.atleast_2d(DataArray([]))
158+
159+
def __set__(self, obj, value):
160+
"""Store *value* as a :class:`DataArray` on the owner instance (descriptor setter).
161+
162+
The setter normalises the incoming value so that it is always a 2-D
163+
:class:`DataArray`. Column headers and ``setas`` assignments are
164+
preserved from the existing data when the number of columns matches.
165+
166+
Args:
167+
obj: The owner instance.
168+
value (array-like): New data to store.
169+
170+
Raises:
171+
ValueError: If *value* has more than 2 dimensions.
172+
"""
173+
nv = value
174+
if not nv.shape: # nv is a scalar - make it a 2D array
175+
nv = ma.atleast_2d(nv)
176+
elif nv.ndim == 1: # nv is a vector - make it a 2D array
177+
nv = ma.atleast_2d(nv).T
178+
elif nv.ndim > 2:
179+
raise ValueError(f"DataFile.data should be no more than 2 dimensional not shape {nv.shape}")
180+
try:
181+
old_data = object.__getattribute__(obj, self._private_name)
182+
except AttributeError:
183+
old_data = DataArray([])
184+
if not isinstance(nv, DataArray): # nv isn't a DataArray, so preserve setas
185+
nv = DataArray(nv)
186+
nv._setas = old_data._setas.clone
187+
elif old_data.ndim >= 2 and nv.shape[1] == old_data.shape[1]: # same columns - preserve column_headers and setas
188+
ch = old_data.column_headers
189+
nv._setas = old_data._setas.clone
190+
nv.column_headers = ch
191+
nv._setas.shape = nv.shape
192+
object.__setattr__(obj, self._private_name, nv)
193+
125194
def _prepare_index(self, ix):
126195
"""Mangle an indexing argument into a standard tuple form."""
127196
match ix:

Stoner/core/property.py

Lines changed: 2 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -72,34 +72,8 @@ def column_headers(self, value):
7272
"""Write the column_headers attribute (delagated to the setas object)."""
7373
self.data._setas.column_headers = value
7474

75-
@property
76-
def data(self):
77-
"""Property Accessors for the main numerical data."""
78-
return np.atleast_2d(self._data)
79-
80-
@data.setter
81-
def data(self, value):
82-
"""Set the data attribute, but force it through numpy.ma.masked_array first."""
83-
nv = value
84-
if not nv.shape: # nv is a scalar - make it a 2D array
85-
nv = ma.atleast_2d(nv)
86-
elif nv.ndim == 1: # nv is a vector - make it a 2D array
87-
nv = ma.atleast_2d(nv).T
88-
elif nv.ndim > 2:
89-
raise ValueError(f"DataFile.data should be no more than 2 dimensional not shape {nv.shape}")
90-
if not isinstance(
91-
nv, DataArray
92-
): # nv isn't a DataArray, so preserve setas (does this preserve column_headers too?)
93-
nv = DataArray(nv)
94-
nv._setas = getattr(self, "_data")._setas.clone
95-
elif (
96-
nv.shape[1] == self.shape[1]
97-
): # nv is a DataArray with the same number of columns - preserve column_headers and setas
98-
ch = getattr(self, "_data").column_headers
99-
nv._setas = getattr(self, "_data")._setas.clone
100-
nv.column_headers = ch
101-
nv._setas.shape = nv.shape
102-
self._data = nv
75+
data = DataArray([])
76+
"""DataArray descriptor that enforces the data attribute is always a :class:`DataArray` instance."""
10377

10478
@property
10579
def dict_records(self):

Stoner/tools/tests.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def isnone(iterator: Optional[IterableType]) -> bool:
171171

172172

173173
def isproperty(obj: Any, name: str) -> bool:
174-
"""Check whether an attribute of an object or class is a property.
174+
"""Check whether an attribute of an object or class is a property or data descriptor.
175175
176176
Args:
177177
obj (instance or class):
@@ -181,13 +181,17 @@ def isproperty(obj: Any, name: str) -> bool:
181181
182182
Returns:
183183
(bool):
184-
Whether the name is a property or not.
184+
Whether the name is a property or data descriptor or not.
185185
"""
186186
if not isinstance(obj, type):
187187
obj = type(obj)
188188
elif not issubclass(obj, object):
189189
raise TypeError(f"Can only check for property status on attributes of an object or a class not a {type(obj)}")
190-
return hasattr(obj, name) and isinstance(getattr(obj, name), property)
190+
for klass in obj.__mro__:
191+
if name in klass.__dict__:
192+
attr = klass.__dict__[name]
193+
return isinstance(attr, property) or (hasattr(attr, "__get__") and hasattr(attr, "__set__"))
194+
return False
191195

192196

193197
def istuple(obj: Any, *args: type, strict: bool = True) -> bool:

tests/Stoner/mixedmetatest.dat

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
TDI Format 1.5 1 2 3 4
2-
Stoner.class{String}=Data 0 1 2 3
3-
t0{Boolean}=True 4 5 6 7
4-
t1{I32}=1 8 9 10 11
5-
t10{List}=[1, (1, 2), 'abc']
6-
t11{List}=[[[1]]]
7-
t12{Void}=None
8-
t2{Double Float}=0.2
9-
t3{Cluster (I32,String)}={'a': 1, 'b': 'abc'}
10-
t4{Cluster (I32,I32)}=(1, 2)
11-
t5{1D Array (Invalid Type)}=array([0, 1, 2])
12-
t6{List}=[1, 2, 3]
13-
t7{String}=abc
14-
t8{String}=\\abc\cde
15-
t9{Double Float}=1e-20
1+
TDI Format 1.5 1 2 3 4
2+
Stoner.class{String}=Data 0 1 2 3
3+
t0{Boolean}=True 4 5 6 7
4+
t1{I32}=1 8 9 10 11
5+
t10{List}=[1, (1, 2), 'abc']
6+
t11{List}=[[[1]]]
7+
t12{Void}=None
8+
t2{Double Float}=0.2
9+
t3{Cluster (I32,String)}={'a': 1, 'b': 'abc'}
10+
t4{Cluster (I32,I32)}=(1, 2)
11+
t5{1D Array (Invalid Type)}=array([0, 1, 2])
12+
t6{List}=[1, 2, 3]
13+
t7{String}=abc
14+
t8{String}=\\abc\cde
15+
t9{Double Float}=1e-20

0 commit comments

Comments
 (0)