Repository: ibis-project/ibis

Issue:
bug: default backend ModuleNotFoundError from duckdb_engine
### What happened?

After a fresh install specifying any extras for non-DuckDB backends, I want to use an example dataset without specifying a backend. 

```bash
$ pip install "ibis-framework[polars]"
```
```py
import ibis
t = ibis.examples.starwars.fetch()
```

It goes through a long traceback but bubbles up to the `_default_backend()` function in https://github.com/ibis-project/ibis/blob/4b57f7fc4fdd3e839cdc2e1014955149bd4f14ca/ibis/config.py#L178-L209

It seems to be attempting to import duckdb which is fine on its own, but later attempts to `import duckdb_engine.datatypes as ducktypes` causing the issue in the event duckdb is installed without duckdb_engine in 

https://github.com/ibis-project/ibis/blob/4b57f7fc4fdd3e839cdc2e1014955149bd4f14ca/ibis/backends/duckdb/datatypes.py#L3

I'm wondering if it should just bubble up with the IbisError being raised sooner versus the long traceback.




### What version of ibis are you using?

6.0.0

### What backend(s) are you using, if any?

DuckDB, kind of.

### Relevant log output

```sh
ModuleNotFoundError                       Traceback (most recent call last)
<ipython-input-15-cd2da738dd51> in <cell line: 1>()
----> 1 t = ibis.examples.starwars.fetch()

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/examples/__init__.py in fetch(self, table_name, backend)
     47 
     48         if backend is None:
---> 49             backend = ibis.get_backend()
     50 
     51         if table_name is None:

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/expr/api.py in get_backend(expr)
   1047         from ibis.config import _default_backend
   1048 
-> 1049         return _default_backend()
   1050     return expr._find_backend(use_default=True)
   1051 

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/config.py in _default_backend()
    204     import ibis
    205 
--> 206     options.default_backend = con = ibis.duckdb.connect(":memory:")
    207     return con
    208 

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/__init__.py in __getattr__(name)
     73 
     74     (entry_point,) = entry_points
---> 75     module = entry_point.load()
     76     backend = module.Backend()
     77     # The first time a backend is loaded, we register its options, and we set

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/setuptools/_vendor/importlib_metadata/__init__.py in load(self)
    192         """
    193         match = self.pattern.match(self.value)
--> 194         module = import_module(match.group('module'))
    195         attrs = filter(None, (match.group('attr') or '').split('.'))
    196         return functools.reduce(getattr, attrs, module)

/usr/local/lib/python3.9/importlib/__init__.py in import_module(name, package)
    125                 break
    126             level += 1
--> 127     return _bootstrap._gcd_import(name[level:], package, level)
    128 
    129 

/usr/local/lib/python3.9/importlib/_bootstrap.py in _gcd_import(name, package, level)

/usr/local/lib/python3.9/importlib/_bootstrap.py in _find_and_load(name, import_)

/usr/local/lib/python3.9/importlib/_bootstrap.py in _find_and_load_unlocked(name, import_)

/usr/local/lib/python3.9/importlib/_bootstrap.py in _load_unlocked(spec)

/usr/local/lib/python3.9/importlib/_bootstrap_external.py in exec_module(self, module)

/usr/local/lib/python3.9/importlib/_bootstrap.py in _call_with_frames_removed(f, *args, **kwds)

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/backends/duckdb/__init__.py in <module>
     30 from ibis import util
     31 from ibis.backends.base.sql.alchemy import BaseAlchemyBackend
---> 32 from ibis.backends.duckdb.compiler import DuckDBSQLCompiler
     33 from ibis.backends.duckdb.datatypes import DuckDBType, parse
     34 from ibis.expr.operations.relations import PandasDataFrameProxy

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/backends/duckdb/compiler.py in <module>
      6 import ibis.expr.operations as ops
      7 from ibis.backends.base.sql.alchemy import AlchemyCompiler, AlchemyExprTranslator
----> 8 from ibis.backends.duckdb.datatypes import DuckDBType
      9 from ibis.backends.duckdb.registry import operation_registry
     10 

~/.cache/pypoetry/virtualenvs/python-kernel-OtKFaj5M-py3.9/lib/python3.9/site-packages/ibis/backends/duckdb/datatypes.py in <module>
      1 from __future__ import annotations
      2 
----> 3 import duckdb_engine.datatypes as ducktypes
      4 import parsy
      5 import sqlalchemy.dialects.postgresql as psql

ModuleNotFoundError: No module named 'duckdb_engine'
```


### Code of Conduct

- [X] I agree to follow this project's Code of Conduct


Discussion:
thanks for reporting @IndexSeek! agreed this is confusing and we can probably have better UX here

as an immediate workaround, you can set the default backend to Polars (`ibis.set_backend("polars")`) and this will work as expected. I've created a fresh virtual environment and `pip install 'ibis-framework[polars]`, then can run:

```python
[ins] In [1]: import ibis

[ins] In [2]: ibis.set_backend("polars")

[ins] In [3]: t = ibis.examples.starwars.fetch()

[ins] In [4]: t
Out[4]:
DatabaseTable: _ibis_examples_starwars_zj3wlwcqcncltmz2hrxocezlo4
  name       string
  height     int64
  mass       float64
  hair_color string
  skin_color string
  eye_color  string
  birth_year float64
  sex        string
  gender     string
  homeworld  string
  species    string
  films      string
  vehicles   string
  starships  string
```
@IndexSeek Is there a specific behavior you'd like to see here?

I don't think it's possible for ibis to sanely handle the case of an environment having some of the dependencies for a backend but not all (because, for example, you installed ibis into an existing environment that contained duckdb for reasons unrelated to ibis).

You're seeing this error because `duckdb_engine` is a required dependency for the duckdb backend, which is the default backend.
One thing we could do is catch import errors when trying to load a backend and raise a more helpful error message pointing users to install that backend specifically. We do this for dask in the submodules (which aren't supported without specific extras) and it's cut down user reports for missing dependencies a lot. Something like:

```python
try:
    our_exisiting_backend_loading_code_here...
except ImportError as exc:
    raise ImportError(f"Failed to import backend {backend} due to a missing dependency, please install like ...") from exc
```
Seems reasonable!
> @IndexSeek Is there a specific behavior you'd like to see here?

I think just raising a cleaner `ImportError` is what I'm looking for here with the other GitHub version of myself. 

> I don't think it's possible for ibis to sanely handle the case of an environment having some of the dependencies for a backend but not all (because, for example, you installed ibis into an existing environment that contained duckdb for reasons unrelated to ibis).

I like @jcrist's solution for this, but I agree; catching all dependencies could get relatively complex. In this case, I was using a Hex project which includes a lot of [pre-installed packages](https://learn.hex.tech/docs/environment-configuration/using-packages#use-a-pre-installed-package), and DuckDB happens to be one as of now, but it doesn't include the engine. 

Normally I would do the install like `pip install ibis-framework[duckdb]` or `conda/mamba install -c conda-forge ibis-duckdb`, but I didn't do that, thinking maybe I would have been covered already. 



Ah, yeah. Usually our backends have more than one dependency :)
I'll push up a quick patch improving the error message now.

Resolving patch:
diff --git a/ibis/__init__.py b/ibis/__init__.py
--- a/ibis/__init__.py
+++ b/ibis/__init__.py
@@ -72,7 +72,15 @@ def __getattr__(name: str) -> BaseBackend:
     import ibis
 
     (entry_point,) = entry_points
-    module = entry_point.load()
+    try:
+        module = entry_point.load()
+    except ImportError as exc:
+        raise ImportError(
+            f"Failed to import the {name} backend due to missing dependencies.\n\n"
+            f"You can pip or conda install the {name} backend as follows:\n\n"
+            f'  python -m pip install -U "ibis-framework[{name}]"  # pip install\n'
+            f"  conda install -c conda-forge ibis-{name}           # or conda install"
+        ) from exc
     backend = module.Backend()
     # The first time a backend is loaded, we register its options, and we set
     # it as an attribute of `ibis`, so `__getattr__` is not called again for it


Test patch: