From 51b290e1f042fa90b8b1a1b8a3a556a4efcf3966 Mon Sep 17 00:00:00 2001 From: Anna Schumaker Date: Tue, 27 Jun 2023 16:27:53 -0400 Subject: [PATCH] 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 --- emmental/texture.py | 62 ++++++++++++++++++++++++++++++++++++++----- tests/test_texture.py | 54 +++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) diff --git a/emmental/texture.py b/emmental/texture.py index 444b25d..991d13d 100644 --- a/emmental/texture.py +++ b/emmental/texture.py @@ -1,26 +1,76 @@ # Copyright 2023 (c) Anna Schumaker. """A cache to hold Gdk.Textures used by cover art.""" import pathlib +import sys +from gi.repository import GLib 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): """A custom dictionary for storing texture files.""" - def __missing__(self, path: pathlib.Path | None) -> Gdk.Texture: - """Load a cache item from disk or add a new item entirely.""" - texture = Gdk.Texture.new_from_filename(str(path)) + def __check_update_cache(self, path: pathlib.Path) -> Gdk.Texture | None: + if path.is_file() \ + 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) 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: """Get a Gdk.Texture cache item from the cache.""" - if path is not None and path.is_file(): - return super().__getitem__(path) + if path is not None: + texture = self.__check_update_cache(path) + return super().__getitem__(path) if texture is None else texture def drop(self, path: pathlib.Path | None) -> None: """Drop a single cache item from the cache.""" - self.pop(path, None) + self.__drop(path, self.__get_cache_path(path)) CACHE = _TextureCache() diff --git a/tests/test_texture.py b/tests/test_texture.py index 022c42c..646585d 100644 --- a/tests/test_texture.py +++ b/tests/test_texture.py @@ -1,7 +1,9 @@ # Copyright 2023 (c) Anna Schumaker. """Tests our Gdk.Texture cache.""" import emmental.texture +import os import pathlib +import tempfile import tests.util import unittest from gi.repository import Gdk @@ -12,8 +14,27 @@ class TestTextureCache(unittest.TestCase): def setUp(self): """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() + 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): """Test that the cache dict is initialized properly.""" self.assertIsInstance(emmental.texture.CACHE, @@ -28,14 +49,47 @@ class TestTextureCache(unittest.TestCase): self.cache[tests.util.COVER_JPG] self.cache.drop(tests.util.COVER_JPG) 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): """Test getting and creating items in the cache dict.""" self.assertIsNone(self.cache[None]) self.assertIsNone(self.cache[pathlib.Path("/no/such/path")]) self.assertDictEqual(self.cache, {}) + self.assertListEqual(list(emmental.texture.CACHE_PATH.iterdir()), []) texture = self.cache[tests.util.COVER_JPG] self.assertIsInstance(texture, Gdk.Texture) self.assertDictEqual(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)