diff --git a/Stoner/core/array.py b/Stoner/core/array.py index 10eb43993..744dc0c17 100755 --- a/Stoner/core/array.py +++ b/Stoner/core/array.py @@ -14,7 +14,7 @@ from ..compat import int_types from ..tools import AttributeStore, all_size, all_type, isiterable, isnone from .exceptions import StonerSetasError -from .setas import Setas as _setas +from .setas import ColumnHeadersDescriptor, Setas as _setas class DataArray(ma.MaskedArray): @@ -52,6 +52,18 @@ class DataArray(ma.MaskedArray): more attractive. """ + # ============================================================================================================== + ############################ Class-level descriptors ############################### + # ============================================================================================================== + + #: setas (:py:class:`Setas`): Descriptor managing column-type assignments (x, y, z, …). + #: The per-instance :py:class:`Setas` object is stored as ``_setas`` on each + #: :py:class:`DataArray` instance and is retrieved / shape-synced by the descriptor. + setas = _setas() + + #: column_headers (list): Descriptor that forwards to ``setas.column_headers``. + column_headers = ColumnHeadersDescriptor() + # ============================================================================================================== ############################ Object Construction ############################### # ============================================================================================================== @@ -326,33 +338,6 @@ def i(self, value): else: # No iterable self._ibase = np.arange(value, value + r) - @property - def column_headers(self): - """Pass through to the setas attribute.""" - return self._setas.column_headers - - @column_headers.setter - def column_headers(self, value): - """Write the column_headers attribute (delagated to the setas object).""" - self._setas.column_headers = value - - @property - def setas(self): - """Return an object for setting column assignments.""" - if self._setas is None: - self._setas = _setas() - if self._setas.shape != self.shape: - self._setas.shape = self.shape - return self._setas - - @setas.setter - def setas(self, value): - """Set the object for setting column assignments.""" - if isinstance(value, _setas): - value = value.clone - setas = self.setas - setas(value) - # ============================================================================================================== ############################ Special Methods #################################################### # ============================================================================================================== diff --git a/Stoner/core/property.py b/Stoner/core/property.py index 2c0ca9a7d..09795dcde 100755 --- a/Stoner/core/property.py +++ b/Stoner/core/property.py @@ -16,6 +16,7 @@ from ..tools.classes import copy_into from ..tools.file import URL_SCHEMES from .array import DataArray +from .setas import ColumnHeadersDescriptor, Setas try: from tabulate import tabulate @@ -30,6 +31,15 @@ class DataFilePropertyMixin: _subclasses = None + #: setas (:py:class:`Setas`): Descriptor that delegates column-type assignments to the + #: internal :py:class:`DataArray` (``_data``). Getting or setting ``obj.setas`` is + #: equivalent to ``obj._data.setas``. + setas = Setas(source="_data") + + #: column_headers (list): Descriptor that forwards to ``setas.column_headers`` via the + #: delegating :py:class:`Setas` descriptor above. + column_headers = ColumnHeadersDescriptor() + @property def _repr_html_(self): """Generate an html representation of the DataFile. @@ -62,16 +72,6 @@ def clone(self): print("Cloning in DataFile") return copy_into(self, c) - @property - def column_headers(self): - """Pass through to the setas attribute.""" - return self.data._setas.column_headers - - @column_headers.setter - def column_headers(self, value): - """Write the column_headers attribute (delagated to the setas object).""" - self.data._setas.column_headers = value - data = DataArray([]) """DataArray descriptor that enforces the data attribute is always a :class:`DataArray` instance.""" @@ -196,17 +196,6 @@ def shape(self): """Pass through the numpy shape attribute of the data.""" return self.data.shape - @property - def setas(self): - """Get the list of column assignments.""" - setas = self._data._setas - return setas - - @setas.setter - def setas(self, value): - """Set a new setas assignment by calling the setas object.""" - self._data._setas(value) - @property def T(self): """Get the current data transposed.""" diff --git a/Stoner/core/setas.py b/Stoner/core/setas.py index 53838fde9..11e1b34c7 100755 --- a/Stoner/core/setas.py +++ b/Stoner/core/setas.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """setas module provides the setas class for DataFile and friends.""" -__all__ = ["Setas"] +__all__ = ["Setas", "ColumnHeadersDescriptor"] import copy import re from collections.abc import Iterable, MutableMapping @@ -38,7 +38,7 @@ class Setas(MutableMapping): assignments before setting new ones (default). """ - def __init__(self, row=False, bless=None): + def __init__(self, row=False, bless=None, source=None): """Construct the setas instance and sets an initial value. Args: @@ -46,6 +46,10 @@ def __init__(self, row=False, bless=None): Keyword Arguments: initial_val (string or list or dict): Initial values to set + source (str, optional): When used as a descriptor, if set the descriptor will delegate to + ``getattr(obj, source).setas`` rather than looking up the private attribute directly. + This is used to make :py:class:`DataFilePropertyMixin` delegate to its internal + :py:class:`DataArray`. """ self._row = row self._cols = AttributeStore() @@ -53,6 +57,7 @@ def __init__(self, row=False, bless=None): self._setas = [] self._column_headers = TypedList(string_types) self._object = bless + self._source = source self._col_defaults = { 2: { "axes": 2, @@ -116,6 +121,78 @@ def __init__(self, row=False, bless=None): }, } # xyzuvw + # ========================================================================== + # Descriptor protocol methods + # ========================================================================== + + def __set_name__(self, owner, name): + """Record the public and private attribute names when assigned to a class. + + Args: + owner (type): The class that owns this descriptor. + name (str): The attribute name under which this descriptor is stored. + + Note: + ``public_name`` is stored for introspection and debugging; the + actual dispatch logic uses ``private_name`` (and ``_source`` when + set). + """ + self.public_name = name + self.private_name = f"_{name}" + + def __get__(self, obj, objtype=None): + """Retrieve the per-instance :py:class:`Setas` object from the owner. + + When used as a class-level descriptor this returns the :py:class:`Setas` instance + stored on *obj* under the private attribute name (e.g. ``_setas``). If + *source* was supplied to the constructor the descriptor instead delegates to + ``getattr(obj, source).setas``, which is the pattern used by + :py:class:`DataFilePropertyMixin` to forward to the underlying + :py:class:`DataArray`. + + Args: + obj: The owner instance (or ``None`` when accessed on the class itself). + objtype (type, optional): The owner class. + + Returns: + :py:class:`Setas`: The per-instance setas object, or *self* when + accessed on the class. + """ + if obj is None: + return self + if self._source is not None: + target = getattr(obj, self._source) + return target.setas + setas = getattr(obj, self.private_name, None) + if setas is None: + # _source is None here (the delegating path returned above), so a + # plain Setas() with no source is exactly what we want. + setas = type(self)() + setattr(obj, self.private_name, setas) + if hasattr(obj, "shape") and setas.shape != obj.shape: + setas.shape = obj.shape + return setas + + def __set__(self, obj, value): + """Set the column assignments on the per-instance :py:class:`Setas` object. + + Delegates the actual assignment to ``setas(value)``, preserving all of the + calling-convention logic already implemented in :py:meth:`Setas.__call__`. + + Args: + obj: The owner instance. + value: Anything accepted by :py:meth:`Setas.__call__` (string, list, + dict, or another :py:class:`Setas`). + """ + if self._source is not None: + target = getattr(obj, self._source) + target.setas = value + return + setas = self.__get__(obj, type(obj)) + if isinstance(value, type(self)): + value = value.clone + setas(value) + def _prepare_call(self, args, kwargs): """Extract a value to be used to evaluate the setas attribute during a call.""" reset = kwargs.pop("reset", True) @@ -862,3 +939,56 @@ def _get_cols(self, what=None, startx=0, no_guess=False): elif what in ("ycols", "zcols", "ucols", "vcols", "wcols", "yerrs", "zerrs"): ret = ret[what[0:-1]] return ret + + +class ColumnHeadersDescriptor: + """Descriptor that exposes the column headers managed by the :py:class:`Setas` object. + + Both :py:class:`DataArray` and :py:class:`DataFilePropertyMixin` use this descriptor + for their ``column_headers`` attribute. Because both classes expose a ``setas`` + attribute (either as a :py:class:`Setas` descriptor or as a delegating property), + this descriptor can obtain the column headers uniformly via ``obj.setas.column_headers``. + + Examples:: + + class DataArray(ma.MaskedArray): + setas = Setas() + column_headers = ColumnHeadersDescriptor() + + class DataFilePropertyMixin: + setas = Setas(source="_data") + column_headers = ColumnHeadersDescriptor() + """ + + def __set_name__(self, owner, name): + """Record the attribute name when assigned to a class. + + Args: + owner (type): The class that owns this descriptor. + name (str): The attribute name under which this descriptor is stored. + """ + self.name = name + + def __get__(self, obj, objtype=None): + """Return the column headers from the owning object's setas. + + Args: + obj: The owner instance, or ``None`` when accessed on the class. + objtype (type, optional): The owner class. + + Returns: + list: The current column header strings. + """ + if obj is None: + return self + return obj.setas.column_headers + + def __set__(self, obj, value): + """Set the column headers on the owning object's setas. + + Args: + obj: The owner instance. + value (list or array-like): New column header strings. + """ + obj.setas.column_headers = value +