diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc6e79b..6471400 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml index 0288cab..9b5a073 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = "talley.lambert@gmail.com" }] 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", ] @@ -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] diff --git a/src/pyconify/_cache.py b/src/pyconify/_cache.py index ffa108b..1749ac3 100644 --- a/src/pyconify/_cache.py +++ b/src/pyconify/_cache.py @@ -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", "") @@ -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: diff --git a/src/pyconify/api.py b/src/pyconify/api.py index e202cd0..ced50ac 100644 --- a/src/pyconify/api.py +++ b/src/pyconify/api.py @@ -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 ( @@ -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. @@ -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, @@ -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. @@ -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( @@ -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 @@ -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, @@ -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, @@ -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. " @@ -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 @@ -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 @@ -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. @@ -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 diff --git a/src/pyconify/freedesktop.py b/src/pyconify/freedesktop.py index 1819dfc..4fc72be 100644 --- a/src/pyconify/freedesktop.py +++ b/src/pyconify/freedesktop.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index c09ac2e..e3428cf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_cache.py b/tests/test_cache.py index d8570ee..bfda482 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -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 @@ -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(): diff --git a/tests/test_pyconify.py b/tests/test_pyconify.py index e19b445..170fb8b 100644 --- a/tests/test_pyconify.py +++ b/tests/test_pyconify.py @@ -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"]