From 05163fe00f5f893828febcc1d14267c7de6b4296 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Tue, 10 Oct 2023 13:24:24 -0400 Subject: [PATCH] feat: add `freedesktop_theme` convenience to generate a theme folder (#8) * update docs * main export * fix tests * fix coverage * update readme --- .github/workflows/ci.yml | 10 + README.md | 43 +++ pyproject.toml | 1 + src/pyconify/__init__.py | 2 + src/pyconify/api.py | 5 +- src/pyconify/freedesktop.py | 538 ++++++++++++++++++++++++++++++++++ src/pyconify/iconify_types.py | 1 + tests/conftest.py | 28 +- tests/test_freedesktop.py | 64 ++++ tests/test_pyconify.py | 12 +- 10 files changed, 684 insertions(+), 20 deletions(-) create mode 100644 src/pyconify/freedesktop.py create mode 100644 tests/test_freedesktop.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7718541..2e9acb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: platform: windows-latest - python-version: "3.10" platform: macos-latest + with-qt: true steps: - uses: actions/checkout@v4 @@ -48,6 +49,15 @@ jobs: - name: ๐Ÿงช Run Tests run: pytest --color=yes --cov --cov-report=xml --cov-report=term-missing + - name: Test Qt + if: matrix.with-qt == 'true' + run: | + # Note pyside2 cannot be installed with PyQt5 + pip install PyQt5 PyQt6 + pytest -k PyQt --cov --cov-report=xml --cov-append + pip install PySide2 PySide6 + pytest -k PySide --cov --cov-report=xml --cov-append + # If something goes wrong with --pre tests, we can open an issue in the repo - name: ๐Ÿ“ Report --pre Failures if: failure() && github.event_name == 'schedule' diff --git a/README.md b/README.md index 61dc60c..6a36abc 100644 --- a/README.md +++ b/README.md @@ -97,3 +97,46 @@ 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`. + +### freedesktop themes + +`pyconify` includes a convenience function to generate a directory of SVG files +in the [freedesktop icon theme specification](https://specifications.freedesktop.org/icon-theme-spec/icon-theme-spec-latest.html) + +It takes a mapping of names from the [icon naming spec](https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html) +to iconify keys (e.g. `"prefix:icon"`). Icons will be placed in the +appropriate freedesktop subdirectory based on the icon name. Unknown icons will be placed +in the `other` subdirectory. + +```python +from pyconify import freedesktop_theme +from pyconify.api import svg +icons = { + "edit-copy": "ic:sharp-content-copy", + "edit-delete": {"key": "ic:sharp-delete", "color": "red"}, + "weather-overcast": "ic:sharp-cloud", + "weather-clear": "ic:sharp-wb-sunny", + "bell": "bi:bell", +} +folder = freedesktop_theme( + "mytheme", + icons, + base_directory="~/Desktop/icons", +) +``` + +would create + +``` +~/Desktop/icons/ +โ”œโ”€โ”€ mytheme +โ”‚ โ”œโ”€โ”€ actions +โ”‚ โ”‚ โ”œโ”€โ”€ edit-copy.svg +โ”‚ โ”‚ โ””โ”€โ”€ edit-delete.svg +โ”‚ โ”œโ”€โ”€ status +โ”‚ โ”‚ โ”œโ”€โ”€ weather-clear.svg +โ”‚ โ”‚ โ””โ”€โ”€ weather-overcast.svg +โ”‚ โ””โ”€โ”€ other +โ”‚ โ””โ”€โ”€ bell.svg +โ””โ”€โ”€ index.theme +``` diff --git a/pyproject.toml b/pyproject.toml index c0696ce..0288cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true +enable_incomplete_feature = ["Unpack"] # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] diff --git a/src/pyconify/__init__.py b/src/pyconify/__init__.py index 4f86bad..21fdab3 100644 --- a/src/pyconify/__init__.py +++ b/src/pyconify/__init__.py @@ -22,6 +22,7 @@ "search", "svg", "svg_path", + "freedesktop_theme", ] from ._cache import clear_cache, get_cache_directory @@ -37,3 +38,4 @@ svg, svg_path, ) +from .freedesktop import freedesktop_theme diff --git a/src/pyconify/api.py b/src/pyconify/api.py index fb51d8d..a82d5f3 100644 --- a/src/pyconify/api.py +++ b/src/pyconify/api.py @@ -23,6 +23,7 @@ APIv2CollectionResponse, APIv2SearchResponse, APIv3KeywordsResponse, + Flip, IconifyInfo, IconifyJSON, Rotation, @@ -133,7 +134,7 @@ def svg( color: str | None = None, height: str | int | None = None, width: str | int | None = None, - flip: Literal["horizontal", "vertical", "horizontal,vertical"] | None = None, + flip: Flip | None = None, rotate: Rotation | None = None, box: bool | None = None, ) -> bytes: @@ -237,7 +238,7 @@ def svg_path( color: str | None = None, height: str | int | None = None, width: str | int | None = None, - flip: Literal["horizontal", "vertical", "horizontal,vertical"] | None = None, + flip: Flip | None = None, rotate: Rotation | None = None, box: bool | None = None, dir: str | Path | None = None, diff --git a/src/pyconify/freedesktop.py b/src/pyconify/freedesktop.py new file mode 100644 index 0000000..1819dfc --- /dev/null +++ b/src/pyconify/freedesktop.py @@ -0,0 +1,538 @@ +from __future__ import annotations + +import atexit +import shutil +from pathlib import Path +from tempfile import mkdtemp +from typing import TYPE_CHECKING, Any, Mapping + +from pyconify.api import svg + +if TYPE_CHECKING: + from typing_extensions import Required, TypedDict, Unpack + + from .iconify_types import Flip, Rotation + + class SVGKwargs(TypedDict, total=False): + """Keyword arguments for the svg function.""" + + color: str | None + height: str | int | None + width: str | int | None + flip: Flip | None + rotate: Rotation | None + box: bool | None + + class SVGKwargsWithKey(SVGKwargs, total=False): + """Keyword arguments for the svg function, with mandatory key.""" + + key: Required[str] + + +MISC_DIR = "other" +HEADER = """ +[Icon Theme] +Name={name} +Comment={comment} +Directories={directories} +""" +SUBDIR = """ +[{directory}] +Size=16 +MinSize=8 +MaxSize=512 +Type=Scalable +""" + + +def freedesktop_theme( + name: str, + icons: Mapping[str, str | SVGKwargsWithKey], + comment: str = "pyconify-generated icon theme", + base_directory: Path | str | None = None, + **kwargs: Unpack[SVGKwargs], +) -> Path: + """Create a freedesktop compliant theme folder. + + This function accepts a mapping of freedesktop icon name to iconify keys (or a dict + of keyword arguments for the `pyconify.svg` function). A new theme directory will + be created in the `base_directory` with the given `name`. The theme will contain + a number of sub-directories, each containing the icons for that category. An + `index.theme` file will also be created in the theme directory. + + Categories are determined by the icon name, and are mapped to directories using the + freedesktop [icon naming + specification](https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html). + For example, the if the key "edit-clear" appears in `icons`, the corresponding icon + will be placed in the "actions" directory, whereas the key + "accessories-calculator" would appear in the apps directory. Unrecognized keys + will be placed in the "other" directory. See the examples below for more details. + + Parameters + ---------- + name : str + The name of the theme. A directory with this name will be created inside the + `base_directory`. + icons : Mapping[str, str | SVGKwargsWithKey] + A mapping of freedesktop icon names to icon names or keyword arguments for + the svg function. See note above and example below. Unrecognized keys are + allowed, and will be placed in the "other" directory. + comment : str, optional + The comment for the index.theme, by default "pyconify-generated icon theme". + base_directory : Path | str | None, optional + The directory in which to create the theme. If `None`, a temporary directory + will be created, and deleted when the program exits. By default `None`. + **kwargs : Unpack[SVGKwargs] + Keyword arguments for the `pyconify.svg` function. These will be passed to + the svg function for each icon (unless overridden by the value in the `icons` + mapping). + + Returns + ------- + Path + The path to the *base* directory of the theme. (NOT the path to the theme + sub-directory which will have been created inside the base) + + Examples + -------- + Pass a theme name and a mapping of freedesktop icon names to iconify keys or + keyword arguments: + + ```python + from pyconify import freedesktop_theme + from pyconify.api import svg + icons = { + "edit-copy": "ic:sharp-content-copy", + "edit-delete": {"key": "ic:sharp-delete", "color": "red"}, + "weather-overcast": "ic:sharp-cloud", + "weather-clear": "ic:sharp-wb-sunny", + "bell": "bi:bell", + } + folder = freedesktop_theme( + "mytheme", + icons, + base_directory="~/Desktop/icons", + ) + ``` + + This will create a folder structure as shown below. Note that the `index.theme` + file is also created, and files are placed in the appropriate freedesktop + sub-directories. Unkown keys (like 'bell' in the example above) are placed in the + "other" directory. + + ``` + ~/Desktop/icons/ + โ”œโ”€โ”€ mytheme + โ”‚ โ”œโ”€โ”€ actions + โ”‚ โ”‚ โ”œโ”€โ”€ edit-copy.svg + โ”‚ โ”‚ โ””โ”€โ”€ edit-delete.svg + โ”‚ โ”œโ”€โ”€ status + โ”‚ โ”‚ โ”œโ”€โ”€ weather-clear.svg + โ”‚ โ”‚ โ””โ”€โ”€ weather-overcast.svg + โ”‚ โ””โ”€โ”€ other + โ”‚ โ””โ”€โ”€ bell.svg + โ””โ”€โ”€ index.theme + ``` + + Note that this folder may be used as a theme in Qt applications: + + ```python + from qtpy.QtGui import QIcon + from qtpy.QtWidgets import QApplication, QPushButton + + app = QApplication([]) + + QIcon.setThemeSearchPaths([str(folder)]) + QIcon.setThemeName("mytheme") + + button = QPushButton() + button.setIcon(QIcon.fromTheme("edit-clear")) + button.show() + + app.exec() + ``` + """ + if base_directory is None: + base = Path(mkdtemp(prefix="pyconify-theme-icons")) + + @atexit.register + def _cleanup() -> None: # pragma: no cover + shutil.rmtree(base, ignore_errors=True) + + else: + base = Path(base_directory).expanduser().resolve() + base.mkdir(parents=True, exist_ok=True) + + theme_dir = base / name + theme_dir.mkdir(parents=True, exist_ok=True) + + dirs: set[str] = set() + for file_name, _svg_kwargs in icons.items(): + # determine which directory to put the icon in + file_key = file_name.lower().replace(".svg", "") + subdir = FREEDESKTOP_ICON_TO_DIR.get(file_key, MISC_DIR) + dest = theme_dir / subdir + # create the directory if it doesn't exist + dest.mkdir(parents=True, exist_ok=True) + # add the directory to the list of directories + dirs.add(subdir) + + # write the svg file + if isinstance(_svg_kwargs, Mapping): + _kwargs: Any = {**kwargs, **_svg_kwargs} + if "key" not in _kwargs: + raise ValueError("Expected 'key' in kwargs") # pragma: no cover + key = _kwargs.pop("key") # must be present + else: + if not isinstance(_svg_kwargs, str): # pragma: no cover + raise TypeError(f"Expected icon name or dict, got {type(_svg_kwargs)}") + key, _kwargs = _svg_kwargs, kwargs + (dest / file_name).with_suffix(".svg").write_bytes(svg(key, **_kwargs)) + + sorted_dirs = sorted(dirs) + index = theme_dir / "index.theme" + index_text = HEADER.format( + name=name, + comment=comment, + directories=",".join(map(str.lower, sorted_dirs)), + ) + for directory in sorted_dirs: + index_text += SUBDIR.format(directory=directory.lower()) + if context := FREEDESKTOP_DIR_TO_CTX.get(directory): + index_text += f"Context={context}\n" + + index.write_text(index_text) + return base + + +# https://specifications.freedesktop.org/icon-naming-spec/icon-naming-spec-latest.html + +# mapping of directory name to Context +FREEDESKTOP_DIR_TO_CTX: dict[str, str] = { + "actions": "Actions", + "animations": "Animations", + "apps": "Applications", + "categories": "Categories", + "devices": "Devices", + "emblems": "Emblems", + "emotes": "Emotes", + "intl": "International", + "mimetypes": "MimeTypes", + "places": "Places", + "status": "Status", + MISC_DIR: "Other", +} + +# mapping of directory name to icon names +FREEDESKTOP_DIR_ICONS: dict[str, set[str]] = { + "actions": { + "address-book-new", + "application-exit", + "appointment-new", + "call-start", + "call-stop", + "contact-new", + "document-new", + "document-open", + "document-open-recent", + "document-page-setup", + "document-print", + "document-print-preview", + "document-properties", + "document-revert", + "document-save", + "document-save-as", + "document-send", + "edit-clear", + "edit-copy", + "edit-cut", + "edit-delete", + "edit-find", + "edit-find-replace", + "edit-paste", + "edit-redo", + "edit-select-all", + "edit-undo", + "folder-new", + "format-indent-less", + "format-indent-more", + "format-justify-center", + "format-justify-fill", + "format-justify-left", + "format-justify-right", + "format-text-direction-ltr", + "format-text-direction-rtl", + "format-text-bold", + "format-text-italic", + "format-text-underline", + "format-text-strikethrough", + "go-bottom", + "go-down", + "go-first", + "go-home", + "go-jump", + "go-last", + "go-next", + "go-previous", + "go-top", + "go-up", + "help-about", + "help-contents", + "help-faq", + "insert-image", + "insert-link", + "insert-object", + "insert-text", + "list-add", + "list-remove", + "mail-forward", + "mail-mark-important", + "mail-mark-junk", + "mail-mark-notjunk", + "mail-mark-read", + "mail-mark-unread", + "mail-message-new", + "mail-reply-all", + "mail-reply-sender", + "mail-send", + "mail-send-receive", + "media-eject", + "media-playback-pause", + "media-playback-start", + "media-playback-stop", + "media-record", + "media-seek-backward", + "media-seek-forward", + "media-skip-backward", + "media-skip-forward", + "object-flip-horizontal", + "object-flip-vertical", + "object-rotate-left", + "object-rotate-right", + "process-stop", + "system-lock-screen", + "system-log-out", + "system-run", + "system-search", + "system-reboot", + "system-shutdown", + "tools-check-spelling", + "view-fullscreen", + "view-refresh", + "view-restore", + "view-sort-ascending", + "view-sort-descending", + "window-close", + "window-new", + "zoom-fit-best", + "zoom-in", + "zoom-original", + "zoom-out", + }, + "animations": { + "process-working", + }, + "apps": { + "accessories-calculator", + "accessories-character-map", + "accessories-dictionary", + "accessories-text-editor", + "help-browser", + "multimedia-volume-control", + "preferences-desktop-accessibility", + "preferences-desktop-font", + "preferences-desktop-keyboard", + "preferences-desktop-locale", + "preferences-desktop-multimedia", + "preferences-desktop-screensaver", + "preferences-desktop-theme", + "preferences-desktop-wallpaper", + "system-file-manager", + "system-software-install", + "system-software-update", + "utilities-system-monitor", + "utilities-terminal", + }, + "categories": { + "applications-accessories", + "applications-development", + "applications-engineering", + "applications-games", + "applications-graphics", + "applications-internet", + "applications-multimedia", + "applications-office", + "applications-other", + "applications-science", + "applications-system", + "applications-utilities", + "preferences-desktop", + "preferences-desktop-peripherals", + "preferences-desktop-personal", + "preferences-other", + "preferences-system", + "preferences-system-network", + "system-help", + }, + "devices": { + "audio-card", + "audio-input-microphone", + "battery", + "camera-photo", + "camera-video", + "camera-web", + "computer", + "drive-harddisk", + "drive-optical", + "drive-removable-media", + "input-gaming", + "input-keyboard", + "input-mouse", + "input-tablet", + "media-flash", + "media-floppy", + "media-optical", + "media-tape", + "modem", + "multimedia-player", + "network-wired", + "network-wireless", + "pda", + "phone", + "printer", + "scanner", + "video-display", + }, + "emblems": { + "emblem-default", + "emblem-documents", + "emblem-downloads", + "emblem-favorite", + "emblem-important", + "emblem-mail", + "emblem-photos", + "emblem-readonly", + "emblem-shared", + "emblem-symbolic-link", + "emblem-synchronized", + "emblem-system", + "emblem-unreadable", + }, + "emotes": { + "face-angel", + "face-angry", + "face-cool", + "face-crying", + "face-devilish", + "face-embarrassed", + "face-kiss", + "face-laugh", + "face-monkey", + "face-plain", + "face-raspberry", + "face-sad", + "face-sick", + "face-smile", + "face-smile-big", + "face-smirk", + "face-surprise", + "face-tired", + "face-uncertain", + "face-wink", + "face-worried", + }, + "mimetypes": { + "application-x-executable", + "audio-x-generic", + "font-x-generic", + "image-x-generic", + "package-x-generic", + "text-html", + "text-x-generic", + "text-x-generic-template", + "text-x-script", + "video-x-generic", + "x-office-address-book", + "x-office-calendar", + "x-office-document", + "x-office-presentation", + "x-office-spreadsheet", + }, + "places": { + "folder", + "folder-remote", + "network-server", + "network-workgroup", + "start-here", + "user-bookmarks", + "user-desktop", + "user-home", + "user-trash", + }, + "status": { + "appointment-missed", + "appointment-soon", + "audio-volume-high", + "audio-volume-low", + "audio-volume-medium", + "audio-volume-muted", + "battery-caution", + "battery-low", + "dialog-error", + "dialog-information", + "dialog-password", + "dialog-question", + "dialog-warning", + "folder-drag-accept", + "folder-open", + "folder-visiting", + "image-loading", + "image-missing", + "mail-attachment", + "mail-unread", + "mail-read", + "mail-replied", + "mail-signed", + "mail-signed-verified", + "media-playlist-repeat", + "media-playlist-shuffle", + "network-error", + "network-idle", + "network-offline", + "network-receive", + "network-transmit", + "network-transmit-receive", + "printer-error", + "printer-printing", + "security-high", + "security-medium", + "security-low", + "software-update-available", + "software-update-urgent", + "sync-error", + "sync-synchronizing", + "task-due", + "task-past-due", + "user-available", + "user-away", + "user-idle", + "user-offline", + "user-trash-full", + "weather-clear", + "weather-clear-night", + "weather-few-clouds", + "weather-few-clouds-night", + "weather-fog", + "weather-overcast", + "weather-severe-alert", + "weather-showers", + "weather-showers-scattered", + "weather-snow", + "weather-storm", + }, +} + +# reverse mapping of icon name to directory name +FREEDESKTOP_ICON_TO_DIR: dict[str, str] = { + icn: dir_ for dir_, icons in FREEDESKTOP_DIR_ICONS.items() for icn in icons +} diff --git a/src/pyconify/iconify_types.py b/src/pyconify/iconify_types.py index 180b4dc..695406d 100644 --- a/src/pyconify/iconify_types.py +++ b/src/pyconify/iconify_types.py @@ -11,6 +11,7 @@ from typing import Literal, NotRequired, Required, TypedDict Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3] + Flip = Literal["horizontal", "vertical", "horizontal,vertical"] class Author(TypedDict, total=False): """Author information.""" diff --git a/tests/conftest.py b/tests/conftest.py index 82c88ba..c0eb71c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,21 +1,35 @@ +import shutil from pathlib import Path from typing import Iterator +from unittest.mock import patch import pytest -from pyconify import get_cache_directory +from pyconify import _cache, api, get_cache_directory -@pytest.fixture(autouse=True, scope="session") +@pytest.fixture +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 + + +@pytest.fixture(autouse=True) def ensure_no_cache() -> Iterator[None]: """Ensure that tests don't modify the user cache.""" cache_dir = Path(get_cache_directory()) - exists = cache_dir.exists() - if exists: + existed = cache_dir.exists() + if existed: # get hash of cache directory cache_hash = hash(tuple(cache_dir.rglob("*"))) try: yield finally: - assert cache_dir.exists() == exists, "Cache directory was created or deleted" - if exists and cache_hash != hash(tuple(cache_dir.rglob("*"))): - raise AssertionError("User Cache directory was modified") + if existed: + assert cache_dir.exists() == existed, "Cache directory was deleted" + if cache_hash != hash(tuple(cache_dir.rglob("*"))): + raise AssertionError("User Cache directory was modified") + elif cache_dir.exists(): + shutil.rmtree(cache_dir, ignore_errors=True) + raise AssertionError("Cache directory was created outside of test fixtures") diff --git a/tests/test_freedesktop.py b/tests/test_freedesktop.py new file mode 100644 index 0000000..d81b2f0 --- /dev/null +++ b/tests/test_freedesktop.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pyconify import freedesktop_theme + +if TYPE_CHECKING: + from pathlib import Path + +ICONS = { + "edit-clear": "ic:sharp-clear", + "edit-delete": {"key": "ic:sharp-delete", "color": "red"}, + "weather-overcast": "ic:sharp-cloud", + "bell": "bi:bell", +} + + +@pytest.mark.usefixtures("no_cache") +def test_freedesktop(tmp_path: Path) -> None: + d = freedesktop_theme("mytheme", ICONS, base_directory=tmp_path, comment="asdff") + assert d == tmp_path + theme_dir = tmp_path / "mytheme" + assert theme_dir.exists() + assert theme_dir.is_dir() + + # index.theme is created + index = theme_dir / "index.theme" + assert index.exists() + index_txt = index.read_text() + assert "asdff" in index_txt + assert "Directories=actions,other,status" in index_txt + + # files are put in their proper freedesktop subdirs + svgs = set(theme_dir.rglob("*.svg")) + assert theme_dir / "actions" / "edit-clear.svg" in svgs + assert theme_dir / "status" / "weather-overcast.svg" in svgs + assert theme_dir / "other" / "bell.svg" in svgs + + +@pytest.mark.usefixtures("no_cache") +def test_freedesktop_tmp_dir() -> None: + d = freedesktop_theme("mytheme", ICONS, comment="asdff") + assert d.exists() + + +@pytest.mark.usefixtures("no_cache") +@pytest.mark.parametrize("backend", ["PySide2", "PyQt5", "PyQt6", "PySide6"]) +def test_freedesktop_qt(backend: str, tmp_path: Path) -> None: + """Test that the created folder works as a Qt Theme.""" + QtGui = pytest.importorskip(f"{backend}.QtGui", reason=f"requires {backend}") + + app = QtGui.QGuiApplication([]) + d = freedesktop_theme("mytheme", ICONS, base_directory=tmp_path, comment="comment") + assert d == tmp_path + + QtGui.QIcon.setThemeSearchPaths([str(d)]) + QtGui.QIcon.setThemeName("mytheme") + assert QtGui.QIcon.hasThemeIcon("bell") + assert not QtGui.QIcon.fromTheme("bell").isNull() + assert QtGui.QIcon.hasThemeIcon("edit-clear") + assert not QtGui.QIcon.fromTheme("edit-clear").isNull() + assert not QtGui.QIcon.hasThemeIcon("nevvvvvver-gonna-be-there") + app.quit() diff --git a/tests/test_pyconify.py b/tests/test_pyconify.py index dbe35d3..cbf3463 100644 --- a/tests/test_pyconify.py +++ b/tests/test_pyconify.py @@ -1,18 +1,8 @@ from pathlib import Path -from typing import Iterator -from unittest.mock import patch import pyconify import pytest -from pyconify import _cache, api - - -@pytest.fixture -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 +from pyconify import _cache def test_collections() -> None: