Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
os: [ubuntu-latest]
include:
- python-version: "3.8"
- python-version: "3.9"
os: windows-latest
- python-version: "3.8"
- python-version: "3.9"
os: macos-latest

test-qt:
Expand Down
48 changes: 21 additions & 27 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,17 @@ name = "pyconify"
dynamic = ["version"]
description = "iconify for python. Universal icon framework"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
license = { text = "BSD-3-Clause" }
authors = [{ name = "Talley Lambert", email = "[email protected]" }]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"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",
]
Expand All @@ -36,44 +36,38 @@ dev = ["black", "ipython", "mypy", "pdbpp", "rich", "ruff", "types-requests"]
homepage = "https://github.com/pyapp-kit/pyconify"
repository = "https://github.com/pyapp-kit/pyconify"

# https://github.com/charliermarsh/ruff
# https://beta.ruff.rs/docs/rules/
[tool.ruff]
line-length = 88
target-version = "py38"
# https://beta.ruff.rs/docs/rules/
target-version = "py39"
src = ["src", "tests"]

[tool.ruff.lint]
pydocstyle = { convention = "numpy" }
select = [
"E", # style errors
"W", # style warnings
"E", # style errors
"F", # flakes
"D", # pydocstyle
"I", # isort
"UP", # pyupgrade
"C", # flake8-comprehensions
"B", # flake8-bugbear
"A001", # flake8-builtins
"S", # bandit
"C4", # comprehensions
"B", # bugbear
"A001", # Variable shadowing a python builtin
"TC", # flake8-type-checking
"TID", # flake8-tidy-imports
"RUF", # ruff-specific rules
"TCH",
"TID",
"PERF", # performance
"SLF", # private access
]

# I do this to get numpy-style docstrings AND retain
# D417 (Missing argument descriptions in the docstring)
# otherwise, see:
# https://beta.ruff.rs/docs/faq/#does-ruff-support-numpy-or-google-style-docstrings
# https://github.com/charliermarsh/ruff/issues/2606
ignore = [
"D100", # Missing docstring in public module
"D107", # Missing docstring in __init__
"D203", # 1 blank line required before class docstring
"D212", # Multi-line docstring summary should start at the first line
"D213", # Multi-line docstring summary should start at the second line
"D401", # First line should be in imperative mood
"D413", # Missing blank line after last section
"D416", # Section name should end with a colon
"D401", # First line should be in imperative mood (remove to opt in)
]

