diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b409a6..c2586bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,8 +23,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.12-dev"] - platform: [ubuntu-latest] + include: + - python-version: "3.8" + platform: windows-latest + - python-version: "3.10" + platform: macos-latest + - python-version: "3.12-dev" + platform: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 3d8f210..c0696ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = ["requests"] [project.optional-dependencies] test = ["pytest", "pytest-cov"] -dev = ["black", "ipython", "mypy", "pdbpp", "rich", "ruff"] +dev = ["black", "ipython", "mypy", "pdbpp", "rich", "ruff", "types-requests"] [project.urls] homepage = "https://github.com/pyapp-kit/pyconify" @@ -92,6 +92,7 @@ filterwarnings = ["error"] # https://coverage.readthedocs.io/en/6.4/config.html [tool.coverage.report] +show_missing = true exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", diff --git a/src/pyconify/__init__.py b/src/pyconify/__init__.py index 0aa6398..2fd52c8 100644 --- a/src/pyconify/__init__.py +++ b/src/pyconify/__init__.py @@ -10,9 +10,11 @@ __author__ = "Talley Lambert" __email__ = "talley.lambert@gmail.com" __all__ = [ + "clear_cache", "collection", "collections", "css", + "get_cache_directory", "icon_data", "iconify_version", "keywords", @@ -22,6 +24,7 @@ "temp_svg", ] +from ._cache import clear_cache, get_cache_directory from .api import ( collection, collections, diff --git a/src/pyconify/_cache.py b/src/pyconify/_cache.py new file mode 100644 index 0000000..b4f13d6 --- /dev/null +++ b/src/pyconify/_cache.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Iterator, MutableMapping + +_SVG_CACHE: MutableMapping[str, bytes] | None = None + + +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: + _SVG_CACHE = {} + return _SVG_CACHE + + +def clear_cache() -> None: + """Clear the pyconify svg cache.""" + import shutil + + shutil.rmtree(get_cache_directory(), ignore_errors=True) + global _SVG_CACHE + _SVG_CACHE = None + + +def get_cache_directory(app_name: str = "pyconify") -> Path: + """Return the pyconify svg cache directory.""" + if os.name == "posix": + return Path.home() / ".cache" / app_name + elif os.name == "nt": + appdata = os.environ.get("LOCALAPPDATA", "~/AppData/Local") + return Path(appdata).expanduser() / app_name + # Fallback to a directory in the user's home directory + return Path.home() / f".{app_name}" # pragma: no cover + + +def cache_key(args: tuple, kwargs: dict) -> 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)) + + +class _SVGCache(MutableMapping[str, bytes]): + """A simple directory cache for SVG files.""" + + def __init__(self, directory: str | Path | None = None) -> None: + super().__init__() + if not directory: + directory = get_cache_directory() / "svg_cache" # pragma: no cover + self.path = Path(directory).expanduser().resolve() + self.path.mkdir(parents=True, exist_ok=True) + self._extention = ".svg" + + def __setitem__(self, _key: str, _value: bytes) -> None: + self.path.joinpath(f"{_key}{self._extention}").write_bytes(_value) + + def __getitem__(self, _key: str) -> bytes: + try: + return self.path.joinpath(f"{_key}{self._extention}").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}")) + + def __delitem__(self, _key: str) -> None: + self.path.joinpath(f"{_key}{self._extention}").unlink() + + def __len__(self) -> int: + return len(list(self.path.glob("*{self._extention}"))) + + def __contains__(self, _key: object) -> bool: + return self.path.joinpath(f"{_key}{self._extention}").exists() diff --git a/src/pyconify/api.py b/src/pyconify/api.py index f51d45d..fbfdb9b 100644 --- a/src/pyconify/api.py +++ b/src/pyconify/api.py @@ -1,5 +1,4 @@ """Wrapper for api calls at https://api.iconify.design/.""" - from __future__ import annotations import atexit @@ -11,12 +10,14 @@ import requests +from ._cache import cache_key, svg_cache + if TYPE_CHECKING: from typing import Callable, TypeVar F = TypeVar("F", bound=Callable) - from .types import ( + from .iconify_types import ( APIv2CollectionResponse, APIv2SearchResponse, APIv3KeywordsResponse, @@ -110,7 +111,7 @@ def last_modified(*prefixes: str) -> APIv3LastModifiedResponse: return resp.json() # type: ignore -@lru_cache(maxsize=None) +# this function uses a special cache inside the body of the function def svg( *key: str, color: str | None = None, @@ -118,7 +119,7 @@ def svg( width: str | int | None = None, flip: Literal["horizontal", "vertical", "horizontal,vertical"] | None = None, rotate: Rotation | None = None, - box: bool = False, + box: bool | None = None, ) -> bytes: """Generate SVG for icon. @@ -155,6 +156,14 @@ def svg( pixels and icon's group ends up being smaller than actual icon, making it harder 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 = _split_prefix_name(key) if rotate not in (None, 1, 2, 3): rotate = str(rotate).replace("deg", "") + "deg" # type: ignore @@ -171,6 +180,9 @@ def svg( resp.raise_for_status() if resp.content == b"404": raise requests.HTTPError(f"Icon '{prefix}:{name}' not found.", response=resp) + + # cache response and return + cache[_key] = resp.content return resp.content @@ -182,7 +194,7 @@ def temp_svg( width: str | int | None = None, flip: Literal["horizontal", "vertical", "horizontal,vertical"] | None = None, rotate: Rotation | None = None, - box: bool = False, + box: bool | None = None, prefix: str | None = None, dir: str | None = None, ) -> str: @@ -465,6 +477,8 @@ def _split_prefix_name( >>> _split_prefix_name(("mdi:account",)) ("mdi", "account") """ + 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 diff --git a/src/pyconify/types.py b/src/pyconify/iconify_types.py similarity index 100% rename from src/pyconify/types.py rename to src/pyconify/iconify_types.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8c40949 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,13 @@ +from typing import Iterator +from unittest.mock import patch + +import pytest +from pyconify import api + + +@pytest.fixture(autouse=True, scope="session") +def no_cache() -> Iterator[None]: + TEST_CACHE: dict = {} + 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 new file mode 100644 index 0000000..3d4929c --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import pytest +from pyconify._cache import _SVGCache, clear_cache, get_cache_directory + + +def test_cache(tmp_path) -> None: + assert isinstance(get_cache_directory(), Path) + clear_cache() + + cache = _SVGCache(tmp_path) + KEY, VAL = "testkey", b"testval" + cache[KEY] = VAL + assert cache[KEY] == VAL + assert cache.path.joinpath(f"{KEY}.svg").exists() + assert list(cache) == [KEY] + assert KEY in cache + del cache[KEY] + assert not cache.path.joinpath(f"{KEY}.svg").exists() + + with pytest.raises(KeyError): + cache["not a key"]