Summary
docx.shared.lazyproperty (src/docx/shared.py) does not correctly cache a wrapped getter whose correct return value is None. Instead of evaluating the getter once and reusing the cached value, it re-invokes the getter on every access whenever the cached value happens to be None.
Mechanism
lazyproperty.__get__ uses presence-in-__dict__-via-None-sentinel to decide whether the value has already been computed:
def __get__(self, obj, type=None):
...
value = obj.__dict__.get(self._name)
if value is None:
value = self._fget(obj)
obj.__dict__[self._name] = value
return cast(T, value)
dict.get(key) returns None both when the key is absent and when the key is present with value None. Since None is a completely valid thing for a getter to legitimately compute (e.g. "look up this optional related object; there isn't one yet"), the two cases are indistinguishable here. Every subsequent access re-runs fget and re-stores None, forever.
This directly contradicts the class's own docstring, which states:
"...evaluated only on first access; the resulting value is cached and that same value returned on second and later access without re-evaluation of the method."
and
"One common use is to construct collaborator objects, removing that 'real work' from the constructor, while still only executing once."
If such a getter has any side effect (e.g. constructs and registers an object, appends to a log, does I/O), that side effect fires on every access instead of once, breaking the documented idempotence guarantee.
Reproduction (verified against PyPI python-docx 1.2.0 and current master)
from docx.shared import lazyproperty
class Widget:
def __init__(self):
self.call_count = 0
@lazyproperty
def maybe_missing_child(self):
self.call_count += 1
return None
w = Widget()
_ = w.maybe_missing_child
_ = w.maybe_missing_child
_ = w.maybe_missing_child
print(w.call_count) # prints 3
Output:
results: None, None, None
fget call_count: 3 (lazyproperty's own docstring promises this must be 1)
Expected vs actual
- Expected: fget runs exactly once; the cached None is returned on subsequent accesses (matching the documented "evaluated only on first access" / "without re-evaluation" contract).
- Actual: fget runs again on every access for as long as it keeps returning None.
Suggested fix
Use a proper "not yet computed" sentinel instead of relying on None, e.g.:
_NOT_COMPUTED = object()
def __get__(self, obj, type=None):
if obj is None:
return self
value = obj.__dict__.get(self._name, _NOT_COMPUTED)
if value is _NOT_COMPUTED:
value = self._fget(obj)
obj.__dict__[self._name] = value
return cast(T, value)
or check self._name in obj.__dict__ instead of comparing the fetched value to None.
Notes
- I did not find any current @lazyproperty-decorated method inside python-docx's own element classes that happens to return None today, so this doesn't currently misbehave through the document-editing API itself. However, lazyproperty is a general-purpose, documented utility in docx.shared that the project's own docstring explicitly recommends for exactly this "may return an absent/optional collaborator" use case, and it's the standard tool used throughout python-docx's own oxml element classes (see e.g. docx/oxml/xmlchemy.py, docx/oxml/text/pagebreak.py) as well as by third-party code extending python-docx with custom oxml element classes (a common, documented customization pattern) - so a future or third-party getter that legitimately returns None will silently lose its caching and idempotence guarantees.
- Verified this reproduces identically against the current master branch (fetched fresh via the GitHub raw URL) - the __get__ implementation is unchanged there.
Summary
docx.shared.lazyproperty (src/docx/shared.py) does not correctly cache a wrapped getter whose correct return value is None. Instead of evaluating the getter once and reusing the cached value, it re-invokes the getter on every access whenever the cached value happens to be None.
Mechanism
lazyproperty.__get__ uses presence-in-__dict__-via-None-sentinel to decide whether the value has already been computed:
dict.get(key) returns None both when the key is absent and when the key is present with value None. Since None is a completely valid thing for a getter to legitimately compute (e.g. "look up this optional related object; there isn't one yet"), the two cases are indistinguishable here. Every subsequent access re-runs fget and re-stores None, forever.
This directly contradicts the class's own docstring, which states:
and
If such a getter has any side effect (e.g. constructs and registers an object, appends to a log, does I/O), that side effect fires on every access instead of once, breaking the documented idempotence guarantee.
Reproduction (verified against PyPI python-docx 1.2.0 and current master)
Output:
Expected vs actual
Suggested fix
Use a proper "not yet computed" sentinel instead of relying on None, e.g.:
or check self._name in obj.__dict__ instead of comparing the fetched value to None.
Notes