diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d61b78a..479bef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,38 +12,27 @@ on: # run every week (for --pre release tests) - cron: "0 0 * * 0" -jobs: - check-manifest: - # check-manifest is a tool that checks that all files in version control are - # included in the sdist (unless explicitly excluded) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - run: pipx run check-manifest +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: test: name: ${{ matrix.platform }} (${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] - platform: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.8", "3.11"] + platform: [ubuntu-latest] steps: - - name: 🛑 Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: 🐍 Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - cache-dependency-path: "pyproject.toml" - cache: "pip" - name: Install Dependencies run: | @@ -76,9 +65,11 @@ jobs: 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@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -94,9 +85,8 @@ jobs: - name: 🚢 Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.TWINE_API_KEY }} - uses: softprops/action-gh-release@v1 with: generate_release_notes: true + files: "./dist/*" diff --git a/pyproject.toml b/pyproject.toml index 8c04fad..50899ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,8 @@ exclude_lines = [ "raise NotImplementedError()", ] [tool.coverage.run] -source = ["src"] +source = ["pyconify"] +omit = ["src/pyconify/types.py"] # https://github.com/mgedmin/check-manifest#configuration # add files that you want check-manifest to explicitly ignore here diff --git a/src/pyconify/__init__.py b/src/pyconify/__init__.py index 3c675b1..22f4952 100644 --- a/src/pyconify/__init__.py +++ b/src/pyconify/__init__.py @@ -4,7 +4,7 @@ try: __version__ = version("pyconify") -except PackageNotFoundError: +except PackageNotFoundError: # pragma: no cover __version__ = "uninstalled" __author__ = "Talley Lambert" __email__ = "talley.lambert@gmail.com" @@ -13,11 +13,12 @@ "collections", "css", "icon_data", + "iconify_version", "keywords", + "last_modified", "search", "svg", "temp_svg", - "iconify_version", ] from .api import ( @@ -27,6 +28,7 @@ icon_data, iconify_version, keywords, + last_modified, search, svg, temp_svg, diff --git a/src/pyconify/_util.py b/src/pyconify/_util.py deleted file mode 100644 index 633cf07..0000000 --- a/src/pyconify/_util.py +++ /dev/null @@ -1,29 +0,0 @@ -from collections import defaultdict -from pathlib import Path - - -def update_type_hints(): - from pyconify import api - - collections = api.collections() - all_icons: defaultdict[str, set[str]] = defaultdict(set) - - for prefix in list(collections)[:1]: - icons = api.collection(prefix) - for icon_list in icons.get("categories", {}).values(): - all_icons[prefix].update(icon_list) - all_icons[prefix].update(icons.get("uncategorized", [])) - - module = "from typing import Literal\n\n" - - keys = [ - f'"{prefix}:{name}"' - for prefix, names in sorted(all_icons.items()) - for name in sorted(names) - ] - inner = ",\n ".join(keys) - module += f"IconName = Literal[\n {inner}\n]\n" - Path(__file__).parent.joinpath("_typing.py").write_text(module) - - -update_type_hints() diff --git a/src/pyconify/api.py b/src/pyconify/api.py index f5016ed..d63f848 100644 --- a/src/pyconify/api.py +++ b/src/pyconify/api.py @@ -3,10 +3,10 @@ import atexit import os import tempfile +import warnings from contextlib import suppress from functools import lru_cache -from logging import warn -from typing import TYPE_CHECKING, Literal, Sequence +from typing import TYPE_CHECKING, Iterable, Literal, cast, overload import requests @@ -21,23 +21,48 @@ Rotation, ) +ROOT = "https://api.iconify.design" + + +@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. -def _split_prefix_name(key: tuple[str, ...]) -> tuple[str, str]: + Examples + -------- + >>> _split_prefix_name(("mdi", "account")) + ("mdi", "account") + >>> _split_prefix_name(("mdi:account",)) + ("mdi", "account") + """ if len(key) == 1: if ":" in key[0]: return tuple(key[0].split(":", maxsplit=1)) # type: ignore else: raise ValueError( - "If only one argument is passed, it must be in the format " - f"'prefix:name'. got {key[0]!r}" + "Single-argument icon names must be in the format 'prefix:name'. " + f"Got {key[0]!r}" ) elif len(key) == 2: - return key # type: ignore - else: - raise ValueError("QIconify must be initialized with either 1 or 2 arguments.") - - -ROOT = "https://api.iconify.design" + return cast("tuple[str, str]", key) + elif not allow_many: + raise ValueError("icon key must be either 1 or 2 arguments.") + return key[0], key[1:] @lru_cache(maxsize=None) @@ -54,9 +79,9 @@ def collections(*prefixes: str) -> dict[str, IconifyInfo]: end with "-", such as "mdi-" matches "mdi-light". """ query_params = {"prefixes": ",".join(prefixes)} - req = requests.get(f"{ROOT}/collections", params=query_params) - req.raise_for_status() - return req.json() # type: ignore + resp = requests.get(f"{ROOT}/collections", params=query_params) + resp.raise_for_status() + return resp.json() # type: ignore @lru_cache(maxsize=None) @@ -83,10 +108,10 @@ def collection( query_params["chars"] = 1 if info: query_params["info"] = 1 - req = requests.get(f"{ROOT}/collection?prefix={prefix}", params=query_params) - req.raise_for_status() - if (content := req.json()) == 404: - raise ValueError(f"Icon set {prefix} not found.") + resp = requests.get(f"{ROOT}/collection?prefix={prefix}", params=query_params) + resp.raise_for_status() + if (content := resp.json()) == 404: + raise requests.HTTPError(f"Icon set {prefix!r} not found.", response=resp) return content # type: ignore @@ -103,9 +128,9 @@ def last_modified(*prefixes: str) -> APIv3LastModifiedResponse: """ # https://api.iconify.design/last-modified?prefixes=mdi,mdi-light,tabler query_params = {"prefixes": ",".join(prefixes)} - req = requests.get(f"{ROOT}/last-modified", params=query_params) - req.raise_for_status() - return req.json() # type: ignore + resp = requests.get(f"{ROOT}/last-modified", params=query_params) + resp.raise_for_status() + return resp.json() # type: ignore @lru_cache(maxsize=None) @@ -163,9 +188,11 @@ def svg( } if box: query_params["box"] = 1 - req = requests.get(f"{ROOT}/{prefix}/{name}.svg", params=query_params) - req.raise_for_status() - return req.content + resp = requests.get(f"{ROOT}/{prefix}/{name}.svg", params=query_params) + resp.raise_for_status() + if resp.content == b"404": + raise requests.HTTPError(f"Icon '{prefix}:{name}' not found.", response=resp) + return resp.content @lru_cache(maxsize=None) @@ -194,7 +221,7 @@ def temp_svg( @atexit.register def _remove_tmp_svg() -> None: - with suppress(FileNotFoundError): + with suppress(FileNotFoundError): # pragma: no cover os.remove(tmp_name) return tmp_name @@ -213,9 +240,9 @@ def css(prefix: str, *icons: str) -> str: # format. Stylesheet formatting option. Matches options used in Sass. Supported values: "expanded", "compact", "compressed". # /mdi.css?icons=account-box,account-cash,account,home - req = requests.get(f"{ROOT}/{prefix}.css?icons={','.join(icons)}") - req.raise_for_status() - return req.text + resp = requests.get(f"{ROOT}/{prefix}.css?icons={','.join(icons)}") + resp.raise_for_status() + return resp.text def icon_data(prefix: str, *names: str) -> IconifyJSON: @@ -233,10 +260,10 @@ def icon_data(prefix: str, *names: str) -> IconifyJSON: names : str, optional Icon name(s). """ - req = requests.get(f"{ROOT}/{prefix}.json?icons={','.join(names)}") - req.raise_for_status() - if (content := req.json()) == 404: - raise requests.HTTPError(f"No data returned for {prefix!r}", response=req) + resp = requests.get(f"{ROOT}/{prefix}.json?icons={','.join(names)}") + resp.raise_for_status() + if (content := resp.json()) == 404: + raise requests.HTTPError(f"No data returned for {prefix!r}", response=resp) return content # type: ignore @@ -244,7 +271,7 @@ def search( query: str, limit: int | None = None, start: int | None = None, - prefixes: Sequence[str] | None = None, + prefixes: Iterable[str] | None = None, category: str | None = None, # similar: bool | None = None, ) -> APIv2SearchResponse: @@ -283,7 +310,7 @@ def search( show. start : int, optional Start index for results, default is 0. - prefixes : str, optional + 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 @@ -301,9 +328,9 @@ def search( params["prefixes"] = ",".join(prefixes) if category is not None: params["category"] = category - req = requests.get(f"{ROOT}/search?query={query}", params=params) - req.raise_for_status() - return req.json() # type: ignore + resp = requests.get(f"{ROOT}/search?query={query}", params=params) + resp.raise_for_status() + return resp.json() # type: ignore def keywords( @@ -327,20 +354,23 @@ def keywords( """ if prefix: if keyword: - warn("Both prefix and keyword specified. Ignoring keyword.") + warnings.warn( + "Cannot specify both prefix and keyword. Ignoring keyword.", + stacklevel=2, + ) params = {"prefix": prefix} elif keyword: params = {"keyword": keyword} else: params = {} - req = requests.get(f"{ROOT}/keywords", params=params) - req.raise_for_status() - return req.json() # type: ignore + resp = requests.get(f"{ROOT}/keywords", params=params) + resp.raise_for_status() + return resp.json() # type: ignore @lru_cache(maxsize=None) def iconify_version() -> str: """Return version of iconify API.""" - req = requests.get(f"{ROOT}/version") - req.raise_for_status() - return req.text + resp = requests.get(f"{ROOT}/version") + resp.raise_for_status() + return resp.text diff --git a/tests/test_pyconify.py b/tests/test_pyconify.py index 363b3e2..20f0443 100644 --- a/tests/test_pyconify.py +++ b/tests/test_pyconify.py @@ -1,2 +1,80 @@ -def test_something(): - pass +import pyconify +import pytest + + +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="No data returned"): + pyconify.icon_data("not", "found") + + +def test_svg() -> None: + result = pyconify.svg("bi", "alarm", rotate=90, box=True) + assert isinstance(result, bytes) + assert result.startswith(b" None: + result = pyconify.temp_svg("bi", "alarm", rotate=90, box=True) + assert isinstance(result, str) + with open(result, "rb") as f: + assert f.read() == pyconify.svg("bi", "alarm", rotate=90, box=True) + + +def test_css() -> None: + result = pyconify.css("bi", "alarm") + assert result.startswith(".icon--bi") + + +def test_last_modified() -> None: + assert isinstance(pyconify.last_modified("bi")["lastModified"]["bi"], int) + + +def test_keywords() -> None: + keywords = pyconify.keywords("home") + assert isinstance(keywords, dict) + assert keywords["prefix"] == "home" + assert keywords["matches"] + + keywords = pyconify.keywords(keyword="home") + assert keywords["keyword"] == "home" + assert keywords["matches"] + + with pytest.warns(UserWarning, match="Cannot specify both prefix and keyword"): + assert isinstance(pyconify.keywords("home", keyword="home"), dict) + + assert 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") + assert result["collections"] + + +def test_iconify_version() -> None: + assert isinstance(pyconify.iconify_version(), str)