8000 rfctr: modernize lazyproperty · python-openxml/python-docx@99e9a0e · GitHub
[go: up one dir, main page]

Skip to content

Commit 99e9a0e

Browse files
committed
rfctr: modernize lazyproperty
The old @lazyproperty implementation worked, but was much messier than the modern version, adding a member to the object __dict__. This later version that stores the value in the descriptor is much more tidy and more performant too I expect.
1 parent ae6592b commit 99e9a0e

File tree

1 file changed

+114
-15
lines changed

1 file changed

+114
-15
lines changed

src/docx/shared.py

Lines changed: 114 additions & 15 deletions
+
nothing to stop assignment to the cached value, which would overwrite the result
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Objects shared by docx modules."""
22

3+
from __future__ import annotations
4+
5+
import functools
6+
from typing import Any, Callable, Generic, TypeVar, cast
7+
38

49
class Length(int):
510
"""Base class for length constructor classes Inches, Cm, Mm, Px, and Emu.
@@ -126,24 +131,118 @@ def from_string(cls, rgb_hex_str):
126131
return cls(r, g, b)
127132

128133

129-
def lazyproperty(f):
130-
"""@lazyprop decorator.
134+
T = TypeVar("T")
131135

132-
Decorated method will be called only on first access to calculate a cached property
133-
value. After that, the cached value is returned.
134-
"""
135-
cache_attr_name = "_%s" % f.__name__ # like '_foobar' for prop 'foobar'
136-
docstring = f.__doc__
137136

138-
def get_prop_value(obj):
139-
try:
140-
return getattr(obj, cache_attr_name)
141-
except AttributeError:
142-
value = f(obj)
143-
setattr(obj, cache_attr_name, value)
144-
return value
137+
class lazyproperty(Generic[T]):
138+
"""Decorator like @property, but evaluated only on first access.
139+
140+
Like @property, this can only be used to decorate methods having only a `self`
141+
parameter, and is accessed like an attribute on an instance, i.e. trailing
142+
parentheses are not used. Unlike @property, the decorated method is only evaluated
143+
on first access; the resulting value is cached and that same value returned on
144+
second and later access without re-evaluation of the method.
145+
146+
Like @property, this class produces a *data descriptor* object, which is stored in
147+
the __dict__ of the *class* under the name of the decorated method ('fget'
148+
nominally). The cached value is stored in the __dict__ of the *instance* under that
149+
same name.
150+
151+
Because it is a data descriptor (as opposed to a *non-data descriptor*), its
152+
`__get__()` method is executed on each access of the decorated attribute; the
153+
__dict__ item of the same name is "shadowed" by the descriptor.
154+
155+
While this may represent a performance improvement over a property, its greater
156+
benefit may be its other characteristics. One common use is to construct
157+
collaborator objects, removing that "real work" from the constructor, while still
158+
only executing once. It also de-couples client code from any sequencing
159+
considerations; if it's accessed from more than one location, it's assured it will
160+
be ready whenever needed.
161+
162+
Loosely based on: https://stackoverflow.com/a/6849299/1902513.
163+
164+
A lazyproperty is read-only. There is no counterpart to the optional "setter" (or
165+
deleter) behavior of an @property. This is critically important to maintaining its
166+
immutability and idempotence guarantees. Attempting to assign to a lazyproperty
167+
raises AttributeError unconditionally.
168+
169+
The parameter names in the methods below correspond to this usage example::
170+
171+
class Obj(object)
145172
146-
return property(get_prop_value, doc=docstring)
173+
@lazyproperty
174+
def fget(self):
175+
return 'some result'
176+
177+
obj = Obj()
178+
179+
Not suitable for wrapping a function (as opposed to a method) because it is not
180+
callable."""
181+
182+
def __init__(self, fget: Callable[..., T]) -> None:
183+
"""*fget* is the decorated method (a "getter" function).
184+
185+
A lazyproperty is read-only, so there is only an *fget* function (a regular
186+
@property can also have an fset and fdel function). This name was chosen for
187+
consistency with Python's `property` class which uses this name for the
188+
corresponding parameter.
189+
"""
190+
# --- maintain a reference to the wrapped getter method
191+
self._fget = fget
192+
# --- and store the name of that decorated method
193+
self._name = fget.__name__
194+
# --- adopt fget's __name__, __doc__, and other attributes
195+
functools.update_wrapper(self, fget) # pyright: ignore
196+
197+
def __get__(self, obj: Any, type: Any = None) -> T:
198+
"""Called on each access of 'fget' attribute on class or instance.
199+
200+
*self* is this instance of a lazyproperty descriptor "wrapping" the property
201+
method it decorates (`fget`, nominally).
202+
203+
*obj* is the "host" object instance when the attribute is accessed from an
204+
object instance, e.g. `obj = Obj(); obj.fget`. *obj* is None when accessed on
205+
the class, e.g. `Obj.fget`.
206+
207+
*type* is the class hosting the decorated getter method (`fget`) on both class
208+
and instance attribute access.
209+
"""
210+
# --- when accessed on class, e.g. Obj.fget, just return this descriptor
211+
# --- instance (patched above to look like fget).
212+
if obj is None:
213+
return self # type: ignore
214+
215+
# --- when accessed on instance, start by checking instance __dict__ for
216+
# --- item with key matching the wrapped function's name
217+
value = obj.__dict__.get(self._name)
218+
if value is None:
219+
# --- on first access, the __dict__ item will be absent. Evaluate fget()
220+
# --- and store that value in the (otherwise unused) host-object
221+
# --- __dict__ value of same name ('fget' nominally)
222+
value = self._fget(obj)
223+
obj.__dict__[self._name] = value
224+
return cast(T, value)
225+
226+
def __set__(self, obj: Any, value: Any) -> None:
227+
"""Raises unconditionally, to preserve read-only behavior.
228+
229+
This decorator is intended to implement immutable (and idempotent) object
230+
attributes. For that reason, assignment to this property must be explicitly
231+
prevented.
232+
233+
If this __set__ method was not present, this descriptor would become a
234+
*non-data descriptor*. That would be nice because the cached value would be
235+
accessed directly once set (__dict__ attrs have precedence over non-data
236+
descriptors on instance attribute lookup). The problem is, there would be
237
238+
of `fget()` and break both the immutability and idempotence guarantees of this
239+
decorator.
240+
241+
The performance with this __set__() method in place was roughly 0.4 usec per
242+
access when measured on a 2.8GHz development machine; so quite snappy and
243+
probably not a rich target for optimization efforts.
244+
"""
245+
raise AttributeError("can't set attribute")
147246

148247

149248
def write_only_property(f):

0 commit comments

Comments
 (0)
0