diff --git a/src/docx/shared.py b/src/docx/shared.py index 6c12dc91e..3236274fe 100644 --- a/src/docx/shared.py +++ b/src/docx/shared.py @@ -231,15 +231,17 @@ def __get__(self, obj: Any, type: Any = None) -> T: return self # type: ignore # --- when accessed on instance, start by checking instance __dict__ for - # --- item with key matching the wrapped function's name - value = obj.__dict__.get(self._name) - if value is None: + # --- item with key matching the wrapped function's name. Checked by key + # --- presence rather than by comparing the stored value to None, since a + # --- getter that legitimately computes None on first access would + # --- otherwise look exactly like one that hasn't been evaluated yet, and + # --- get re-evaluated on every later access instead of cached. + if self._name not in obj.__dict__: # --- on first access, the __dict__ item will be absent. Evaluate fget() # --- and store that value in the (otherwise unused) host-object # --- __dict__ value of same name ('fget' nominally) - value = self._fget(obj) - obj.__dict__[self._name] = value - return cast(T, value) + obj.__dict__[self._name] = self._fget(obj) + return cast(T, obj.__dict__[self._name]) def __set__(self, obj: Any, value: Any) -> None: """Raises unconditionally, to preserve read-only behavior. diff --git a/tests/test_shared.py b/tests/test_shared.py index fb6c273cb..5fca42047 100644 --- a/tests/test_shared.py +++ b/tests/test_shared.py @@ -5,7 +5,18 @@ import pytest from docx.opc.part import XmlPart -from docx.shared import Cm, ElementProxy, Emu, Inches, Length, Mm, Pt, RGBColor, Twips +from docx.shared import ( + Cm, + ElementProxy, + Emu, + Inches, + Length, + Mm, + Pt, + RGBColor, + Twips, + lazyproperty, +) from .unitutil.cxml import element from .unitutil.mock import FixtureRequest, Mock, instance_mock @@ -119,3 +130,40 @@ def it_can_provide_a_hex_string_rgb_value(self): def it_has_a_custom_repr(self): rgb_color = RGBColor(0x42, 0xF0, 0xBA) assert repr(rgb_color) == "RGBColor(0x42, 0xf0, 0xba)" + + +class DescribeLazyproperty: + """Unit-test suite for `docx.shared.lazyproperty` objects.""" + + def it_evaluates_the_getter_only_once_when_it_returns_None(self): + class Widget: + def __init__(self): + self.call_count = 0 + + @lazyproperty + def maybe_missing_child(self): + self.call_count += 1 + return None + + widget = Widget() + + assert widget.maybe_missing_child is None + assert widget.maybe_missing_child is None + assert widget.maybe_missing_child is None + assert widget.call_count == 1 + + def it_still_evaluates_the_getter_only_once_for_a_non_None_value(self): + class Widget: + def __init__(self): + self.call_count = 0 + + @lazyproperty + def value(self): + self.call_count += 1 + return "some result" + + widget = Widget() + + assert widget.value == "some result" + assert widget.value == "some result" + assert widget.call_count == 1