diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f90292a..7142d156 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,11 +53,14 @@ jobs: run: pip install tox coverage - name: Run Tox run: tox -e py-cov - - name: Re-run Tox (without cattrs) + - name: Re-run Tox without cattrs if: startsWith(matrix.platform, 'ubuntu-latest') && startsWith(matrix.python-version, '3.10') run: | - .tox/py-cov/bin/pip uninstall -y cattrs - tox -e py-cov --skip-pkg-install + tox -e py-nocattrs + - name: Re-run Tox without msgpack + if: startsWith(matrix.platform, 'ubuntu-latest') && startsWith(matrix.python-version, '3.10') + run: | + tox -e py-nomsgpack - name: Produce coverage files run: | coverage combine diff --git a/src/ufoLib2/errors.py b/src/ufoLib2/errors.py index 292b963d..6ace0c5e 100644 --- a/src/ufoLib2/errors.py +++ b/src/ufoLib2/errors.py @@ -1,5 +1,17 @@ from __future__ import annotations +from typing import Any + class Error(Exception): """The base exception for ufoLib2.""" + + +class ExtrasNotInstalledError(Error): + """The extras required for this feature are not installed.""" + + def __init__(self, extras: str) -> None: + super().__init__(f"Extras not installed: ufoLib2[{extras}]") + + def __call__(self, *args: Any, **kwargs: Any) -> None: + raise self diff --git a/src/ufoLib2/serde/__init__.py b/src/ufoLib2/serde/__init__.py index 0573abb8..2659e9b2 100644 --- a/src/ufoLib2/serde/__init__.py +++ b/src/ufoLib2/serde/__init__.py @@ -4,6 +4,7 @@ from importlib import import_module from typing import IO, Any, AnyStr, Callable, Type +from ufoLib2.errors import ExtrasNotInstalledError from ufoLib2.typing import PathLike, T _SERDE_FORMATS_ = ("json", "msgpack") @@ -75,12 +76,9 @@ def serde(cls: Type[T]) -> Type[T]: try: serde_submodule = import_module(f"ufoLib2.serde.{fmt}") - except ImportError as e: - exc = e - - def raise_error(*args: Any, **kwargs: Any) -> None: - raise exc - + except ImportError as exc: + raise_error = ExtrasNotInstalledError(fmt) + raise_error.__cause__ = exc for method in ("loads", "load", "dumps", "dump"): setattr(cls, f"{fmt}_{method}", raise_error) else: diff --git a/tests/serde/test_json.py b/tests/serde/test_json.py index 67cdab8b..53388a9d 100644 --- a/tests/serde/test_json.py +++ b/tests/serde/test_json.py @@ -20,6 +20,8 @@ def test_dumps_loads( ) -> None: if not have_orjson: monkeypatch.setattr(ufoLib2.serde.json, "have_orjson", have_orjson) + else: + pytest.importorskip("orjson") font = ufo_UbuTestData data = font.json_dumps() # type: ignore @@ -82,6 +84,7 @@ def test_dump_load( @pytest.mark.parametrize("indent", [1, 3], ids=["indent-1", "indent-3"]) def test_indent_not_2_orjson(indent: int) -> None: + pytest.importorskip("orjson") with pytest.raises(ValueError): ufoLib2.serde.json.dumps(None, indent=indent) diff --git a/tests/serde/test_msgpack.py b/tests/serde/test_msgpack.py index 886582f5..78f717c3 100644 --- a/tests/serde/test_msgpack.py +++ b/tests/serde/test_msgpack.py @@ -1,12 +1,14 @@ from pathlib import Path -import msgpack # type: ignore import pytest import ufoLib2.objects # isort: off pytest.importorskip("cattrs") +pytest.importorskip("msgpack") + +import msgpack # type: ignore # noqa import ufoLib2.serde.msgpack # noqa: E402 diff --git a/tests/serde/test_serde.py b/tests/serde/test_serde.py index f45e210e..7fe0be61 100644 --- a/tests/serde/test_serde.py +++ b/tests/serde/test_serde.py @@ -1,20 +1,37 @@ import importlib -import sys from typing import Any, Dict, List import pytest from attrs import define import ufoLib2.objects +from ufoLib2.errors import ExtrasNotInstalledError from ufoLib2.serde import _SERDE_FORMATS_, serde +cattrs = None +try: + import cattrs # type: ignore +except ImportError: + pass -def test_raise_import_error(monkeypatch: Any) -> None: - # pretend we can't import the module (e.g. msgpack not installed) - monkeypatch.setitem(sys.modules, "ufoLib2.serde.msgpack", None) - with pytest.raises(ImportError, match="ufoLib2.serde.msgpack"): - importlib.import_module("ufoLib2.serde.msgpack") +msgpack = None +try: + import msgpack # type: ignore +except ImportError: + pass + + +EXTRAS_REQUIREMENTS = { + "json": ["cattrs"], + "msgpack": ["cattrs", "msgpack"], +} + + +def assert_extras_not_installed(extras: str, missing_dependency: str) -> None: + # sanity check that the dependency is not installed + with pytest.raises(ImportError, match=missing_dependency): + importlib.import_module(missing_dependency) @serde @define @@ -24,10 +41,28 @@ class Foo: foo = Foo(1) - with pytest.raises(ImportError, match="ufoLib2.serde.msgpack"): - # since the method is only added dynamically at runtime, mypy complains that - # "Foo" has no attribute "msgpack_dumps" -- so I shut it up - foo.msgpack_dumps() # type: ignore + with pytest.raises( + ExtrasNotInstalledError, match=rf"Extras not installed: ufoLib2\[{extras}\]" + ) as exc_info: + dumps_method = getattr(foo, f"{extras}_dumps") + dumps_method() + + assert isinstance(exc_info.value.__cause__, ModuleNotFoundError) + + +@pytest.mark.skipif(cattrs is not None, reason="cattrs installed, not applicable") +def test_json_cattrs_not_installed() -> None: + assert_extras_not_installed("json", "cattrs") + + +@pytest.mark.skipif(cattrs is not None, reason="cattrs installed, not applicable") +def test_msgpack_cattrs_not_installed() -> None: + assert_extras_not_installed("msgpack", "cattrs") + + +@pytest.mark.skipif(msgpack is not None, reason="msgpack installed, not applicable") +def test_msgpack_not_installed() -> None: + assert_extras_not_installed("msgpack", "msgpack") BASIC_EMPTY_OBJECTS: List[Dict[str, Any]] = [ @@ -61,9 +96,8 @@ class Foo: ids=lambda x: x["class_name"], # type: ignore ) def test_serde_all_objects(fmt: str, object_info: Dict[str, Any]) -> None: - if fmt in ("json", "msgpack"): - # skip these format tests if cattrs is not installed - pytest.importorskip("cattrs") + for req in EXTRAS_REQUIREMENTS[fmt]: + pytest.importorskip(req) klass = getattr(ufoLib2.objects, object_info["class_name"]) loads = getattr(klass, f"{fmt}_loads") diff --git a/tox.ini b/tox.ini index e55a83b3..d34ec05c 100644 --- a/tox.ini +++ b/tox.ini @@ -7,6 +7,9 @@ deps = -r requirements.txt -r requirements-dev.txt commands = + nocattrs: pip uninstall -y cattrs + noorjson: pip uninstall -y orjson + nomsgpack: pip uninstall -y msgpack cov: coverage run --parallel-mode -m pytest {posargs} !cov: pytest {posargs}