From 80973db18d9fc61698785a1202018c5859091ce2 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 25 Jan 2023 17:48:24 +0100 Subject: [PATCH] Support slicing properties as properties (aka. support obj[i:j].shape). Currently, after `sliced = pims.open(...)[i:j]`, the `sliced` object doesn't have a `.shape` attribute, even though it does have a `len()` and a `.frame_shape` (the latter being propagated via propagated_attrs) *and* even though `FramesSequence.shape` is actually a property (`return (len(self), *self.frame_shape)`). This PR adds the ability to mark properties as being propagated, not as values, but rather as properties in the sliced object too (the decorator name, `@sliceable_property`, can be bikeshedded). This is done by having `__getitem__` not return a Slicerator, but a subclass of Slicerator with the relevant properties. While we're at it, "standard" propagated attributes are also implemented using properties now (which directly call getattr on the ancestor, not going through any fget); this is a slight API change in that later changes to the attribute *on the ancestor* will now be reflected on the sliced object, but the keeping the implementation consistent with sliceable properties seems nice and having these attributes tab-completable (rather than being hidden behind `__getattr__` is good for usability). In any case this could be changed back if the API change is problematic. As a result, with this PR, pims/base_frames.py just needs a small extra patch: diff --git i/pims/base_frames.py w/pims/base_frames.py index d91df98..122b12d 100644 --- i/pims/base_frames.py +++ w/pims/base_frames.py @@ -1,6 +1,6 @@ import numpy as np import itertools -from slicerator import Slicerator, propagate_attr, index_attr +from slicerator import Slicerator, propagate_attr, index_attr, sliceable_property from .frame import Frame from abc import ABC, abstractmethod, abstractproperty from warnings import warn @@ -92,7 +92,7 @@ class FramesSequence(FramesStream): Must be finite length. """ - propagate_attrs = ['frame_shape', 'pixel_type'] + propagate_attrs = ['frame_shape', 'pixel_type', 'shape'] def __getitem__(self, key): """__getitem__ is handled by Slicerator. In all pims readers, the data @@ -102,7 +102,7 @@ class FramesSequence(FramesStream): def __iter__(self): return iter(self[:]) - @property + @sliceable_property def shape(self): return (len(self), *self.frame_shape) to support `sliced = pims.open(...)[i:j]; sliced.shape`. --- slicerator/__init__.py | 64 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/slicerator/__init__.py b/slicerator/__init__.py index 4969bf3..f7061b3 100644 --- a/slicerator/__init__.py +++ b/slicerator/__init__.py @@ -35,12 +35,16 @@ def __init__(self, ancestor, indices=None, length=None, Also, the attributes of the parent object can be propagated, exposed through the child Slicerators. By default, no attributes are propagated. Attributes can be white_listed by using the optional - parameter `propagated_attrs`. + parameter `propagate_attrs`. Methods taking an index will be remapped if they are decorated with `index_attr`. They also have to be present in the `propagate_attrs` list. + Properties declared using `sliceable_property` will be copied *as + properties* in subslices, i.e., on access, the property's getter will + be called on the subsliced object. + Parameters ---------- ancestor : object @@ -186,7 +190,8 @@ def __getitem__(self, i): if new_length is None: return self._get(indices) else: - return cls(self, indices, new_length, propagate_attrs) + return _instantiate_slicerator_subclass_with_attrs( + self, indices, new_length, propagate_attrs) for name in ['__name__', '__module__', '__repr__']: try: @@ -238,20 +243,14 @@ def __getitem__(self, key): if new_length is None: return (self[k] for k in rel_indices) indices = _index_generator(rel_indices, self.indices) - return Slicerator(self._ancestor, indices, new_length, - self._propagate_attrs) + return _instantiate_slicerator_subclass_with_attrs( + self._ancestor, indices, new_length, self._propagate_attrs) def __getattr__(self, name): - # to avoid infinite recursion, always check if public field is there if '_propagate_attrs' not in self.__dict__: self._propagate_attrs = [] if name in self._propagate_attrs: - attr = getattr(self._ancestor, name) - if (isinstance(attr, SliceableAttribute) or - hasattr(attr, '_index_flag')): - return SliceableAttribute(self, attr) - else: - return attr + return _make_property_fget(name)(self) raise AttributeError def __getstate__(self): @@ -264,6 +263,41 @@ def __setstate__(self, data_as_list): return self.__init__(data_as_list) +def _make_property_fget(name): + def fget(self): + if name not in self._ancestor.__dict__: + cls_attr = getattr(type(self._ancestor), name, None) + if (isinstance(cls_attr, property) + and getattr(cls_attr.fget, '_slice_as_property', False)): + return cls_attr.fget(self) + attr = getattr(self._ancestor, name) + if (isinstance(attr, SliceableAttribute) or + hasattr(attr, '_index_flag')): + return SliceableAttribute(self, attr) + else: + return attr + + return fget + + +_slicerator_subclass_cache = {} + + +def _instantiate_slicerator_subclass_with_attrs( + ancestor, indices, length, propagate_attrs): + if (ancestor, propagate_attrs) in _slicerator_subclass_cache: + cls = _slicerator_subclass_cache[ancestor, propagate_attrs] + return cls(ancestor, indices, length, propagate_attrs) + else: + class Sliced(Slicerator): pass + obj = Sliced(ancestor, indices, length, propagate_attrs) + # to avoid infinite recursion, always check if public field is there + for name in obj.__dict__.get('_propagate_attrs', []): + setattr(Sliced, name, property(_make_property_fget(name))) + _slicerator_subclass_cache[ancestor, propagate_attrs] = Sliced + return obj + + def key_to_indices(key, length): """Converts a fancy key into a list of indices. @@ -478,7 +512,8 @@ def __getitem__(self, i): if new_length is None: return self._get(indices) else: - return Slicerator(self, indices, new_length, self._propagate_attrs) + return _instantiate_slicerator_subclass_with_attrs( + self, indices, new_length, self._propagate_attrs) def __getattr__(self, name): # to avoid infinite recursion, always check if public field is there @@ -706,6 +741,11 @@ def propagate_attr(func): return func +def sliceable_property(func): + func._slice_as_property = True + return property(func) + + def index_attr(func): @wraps(func) def wrapper(obj, key, *args, **kwargs):