diff --git a/README.md b/README.md index 4105be7..31fcd6b 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ data = pyconify.icon_data("fa-brands", "python") # Get SVG svg = pyconify.svg("fa-brands", "python") -# Get path to SVG temporary file for the session -file_name = pyconify.temp_svg("fa-brands", "python") +# Get path to SVG on disk +# will either return cached version, or write to temp file +file_name = pyconify.svg_path("fa-brands", "python") # Get CSS css = pyconify.css("fa-brands", "python") @@ -58,14 +59,20 @@ See details for each of these results in the [Iconify API documentation](https:/ ### cache While the first fetch of any given SVG will require internet access, -pyconfiy caches svgs for faster retrieval and offline use. To +pyconfiy caches svgs for faster retrieval and offline use. To see or clear cache directory: ```python import pyconify -pyconify.get_cache_directory() # reveal location of cache -pyconify.clear_cache() # remove the cache directory +# reveal location of cache +# will be ~/.cache/pyconify on linux and macos +# will be %LOCALAPPDATA%\pyconify on windows +# falls back to ~/.pyconify if none of the above are available +pyconify.get_cache_directory() + +# remove the cache directory (and all its contents) +pyconify.clear_cache() ``` If you'd like to precache a number of svgs, the current recommendation @@ -82,3 +89,8 @@ for key in ICONS_TO_STORE: ``` Later calls to `svg()` will use the cached values. + +To specify a custom cache directory, set the `PYCONIFY_CACHE` environment +variable to your desired directory. +To disable caching altogether, set the `PYCONIFY_CACHE` environment variable to +`false` or `0`. diff --git a/src/pyconify/__init__.py b/src/pyconify/__init__.py index 2fd52c8..4f86bad 100644 --- a/src/pyconify/__init__.py +++ b/src/pyconify/__init__.py @@ -21,7 +21,7 @@ "last_modified", "search", "svg", - "temp_svg", + "svg_path", ] from ._cache import clear_cache, get_cache_directory @@ -35,5 +35,5 @@ last_modified, search, svg, - temp_svg, + svg_path, ) diff --git a/src/pyconify/_cache.py b/src/pyconify/_cache.py index b4f13d6..e40f646 100644 --- a/src/pyconify/_cache.py +++ b/src/pyconify/_cache.py @@ -1,20 +1,27 @@ from __future__ import annotations import os +from contextlib import suppress from pathlib import Path from typing import Iterator, MutableMapping _SVG_CACHE: MutableMapping[str, bytes] | None = None +PYCONIFY_CACHE = os.environ.get("PYCONIFY_CACHE", "") +DISABLE_CACHE = PYCONIFY_CACHE.lower() in ("0", "false", "no") def svg_cache() -> MutableMapping[str, bytes]: # pragma: no cover """Return a cache for SVG files.""" global _SVG_CACHE if _SVG_CACHE is None: - try: - _SVG_CACHE = _SVGCache() - except Exception: + if DISABLE_CACHE: _SVG_CACHE = {} + else: + try: + _SVG_CACHE = _SVGCache() + _delete_stale_svgs(_SVG_CACHE) + except Exception: + _SVG_CACHE = {} return _SVG_CACHE @@ -22,13 +29,20 @@ def clear_cache() -> None: """Clear the pyconify svg cache.""" import shutil + from .api import svg_path + shutil.rmtree(get_cache_directory(), ignore_errors=True) global _SVG_CACHE _SVG_CACHE = None + with suppress(AttributeError): + svg_path.cache_clear() # type: ignore def get_cache_directory(app_name: str = "pyconify") -> Path: """Return the pyconify svg cache directory.""" + if PYCONIFY_CACHE: + return Path(PYCONIFY_CACHE).expanduser().resolve() + if os.name == "posix": return Path.home() / ".cache" / app_name elif os.name == "nt": @@ -38,14 +52,19 @@ def get_cache_directory(app_name: str = "pyconify") -> Path: return Path.home() / f".{app_name}" # pragma: no cover -def cache_key(args: tuple, kwargs: dict) -> str: +# delimiter for the cache key +DELIM = "_" + + +def cache_key(args: tuple, kwargs: dict, last_modified: int) -> str: """Generate a key for the cache based on the function arguments.""" _keys: tuple = args if kwargs: for item in sorted(kwargs.items()): if item[1] is not None: _keys += item - return "-".join(map(str, _keys)) + _keys += (last_modified,) + return DELIM.join(map(str, _keys)) class _SVGCache(MutableMapping[str, bytes]): @@ -59,23 +78,41 @@ def __init__(self, directory: str | Path | None = None) -> None: self.path.mkdir(parents=True, exist_ok=True) self._extention = ".svg" + def path_for(self, _key: str) -> Path: + return self.path.joinpath(f"{_key}{self._extention}") + + def _svg_files(self) -> Iterator[Path]: + yield from self.path.glob(f"*{self._extention}") + def __setitem__(self, _key: str, _value: bytes) -> None: - self.path.joinpath(f"{_key}{self._extention}").write_bytes(_value) + self.path_for(_key).write_bytes(_value) def __getitem__(self, _key: str) -> bytes: try: - return self.path.joinpath(f"{_key}{self._extention}").read_bytes() + return self.path_for(_key).read_bytes() except FileNotFoundError: raise KeyError(_key) from None def __iter__(self) -> Iterator[str]: - yield from (x.stem for x in self.path.glob(f"*{self._extention}")) + yield from (x.stem for x in self._svg_files()) def __delitem__(self, _key: str) -> None: - self.path.joinpath(f"{_key}{self._extention}").unlink() + self.path_for(_key).unlink() def __len__(self) -> int: - return len(list(self.path.glob("*{self._extention}"))) + return len(list(self._svg_files())) def __contains__(self, _key: object) -> bool: - return self.path.joinpath(f"{_key}{self._extention}").exists() + return self.path_for(_key).exists() if isinstance(_key, str) else False + + +def _delete_stale_svgs(cache: MutableMapping) -> None: # pragma: no cover + """Remove all SVG files with an outdated last_modified date from the cache.""" + from .api import last_modified + + last_modified_dates = last_modified() + for key in list(cache): + with suppress(ValueError): + prefix, *_, cached_last_mod = key.split(DELIM) + if int(cached_last_mod) < last_modified_dates.get(prefix, 0): + del cache[key] diff --git a/src/pyconify/api.py b/src/pyconify/api.py index fbfdb9b..e66af20 100644 --- a/src/pyconify/api.py +++ b/src/pyconify/api.py @@ -6,11 +6,12 @@ import tempfile import warnings from contextlib import suppress -from typing import TYPE_CHECKING, Iterable, Literal, cast, overload +from pathlib import Path +from typing import TYPE_CHECKING, Iterable, Literal, overload import requests -from ._cache import cache_key, svg_cache +from ._cache import _SVGCache, cache_key, svg_cache if TYPE_CHECKING: from typing import Callable, TypeVar @@ -21,7 +22,6 @@ APIv2CollectionResponse, APIv2SearchResponse, APIv3KeywordsResponse, - APIv3LastModifiedResponse, IconifyInfo, IconifyJSON, Rotation, @@ -33,7 +33,6 @@ def lru_cache(maxsize: int | None = None) -> Callable[[F], F]: else: from functools import lru_cache - ROOT = "https://api.iconify.design" @@ -92,23 +91,35 @@ def collection( @lru_cache(maxsize=None) -def last_modified(*prefixes: str) -> APIv3LastModifiedResponse: +def last_modified(*prefixes: str) -> dict[str, int]: """Return last modified date for icon sets. https://iconify.design/docs/api/last-modified.html + Example: + https://api.iconify.design/last-modified?prefixes=mdi,mdi-light,tabler + Parameters ---------- prefixes : Sequence[str], optional Comma separated list of icon set prefixes. You can use partial prefixes that end with "-", such as "mdi-" matches "mdi-light". If None, return all collections. + + Returns + ------- + dict[str, int] + Dictionary where key is icon set prefix, value is last modified date as + UTC integer timestamp. """ - # https://api.iconify.design/last-modified?prefixes=mdi,mdi-light,tabler query_params = {"prefixes": ",".join(prefixes)} resp = requests.get(f"{ROOT}/last-modified", params=query_params) resp.raise_for_status() - return resp.json() # type: ignore + if "lastModified" not in (content := resp.json()): # pragma: no cover + raise ValueError( + f"Unexpected response from API: {content}. Expected 'lastModified'." + ) + return content["lastModified"] # type: ignore # this function uses a special cache inside the body of the function @@ -157,14 +168,11 @@ def svg( to align it in design. """ # check cache - _kwargs = locals() - _kwargs.pop("key") - _key = cache_key(key, _kwargs) - cache = svg_cache() - if _key in cache: - return cache[_key] + prefix, name, svg_cache_key = _svg_keys(key, locals()) + + if svg_cache_key in (cache := svg_cache()): + return cache[svg_cache_key] - prefix, name = _split_prefix_name(key) if rotate not in (None, 1, 2, 3): rotate = str(rotate).replace("deg", "") + "deg" # type: ignore query_params = { @@ -182,12 +190,34 @@ def svg( raise requests.HTTPError(f"Icon '{prefix}:{name}' not found.", response=resp) # cache response and return - cache[_key] = resp.content + cache[svg_cache_key] = resp.content return resp.content +def _svg_keys(args: tuple, kwargs: dict) -> tuple[str, str, str]: + prefix, name = _split_prefix_name(args) + last_mod = last_modified().get(prefix, 0) + _kwargs = { + k: v + for k, v in kwargs.items() + if k in {"color", "height", "width", "flip", "rotate", "box"} + } + + svg_cache_key = cache_key((prefix, name), _kwargs, last_mod) + return prefix, name, svg_cache_key + + +def _svg_path(svg_cache_key: str) -> Path | None: + """Return path to existing SVG file for `key` or None.""" + cache = svg_cache() + if isinstance(cache, _SVGCache): + if (path := cache.path_for(svg_cache_key)) and path.is_file(): + return path + return None # pragma: no cover + + @lru_cache(maxsize=None) -def temp_svg( +def svg_path( *key: str, color: str | None = None, height: str | int | None = None, @@ -195,27 +225,38 @@ def temp_svg( flip: Literal["horizontal", "vertical", "horizontal,vertical"] | None = None, rotate: Rotation | None = None, box: bool | None = None, - prefix: str | None = None, - dir: str | None = None, -) -> str: - """Create a temporary SVG file for `key` for the duration of the session.""" + dir: str | Path | None = None, +) -> Path: + """Similar to `svg` but returns a path to SVG file for `key`. + + Arguments are the same as for `pyconfify.api.svg` except for `dir` which is the + directory to save the SVG file to (it will be passed to `tempfile.mkstemp`). + """ + # first look for SVG file in cache + if dir is None: + *_, svg_cache_key = _svg_keys(key, locals()) + if path := _svg_path(svg_cache_key): + # if it exists return that string + return path + + # otherwise, we need to download it and save it to a temporary file svg_bytes = svg( *key, color=color, height=height, width=width, flip=flip, rotate=rotate, box=box ) - if not prefix: - prefix = f"pyconify_{'-'.join(key)}".replace(":", "-") - - fd, tmp_name = tempfile.mkstemp(prefix=prefix, suffix=".svg", dir=dir) + # make a temporary file + file_prefix = f"pyconify_{'-'.join(key)}".replace(":", "-") + fd, tmp_name = tempfile.mkstemp(prefix=file_prefix, suffix=".svg", dir=str(dir)) with os.fdopen(fd, "wb") as f: f.write(svg_bytes) + # cleanup the temporary file when the program exits @atexit.register def _remove_tmp_svg() -> None: with suppress(FileNotFoundError): # pragma: no cover os.remove(tmp_name) - return tmp_name + return Path(tmp_name) @lru_cache(maxsize=None) @@ -302,7 +343,7 @@ def css( return resp.text -def icon_data(prefix: str, *names: str) -> IconifyJSON: +def icon_data(*keys: str) -> IconifyJSON: """Return icon data for `names` in `prefix`. https://iconify.design/docs/api/icon-data.html @@ -314,11 +355,13 @@ def icon_data(prefix: str, *names: str) -> IconifyJSON: Parameters ---------- - prefix : str - Icon set prefix. + keys : str + Icon set prefix and name(s). May be passed as a single string in the format + `"prefix:icon"` or as multiple strings: `'prefix', 'icon1', 'icon2'`. names : str, optional Icon name(s). """ + prefix, names = _split_prefix_name(keys, allow_many=True) resp = requests.get(f"{ROOT}/{prefix}.json?icons={','.join(names)}") resp.raise_for_status() if (content := resp.json()) == 404: @@ -480,15 +523,16 @@ def _split_prefix_name( if not key: raise ValueError("icon key must be at least one string.") if len(key) == 1: - if ":" in key[0]: - return tuple(key[0].split(":", maxsplit=1)) # type: ignore - else: + if ":" not in key[0]: raise ValueError( "Single-argument icon names must be in the format 'prefix:name'. " f"Got {key[0]!r}" ) - elif len(key) == 2: - return cast("tuple[str, str]", key) - elif not allow_many: - raise ValueError("icon key must be either 1 or 2 arguments.") - return key[0], key[1:] + prefix, name = key[0].split(":", maxsplit=1) + return (prefix, (name,)) if allow_many else (prefix, name) + prefix, *rest = key + if not allow_many: + if len(rest) > 1: + raise ValueError("icon key must be either 1 or 2 arguments.") + return prefix, rest[0] + return prefix, tuple(rest) diff --git a/tests/conftest.py b/tests/conftest.py index 8c40949..32c0ec2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,12 @@ from unittest.mock import patch import pytest -from pyconify import api +from pyconify import _cache, api @pytest.fixture(autouse=True, scope="session") -def no_cache() -> Iterator[None]: - TEST_CACHE: dict = {} +def no_cache(tmp_path_factory: pytest.TempPathFactory) -> Iterator[None]: + tmp = tmp_path_factory.mktemp("pyconify") + TEST_CACHE = _cache._SVGCache(directory=tmp) with patch.object(api, "svg_cache", lambda: TEST_CACHE): yield - assert TEST_CACHE diff --git a/tests/test_cache.py b/tests/test_cache.py index 3d4929c..426d4df 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,12 +1,17 @@ from pathlib import Path +from unittest.mock import patch import pytest +from pyconify import _cache from pyconify._cache import _SVGCache, clear_cache, get_cache_directory -def test_cache(tmp_path) -> None: +def test_cache(tmp_path: Path) -> None: assert isinstance(get_cache_directory(), Path) - clear_cache() + + # don't delete the real cache, regardless of other monkeypatching + with patch.object(_cache, "get_cache_directory", lambda: tmp_path / "tmp"): + clear_cache() cache = _SVGCache(tmp_path) KEY, VAL = "testkey", b"testval" @@ -20,3 +25,15 @@ def test_cache(tmp_path) -> None: with pytest.raises(KeyError): cache["not a key"] + + +def test_cache_dir(monkeypatch) -> None: + some_path = Path("/some/path").expanduser().resolve() + monkeypatch.setattr(_cache, "PYCONIFY_CACHE", str(some_path)) + assert get_cache_directory() == some_path + + +def test_delete_stale() -> None: + cache = {"fa_0": b""} + _cache._delete_stale_svgs(cache) + assert not cache diff --git a/tests/test_pyconify.py b/tests/test_pyconify.py index 5549db5..4e6b58d 100644 --- a/tests/test_pyconify.py +++ b/tests/test_pyconify.py @@ -1,3 +1,5 @@ +from pathlib import Path + import pyconify import pytest @@ -36,11 +38,17 @@ def test_svg() -> None: pyconify.svg("not", "found") -def test_tmp_svg() -> None: - result = pyconify.temp_svg("bi", "alarm", rotate=90, box=True) - assert isinstance(result, str) - with open(result, "rb") as f: - assert f.read() == pyconify.svg("bi", "alarm", rotate=90, box=True) +def test_tmp_svg(tmp_path) -> None: + result1 = pyconify.svg_path("bi", "alarm", rotate=90, box=True) + assert isinstance(result1, Path) + assert result1.read_bytes() == pyconify.svg("bi", "alarm", rotate=90, box=True) + + # this one shouldn't be in the cache at this point + result2 = pyconify.svg_path("bi", "alarm", rotate=90, box=True, dir=tmp_path) + assert isinstance(result2, Path) + assert result2.parent == tmp_path + assert result2 != result1 + assert result2.read_bytes() == pyconify.svg("bi", "alarm", rotate=90, box=True) def test_css() -> None: @@ -65,7 +73,7 @@ def test_css() -> None: def test_last_modified() -> None: - assert isinstance(pyconify.last_modified("bi")["lastModified"]["bi"], int) + assert isinstance(pyconify.last_modified("bi")["bi"], int) def test_keywords() -> None: