Skip to content

Commit e0e50df

Browse files
committed
RF: Replace OneTimeProperty/auto_attr with cached_property
1 parent beb4f7b commit e0e50df

File tree

2 files changed

+51
-103
lines changed

2 files changed

+51
-103
lines changed

nibabel/onetime.py

Lines changed: 24 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Descriptor support for NIPY
22
3-
Utilities to support special Python descriptors [1,2], in particular the use of
4-
a useful pattern for properties we call 'one time properties'. These are
5-
object attributes which are declared as properties, but become regular
6-
attributes once they've been read the first time. They can thus be evaluated
3+
Utilities to support special Python descriptors [1,2], in particular
4+
:func:`~functools.cached_property`, which has been available in the Python
5+
standard library since Python 3.8. We currently maintain aliases from
6+
earlier names for this descriptor, specifically `OneTimeProperty` and `auto_attr`.
7+
8+
:func:`~functools.cached_property` creates properties that are computed once
9+
and then stored as regular attributes. They can thus be evaluated
710
later in the object's life cycle, but once evaluated they become normal, static
811
attributes with no function call overhead on access or any other constraints.
912
@@ -21,10 +24,7 @@
2124

2225
from __future__ import annotations
2326

24-
import typing as ty
25-
26-
InstanceT = ty.TypeVar('InstanceT')
27-
T = ty.TypeVar('T')
27+
from functools import cached_property
2828

2929
from nibabel.deprecated import deprecate_with_version
3030

@@ -34,22 +34,22 @@
3434

3535

3636
class ResetMixin:
37-
"""A Mixin class to add a .reset() method to users of OneTimeProperty.
37+
"""A Mixin class to add a .reset() method to users of cached_property.
3838
39-
By default, auto attributes once computed, become static. If they happen
39+
By default, cached properties, once computed, become static. If they happen
4040
to depend on other parts of an object and those parts change, their values
4141
may now be invalid.
4242
4343
This class offers a .reset() method that users can call *explicitly* when
4444
they know the state of their objects may have changed and they want to
4545
ensure that *all* their special attributes should be invalidated. Once
46-
reset() is called, all their auto attributes are reset to their
47-
OneTimeProperty descriptors, and their accessor functions will be triggered
48-
again.
46+
reset() is called, all their cached properties are reset to their
47+
:func:`~functools.cached_property` descriptors,
48+
and their accessor functions will be triggered again.
4949
5050
.. warning::
5151
52-
If a class has a set of attributes that are OneTimeProperty, but that
52+
If a class has a set of attributes that are cached_property, but that
5353
can be initialized from any one of them, do NOT use this mixin! For
5454
instance, UniformTimeSeries can be initialized with only sampling_rate
5555
and t0, sampling_interval and time are auto-computed. But if you were
@@ -68,33 +68,37 @@ class ResetMixin:
6868
... def __init__(self,x=1.0):
6969
... self.x = x
7070
...
71-
... @auto_attr
71+
... @cached_property
7272
... def y(self):
7373
... print('*** y computation executed ***')
7474
... return self.x / 2.0
75-
...
7675
7776
>>> a = A(10)
7877
7978
About to access y twice, the second time no computation is done:
79+
8080
>>> a.y
8181
*** y computation executed ***
8282
5.0
8383
>>> a.y
8484
5.0
8585
8686
Changing x
87+
8788
>>> a.x = 20
8889
8990
a.y doesn't change to 10, since it is a static attribute:
91+
9092
>>> a.y
9193
5.0
9294
9395
We now reset a, and this will then force all auto attributes to recompute
9496
the next time we access them:
97+
9598
>>> a.reset()
9699
97100
About to access y twice again after reset():
101+
98102
>>> a.y
99103
*** y computation executed ***
100104
10.0
@@ -103,88 +107,18 @@ class ResetMixin:
103107
"""
104108

105109
def reset(self) -> None:
106-
"""Reset all OneTimeProperty attributes that may have fired already."""
110+
"""Reset all cached_property attributes that may have fired already."""
107111
# To reset them, we simply remove them from the instance dict. At that
108112
# point, it's as if they had never been computed. On the next access,
109113
# the accessor function from the parent class will be called, simply
110114
# because that's how the python descriptor protocol works.
111115
for mname, mval in self.__class__.__dict__.items():
112-
if mname in self.__dict__ and isinstance(mval, OneTimeProperty):
116+
if mname in self.__dict__ and isinstance(mval, cached_property):
113117
delattr(self, mname)
114118

115119

116-
class OneTimeProperty(ty.Generic[T]):
117-
"""A descriptor to make special properties that become normal attributes.
118-
119-
This is meant to be used mostly by the auto_attr decorator in this module.
120-
"""
121-
122-
def __init__(self, func: ty.Callable[[InstanceT], T]) -> None:
123-
"""Create a OneTimeProperty instance.
124-
125-
Parameters
126-
----------
127-
func : method
128-
129-
The method that will be called the first time to compute a value.
130-
Afterwards, the method's name will be a standard attribute holding
131-
the value of this computation.
132-
"""
133-
self.getter = func
134-
self.name = func.__name__
135-
self.__doc__ = func.__doc__
136-
137-
@ty.overload
138-
def __get__(
139-
self, obj: None, objtype: type[InstanceT] | None = None
140-
) -> ty.Callable[[InstanceT], T]: ...
141-
142-
@ty.overload
143-
def __get__(self, obj: InstanceT, objtype: type[InstanceT] | None = None) -> T: ...
144-
145-
def __get__(
146-
self, obj: InstanceT | None, objtype: type[InstanceT] | None = None
147-
) -> T | ty.Callable[[InstanceT], T]:
148-
"""This will be called on attribute access on the class or instance."""
149-
if obj is None:
150-
# Being called on the class, return the original function. This
151-
# way, introspection works on the class.
152-
return self.getter
153-
154-
# Errors in the following line are errors in setting a OneTimeProperty
155-
val = self.getter(obj)
156-
157-
obj.__dict__[self.name] = val
158-
return val
159-
160-
161-
def auto_attr(func: ty.Callable[[InstanceT], T]) -> OneTimeProperty[T]:
162-
"""Decorator to create OneTimeProperty attributes.
163-
164-
Parameters
165-
----------
166-
func : method
167-
The method that will be called the first time to compute a value.
168-
Afterwards, the method's name will be a standard attribute holding the
169-
value of this computation.
170-
171-
Examples
172-
--------
173-
>>> class MagicProp:
174-
... @auto_attr
175-
... def a(self):
176-
... return 99
177-
...
178-
>>> x = MagicProp()
179-
>>> 'a' in x.__dict__
180-
False
181-
>>> x.a
182-
99
183-
>>> 'a' in x.__dict__
184-
True
185-
"""
186-
return OneTimeProperty(func)
187-
120+
OneTimeProperty = cached_property
121+
auto_attr = cached_property
188122

189123
# -----------------------------------------------------------------------------
190124
# Deprecated API

nibabel/tests/test_onetime.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1-
from nibabel.onetime import auto_attr, setattr_on_read
1+
from functools import cached_property
2+
3+
from nibabel.onetime import ResetMixin, setattr_on_read
24
from nibabel.testing import deprecated_to, expires
35

46

7+
class A(ResetMixin):
8+
@cached_property
9+
def y(self):
10+
return self.x / 2.0
11+
12+
@cached_property
13+
def z(self):
14+
return self.x / 3.0
15+
16+
def __init__(self, x=1.0):
17+
self.x = x
18+
19+
520
@expires('5.0.0')
621
def test_setattr_on_read():
722
with deprecated_to('5.0.0'):
@@ -19,15 +34,14 @@ def a(self):
1934
assert x.a is obj
2035

2136

22-
def test_auto_attr():
23-
class MagicProp:
24-
@auto_attr
25-
def a(self):
26-
return object()
27-
28-
x = MagicProp()
29-
assert 'a' not in x.__dict__
30-
obj = x.a
31-
assert 'a' in x.__dict__
32-
# Each call to object() produces a unique object. Verify we get the same one every time.
33-
assert x.a is obj
37+
def test_ResetMixin():
38+
a = A(10)
39+
assert 'y' not in a.__dict__
40+
assert a.y == 5
41+
assert 'y' in a.__dict__
42+
a.x = 20
43+
assert a.y == 5
44+
# Call reset and no error should be raised even though z was never accessed
45+
a.reset()
46+
assert 'y' not in a.__dict__
47+
assert a.y == 10

0 commit comments

Comments
 (0)