|
1 | 1 | """Objects shared by docx modules."""
|
2 | 2 |
|
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import functools |
| 6 | +from typing import Any, Callable, Generic, TypeVar, cast |
| 7 | + |
3 | 8 |
|
4 | 9 | class Length(int):
|
5 | 10 | """Base class for length constructor classes Inches, Cm, Mm, Px, and Emu.
|
@@ -126,24 +131,118 @@ def from_string(cls, rgb_hex_str):
|
126 | 131 | return cls(r, g, b)
|
127 | 132 |
|
128 | 133 |
|
129 |
| -def lazyproperty(f): |
130 |
| - """@lazyprop decorator. |
| 134 | +T = TypeVar("T") |
131 | 135 |
|
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__ |
137 | 136 |
|
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) |
145 | 172 |
|
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 | + nothing to stop assignment to the cached value, which would overwrite the result
| 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") |
147 | 246 |
|
148 | 247 |
|
149 | 248 | def write_only_property(f):
|
|
0 commit comments