Repository: fonttools/ufoLib2

Issue:
msgpack also required for json output
This code triggers a msgpack import (neither json nor msgpack extra is installed):

```py
ufo = Font.open("some.ufo")
ufo.json_dump("a.json")
```

Can't even seem to catch it using `except Exception as e` in `def serde`. A missing cattrs import ends the script as well.

Discussion:

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4f90292..7142d15 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 292b963..6ace0c5 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 0573abb..2659e9b 100644
--- a/src/ufoLib2/serde/__init__.py
+++ b/src/ufoLib2/serde/__init__.py
@@ -4,6 +4,7 @@ from functools import partialmethod
 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/tox.ini b/tox.ini
index e55a83b..d34ec05 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}
 


Test patch:
diff --git a/tests/serde/test_json.py b/tests/serde/test_json.py
index 67cdab8..53388a9 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 886582f..78f717c 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 f45e210..7fe0be6 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 @@ def test_raise_import_error(monkeypatch: Any) -> None:
 
     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 @@ assert {d["class_name"] for d in BASIC_EMPTY_OBJECTS} == set(ufoLib2.objects.__a
     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")