1
0
mirror of https://github.com/ihabunek/toot.git synced 2024-11-03 04:17:21 -05:00

Images are now cached in memory (10MB) with a LRU eviction policy

This commit is contained in:
Daniel Schwarz 2023-07-01 09:25:18 -04:00 committed by Ivan Habunek
parent cb27d38e9b
commit 8d1a562d1d
No known key found for this signature in database
GPG Key ID: F5F0623FF5EBCB3D
4 changed files with 153 additions and 7 deletions

View File

@ -1,8 +1,11 @@
from argparse import ArgumentTypeError from argparse import ArgumentTypeError
import pytest import pytest
import sys
from toot.console import duration from toot.console import duration
from toot.wcstring import wc_wrap, trunc, pad, fit_text from toot.wcstring import wc_wrap, trunc, pad, fit_text
from toot.tui.utils import ImageCache
from PIL import Image
from collections import namedtuple
def test_pad(): def test_pad():
@ -201,3 +204,108 @@ def test_duration():
with pytest.raises(ArgumentTypeError): with pytest.raises(ArgumentTypeError):
duration("banana") duration("banana")
def test_cache_null():
"""Null dict is null."""
cache = ImageCache(cache_max_bytes=1024)
assert cache.__len__() == 0
Case = namedtuple("Case", ["cache_len", "len", "init"])
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
@pytest.mark.parametrize(
"case",
[
Case(9, 0, []),
Case(9, 1, [("one", img)]),
Case(9, 2, [("one", img), ("two", img)]),
Case(2, 2, [("one", img), ("two", img)]),
Case(1, 1, [("one", img), ("two", img)]),
],
)
@pytest.mark.parametrize("method", ["assign", "init"])
def test_cache_init(case, method):
"""Check that the # of elements is right, given # given and cache_len."""
if method == "init":
cache = ImageCache(case.init, cache_max_bytes=img_size * case.cache_len)
elif method == "assign":
cache = ImageCache(cache_max_bytes=img_size * case.cache_len)
for (key, val) in case.init:
cache[key] = val
else:
assert False
# length is max(#entries, cache_len)
assert cache.__len__() == case.len
# make sure the first entry is the one ejected
if case.cache_len > 1 and case.init:
assert "one" in cache.keys()
else:
assert "one" not in cache.keys()
@pytest.mark.parametrize("method", ["init", "assign"])
def test_cache_overflow_default(method):
"""Test default overflow logic."""
if method == "init":
cache = ImageCache([("one", img), ("two", img), ("three", img)], cache_max_bytes=img_size * 2)
elif method == "assign":
cache = ImageCache(cache_max_bytes=img_size * 2)
cache["one"] = img
cache["two"] = img
cache["three"] = img
else:
assert False
assert "one" not in cache.keys()
assert "two" in cache.keys()
assert "three" in cache.keys()
@pytest.mark.parametrize("mode", ["get", "set"])
@pytest.mark.parametrize("add_third", [False, True])
def test_cache_lru_overflow(mode, add_third):
img = Image.new('RGB', (100, 100))
img_size = sys.getsizeof(img.tobytes())
"""Test that key access resets LRU logic."""
cache = ImageCache([("one", img), ("two", img)], cache_max_bytes=img_size * 2)
if mode == "get":
dummy = cache["one"]
elif mode == "set":
cache["one"] = img
else:
assert False
if add_third:
cache["three"] = img
assert "one" in cache.keys()
assert "two" not in cache.keys()
assert "three" in cache.keys()
else:
assert "one" in cache.keys()
assert "two" in cache.keys()
assert "three" not in cache.keys()
def test_cache_keyerror():
cache = ImageCache()
with pytest.raises(KeyError):
cache["foo"]
def test_cache_miss_doesnt_eject():
cache = ImageCache([("one", img), ("two", img)], cache_max_bytes=img_size * 3)
with pytest.raises(KeyError):
cache["foo"]
assert len(cache) == 2
assert "one" in cache.keys()
assert "two" in cache.keys()

View File

@ -15,7 +15,7 @@ from .overlays import ExceptionStackTrace, GotoMenu, Help, StatusSource, StatusL
from .overlays import StatusDeleteConfirmation, Account from .overlays import StatusDeleteConfirmation, Account
from .poll import Poll from .poll import Poll
from .timeline import Timeline from .timeline import Timeline
from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard from .utils import get_max_toot_chars, parse_content_links, show_media, copy_to_clipboard, ImageCache
from PIL import Image from PIL import Image
from term_image.widget import UrwidImageScreen from term_image.widget import UrwidImageScreen
@ -663,13 +663,13 @@ class TUI(urwid.Frame):
return return
if not hasattr(timeline, "images"): if not hasattr(timeline, "images"):
timeline.images = dict() timeline.images = ImageCache() # use the default 10MB image cache for now
try: try:
img = Image.open(requests.get(path, stream=True).raw) img = Image.open(requests.get(path, stream=True).raw)
if img.format == 'PNG' and img.mode != 'RGBA': if img.format == 'PNG' and img.mode != 'RGBA':
img = img.convert("RGBA") img = img.convert("RGBA")
timeline.images[str(hash(path))] = img timeline.images[str(hash(path))] = img
except: # noqa E722 except Exception:
pass # ignore errors; if we can't load an image, just show blank pass # ignore errors; if we can't load an image, just show blank
def _done(loop): def _done(loop):

View File

@ -314,8 +314,12 @@ class Timeline(urwid.Columns):
assert self.statuses[index].id == status.id # Sanity check assert self.statuses[index].id == status.id # Sanity check
# get the image and replace the placeholder with a graphics widget # get the image and replace the placeholder with a graphics widget
img = None
if hasattr(self, "images"): if hasattr(self, "images"):
img = self.images.get(str(hash(path))) try:
img = self.images[(str(hash(path)))]
except KeyError:
pass
if img: if img:
try: try:
render_img = add_corners(img, 10) if self.can_render_pixels else img render_img = add_corners(img, 10) if self.can_render_pixels else img
@ -383,7 +387,10 @@ class StatusDetails(urwid.Pile):
img = None img = None
if hasattr(self.timeline, "images"): if hasattr(self.timeline, "images"):
img = self.timeline.images.get(str(hash(path))) try:
img = self.timeline.images[(str(hash(path)))]
except KeyError:
pass
if img: if img:
render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img render_img = add_corners(img, 10) if self.timeline.can_render_pixels else img
return (urwid.BoxAdapter( return (urwid.BoxAdapter(

View File

@ -2,9 +2,10 @@ import base64
import re import re
import shutil import shutil
import subprocess import subprocess
import sys
import urwid import urwid
import math import math
from collections import OrderedDict
from functools import reduce from functools import reduce
from html.parser import HTMLParser from html.parser import HTMLParser
from typing import List from typing import List
@ -180,3 +181,33 @@ def deep_get(adict: dict, path: List[str], default=None):
path, path,
adict adict
) )
class ImageCache(OrderedDict):
"""Dict with a limited size, ejecting LRUs as needed.
Default max size = 10Mb"""
def __init__(self, *args, cache_max_bytes: int = 1024 * 1024 * 10, **kwargs):
assert cache_max_bytes > 0
self.total_value_size = 0
self.cache_max_bytes = cache_max_bytes
super().__init__(*args, **kwargs)
def __setitem__(self, key: str, value: Image):
if key in self:
self.total_value_size -= sys.getsizeof(super().__getitem__(key).tobytes())
self.total_value_size += sys.getsizeof(value.tobytes())
super().__setitem__(key, value)
super().move_to_end(key)
while self.total_value_size > self.cache_max_bytes:
old_key, value = next(iter(self.items()))
sz = sys.getsizeof(value.tobytes())
super().__delitem__(old_key)
self.total_value_size -= sz
def __getitem__(self, key: str):
val = super().__getitem__(key)
super().move_to_end(key)
return val