pax_global_header 0000666 0000000 0000000 00000000064 14751133272 0014517 g ustar 00root root 0000000 0000000 52 comment=fbb64f98d01cef63912b9a7dcf4779e03260f830
pyconify-0.2.1/ 0000775 0000000 0000000 00000000000 14751133272 0013357 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/.github/ 0000775 0000000 0000000 00000000000 14751133272 0014717 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/.github/ISSUE_TEMPLATE.md 0000664 0000000 0000000 00000000477 14751133272 0017434 0 ustar 00root root 0000000 0000000 * 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.1/.github/TEST_FAIL_TEMPLATE.md 0000664 0000000 0000000 00000000600 14751133272 0020102 0 ustar 00root root 0000000 0000000 ---
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.1/.github/dependabot.yml 0000664 0000000 0000000 00000000424 14751133272 0017547 0 ustar 00root root 0000000 0000000 # 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.1/.github/workflows/ 0000775 0000000 0000000 00000000000 14751133272 0016754 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/.github/workflows/ci.yml 0000664 0000000 0000000 00000004041 14751133272 0020071 0 ustar 00root root 0000000 0000000 name: 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.1/.gitignore 0000664 0000000 0000000 00000002324 14751133272 0015350 0 ustar 00root root 0000000 0000000 # 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.1/.pre-commit-config.yaml 0000664 0000000 0000000 00000001450 14751133272 0017640 0 ustar 00root root 0000000 0000000 # 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.9.4
hooks:
- id: ruff
args: [--fix]
- repo: https://github.com/psf/black
rev: 25.1.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.1/LICENSE 0000664 0000000 0000000 00000002735 14751133272 0014373 0 ustar 00root root 0000000 0000000 BSD 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.1/README.md 0000664 0000000 0000000 00000010207 14751133272 0014636 0 ustar 00root root 0000000 0000000 # pyconify
[](https://github.com/pyapp-kit/pyconify/raw/main/LICENSE)
[](https://pypi.org/project/pyconify)
[](https://github.com/conda-forge/pyconify-feedstock)
[](https://python.org)
[](https://github.com/pyapp-kit/pyconify/actions/workflows/ci.yml)
[](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
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.1/pyproject.toml 0000664 0000000 0000000 00000005153 14751133272 0016277 0 ustar 00root root 0000000 0000000 # 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.1/src/ 0000775 0000000 0000000 00000000000 14751133272 0014146 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/src/pyconify/ 0000775 0000000 0000000 00000000000 14751133272 0016006 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/src/pyconify/__init__.py 0000664 0000000 0000000 00000001634 14751133272 0020123 0 ustar 00root root 0000000 0000000 """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_api_cache",
"clear_cache",
"collection",
"collections",
"css",
"freedesktop_theme",
"get_cache_directory",
"icon_data",
"iconify_version",
"keywords",
"last_modified",
"search",
"set_api_cache_maxsize",
"svg",
"svg_path",
]
from ._cache import clear_cache, get_cache_directory
from .api import (
clear_api_cache,
collection,
collections,
css,
icon_data,
iconify_version,
keywords,
last_modified,
search,
set_api_cache_maxsize,
svg,
svg_path,
)
from .freedesktop import freedesktop_theme
pyconify-0.2.1/src/pyconify/_cache.py 0000664 0000000 0000000 00000007374 14751133272 0017575 0 ustar 00root root 0000000 0000000 from __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.1/src/pyconify/api.py 0000664 0000000 0000000 00000055200 14751133272 0017133 0 ustar 00root root 0000000 0000000 """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, Any, Literal, Protocol, cast, 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
class _lru_cache_wrapper(Protocol):
__wrapped__: Callable[..., Any]
def cache_clear(self) -> None: ...
F = TypeVar("F", bound=Callable)
from .iconify_types import (
APIv2CollectionResponse,
APIv2SearchResponse,
APIv3KeywordsResponse,
Flip,
IconifyInfo,
IconifyJSON,
Rotation,
)
ROOT = "https://api.iconify.design"
API_FUNCTIONS: set[str] = {"collections", "collection", "last_modified", "css"}
def clear_api_cache() -> None:
"""Clear all cached responses to the iconify API from this session."""
for func_name in API_FUNCTIONS:
wrapper = cast("_lru_cache_wrapper", globals()[func_name])
wrapper.cache_clear()
def set_api_cache_maxsize(maxsize: int | None) -> None:
"""Set the `lru_cache` maxsize for all calls to the iconify API.
This is NOT the same thing as the on-disk SVG cache
This will also clear all cached responses to the iconify API from this session.
"""
import pyconify
if maxsize is not None:
if not isinstance(maxsize, int): # pragma: no cover
raise TypeError(
f"maxsize must be an integer, not {type(maxsize).__name__}."
)
if maxsize < 1: # pragma: no cover
maxsize = 0
for func_name in API_FUNCTIONS:
# get the lrue_cache-wrapped function and clear it
wrapper = cast("_lru_cache_wrapper", globals()[func_name])
wrapper.cache_clear()
# get the original function and wrap it with the new maxsize
func = wrapper.__wrapped__
new_func = functools.lru_cache(maxsize=maxsize)(func)
# update the names in both this module and top-level pyconify
globals()[func_name] = new_func
setattr(pyconify, func_name, new_func)
@functools.cache
def _session() -> requests.Session:
"""Return a requests session."""
import requests
session = requests.Session()
session.headers.update({"User-Agent": "pyconify"})
return session
@functools.lru_cache(maxsize=128)
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.lru_cache(maxsize=128)
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.lru_cache(maxsize=128)
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.lru_cache(maxsize=128)
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.1/src/pyconify/freedesktop.py 0000664 0000000 0000000 00000036555 14751133272 0020711 0 ustar 00root root 0000000 0000000 from __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.1/src/pyconify/iconify_types.py 0000664 0000000 0000000 00000011530 14751133272 0021244 0 ustar 00root root 0000000 0000000 """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.1/src/pyconify/py.typed 0000664 0000000 0000000 00000000000 14751133272 0017473 0 ustar 00root root 0000000 0000000 pyconify-0.2.1/tests/ 0000775 0000000 0000000 00000000000 14751133272 0014521 5 ustar 00root root 0000000 0000000 pyconify-0.2.1/tests/conftest.py 0000664 0000000 0000000 00000002271 14751133272 0016722 0 ustar 00root root 0000000 0000000 import 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.1/tests/test_cache.py 0000664 0000000 0000000 00000006135 14751133272 0017202 0 ustar 00root root 0000000 0000000 from 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.1/tests/test_freedesktop.py 0000664 0000000 0000000 00000004145 14751133272 0020451 0 ustar 00root root 0000000 0000000 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()
pyconify-0.2.1/tests/test_pyconify.py 0000664 0000000 0000000 00000007214 14751133272 0017776 0 ustar 00root root 0000000 0000000 from 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"