Skip to content

Commit

Permalink
add cache (#2)
Browse files Browse the repository at this point in the history
* wip

* working on cache

* finish cache

* test windows

* test mac

* additional windows

* use different windows path
  • Loading branch information
tlambert03 authored Sep 30, 2023
1 parent 5911b48 commit 7fe9303
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 8 deletions.
9 changes: 7 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:",
Expand Down
3 changes: 3 additions & 0 deletions src/pyconify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -22,6 +24,7 @@
"temp_svg",
]

from ._cache import clear_cache, get_cache_directory
from .api import (
collection,
collections,
Expand Down
81 changes: 81 additions & 0 deletions src/pyconify/_cache.py
Original file line number Diff line number Diff line change
@@ -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()
24 changes: 19 additions & 5 deletions src/pyconify/api.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Wrapper for api calls at https://api.iconify.design/."""

from __future__ import annotations

import atexit
Expand All @@ -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,
Expand Down Expand Up @@ -110,15 +111,15 @@ 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,
height: str | int | None = None,
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.
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
File renamed without changes.
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -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"]

0 comments on commit 7fe9303

Please sign in to comment.