texture: Save the Texture Cache items to disk

I use the filepath of the requested item to derive a cache file name in
the user's xdg cachedirectory. I also add a way to update items in the
cache if we detect that the mtime has changed, and support loading items
from the cache if the source file has been deleted.

Implements #52 ("Save the Texture Cache textures to the .cache directory")
Signed-off-by: Anna Schumaker <Anna@NoWheyCreamery.com>
This commit is contained in:
Anna Schumaker 2023-06-27 16:27:53 -04:00
parent fa203a72dd
commit 51b290e1f0
2 changed files with 110 additions and 6 deletions

View File

@ -1,26 +1,76 @@
# Copyright 2023 (c) Anna Schumaker. # Copyright 2023 (c) Anna Schumaker.
"""A cache to hold Gdk.Textures used by cover art.""" """A cache to hold Gdk.Textures used by cover art."""
import pathlib import pathlib
import sys
from gi.repository import GLib
from gi.repository import Gdk from gi.repository import Gdk
if "unittest" in sys.modules:
import tempfile
TEMP_DIR = tempfile.TemporaryDirectory(prefix="emmental-")
CACHE_PATH = pathlib.Path(TEMP_DIR.name)
else:
from . import gsetup
CACHE_PATH = gsetup.CACHE_DIR
CACHE_PATH.mkdir(parents=True, exist_ok=True)
class _TextureCache(dict): class _TextureCache(dict):
"""A custom dictionary for storing texture files.""" """A custom dictionary for storing texture files."""
def __missing__(self, path: pathlib.Path | None) -> Gdk.Texture: def __check_update_cache(self, path: pathlib.Path) -> Gdk.Texture | None:
"""Load a cache item from disk or add a new item entirely.""" if path.is_file() \
texture = Gdk.Texture.new_from_filename(str(path)) and (cache_path := self.__get_cache_path(path)).exists() \
and cache_path.stat().st_mtime < path.stat().st_mtime:
self.__drop(path, cache_path)
return self.__load_new_item(path, cache_path)
def __drop(self, path: pathlib.Path, cache_path: pathlib.Path) -> None:
self.pop(path, None)
cache_path.unlink(missing_ok=True)
def __get_cache_path(self, path: pathlib.Path) -> pathlib.Path:
return CACHE_PATH / path.absolute().relative_to("/")
def __load_cached_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
texture = Gdk.Texture.new_from_filename(str(cache_path))
self.__setitem__(path, texture) self.__setitem__(path, texture)
return texture return texture
def __load_new_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
cache_path.parent.mkdir(parents=True, exist_ok=True)
with path.open("rb") as f_path:
bytes = f_path.read()
with cache_path.open("wb") as f_cache:
f_cache.write(bytes)
texture = Gdk.Texture.new_from_bytes(GLib.Bytes.new(bytes))
self.__setitem__(path, texture)
return texture
def __get_missing_item(self, path: pathlib.Path,
cache_path: pathlib.Path) -> Gdk.Texture:
if cache_path.is_file():
return self.__load_cached_item(path, cache_path)
elif path.is_file():
return self.__load_new_item(path, cache_path)
def __missing__(self, path: pathlib.Path | None) -> Gdk.Texture:
"""Load a cache item from disk or add a new item entirely."""
return self.__get_missing_item(path, self.__get_cache_path(path))
def __getitem__(self, path: pathlib.Path | None) -> Gdk.Texture | None: def __getitem__(self, path: pathlib.Path | None) -> Gdk.Texture | None:
"""Get a Gdk.Texture cache item from the cache.""" """Get a Gdk.Texture cache item from the cache."""
if path is not None and path.is_file(): if path is not None:
return super().__getitem__(path) texture = self.__check_update_cache(path)
return super().__getitem__(path) if texture is None else texture
def drop(self, path: pathlib.Path | None) -> None: def drop(self, path: pathlib.Path | None) -> None:
"""Drop a single cache item from the cache.""" """Drop a single cache item from the cache."""
self.pop(path, None) self.__drop(path, self.__get_cache_path(path))
CACHE = _TextureCache() CACHE = _TextureCache()

View File

@ -1,7 +1,9 @@
# Copyright 2023 (c) Anna Schumaker. # Copyright 2023 (c) Anna Schumaker.
"""Tests our Gdk.Texture cache.""" """Tests our Gdk.Texture cache."""
import emmental.texture import emmental.texture
import os
import pathlib import pathlib
import tempfile
import tests.util import tests.util
import unittest import unittest
from gi.repository import Gdk from gi.repository import Gdk
@ -12,8 +14,27 @@ class TestTextureCache(unittest.TestCase):
def setUp(self): def setUp(self):
"""Set up common variables.""" """Set up common variables."""
cover = tests.util.COVER_JPG.absolute().relative_to("/")
self.target = emmental.texture.CACHE_PATH / cover
self.target2 = self.target.with_name("cover2.jpg")
self.cache = emmental.texture._TextureCache() self.cache = emmental.texture._TextureCache()
def tearDown(self):
"""Clean up."""
self.target2.unlink(missing_ok=True)
(path := self.target).unlink(missing_ok=True)
while (path := path.parent) != emmental.texture.CACHE_PATH:
if path.is_dir():
path.rmdir()
def test_path(self):
"""Test the on-disk path of the texture cache."""
self.assertIsInstance(emmental.texture.TEMP_DIR,
tempfile.TemporaryDirectory)
self.assertEqual(emmental.texture.CACHE_PATH,
pathlib.Path(emmental.texture.TEMP_DIR.name))
self.assertTrue(emmental.texture.CACHE_PATH.is_dir())
def test_init(self): def test_init(self):
"""Test that the cache dict is initialized properly.""" """Test that the cache dict is initialized properly."""
self.assertIsInstance(emmental.texture.CACHE, self.assertIsInstance(emmental.texture.CACHE,
@ -28,14 +49,47 @@ class TestTextureCache(unittest.TestCase):
self.cache[tests.util.COVER_JPG] self.cache[tests.util.COVER_JPG]
self.cache.drop(tests.util.COVER_JPG) self.cache.drop(tests.util.COVER_JPG)
self.assertDictEqual(self.cache, {}) self.assertDictEqual(self.cache, {})
self.assertFalse(self.target.exists())
self.cache[tests.util.COVER_JPG]
self.cache.clear()
self.cache.drop(tests.util.COVER_JPG)
self.assertFalse(self.target.exists())
def test_getitem(self): def test_getitem(self):
"""Test getting and creating items in the cache dict.""" """Test getting and creating items in the cache dict."""
self.assertIsNone(self.cache[None]) self.assertIsNone(self.cache[None])
self.assertIsNone(self.cache[pathlib.Path("/no/such/path")]) self.assertIsNone(self.cache[pathlib.Path("/no/such/path")])
self.assertDictEqual(self.cache, {}) self.assertDictEqual(self.cache, {})
self.assertListEqual(list(emmental.texture.CACHE_PATH.iterdir()), [])
texture = self.cache[tests.util.COVER_JPG] texture = self.cache[tests.util.COVER_JPG]
self.assertIsInstance(texture, Gdk.Texture) self.assertIsInstance(texture, Gdk.Texture)
self.assertDictEqual(self.cache, {tests.util.COVER_JPG: texture}) self.assertDictEqual(self.cache, {tests.util.COVER_JPG: texture})
self.assertEqual(self.cache[tests.util.COVER_JPG], texture) self.assertEqual(self.cache[tests.util.COVER_JPG], texture)
self.assertTrue(self.target.is_file())
self.cache.clear()
self.assertIsInstance(self.cache[tests.util.COVER_JPG], Gdk.Texture)
def test_getitem_cache_only(self):
"""Test getting a cached item with deleted source path."""
cover2 = tests.util.COVER_JPG.with_name("cover2.jpg")
texture = self.cache[tests.util.COVER_JPG]
self.cache[cover2] = texture
del self.cache[tests.util.COVER_JPG]
self.assertEqual(self.cache[cover2], texture)
self.cache.clear()
self.target.rename(self.target2)
self.assertIsInstance(self.cache[cover2], Gdk.Texture)
def test_mtime_update(self):
"""Test updating an item in the cache."""
texture = self.cache[tests.util.COVER_JPG]
os.utime(self.target, (123456789, 123456789))
new = self.cache[tests.util.COVER_JPG]
self.assertIsInstance(new, Gdk.Texture)
self.assertNotEqual(new, texture)