[tool.ruff.per-file-ignores]
"tests/*.py" = ["D"]
[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["D", "S101", "E501", "SLF"]

# https://mypy.readthedocs.io/en/stable/config_file.html
[tool.mypy]
Expand Down
4 changes: 2 additions & 2 deletions src/pyconify/_cache.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from __future__ import annotations

import os
from collections.abc import Iterator, MutableMapping
from contextlib import suppress
from pathlib import Path
from typing import Iterator, MutableMapping

_SVG_CACHE: MutableMapping[str, bytes] | None = None
PYCONIFY_CACHE: str = os.environ.get("PYCONIFY_CACHE", "")
Expand Down Expand Up @@ -36,7 +36,7 @@ def clear_cache() -> None:
global _SVG_CACHE
_SVG_CACHE = None
with suppress(AttributeError):
svg_path.cache_clear() # type: ignore
svg_path.cache_clear()


def get_cache_directory(app_name: str = "pyconify") -> Path:
Expand Down
85 changes: 46 additions & 39 deletions src/pyconify/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
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, Iterable, Literal, overload

import requests
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 (
Expand All @@ -30,16 +32,21 @@
Rotation,
)

def lru_cache(maxsize: int | None = None) -> Callable[[F], F]:
"""Dummy lru_cache decorator for type checking."""

else:
from functools import lru_cache

ROOT = "https://api.iconify.design"


@lru_cache(maxsize=None)
@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.

Expand All @@ -55,12 +62,12 @@ def collections(*prefixes: str) -> dict[str, IconifyInfo]:
end with "-", such as "mdi-" matches "mdi-light".
"""
query_params = {"prefixes": ",".join(prefixes)}
resp = requests.get(f"{ROOT}/collections", params=query_params)
resp = _session().get(f"{ROOT}/collections", params=query_params, timeout=2)
resp.raise_for_status()
return resp.json() # type: ignore


@lru_cache(maxsize=None)
@functools.cache
def collection(
prefix: str,
info: bool = False,
Expand All @@ -86,18 +93,19 @@ def collection(
query_params["chars"] = 1
if info:
query_params["info"] = 1
resp = requests.get(f"{ROOT}/collection?prefix={prefix}", params=query_params)
resp.raise_for_status()
if (content := resp.json()) == 404:
raise requests.HTTPError(
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",
response=resp,
)
return content # type: ignore
resp.raise_for_status()
return resp.json() # type: ignore


@lru_cache(maxsize=None)
@functools.cache
def last_modified(*prefixes: str) -> dict[str, int]:
"""Return last modified date for icon sets.

Expand All @@ -120,7 +128,7 @@ def last_modified(*prefixes: str) -> dict[str, int]:
UTC integer timestamp.
"""
query_params = {"prefixes": ",".join(prefixes)}
resp = requests.get(f"{ROOT}/last-modified", params=query_params)
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(
Expand Down Expand Up @@ -200,14 +208,13 @@ def svg(
}
if box:
query_params["box"] = 1
resp = requests.get(f"{ROOT}/{prefix}/{name}.svg", params=query_params)
resp.raise_for_status()
if resp.content == b"404":
raise requests.HTTPError(
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}",
response=resp,
)
resp.raise_for_status()

# cache response and return
cache[svg_cache_key] = resp.content
Expand Down Expand Up @@ -253,7 +260,7 @@ def _cached_svg_path(svg_cache_key: str) -> Path | None:
return None # pragma: no cover


@lru_cache(maxsize=None)
@functools.cache
def svg_path(
*key: str,
color: str | None = None,
Expand Down Expand Up @@ -320,7 +327,7 @@ def _remove_tmp_svg() -> None:
return Path(tmp_name)


@lru_cache(maxsize=None)
@functools.cache
def css(
*keys: str,
selector: str | None = None,
Expand Down Expand Up @@ -389,14 +396,15 @@ def css(
if square:
params["square"] = 1

resp = requests.get(f"{ROOT}/{prefix}.css?icons={','.join(icons)}", params=params)
resp.raise_for_status()
if resp.text == "404":
raise requests.HTTPError(
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",
response=resp,
)
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. "
Expand Down Expand Up @@ -425,14 +433,13 @@ def icon_data(*keys: str) -> IconifyJSON:
Icon name(s).
"""
prefix, names = _split_prefix_name(keys, allow_many=True)
resp = requests.get(f"{ROOT}/{prefix}.json?icons={','.join(names)}")
resp.raise_for_status()
resp = _session().get(f"{ROOT}/{prefix}.json?icons={','.join(names)}", timeout=2)
if (content := resp.json()) == 404:
raise requests.HTTPError(
raise OSError(
f"Icon set {prefix!r} not found. "
"Search for icons at https://icon-sets.iconify.design",
response=resp,
)
resp.raise_for_status()
return content # type: ignore


Expand Down Expand Up @@ -499,7 +506,7 @@ def search(
params["prefixes"] = ",".join(prefixes)
if category is not None:
params["category"] = category
resp = requests.get(f"{ROOT}/search?query={query}", params=params)
resp = _session().get(f"{ROOT}/search?query={query}", params=params, timeout=2)
resp.raise_for_status()
return resp.json() # type: ignore

Expand Down Expand Up @@ -536,12 +543,12 @@ def keywords(
params = {"keyword": keyword}
else:
params = {}
resp = requests.get(f"{ROOT}/keywords", params=params)
resp = _session().get(f"{ROOT}/keywords", params=params, timeout=2)
resp.raise_for_status()
return resp.json() # type: ignore


@lru_cache(maxsize=None)
@functools.cache
def iconify_version() -> str:
"""Return version of iconify API.

Expand All @@ -556,7 +563,7 @@ def iconify_version() -> str:
>>> iconify_version()
'Iconify API version 3.0.0-beta.1'
"""
resp = requests.get(f"{ROOT}/version")
resp = _session().get(f"{ROOT}/version", timeout=2)
resp.raise_for_status()
return resp.text

Expand Down
3 changes: 2 additions & 1 deletion src/pyconify/freedesktop.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import atexit
import shutil
from collections.abc import Mapping
from pathlib import Path
from tempfile import mkdtemp
from typing import TYPE_CHECKING, Any, Mapping
from typing import TYPE_CHECKING, Any

from pyconify.api import svg

Expand Down
2 changes: 1 addition & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import shutil
from collections.abc import Iterator
from pathlib import Path
from typing import Iterator
from unittest.mock import patch

import pytest
Expand Down
8 changes: 4 additions & 4 deletions tests/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator
from unittest.mock import patch

import pytest
import requests

import pyconify
from pyconify import _cache
from pyconify import _cache, api
from pyconify._cache import _SVGCache, clear_cache, get_cache_directory


Expand Down Expand Up @@ -62,8 +62,8 @@ def test_tmp_svg_with_fixture() -> None:
@contextmanager
def internet_offline() -> Iterator[None]:
"""Simulate an offline internet connection."""

with patch.object(requests, "get") as mock:
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():
Expand Down
5 changes: 3 additions & 2 deletions tests/test_pyconify.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,15 @@ def test_keywords() -> None:
with pytest.warns(UserWarning, match="Cannot specify both prefix and keyword"):
assert isinstance(pyconify.keywords("home", keyword="home"), dict)

assert pyconify.keywords()
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("arrow", prefixes="bi", category="General")
result = pyconify.search("home", prefixes="material-symbols", category="Material")
assert result["collections"]


Expand Down
Loading