FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

lazyproperty re-evaluates its getter on every access when the cached value is None · Issue #1600 · python-openxml/python-docx · GitHub

lazyproperty re-evaluates its getter on every access when the cached value is None #1600

Description

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions


    Back | FazBrowse Home | New Git URL