Repository: dask/distributed

Issue:
ipywidget error on minimal install
This issue was filed/closed in [dask-jobqueue](https://github.com/dask/dask-jobqueue/issues/271) but doesn't seem to be open here. I just ran into it today, figured I'd file a bug report.

I have a minimal install of dask (just dask and dask.distributed) on miniconda. A cell with the following code

```python
from dask.distributed import LocalCluster
cluster = LocalCluster()
cluster
```

throws the following traceback.
```python
---------------------------------------------------------------------------
ModuleNotFoundError                       Traceback (most recent call last)
~\AppData\Local\Continuum\miniconda3\envs\dask-tutorial\lib\site-packages\IPython\core\formatters.py in __call__(self, obj)
    916             method = get_real_method(obj, self.print_method)
    917             if method is not None:
--> 918                 method()
    919                 return True
    920 

~\AppData\Local\Continuum\miniconda3\envs\dask-tutorial\lib\site-packages\distributed\deploy\cluster.py in _ipython_display_(self, **kwargs)
    206 
    207     def _ipython_display_(self, **kwargs):
--> 208         return self._widget()._ipython_display_(**kwargs)

~\AppData\Local\Continuum\miniconda3\envs\dask-tutorial\lib\site-packages\distributed\deploy\cluster.py in _widget(self)
    143             pass
    144 
--> 145         from ipywidgets import Layout, VBox, HBox, IntText, Button, HTML, Accordion
    146 
    147         layout = Layout(width='150px')

ModuleNotFoundError: No module named 'ipywidgets'
```
Let me know if you need more configuration info.


Discussion:
@jcrist if memory serves you have a decent solution of this in dask-yarn.  Are you still ok to push that work upstream when you get back from vacation?
Sure, I'll make a PR tomorrow.

Resolving patch:
diff --git a/distributed/deploy/cluster.py b/distributed/deploy/cluster.py
--- a/distributed/deploy/cluster.py
+++ b/distributed/deploy/cluster.py
@@ -246,7 +246,11 @@ def _widget(self):
         except AttributeError:
             pass
 
-        from ipywidgets import Layout, VBox, HBox, IntText, Button, HTML, Accordion
+        try:
+            from ipywidgets import Layout, VBox, HBox, IntText, Button, HTML, Accordion
+        except ImportError:
+            self._cached_widget = None
+            return None
 
         layout = Layout(width="150px")
 
@@ -258,7 +262,7 @@ def _widget(self):
         else:
             link = ""
 
-        title = "<h2>%s</h2>" % type(self).__name__
+        title = "<h2>%s</h2>" % self._cluster_class_name
         title = HTML(title)
         dashboard = HTML(link)
 
@@ -311,8 +315,32 @@ def update():
 
         return box
 
+    def _repr_html_(self):
+        if self.dashboard_link:
+            dashboard = "<a href='{0}' target='_blank'>{0}</a>".format(
+                self.dashboard_link
+            )
+        else:
+            dashboard = "Not Available"
+        return (
+            "<div style='background-color: #f2f2f2; display: inline-block; "
+            "padding: 10px; border: 1px solid #999999;'>\n"
+            "  <h3>{cls}</h3>\n"
+            "  <ul>\n"
+            "    <li><b>Dashboard: </b>{dashboard}\n"
+            "  </ul>\n"
+            "</div>\n"
+        ).format(cls=self._cluster_class_name, dashboard=dashboard)
+
     def _ipython_display_(self, **kwargs):
-        return self._widget()._ipython_display_(**kwargs)
+        widget = self._widget()
+        if widget is not None:
+            return widget._ipython_display_(**kwargs)
+        else:
+            from IPython.display import display
+
+            data = {"text/plain": repr(self), "text/html": self._repr_html_()}
+            display(data, raw=True)
 
     async def __aenter__(self):
         await self
@@ -325,9 +353,13 @@ async def __aexit__(self, typ, value, traceback):
     def scheduler_address(self):
         return self.scheduler_comm.address
 
+    @property
+    def _cluster_class_name(self):
+        return getattr(self, "_name", type(self).__name__)
+
     def __repr__(self):
         text = "%s(%r, workers=%d, threads=%d" % (
-            getattr(self, "_name", type(self).__name__),
+            self._cluster_class_name,
             self.scheduler_address,
             len(self.workers),
             sum(w["nthreads"] for w in self.scheduler_info["workers"].values()),


Test patch:
diff --git a/distributed/deploy/tests/test_local.py b/distributed/deploy/tests/test_local.py
--- a/distributed/deploy/tests/test_local.py
+++ b/distributed/deploy/tests/test_local.py
@@ -530,6 +530,31 @@ def test_ipywidgets(loop):
         assert isinstance(box, ipywidgets.Widget)
 
 
+def test_no_ipywidgets(loop, monkeypatch):
+    from unittest.mock import MagicMock
+
+    mock_display = MagicMock()
+
+    monkeypatch.setitem(sys.modules, "ipywidgets", None)
+    monkeypatch.setitem(sys.modules, "IPython.display", mock_display)
+
+    with LocalCluster(
+        n_workers=0,
+        scheduler_port=0,
+        silence_logs=False,
+        loop=loop,
+        dashboard_address=False,
+        processes=False,
+    ) as cluster:
+        cluster._ipython_display_()
+        args, kwargs = mock_display.display.call_args
+        res = args[0]
+        assert kwargs == {"raw": True}
+        assert isinstance(res, dict)
+        assert "text/plain" in res
+        assert "text/html" in res
+
+
 def test_scale(loop):
     """ Directly calling scale both up and down works as expected """
     with LocalCluster(