Skip to content

Commit

Permalink
feat: delay import of requests, drop python 3.8, add python 3.13 (#27)
Browse files Browse the repository at this point in the history
* feat: delay requests import

* style(pre-commit.ci): auto fixes [...]

* fix lint

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
  • Loading branch information
tlambert03 and pre-commit-ci[bot] authored Jan 3, 2025
1 parent e674bb1 commit 9716aa9
Show file tree
Hide file tree
Showing 8 changed files with 82 additions and 79 deletions.
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

0 comments on commit 9716aa9

Please sign in to comment.