pax_global_header00006660000000000000000000000064147357711440014527gustar00rootroot0000000000000052 comment=9716aa90b9adbcfbde759f25bb9a8b6191c41cdc pyconify-0.2/000077500000000000000000000000001473577114400132305ustar00rootroot00000000000000pyconify-0.2/.github/000077500000000000000000000000001473577114400145705ustar00rootroot00000000000000pyconify-0.2/.github/ISSUE_TEMPLATE.md000066400000000000000000000004771473577114400173050ustar00rootroot00000000000000* pyconify version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` pyconify-0.2/.github/TEST_FAIL_TEMPLATE.md000066400000000000000000000006001473577114400177530ustar00rootroot00000000000000--- title: "{{ env.TITLE }}" labels: [bug] --- The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} with commit: {{ sha }} Full run: https://github.com/{{ repo }}/actions/runs/{{ env.RUN_ID }} (This post will be updated if another test fails, as long as this issue remains open.) pyconify-0.2/.github/dependabot.yml000066400000000000000000000004241473577114400174200ustar00rootroot00000000000000# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "ci(dependabot):" pyconify-0.2/.github/workflows/000077500000000000000000000000001473577114400166255ustar00rootroot00000000000000pyconify-0.2/.github/workflows/ci.yml000066400000000000000000000040411473577114400177420ustar00rootroot00000000000000name: CI on: push: branches: [main] tags: [v*] pull_request: workflow_dispatch: schedule: # run every week (for --pre release tests) - cron: "0 0 * * 0" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 secrets: inherit with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} pip-install-pre-release: ${{ github.event_name == 'schedule' }} report-failures: ${{ github.event_name == 'schedule' }} strategy: fail-fast: false matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: [ubuntu-latest] include: - python-version: "3.9" os: windows-latest - python-version: "3.9" os: macos-latest test-qt: uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 secrets: inherit with: os: ubuntu-latest python-version: "3.10" qt: ${{ matrix.qt }} pytest-args: "-k Py" pip-install-pre-release: ${{ github.event_name == 'schedule' }} report-failures: ${{ github.event_name == 'schedule' }} strategy: fail-fast: false matrix: qt: ["PyQt5", "PyQt6", "PySide2", "PySide6"] deploy: name: Deploy needs: test if: success() && startsWith(github.ref, 'refs/tags/') && github.event_name != 'schedule' runs-on: ubuntu-latest permissions: id-token: write contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: 🐍 Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: 👷 Build run: | python -m pip install build python -m build - name: 🚢 Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: "./dist/*" pyconify-0.2/.gitignore000066400000000000000000000023241473577114400152210ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg .DS_Store # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # ruff .ruff_cache/ # IDE settings .vscode/ .idea/ pyconify-0.2/.pre-commit-config.yaml000066400000000000000000000014511473577114400175120ustar00rootroot00000000000000# enable pre-commit.ci at https://pre-commit.ci/ # it adds: # 1. auto fixing pull requests # 2. auto updating the pre-commit configuration ci: autoupdate_schedule: monthly autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" repos: - repo: https://github.com/abravalheri/validate-pyproject rev: v0.23 hooks: - id: validate-pyproject - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.8.5 hooks: - id: ruff args: [--fix] - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.14.1 hooks: - id: mypy files: "^src/" additional_dependencies: - types-requests pyconify-0.2/LICENSE000066400000000000000000000027351473577114400142440ustar00rootroot00000000000000BSD 3-Clause License Copyright (c) 2023, Talley Lambert Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. pyconify-0.2/README.md000066400000000000000000000102301473577114400145030ustar00rootroot00000000000000# pyconify [![License](https://img.shields.io/pypi/l/pyconify.svg?color=green)](https://github.com/pyapp-kit/pyconify/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/pyconify.svg?color=green)](https://pypi.org/project/pyconify) [![Conda](https://img.shields.io/conda/vn/conda-forge/pyconify)](https://github.com/conda-forge/pyconify-feedstock) [![Python Version](https://img.shields.io/pypi/pyversions/pyconify.svg?color=green)](https://python.org) [![CI](https://github.com/pyapp-kit/pyconify/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/pyconify/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/pyapp-kit/pyconify/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/pyconify) Python wrapper for the [Iconify](https://github.com/iconify) API. Iconify is a versatile icon framework that includes 100+ icon sets with more than 100,000 icons from FontAwesome, Material Design Icons, DashIcons, Feather Icons, EmojiOne, Noto Emoji and many other open source icon sets. Search for icons at: https://icon-sets.iconify.design ## Installation ```sh pip install pyconify # or conda install -c conda-forge pyconify ``` ## Usage ```python import pyconify # Info on available collections collections = pyconify.collections() # Info on specific collection(s) details = pyconify.collection("fa", "fa-brands") # Search for icons hits = pyconify.search("python") # Get icon data data = pyconify.icon_data("fa-brands", "python") # Get SVG svg = pyconify.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") # Keywords pyconify.keywords('home') # API version pyconify.iconify_version() ``` See details for each of these results in the [Iconify API documentation](https://iconify.design/docs/api/queries.html). ### cache While the first fetch of any given SVG will require internet access, pyconfiy caches svgs for faster retrieval and offline use. To see or clear cache directory: ```python import pyconify # 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 is to use the `svg()` function: ```python import pyconify import pyconify ICONS_TO_STORE = {"mdi:bell", "mdi:bell-off", "mdi:bell-outline"} for key in ICONS_TO_STORE: pyconify.svg(key) ``` 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`. ### 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 ``` pyconify-0.2/pyproject.toml000066400000000000000000000051531473577114400161500ustar00rootroot00000000000000# https://peps.python.org/pep-0517/ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [tool.hatch.version] source = "vcs" # https://peps.python.org/pep-0621/ [project] name = "pyconify" dynamic = ["version"] description = "iconify for python. Universal icon framework" readme = "README.md" requires-python = ">=3.9" license = { text = "BSD-3-Clause" } authors = [{ name = "Talley Lambert", email = "talley.lambert@gmail.com" }] classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "License :: OSI Approved :: BSD License", "Typing :: Typed", ] dependencies = ["requests"] [project.optional-dependencies] test = ["pytest", "pytest-cov"] dev = ["black", "ipython", "mypy", "pdbpp", "rich", "ruff", "types-requests"] [project.urls] homepage = "https://github.com/pyapp-kit/pyconify" repository = "https://github.com/pyapp-kit/pyconify" # https://beta.ruff.rs/docs/rules/ [tool.ruff] line-length = 88 target-version = "py39" src = ["src", "tests"] [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ "W", # style warnings "E", # style errors "F", # flakes "D", # pydocstyle "I", # isort "UP", # pyupgrade "S", # bandit "C4", # comprehensions "B", # bugbear "A001", # Variable shadowing a python builtin "TC", # flake8-type-checking "TID", # flake8-tidy-imports "RUF", # ruff-specific rules "PERF", # performance "SLF", # private access ] ignore = [ "D100", # Missing docstring in public module "D401", # First line should be in imperative mood (remove to opt in) ] [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D", "S101", "E501", "SLF"] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] files = "src/**/" strict = true 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] minversion = "6.0" testpaths = ["tests"] 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:", "@overload", "except ImportError", "\\.\\.\\.", "raise NotImplementedError()", ] [tool.coverage.run] source = ["pyconify"] pyconify-0.2/src/000077500000000000000000000000001473577114400140175ustar00rootroot00000000000000pyconify-0.2/src/pyconify/000077500000000000000000000000001473577114400156575ustar00rootroot00000000000000pyconify-0.2/src/pyconify/__init__.py000066400000000000000000000014701473577114400177720ustar00rootroot00000000000000"""iconify for python. Universal icon framework.""" from importlib.metadata import PackageNotFoundError, version try: __version__ = version("pyconify") except PackageNotFoundError: # pragma: no cover __version__ = "uninstalled" __author__ = "Talley Lambert" __email__ = "talley.lambert@gmail.com" __all__ = [ "clear_cache", "collection", "collections", "css", "freedesktop_theme", "get_cache_directory", "icon_data", "iconify_version", "keywords", "last_modified", "search", "svg", "svg_path", ] from ._cache import clear_cache, get_cache_directory from .api import ( collection, collections, css, icon_data, iconify_version, keywords, last_modified, search, svg, svg_path, ) from .freedesktop import freedesktop_theme pyconify-0.2/src/pyconify/_cache.py000066400000000000000000000073741473577114400174460ustar00rootroot00000000000000from __future__ import annotations import os from collections.abc import Iterator, MutableMapping from contextlib import suppress from pathlib import Path _SVG_CACHE: MutableMapping[str, bytes] | None = None PYCONIFY_CACHE: str = os.environ.get("PYCONIFY_CACHE", "") CACHE_DISABLED: bool = 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: if CACHE_DISABLED: _SVG_CACHE = {} else: try: _SVG_CACHE = _SVGCache() except Exception: _SVG_CACHE = {} with suppress(OSError): _delete_stale_svgs(_SVG_CACHE) return _SVG_CACHE 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() 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": 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 # delimiter for the cache key DELIM = "_" def cache_key(args: tuple, kwargs: dict, last_modified: int | str) -> 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 _keys += (last_modified,) return DELIM.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 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_for(_key).write_bytes(_value) def __getitem__(self, _key: str) -> bytes: try: 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._svg_files()) def __delitem__(self, _key: str) -> None: self.path_for(_key).unlink() def __len__(self) -> int: return len(list(self._svg_files())) def __contains__(self, _key: object) -> bool: 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] pyconify-0.2/src/pyconify/api.py000066400000000000000000000520451473577114400170100ustar00rootroot00000000000000"""Wrapper for api calls at https://api.iconify.design/.""" from __future__ import annotations import atexit import functools import os import re import tempfile import warnings from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Literal, overload from ._cache import CACHE_DISABLED, _SVGCache, cache_key, svg_cache if TYPE_CHECKING: from collections.abc import Iterable from typing import Callable, TypeVar import requests F = TypeVar("F", bound=Callable) from .iconify_types import ( APIv2CollectionResponse, APIv2SearchResponse, APIv3KeywordsResponse, Flip, IconifyInfo, IconifyJSON, Rotation, ) ROOT = "https://api.iconify.design" @functools.cache def _session() -> requests.Session: """Return a requests session.""" import requests session = requests.Session() session.headers.update({"User-Agent": "pyconify"}) return session @functools.cache def collections(*prefixes: str) -> dict[str, IconifyInfo]: """Return collections where key is icon set prefix, value is IconifyInfo object. https://iconify.design/docs/api/collections.html Parameters ---------- prefix : str, optional Icon set prefix if you want to get the result only for one icon set. If None, return all collections. 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". """ query_params = {"prefixes": ",".join(prefixes)} resp = _session().get(f"{ROOT}/collections", params=query_params, timeout=2) resp.raise_for_status() return resp.json() # type: ignore @functools.cache def collection( prefix: str, info: bool = False, chars: bool = False, ) -> APIv2CollectionResponse: """Return a list of icons in an icon set. https://iconify.design/docs/api/collection.html Parameters ---------- prefix : str Icon set prefix. info : bool, optional If enabled, the response will include icon set information. chars : bool, optional If enabled, the response will include the character map. The character map exists only in icon sets that were imported from icon fonts. """ # https://api.iconify.design/collection?prefix=line-md&pretty=1 query_params = {} if chars: query_params["chars"] = 1 if info: query_params["info"] = 1 resp = _session().get( f"{ROOT}/collection?prefix={prefix}", params=query_params, timeout=2 ) if 400 <= resp.status_code < 500: raise OSError( f"Icon set {prefix!r} not found. " "Search for icons at https://icon-sets.iconify.design", ) resp.raise_for_status() return resp.json() # type: ignore @functools.cache 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. """ query_params = {"prefixes": ",".join(prefixes)} resp = _session().get(f"{ROOT}/last-modified", params=query_params, timeout=2) resp.raise_for_status() 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 def svg( *key: str, color: str | None = None, height: str | int | None = None, width: str | int | None = None, flip: Flip | None = None, rotate: Rotation | None = None, box: bool | None = None, ) -> bytes: """Generate SVG for icon. https://iconify.design/docs/api/svg.html Returns a bytes object containing the SVG data: `b'...'` Example: https://api.iconify.design/fluent-emoji-flat/alarm-clock.svg?height=48&width=48 SVGs are cached to disk by default. To disable caching, set the `PYCONIFY_CACHE` environment variable to `0` (before importing pyconify). To customize the location of the cache, set the `PYCONIFY_CACHE` environment variable to the path of the desired cache directory. To reveal the location of the cache, use `pyconify.get_cache_directory()`. Parameters ---------- key: str Icon set prefix and name. May be passed as a single string in the format `"prefix:name"` or as two separate strings: `'prefix', 'name'`. color : str, optional Icon color. Replaces currentColor with specific color, resulting in icon with hardcoded palette. height : str | int, optional Icon height. If only one dimension is specified, such as height, other dimension will be automatically set to match it. width : str | int, optional Icon width. If only one dimension is specified, such as height, other dimension will be automatically set to match it. flip : str, optional Flip icon. rotate : str | int, optional Rotate icon. If an integer is provided, it is assumed to be in degrees. box : bool, optional Adds an empty rectangle to SVG that matches the icon's viewBox. It is needed when importing SVG to various UI design tools that ignore viewBox. Those tools, such as Sketch, create layer groups that automatically resize to fit content. Icons usually have empty pixels around icon, so such software crops those empty pixels and icon's group ends up being smaller than actual icon, making it harder to align it in design. """ # check cache prefix, name, svg_cache_key = _svg_keys(key, locals()) if svg_cache_key in (cache := svg_cache()): return cache[svg_cache_key] if path := _cached_svg_path(svg_cache_key): # this will catch cases offline cases where last_modified is not available return path.read_bytes() if rotate not in (None, 1, 2, 3): rotate = str(rotate).replace("deg", "") + "deg" # type: ignore query_params = { "color": color, "height": height, "width": width, "flip": flip, "rotate": rotate, } if box: query_params["box"] = 1 resp = _session().get(f"{ROOT}/{prefix}/{name}.svg", params=query_params, timeout=2) if 400 <= resp.status_code < 500: raise OSError( f"Icon '{prefix}:{name}' not found. " f"Search for icons at https://icon-sets.iconify.design?query={name}", ) resp.raise_for_status() # cache response and return cache[svg_cache_key] = resp.content return resp.content NO_LAST_MOD = "000" def _svg_keys(args: tuple, kwargs: dict) -> tuple[str, str, str]: prefix, name = _split_prefix_name(args) try: # important not to rely on internet when looking for cached file last_mod = last_modified().get(prefix, NO_LAST_MOD) except OSError: last_mod = NO_LAST_MOD _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 _cached_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)).is_file(): return path if svg_cache_key.endswith(NO_LAST_MOD): # if the last modified date is not available, try to find a file with any # last modified date key_stem = svg_cache_key.split(NO_LAST_MOD, 1)[0] for existing_key in cache: if ( existing_key.startswith(key_stem) and (path := cache.path_for(existing_key)).is_file() ): return path return None # pragma: no cover @functools.cache def svg_path( *key: str, color: str | None = None, height: str | int | None = None, width: str | int | None = None, flip: Flip | None = None, rotate: Rotation | None = None, box: bool | None = None, 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`). If `dir` is specified, the SVG will be downloaded to a temporary file in that directory, and the path to that file will be returned. The temporary file will be deleted when the program exits. If `dir` is `None` and caching is enabled (the default), the SVG will be downloaded and cached to disk and the path to the cached file will be returned. If `dir` is `None` and caching is disabled (by setting the `PYCONIFY_CACHE` environment variable to `'0'` before import), a temporary file will be created (using `tempfile.mkstemp`) and the path to that file will be returned. As with `pyconfify.api.svg`, calls to `svg_path` result in SVGs being cached to disk. To disable caching, set the `PYCONIFY_CACHE` environment variable to `0` (before importing pyconify). To customize the location of the cache, set the `PYCONIFY_CACHE` environment variable to the path of the desired cache directory. To reveal the location of the cache, use `pyconify.get_cache_directory()`. """ # if there is no request to store outside cache # and default cache is not disabled then get it from cache if dir is None: *_, svg_cache_key = _svg_keys(key, locals()) if path := _cached_svg_path(svg_cache_key): # if it exists return that string # if cache is disabled globally, this will always be None 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 dir is None and not CACHE_DISABLED and (path := _cached_svg_path(svg_cache_key)): # if the first hit failed, then the call to svg() will have cached the result # and we can now return it. # if cache is disabled globally, this will still be None and we proceed with # creating a temporary file return path # make a temporary file file_prefix = f"pyconify_{'-'.join(key)}".replace(":", "-") fd, tmp_name = tempfile.mkstemp(prefix=file_prefix, suffix=".svg", dir=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 Path(tmp_name) @functools.cache def css( *keys: str, selector: str | None = None, common: str | None = None, override: str | None = None, pseudo: bool | None = None, var: str | None = None, square: bool | None = None, color: str | None = None, mode: Literal["mask", "background"] | None = None, format: Literal["expanded", "compact", "compressed"] | None = None, ) -> str: """Return CSS for `icons` in `prefix`. https://iconify.design/docs/api/css.html Iconify API can dynamically generate CSS for icons, where icons are used as background or mask image. Example: https://api.iconify.design/mdi.css?icons=account-box,account-cash,account,home Parameters ---------- keys : str Icon set prefix and name(s). May be passed as a single string in the format `"prefix:name"` or as multiple strings: `'prefix', 'name1', 'name2'`. To generate CSS for icons from multiple icon sets, send separate queries for each icon set. selector : str, optional CSS selector for icons. If not set, defaults to ".icon--{prefix}--{name}" Variable "{prefix}" is replaced with icon set prefix, "{name}" with icon name. common : str, optional Common selector for icons, defaults to ".icon--{prefix}". Set it to empty to disable common code. Variable "{prefix}" is replaced with icon set prefix. override : str, optional Selector that mixes `selector` and `common` to generate icon specific style that overrides common style. Default value is `".icon--{prefix}.icon--{prefix}--{name}"`. pseudo : bool, optional Set it to `True` if selector for icon is a pseudo-selector, such as ".icon--{prefix}--{name}::after". var : str, optional Name for variable to use for icon, defaults to `"svg"` for monotone icons, `None` for icons with palette. Set to null to disable. square : bool, optional Forces icons to have width of 1em. color : str, optional Sets color for monotone icons. Also renders icons as background images. mode : Literal["mask", "background"], optional Forces icon to render as mask image or background image. If not set, mode will be detected from icon content: icons that contain currentColor will be rendered as mask image, other icons as background image. format : Literal["expanded", "compact", "compressed"], optional Stylesheet formatting option. Matches options used in Sass. Supported values are "expanded", "compact" and "compressed". """ prefix, icons = _split_prefix_name(keys, allow_many=True) params: dict = {} for k in ("selector", "common", "override", "var", "color", "mode", "format"): if (val := locals()[k]) is not None: params[k] = val if pseudo: params["pseudo"] = 1 if square: params["square"] = 1 resp = _session().get( f"{ROOT}/{prefix}.css?icons={','.join(icons)}", params=params, timeout=2 ) if 400 <= resp.status_code < 500: raise OSError( f"Icon set {prefix!r} not found. " "Search for icons at https://icon-sets.iconify.design", ) resp.raise_for_status() if missing := set(re.findall(r"Could not find icon: ([^\s]*) ", resp.text)): warnings.warn( f"Icon(s) {sorted(missing)} not found. " "Search for icons at https://icon-sets.iconify.design", stacklevel=2, ) return resp.text def icon_data(*keys: str) -> IconifyJSON: """Return icon data for `names` in `prefix`. https://iconify.design/docs/api/icon-data.html Example: https://api.iconify.design/mdi.json?icons=acount-box,account-cash,account,home Missing icons are added to `not_found` property of response. Parameters ---------- 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 = _session().get(f"{ROOT}/{prefix}.json?icons={','.join(names)}", timeout=2) if (content := resp.json()) == 404: raise OSError( f"Icon set {prefix!r} not found. " "Search for icons at https://icon-sets.iconify.design", ) resp.raise_for_status() return content # type: ignore def search( query: str, limit: int | None = None, start: int | None = None, prefixes: Iterable[str] | None = None, category: str | None = None, # similar: bool | None = None, ) -> APIv2SearchResponse: """Search icons. https://iconify.design/docs/api/search.html Example: https://api.iconify.design/search?query=arrows-horizontal&limit=999 The Search query can include special keywords. For most keywords, the keyword and value can be separated by ":" or "=". It is recommended to use "=" because the colon can also be treated as icon set prefix. Keywords with boolean values can have the following values: "true" or "1" = true. "false" or "0" = false. Supported keywords: - `palette` (bool). Filter icon sets by palette. Example queries: "home palette=false", "cat palette=true". - `style` ("fill" | "stroke"). Filter icons by code. Example queries: "home style=fill", "cat style=stroke". - `fill` and `stroke` (bool). Same as above, but as boolean. Only one of keywords can be set: "home fill=true". - `prefix` (str). Same as prefix property from search query parameters, but in keyword. Overrides parameter. - `prefixes` (string). Same as prefixes property from search query parameters, but in keyword. Overrides parameter. Parameters ---------- query : str Search string. Case insensitive. limit : int, optional Maximum number of items in response, default is 64. Min 32, max 999. If numer of icons in result matches limit, it means there are more icons to show. start : int, optional Start index for results, default is 0. prefixes : str | Iterable[str], optional List of icon set prefixes. You can use partial prefixes that end with "-", such as "mdi-" matches "mdi-light". category : str, optional Filter icon sets by category. """ params: dict = {} if limit is not None: params["limit"] = limit if start is not None: params["start"] = start if prefixes is not None: if isinstance(prefixes, str): params["prefix"] = prefixes else: params["prefixes"] = ",".join(prefixes) if category is not None: params["category"] = category resp = _session().get(f"{ROOT}/search?query={query}", params=params, timeout=2) resp.raise_for_status() return resp.json() # type: ignore def keywords( prefix: str | None = None, keyword: str | None = None ) -> APIv3KeywordsResponse: """Intended for use in suggesting search queries. https://iconify.design/docs/api/keywords.html One of `prefix` or `keyword` MUST be specified. Keyword can only contain letters numbers and dash. If it contains "-", only the last part after "-" is used. Must be at least 2 characters long. Parameters ---------- prefix : str, optional Keyword Prefix. API returns all keywords that start with `prefix`. keyword : str, optional Partial keyword. API returns all keywords that start or end with `keyword`. (Ignored if `prefix` is specified). """ if prefix: if keyword: warnings.warn( "Cannot specify both prefix and keyword. Ignoring keyword.", stacklevel=2, ) params = {"prefix": prefix} elif keyword: params = {"keyword": keyword} else: params = {} resp = _session().get(f"{ROOT}/keywords", params=params, timeout=2) resp.raise_for_status() return resp.json() # type: ignore @functools.cache def iconify_version() -> str: """Return version of iconify API. https://iconify.design/docs/api/version.html The purpose of this query is to be able to tell which server you are connected to, but without exposing actual location of server, which can help debug error. This is used in networks when many servers are running. Examples -------- >>> iconify_version() 'Iconify API version 3.0.0-beta.1' """ resp = _session().get(f"{ROOT}/version", timeout=2) resp.raise_for_status() return resp.text @overload def _split_prefix_name( key: tuple[str, ...], allow_many: Literal[False] = ... ) -> tuple[str, str]: ... @overload def _split_prefix_name( key: tuple[str, ...], allow_many: Literal[True] ) -> tuple[str, tuple[str, ...]]: ... def _split_prefix_name( key: tuple[str, ...], allow_many: bool = False ) -> tuple[str, str] | tuple[str, tuple[str, ...]]: """Convenience function to split prefix and name from key. Examples -------- >>> _split_prefix_name(("mdi", "account")) ("mdi", "account") >>> _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 ":" not in key[0]: raise ValueError( "Single-argument icon names must be in the format 'prefix:name'. " f"Got {key[0]!r}" ) 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) pyconify-0.2/src/pyconify/freedesktop.py000066400000000000000000000365551473577114400205620ustar00rootroot00000000000000from __future__ import annotations import atexit import shutil from collections.abc import Mapping from pathlib import Path from tempfile import mkdtemp from typing import TYPE_CHECKING, Any 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 } pyconify-0.2/src/pyconify/iconify_types.py000066400000000000000000000115301473577114400211150ustar00rootroot00000000000000"""Type definitions for iconify response objects. This module should only be imported behind a TYPE_CHECKING guard. """ from __future__ import annotations # pragma: no cover from typing import TYPE_CHECKING # pragma: no cover if TYPE_CHECKING: 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.""" name: Required[str] # author name url: NotRequired[str] # author website class License(TypedDict, total=False): """License information.""" title: Required[str] # license title spdx: str # SPDX license ID url: str # license URL class IconifyInfo(TypedDict, total=False): """Icon set information block.""" name: Required[str] # icon set name author: Required[Author] # author info license: Required[License] # license info total: int # total number of icons version: str # version string height: int | list[int] # Icon grid: number or array of numbers. displayHeight: int # display height for samples: 16 - 24 category: str # category on Iconify collections list tags: list[str] # list of tags to group similar icon sets # True if icons have predefined color scheme, false if icons use currentColor. palette: bool # palette status. hidden: bool # if true, icon set should not appear in icon sets list class APIv2CollectionResponse(TypedDict, total=False): """Object returned from collection(prefix).""" prefix: Required[str] # icon set prefix total: Required[int] # Number of icons (duplicate of info?.total) title: str # Icon set title, if available (duplicate of info?.name) info: IconifyInfo # Icon set info uncategorized: list[str] # List of icons without categories categories: dict[str, list[str]] # List of icons, sorted by category hidden: list[str] # List of hidden icons aliases: dict[str, str] # List of aliases, key = alias, value = parent icon chars: dict[str, str] # Characters, key = character, value = icon name # https://iconify.design/docs/types/iconify-json-metadata.html#themes prefixes: dict[str, str] suffixes: dict[str, str] class APIv3LastModifiedResponse(TypedDict): """key is icon set prefix, value is lastModified property from that icon set.""" lastModified: dict[str, int] class IconifyOptional(TypedDict, total=False): """Optional properties that contain icon dimensions and transformations.""" left: int # left position of the ViewBox, default = 0 top: int # top position of the ViewBox, default = 0 width: int # width of the ViewBox, default = 16 height: int # height of the ViewBox, default = 16 rotate: int # number of 90-degree rotations (1=90deg, etc...), default = 0 hFlip: bool # horizontal flip, default = false vFlip: bool # vertical flip, default = false class IconifyIcon(IconifyOptional, total=False): """Iconify icon object.""" body: Required[str] class IconifyJSON(IconifyOptional, total=False): """Return value of icon_data(prefix, *names).""" prefix: Required[str] icons: Required[dict[str, IconifyIcon]] lastModified: int aliases: dict[str, str] not_found: list[str] class APIv2SearchResponse(TypedDict, total=False): """Return value of search(query).""" icons: list[str] # list of prefix:name total: int # Number of results. If same as `limit`, more results are available limit: int # Number of results shown start: int # Index of first result collections: dict[str, IconifyInfo] # List of icon sets that match query request: APIv2SearchParams # Copy of request parameters class APIv2SearchParams(TypedDict, total=False): """Request parameters for search(query).""" query: Required[str] # search string limit: int # maximum number of items in response start: int # start index for results prefix: str # filter icon sets by one prefix # collection: str # filter icon sets by one collection prefixes: str # filter icon sets by multiple prefixes or partial category: str # filter icon sets by category similar: bool # include partial matches for words (default = True) class APIv3KeywordsResponse(TypedDict, total=False): """Return value of keywords().""" keyword: str # one of these two will be there prefix: str exists: Required[bool] matches: Required[list[str]] invalid: Literal[True] pyconify-0.2/src/pyconify/py.typed000066400000000000000000000000001473577114400173440ustar00rootroot00000000000000pyconify-0.2/tests/000077500000000000000000000000001473577114400143725ustar00rootroot00000000000000pyconify-0.2/tests/conftest.py000066400000000000000000000022711473577114400165730ustar00rootroot00000000000000import shutil from collections.abc import Iterator from pathlib import Path from unittest.mock import patch import pytest from pyconify import _cache, api, get_cache_directory @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()) existed = cache_dir.exists() if existed: # get hash of cache directory cache_hash = hash(tuple(cache_dir.rglob("*"))) try: yield finally: 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") pyconify-0.2/tests/test_cache.py000066400000000000000000000061351473577114400170530ustar00rootroot00000000000000from collections.abc import Iterator from contextlib import contextmanager from pathlib import Path from unittest.mock import patch import pytest import requests import pyconify from pyconify import _cache, api from pyconify._cache import _SVGCache, clear_cache, get_cache_directory def test_cache(tmp_path: Path) -> None: assert isinstance(get_cache_directory(), Path) # 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" 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"] def test_cache_dir(monkeypatch: pytest.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 @pytest.fixture def tmp_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]: cache = tmp_path / "cache" monkeypatch.setattr(_cache, "PYCONIFY_CACHE", str(cache)) monkeypatch.setattr(_cache, "_SVG_CACHE", None) yield cache @pytest.mark.usefixtures("tmp_cache") def test_tmp_svg_with_fixture() -> None: """Test that we can set the cache directory to tmp_path with monkeypatch.""" result3 = pyconify.svg_path("bi", "alarm-fill") assert str(result3).startswith(str(_cache.get_cache_directory())) @contextmanager def internet_offline() -> Iterator[None]: """Simulate an offline internet connection.""" session = api._session() with patch.object(session, "get") as mock: mock.side_effect = requests.ConnectionError("No internet connection.") # clear functools caches... for val in vars(pyconify).values(): if hasattr(val, "cache_clear"): val.cache_clear() yield @pytest.mark.usefixtures("tmp_cache") def test_cache_used_offline() -> None: svg = pyconify.svg_path("mdi:pen-add", color="#333333") svgb = pyconify.svg("mdi:pen-add", color="#333333") # make sure a previously cached icon works offline with internet_offline(): # make sure the patch works with pytest.raises(requests.ConnectionError): pyconify.svg_path("mdi:pencil-plus-outline") # make sure the cached icon works svg2 = pyconify.svg_path("mdi:pen-add", color="#333333") assert svg == svg2 svgb2 = pyconify.svg("mdi:pen-add", color="#333333") assert svgb == svgb2 @pytest.mark.usefixtures("tmp_cache") def test_cache_loaded_offline(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(_cache, "_SVG_CACHE", None) with internet_offline(): assert isinstance(_cache.svg_cache(), _cache._SVGCache) pyconify-0.2/tests/test_freedesktop.py000066400000000000000000000041451473577114400203220ustar00rootroot00000000000000from __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() pyconify-0.2/tests/test_pyconify.py000066400000000000000000000064621473577114400176530ustar00rootroot00000000000000from pathlib import Path import pytest import pyconify def test_collections() -> None: result = pyconify.collections("bi", "fa") assert isinstance(result, dict) assert set(result) == {"bi", "fa"} def test_collection() -> None: result = pyconify.collection("geo", chars=True, info=True) assert isinstance(result, dict) assert result["prefix"] == "geo" with pytest.raises(IOError, match="Icon set 'not' not found."): pyconify.collection("not") def test_icon_data() -> None: result = pyconify.icon_data("bi", "alarm") assert isinstance(result, dict) assert result["prefix"] == "bi" assert "alarm" in result["icons"] with pytest.raises(IOError, match="Icon set 'not' not found"): pyconify.icon_data("not", "found") @pytest.mark.usefixtures("no_cache") def test_svg() -> None: result = pyconify.svg("bi", "alarm", rotate=90, box=True) assert isinstance(result, bytes) assert result.startswith(b" 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: result = pyconify.css("bi", "alarm") assert result.startswith(".icon--bi") # FIXME... this isn't returning a valid thingy result2 = pyconify.css( "bi", "alarm", selector=".test", common="common", override="override", pseudo=True, var="asdf", square=True, color="red", mode="mask", format="compact", ) assert result2.startswith("common") with pytest.raises(IOError, match="Icon set 'not' not found."): pyconify.css("not:found") with pytest.warns(UserWarning, match=r"Icon\(s\) \['nor-this', 'not-an-icon'\]"): pyconify.css("bi:not-an-icon,nor-this") def test_last_modified() -> None: assert isinstance(pyconify.last_modified("bi")["bi"], int) def test_keywords() -> None: keywords = pyconify.keywords("home") assert isinstance(keywords, dict) assert keywords["prefix"] == "home" assert keywords["matches"] keywords = pyconify.keywords(keyword="home") assert keywords["keyword"] == "home" assert keywords["matches"] with pytest.warns(UserWarning, match="Cannot specify both prefix and keyword"): assert isinstance(pyconify.keywords("home", keyword="home"), dict) with pytest.raises(OSError): pyconify.keywords() def test_search() -> None: result = pyconify.search("arrow", prefixes={"bi"}, limit=10, start=2) assert result["collections"] result = pyconify.search("home", prefixes="material-symbols", category="Material") assert result["collections"] def test_iconify_version() -> None: assert isinstance(pyconify.iconify_version(), str)