Repository: Textualize/rich (python)

Issue:
Remove lru_cache from methods
Using lru_cache on a method may cause Python to hold on to references (via self) for longer than strictly necessary.

We should review instances of lru_cache decorator in Rich, and if affected we might want to replace them with the LRUCache object.

It may not be a huge problem since I think must uses of lru_cache set a sensible maximum size.

Pull request description:
Stop using functools.lru_cache decorator on instance methods
## Type of changes

- [x] Bug fix
- [ ] New feature
- [ ] Documentation / docstrings
- [ ] Tests
- [ ] Other

## Checklist

- [x] I've run the latest [black](https://github.com/psf/black) with default args on new code.
- [x] I've updated CHANGELOG.md and CONTRIBUTORS.md where appropriate.
- [x] I've added tests for new code. (there is no new code, just shuffling things around mostly)
- [x] I accept that @willmcgugan may be pedantic in the code review.

## Description

Mostly extracting the body of a method and shoving it into a function. There's a couple of smaller changes e.g. converting list to tuple since list isn't hashable too.

Fixes #1831 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0de277c2..b756e29d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ## [Unreleased]
 
 ### Added
-
+- Stop using `functools.lru_cache` decorator on instance methods
 - Fix width measurement of 353 emoji variation sequences https://github.com/Textualize/rich/pull/1832
 
 ## [11.0.0] - 2022-01-09
diff --git a/rich/color.py b/rich/color.py
index f0fa026d..2a686496 100644
--- a/rich/color.py
+++ b/rich/color.py
@@ -1,3 +1,4 @@
+import functools
 import platform
 import re
 from colorsys import rgb_to_hls
@@ -7,14 +8,13 @@ from typing import TYPE_CHECKING, NamedTuple, Optional, Tuple
 
 from ._palettes import EIGHT_BIT_PALETTE, STANDARD_PALETTE, WINDOWS_PALETTE
 from .color_triplet import ColorTriplet
-from .repr import rich_repr, Result
+from .repr import Result, rich_repr
 from .terminal_theme import DEFAULT_TERMINAL_THEME
 
 if TYPE_CHECKING:  # pragma: no cover
     from .terminal_theme import TerminalTheme
     from .text import Text
 
-
 WINDOWS = platform.system() == "Windows"
 
 
@@ -279,8 +279,8 @@ class Color(NamedTuple):
 
     def __rich__(self) -> "Text":
         """Dispays the actual color if Rich printed."""
-        from .text import Text
         from .style import Style
+        from .text import Text
 
         return Text.assemble(
             f"<color {self.name!r} ({self.type.name.lower()})",
@@ -432,7 +432,7 @@ class Color(NamedTuple):
                 number=number,
             )
 
-        else:  #  color_rgb:
+        else:  # color_rgb:
             components = color_rgb.split(",")
             if len(components) != 3:
                 raise ColorParseError(
@@ -446,87 +446,94 @@ class Color(NamedTuple):
                 )
             return cls(color, ColorType.TRUECOLOR, triplet=triplet)
 
-    @lru_cache(maxsize=1024)
     def get_ansi_codes(self, foreground: bool = True) -> Tuple[str, ...]:
         """Get the ANSI escape codes for this color."""
-        _type = self.type
-        if _type == ColorType.DEFAULT:
-            return ("39" if foreground else "49",)
-
-        elif _type == ColorType.WINDOWS:
-            number = self.number
-            assert number is not None
-            fore, back = (30, 40) if number < 8 else (82, 92)
-            return (str(fore + number if foreground else back + number),)
-
-        elif _type == ColorType.STANDARD:
-            number = self.number
-            assert number is not None
-            fore, back = (30, 40) if number < 8 else (82, 92)
-            return (str(fore + number if foreground else back + number),)
-
-        elif _type == ColorType.EIGHT_BIT:
-            assert self.number is not None
-            return ("38" if foreground else "48", "5", str(self.number))
-
-        else:  # self.standard == ColorStandard.TRUECOLOR:
-            assert self.triplet is not None
-            red, green, blue = self.triplet
-            return ("38" if foreground else "48", "2", str(red), str(green), str(blue))
+        return _get_ansi_codes_cached(color=self, foreground=foreground)
 
-    @lru_cache(maxsize=1024)
     def downgrade(self, system: ColorSystem) -> "Color":
         """Downgrade a color system to a system with fewer colors."""
+        return _downgrade_cached(color=self, system=system)
+
+
+@functools.lru_cache(maxsize=1024)
+def _get_ansi_codes_cached(color: Color, foreground: bool) -> Tuple[str, ...]:
+    _type = color.type
+    if _type == ColorType.DEFAULT:
+        return ("39" if foreground else "49",)
+
+    elif _type == ColorType.WINDOWS:
+        number = color.number
+        assert number is not None
+        fore, back = (30, 40) if number < 8 else (82, 92)
+        return (str(fore + number if foreground else back + number),)
+
+    elif _type == ColorType.STANDARD:
+        number = color.number
+        assert number is not None
+        fore, back = (30, 40) if number < 8 else (82, 92)
+        return (str(fore + number if foreground else back + number),)
+
+    elif _type == ColorType.EIGHT_BIT:
+        assert color.number is not None
+        return ("38" if foreground else "48", "5", str(color.number))
+
+    else:  # color.standard == ColorStandard.TRUECOLOR:
+        assert color.triplet is not None
+        red, green, blue = color.triplet
+        return ("38" if foreground else "48", "2", str(red), str(green), str(blue))
+
+
+@lru_cache(maxsize=1024)
+def _downgrade_cached(color: Color, system: ColorSystem) -> Color:
+    if color.type in [ColorType.DEFAULT, system]:
+        return color
+    # Convert to 8-bit color from truecolor color
+    if system == ColorSystem.EIGHT_BIT and color.system == ColorSystem.TRUECOLOR:
+        assert color.triplet is not None
+        red, green, blue = color.triplet.normalized
+        _h, l, s = rgb_to_hls(red, green, blue)
+        # If saturation is under 10% assume it is grayscale
+        if s < 0.1:
+            gray = round(l * 25.0)
+            if gray == 0:
+                color_number = 16
+            elif gray == 25:
+                color_number = 231
+            else:
+                color_number = 231 + gray
+            return Color(color.name, ColorType.EIGHT_BIT, number=color_number)
+
+        color_number = (
+            16 + 36 * round(red * 5.0) + 6 * round(green * 5.0) + round(blue * 5.0)
+        )
+        return Color(color.name, ColorType.EIGHT_BIT, number=color_number)
+
+    # Convert to standard from truecolor or 8-bit
+    elif system == ColorSystem.STANDARD:
+        if color.system == ColorSystem.TRUECOLOR:
+            assert color.triplet is not None
+            triplet = color.triplet
+        else:  # color.system == ColorSystem.EIGHT_BIT
+            assert color.number is not None
+            triplet = ColorTriplet(*EIGHT_BIT_PALETTE[color.number])
+
+        color_number = STANDARD_PALETTE.match(triplet)
+        return Color(color.name, ColorType.STANDARD, number=color_number)
+
+    elif system == ColorSystem.WINDOWS:
+        if color.system == ColorSystem.TRUECOLOR:
+            assert color.triplet is not None
+            triplet = color.triplet
+        else:  # color.system == ColorSystem.EIGHT_BIT
+            assert color.number is not None
+            if color.number < 16:
+                return Color(color.name, ColorType.WINDOWS, number=color.number)
+            triplet = ColorTriplet(*EIGHT_BIT_PALETTE[color.number])
+
+        color_number = WINDOWS_PALETTE.match(triplet)
+        return Color(color.name, ColorType.WINDOWS, number=color_number)
 
-        if self.type in [ColorType.DEFAULT, system]:
-            return self
-        # Convert to 8-bit color from truecolor color
-        if system == ColorSystem.EIGHT_BIT and self.system == ColorSystem.TRUECOLOR:
-            assert self.triplet is not None
-            red, green, blue = self.triplet.normalized
-            _h, l, s = rgb_to_hls(red, green, blue)
-            # If saturation is under 10% assume it is grayscale
-            if s < 0.1:
-                gray = round(l * 25.0)
-                if gray == 0:
-                    color_number = 16
-                elif gray == 25:
-                    color_number = 231
-                else:
-                    color_number = 231 + gray
-                return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
-
-            color_number = (
-                16 + 36 * round(red * 5.0) + 6 * round(green * 5.0) + round(blue * 5.0)
-            )
-            return Color(self.name, ColorType.EIGHT_BIT, number=color_number)
-
-        # Convert to standard from truecolor or 8-bit
-        elif system == ColorSystem.STANDARD:
-            if self.system == ColorSystem.TRUECOLOR:
-                assert self.triplet is not None
-                triplet = self.triplet
-            else:  # self.system == ColorSystem.EIGHT_BIT
-                assert self.number is not None
-                triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number])
-
-            color_number = STANDARD_PALETTE.match(triplet)
-            return Color(self.name, ColorType.STANDARD, number=color_number)
-
-        elif system == ColorSystem.WINDOWS:
-            if self.system == ColorSystem.TRUECOLOR:
-                assert self.triplet is not None
-                triplet = self.triplet
-            else:  # self.system == ColorSystem.EIGHT_BIT
-                assert self.number is not None
-                if self.number < 16:
-                    return Color(self.name, ColorType.WINDOWS, number=self.number)
-                triplet = ColorTriplet(*EIGHT_BIT_PALETTE[self.number])
-
-            color_number = WINDOWS_PALETTE.match(triplet)
-            return Color(self.name, ColorType.WINDOWS, number=color_number)
-
-        return self
+    return color
 
 
 def parse_rgb_hex(hex_color: str) -> ColorTriplet:
diff --git a/rich/palette.py b/rich/palette.py
index f2958794..cd55dec9 100644
--- a/rich/palette.py
+++ b/rich/palette.py
@@ -1,6 +1,6 @@
-from math import sqrt
 from functools import lru_cache
-from typing import Sequence, Tuple, TYPE_CHECKING
+from math import sqrt
+from typing import TYPE_CHECKING, Sequence, Tuple
 
 from .color_triplet import ColorTriplet
 
@@ -20,8 +20,8 @@ class Palette:
     def __rich__(self) -> "Table":
         from rich.color import Color
         from rich.style import Style
-        from rich.text import Text
         from rich.table import Table
+        from rich.text import Text
 
         table = Table(
             "index",
@@ -40,8 +40,6 @@ class Palette:
             )
         return table
 
-    # This is somewhat inefficient and needs caching
-    @lru_cache(maxsize=1024)
     def match(self, color: Tuple[int, int, int]) -> int:
         """Find a color from a palette that most closely matches a given color.
 
@@ -51,30 +49,38 @@ class Palette:
         Returns:
             int: Index of closes matching color.
         """
-        red1, green1, blue1 = color
-        _sqrt = sqrt
-        get_color = self._colors.__getitem__
-
-        def get_color_distance(index: int) -> float:
-            """Get the distance to a color."""
-            red2, green2, blue2 = get_color(index)
-            red_mean = (red1 + red2) // 2
-            red = red1 - red2
-            green = green1 - green2
-            blue = blue1 - blue2
-            return _sqrt(
-                (((512 + red_mean) * red * red) >> 8)
-                + 4 * green * green
-                + (((767 - red_mean) * blue * blue) >> 8)
-            )
+        return _match_palette_cached(color=color, available_colors=tuple(self._colors))
+
+
+@lru_cache(maxsize=1024)
+def _match_palette_cached(
+    color: Tuple[int, int, int], available_colors: Tuple[Tuple[int, int, int]]
+) -> int:
+    red1, green1, blue1 = color
+    _sqrt = sqrt
+    get_color = available_colors.__getitem__
+
+    def get_color_distance(index: int) -> float:
+        """Get the distance to a color."""
+        red2, green2, blue2 = get_color(index)
+        red_mean = (red1 + red2) // 2
+        red = red1 - red2
+        green = green1 - green2
+        blue = blue1 - blue2
+        return _sqrt(
+            (((512 + red_mean) * red * red) >> 8)
+            + 4 * green * green
+            + (((767 - red_mean) * blue * blue) >> 8)
+        )
 
-        min_index = min(range(len(self._colors)), key=get_color_distance)
-        return min_index
+    min_index = min(range(len(available_colors)), key=get_color_distance)
+    return min_index
 
 
 if __name__ == "__main__":  # pragma: no cover
     import colorsys
     from typing import Iterable
+
     from rich.color import Color
     from rich.console import Console, ConsoleOptions
     from rich.segment import Segment
diff --git a/rich/progress_bar.py b/rich/progress_bar.py
index 1797b5f7..ce51a860 100644
--- a/rich/progress_bar.py
+++ b/rich/progress_bar.py
@@ -1,5 +1,5 @@
+import functools
 import math
-from functools import lru_cache
 from time import monotonic
 from typing import Iterable, List, Optional
 
@@ -15,6 +15,51 @@ from .style import Style, StyleType
 PULSE_SIZE = 20
 
 
+@functools.lru_cache(maxsize=16)
+def _get_pulse_segments(
+    fore_style: Style,
+    back_style: Style,
+    color_system: str,
+    no_color: bool,
+    ascii: bool = False,
+) -> List[Segment]:
+    """Get a list of segments to render a pulse animation.
+
+    Returns:
+        List[Segment]: A list of segments, one segment per character.
+    """
+    bar = "-" if ascii else "━"
+    segments: List[Segment] = []
+    if color_system not in ("standard", "eight_bit", "truecolor") or no_color:
+        segments += [Segment(bar, fore_style)] * (PULSE_SIZE // 2)
+        segments += [Segment(" " if no_color else bar, back_style)] * (
+            PULSE_SIZE - (PULSE_SIZE // 2)
+        )
+        return segments
+
+    append = segments.append
+    fore_color = (
+        fore_style.color.get_truecolor()
+        if fore_style.color
+        else ColorTriplet(255, 0, 255)
+    )
+    back_color = (
+        back_style.color.get_truecolor() if back_style.color else ColorTriplet(0, 0, 0)
+    )
+    cos = math.cos
+    pi = math.pi
+    _Segment = Segment
+    _Style = Style
+    from_triplet = Color.from_triplet
+
+    for index in range(PULSE_SIZE):
+        position = index / PULSE_SIZE
+        fade = 0.5 + cos((position * pi * 2)) / 2.0
+        color = blend_rgb(fore_color, back_color, cross_fade=fade)
+        append(_Segment(bar, _Style(color=from_triplet(color))))
+    return segments
+
+
 class ProgressBar(JupyterMixin):
     """Renders a (progress) bar. Used by rich.progress.
 
@@ -64,53 +109,6 @@ class ProgressBar(JupyterMixin):
         completed = min(100, max(0.0, completed))
         return completed
 
-    @lru_cache(maxsize=16)
-    def _get_pulse_segments(
-        self,
-        fore_style: Style,
-        back_style: Style,
-        color_system: str,
-        no_color: bool,
-        ascii: bool = False,
-    ) -> List[Segment]:
-        """Get a list of segments to render a pulse animation.
-
-        Returns:
-            List[Segment]: A list of segments, one segment per character.
-        """
-        bar = "-" if ascii else "━"
-        segments: List[Segment] = []
-        if color_system not in ("standard", "eight_bit", "truecolor") or no_color:
-            segments += [Segment(bar, fore_style)] * (PULSE_SIZE // 2)
-            segments += [Segment(" " if no_color else bar, back_style)] * (
-                PULSE_SIZE - (PULSE_SIZE // 2)
-            )
-            return segments
-
-        append = segments.append
-        fore_color = (
-            fore_style.color.get_truecolor()
-            if fore_style.color
-            else ColorTriplet(255, 0, 255)
-        )
-        back_color = (
-            back_style.color.get_truecolor()
-            if back_style.color
-            else ColorTriplet(0, 0, 0)
-        )
-        cos = math.cos
-        pi = math.pi
-        _Segment = Segment
-        _Style = Style
-        from_triplet = Color.from_triplet
-
-        for index in range(PULSE_SIZE):
-            position = index / PULSE_SIZE
-            fade = 0.5 + cos((position * pi * 2)) / 2.0
-            color = blend_rgb(fore_color, back_color, cross_fade=fade)
-            append(_Segment(bar, _Style(color=from_triplet(color))))
-        return segments
-
     def update(self, completed: float, total: Optional[float] = None) -> None:
         """Update progress with new values.
 
@@ -139,7 +137,7 @@ class ProgressBar(JupyterMixin):
         fore_style = console.get_style(self.pulse_style, default="white")
         back_style = console.get_style(self.style, default="black")
 
-        pulse_segments = self._get_pulse_segments(
+        pulse_segments = _get_pulse_segments(
             fore_style, back_style, console.color_system, console.no_color, ascii=ascii
         )
         segment_count = len(pulse_segments)
diff --git a/rich/style.py b/rich/style.py
index 0787c331..4153e277 100644
--- a/rich/style.py
+++ b/rich/style.py
@@ -1,15 +1,14 @@
 import sys
 from functools import lru_cache
-from marshal import loads, dumps
+from marshal import dumps, loads
 from random import randint
-from typing import Any, cast, Dict, Iterable, List, Optional, Type, Union
+from typing import Any, Dict, Iterable, List, Optional, Type, Union, cast
 
 from . import errors
 from .color import Color, ColorParseError, ColorSystem, blend_rgb
-from .repr import rich_repr, Result
+from .repr import Result, rich_repr
 from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme
 
-
 # Style instances and style definitions are often interchangeable
 StyleType = Union[str, "Style"]
 
@@ -575,42 +574,9 @@ class Style:
         style = Style(color=color, bgcolor=bgcolor, link=link, **attributes)
         return style
 
-    @lru_cache(maxsize=1024)
     def get_html_style(self, theme: Optional[TerminalTheme] = None) -> str:
         """Get a CSS style rule."""
-        theme = theme or DEFAULT_TERMINAL_THEME
-        css: List[str] = []
-        append = css.append
-
-        color = self.color
-        bgcolor = self.bgcolor
-        if self.reverse:
-            color, bgcolor = bgcolor, color
-        if self.dim:
-            foreground_color = (
-                theme.foreground_color if color is None else color.get_truecolor(theme)
-            )
-            color = Color.from_triplet(
-                blend_rgb(foreground_color, theme.background_color, 0.5)
-            )
-        if color is not None:
-            theme_color = color.get_truecolor(theme)
-            append(f"color: {theme_color.hex}")
-            append(f"text-decoration-color: {theme_color.hex}")
-        if bgcolor is not None:
-            theme_color = bgcolor.get_truecolor(theme, foreground=False)
-            append(f"background-color: {theme_color.hex}")
-        if self.bold:
-            append("font-weight: bold")
-        if self.italic:
-            append("font-style: italic")
-        if self.underline:
-            append("text-decoration: underline")
-        if self.strike:
-            append("text-decoration: line-through")
-        if self.overline:
-            append("text-decoration: overline")
-        return "; ".join(css)
+        return _get_html_style_cached(self, theme)
 
     @classmethod
     def combine(cls, styles: Iterable["Style"]) -> "Style":
@@ -751,6 +717,43 @@ class Style:
 NULL_STYLE = Style()
 
 
+@lru_cache(maxsize=1024)
+def _get_html_style_cached(style: Style, theme: Optional[TerminalTheme] = None) -> str:
+    theme = theme or DEFAULT_TERMINAL_THEME
+    css: List[str] = []
+    append = css.append
+
+    color = style.color
+    bgcolor = style.bgcolor
+    if style.reverse:
+        color, bgcolor = bgcolor, color
+    if style.dim:
+        foreground_color = (
+            theme.foreground_color if color is None else color.get_truecolor(theme)
+        )
+        color = Color.from_triplet(
+            blend_rgb(foreground_color, theme.background_color, 0.5)
+        )
+    if color is not None:
+        theme_color = color.get_truecolor(theme)
+        append(f"color: {theme_color.hex}")
+        append(f"text-decoration-color: {theme_color.hex}")
+    if bgcolor is not None:
+        theme_color = bgcolor.get_truecolor(theme, foreground=False)
+        append(f"background-color: {theme_color.hex}")
+    if style.bold:
+        append("font-weight: bold")
+    if style.italic:
+        append("font-style: italic")
+    if style.underline:
+        append("text-decoration: underline")
+    if style.strike:
+        append("text-decoration: line-through")
+    if style.overline:
+        append("text-decoration: overline")
+    return "; ".join(css)
+
+
 class StyleStack:
     """A stack of styles."""
 


Test patch:
diff --git a/tests/test_bar.py b/tests/test_bar.py
index 021a8aaa..5d36a8ca 100644
--- a/tests/test_bar.py
+++ b/tests/test_bar.py
@@ -1,5 +1,5 @@
 from rich.console import Console
-from rich.progress_bar import ProgressBar
+from rich.progress_bar import ProgressBar, _get_pulse_segments
 from rich.segment import Segment
 from rich.style import Style
 
@@ -63,7 +63,7 @@ def test_pulse():
 
 def test_get_pulse_segments():
     bar = ProgressBar()
-    segments = bar._get_pulse_segments(
+    segments = _get_pulse_segments(
         Style.parse("red"), Style.parse("yellow"), None, False, False
     )
     print(repr(segments))