Repository: intake/intake
Pull request #398 (2019-08-02 15:43:39)

add viz layer to GUI


Patch:
diff --git a/.travis.yml b/.travis.yml
index 953a43b..6ac2095 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -34,7 +34,7 @@ matrix:
         - sudo apt update
         - sudo apt install libsnappy-dev
         - pip install -e .[complete]
-        - pip install panel==0.7.0
+        - pip install "panel>=0.8.0" "bokeh<=2.0.0"
         - pip install git+https://github.com/dask/dask --upgrade --no-deps
         - pip install pytest
       script:
diff --git a/docs/environment.yml b/docs/environment.yml
index 58efc2c..43a4064 100644
--- a/docs/environment.yml
+++ b/docs/environment.yml
@@ -15,8 +15,9 @@ dependencies:
   - jinja2
   - python-snappy
   - pyyaml
-  - hvplot
+  - hvplot >=0.5.2
+  - panel >=0.8.0
+  - bokeh <2.0.0
   - sphinx
   - sphinx_rtd_theme
   - numpydoc
-  - panel => 0.8.0
diff --git a/docs/source/gui.rst b/docs/source/gui.rst
index 35970fa..0676473 100644
--- a/docs/source/gui.rst
+++ b/docs/source/gui.rst
@@ -4,7 +4,7 @@ GUI
 Using the GUI
 -------------
 
-**Note**: the GUI requires the latest release versions of ``panel`` and ``bokeh`` to
+**Note**: the GUI requires ``panel`` and ``bokeh`` to
 be available in the current environment.
 
 The Intake top-level singleton ``intake.gui`` gives access to a graphical data browser
diff --git a/intake/catalog/gui.py b/intake/catalog/gui.py
index a2459f3..04528cd 100644
--- a/intake/catalog/gui.py
+++ b/intake/catalog/gui.py
@@ -8,7 +8,7 @@ from functools import partial
 
 try:
     import panel as pn
-    from ..gui.gui import SourceGUI, MAX_WIDTH
+    from ..gui.gui import SourceGUI, MAX_WIDTH, ICONS
     css = """
     .scrolling {
         overflow: scroll;
@@ -20,36 +20,79 @@ try:
     class EntryGUI(SourceGUI):
         def __init__(self, source=None, **kwargs):
             self.source = source
-            # set logo to false because we'll use a special logo_panel
-            kwargs['logo'] = False
             super().__init__(sources=[self.source], **kwargs)
-            self.panel = pn.Row(name='Source')
+            self.panel = pn.Column(
+                pn.Row(ICONS['logo'], pn.Column(*self.controls)),
+                self.description.panel,
+                self.plot.panel
+            )
 
         def setup(self):
-            super().setup()
-            self.select.visible = False
-            self.children = [
-                pn.Column(
-                    self.logo_panel,
-                    *self.controls,
-                    margin=0,
-                ),
-                pn.Column(
-                    self.description.panel,
-                    self.plot.panel,
-                    width_policy='max',
-                    max_width=MAX_WIDTH,
-                ),
-            ]
+            self._setup_watchers()
+
+        @SourceGUI.visible.setter
+        def visible(self, visible):
+            self._visible = visible
+
+            if visible:
+                self.setup()
+                self.select.visible = True
+                self.description.visible = True
+            elif not visible:
+                self.unwatch()
+                # do children
+                self.select.visible = False
+                self.description.visible = False
+                self.plot.visible = False
+            if self.visible_callback:
+                self.visible_callback(visible)
 
         @property
         def item(self):
             return self.source
 
     class CatalogGUI(SourceGUI):
-        def __init__(self, cat, logo=True, **kwargs):
+        def __init__(self, cat, **kwargs):
             self.cat = cat
-            super().__init__(cats=[self.cat], logo=logo, **kwargs)
+            super().__init__(cats=[self.cat], **kwargs)
+            self.panel = pn.Column(
+                pn.Row(
+                    pn.panel(ICONS['logo']),
+                    pn.Row(
+                        pn.Column(
+                            self.select.panel,
+                            self.control_panel,
+                            margin=0
+                        ),
+                        self.description.panel,
+                        margin=0
+                    ),
+                ),
+                self.plot.panel
+            )
+
+        def setup(self):
+            self._setup_watchers()
+
+        @SourceGUI.visible.setter
+        def visible(self, visible):
+            self._visible = visible
+
+            if visible:
+                self.setup()
+                self.select.visible = True
+                self.description.visible = True
+                if len(self.control_panel.objects) == 0:
+                    self.control_panel.extend(self.controls)
+            elif not visible:
+                self.unwatch()
+                # do children
+                self.select.visible = False
+                self.control_panel.clear()
+                self.description.visible = False
+                self.plot.visible = False
+            if self.visible_callback:
+                self.visible_callback(visible)
 
         @property
         def item(self):
@@ -65,7 +108,7 @@ except ImportError:
             pass
         def __repr__(self):
             raise RuntimeError("Please install panel to use the GUI (`conda "
-                               "install -c conda-forge panel==0.5.1`)")
+                               "install -c conda-forge panel>0.8.0`)")
 
     EntryGUI = GUI
     CatalogGUI = GUI
diff --git a/intake/cli/client/subcommands/cache.py b/intake/cli/client/subcommands/cache.py
index 426bd93..aead80e 100644
--- a/intake/cli/client/subcommands/cache.py
+++ b/intake/cli/client/subcommands/cache.py
@@ -4,35 +4,18 @@
 #
 # The full license is in the LICENSE file, distributed with this software.
 #-----------------------------------------------------------------------------
-'''
 
-'''
-
-import logging
-log = logging.getLogger(__name__)
-
-#-----------------------------------------------------------------------------
-# Imports
-#-----------------------------------------------------------------------------
-
-# Standard library imports
 import os
-
-# External imports
 import yaml
-
-# Intake imports
 from intake.cli.util import Subcommand
-from intake.utils import make_path_posix
+import logging
+log = logging.getLogger(__name__)
 
-#-----------------------------------------------------------------------------
-# API
-#-----------------------------------------------------------------------------
 
 class Cache(Subcommand):
-    ''' Locally cached files
+    """ Locally cached files
 
-    '''
+    """
 
     name = "cache"
 
diff --git a/intake/cli/client/subcommands/drivers.py b/intake/cli/client/subcommands/drivers.py
index b75c7c1..9f9be0e 100644
--- a/intake/cli/client/subcommands/drivers.py
+++ b/intake/cli/client/subcommands/drivers.py
@@ -4,22 +4,17 @@
 #
 # The full license is in the LICENSE file, distributed with this software.
 #-----------------------------------------------------------------------------
-'''
+"""
 CLI for listing, enabling, disabling intake drivers
-'''
+"""
 
-import logging
-log = logging.getLogger(__name__)
 
-#-----------------------------------------------------------------------------
+# -----------------------------------------------------------------------------
 # Imports
-#-----------------------------------------------------------------------------
+# -----------------------------------------------------------------------------
 
 # Standard library imports
-import errno
-from importlib import import_module
 import inspect
-import os
 import sys
 
 # External imports
@@ -31,9 +26,12 @@ from intake.source.discovery import autodiscover, autodiscover_all, enable, disa
 from intake.config import confdir
 import intake
 
-#-----------------------------------------------------------------------------
+import logging
+log = logging.getLogger(__name__)
+
+# -----------------------------------------------------------------------------
 # API
-#-----------------------------------------------------------------------------
+# -----------------------------------------------------------------------------
 
 
 class Drivers(Subcommand):
diff --git a/intake/cli/client/subcommands/precache.py b/intake/cli/client/subcommands/precache.py
index 6d53df2..53b6c1a 100644
--- a/intake/cli/client/subcommands/precache.py
+++ b/intake/cli/client/subcommands/precache.py
@@ -5,7 +5,6 @@
 # The full license is in the LICENSE file, distributed with this software.
 #-----------------------------------------------------------------------------
 '''
-
 '''
 
 import logging
@@ -29,7 +28,6 @@ from intake.cli.util import Subcommand
 
 class Precache(Subcommand):
     ''' Populate caching for catalog entries that define caching.
-
     '''
 
     name = "precache"
@@ -40,7 +38,11 @@ class Precache(Subcommand):
     def invoke(self, args):
         catalog = open_catalog(args.uri)
         for entry in list(catalog):
-            s = catalog[entry]()
-            if s.cache:
-                print("Caching for entry %s" % entry)
+            s = catalog[entry]
+            try:
+                s = s()
                 s.read()
+                if s.cache:
+                    print("Caching for entry %s" % entry)
+            except Exception as e:
+                print("Skipping {} due to {}".format(entry, e))
diff --git a/intake/container/__init__.py b/intake/container/__init__.py
index 18a8203..42b4948 100644
--- a/intake/container/__init__.py
+++ b/intake/container/__init__.py
@@ -17,6 +17,7 @@ _container_map = {
     'dataframe': RemoteDataFrame,
     'python': RemoteSequenceSource,
     'ndarray': RemoteArray,
+    'numpy': RemoteArray,
     'catalog': RemoteCatalog
 }
 container_map = ContainerRegistryView(_container_map)  # public, read-only view
diff --git a/intake/container/persist.py b/intake/container/persist.py
index 6064422..8b1899c 100644
--- a/intake/container/persist.py
+++ b/intake/container/persist.py
@@ -17,21 +17,6 @@ from ..source import import_name
 from ..utils import make_path_posix
 
 
-def _maybe_add_rm(fs):
-    # monkey-path local filesystem
-    # this goes away if we can use fsspec's local file-system
-    from fsspec.implementations.local import LocalFileSystem
-    if isinstance(fs, LocalFileSystem):
-        def rm(path, recursive=False):
-            if recursive:
-                import shutil
-                shutil.rmtree(path)
-            else:
-                import os
-                os.remove(path)
-        fs.rm = rm
-
-
 class PersistStore(YAMLFileCatalog):
     """
     Specialised catalog for persisted data-sources
@@ -47,15 +32,13 @@ class PersistStore(YAMLFileCatalog):
             cls._singleton[0] = o
         return cls._singleton[0]
 
-    def __init__(self, path=None):
-        # from fsspec.registry import filesystem
+    def __init__(self, path=None, **storage_options):
         from fsspec import filesystem
+        from fsspec.core import split_protocol
         self.pdir = make_path_posix(path or conf.get('persist_path'))
+        protocol, _ = split_protocol(self.pdir)
         path = posixpath.join(self.pdir, 'cat.yaml')
-        protocol = (self.pdir.split('://', 1)[0]
-                    if "://" in self.pdir else 'file')
-        self.fs = filesystem(protocol)
-        _maybe_add_rm(self.fs)
+        self.fs = filesystem(protocol, **storage_options)
         super(PersistStore, self).__init__(path)
 
     def _load(self):
diff --git a/intake/gui/__init__.py b/intake/gui/__init__.py
index 9d27777..600f1b8 100644
--- a/intake/gui/__init__.py
+++ b/intake/gui/__init__.py
@@ -18,12 +18,14 @@ def do_import():
         pn.config.raw_css.append(css)  # add scrolling class from css (panel GH#383, GH#384)
         pn.extension()
 
-    except ImportError:
-
+    except ImportError as e:
+        error = e
+        
         class GUI(object):
             def __repr__(self):
                 raise RuntimeError("Please install panel to use the GUI `conda "
-                                   "install -c conda-forge panel==0.5.1`")
+                                   "install -c conda-forge panel>=0.8.0`. Import "
+                                   "failed with error: %s" % error)
 
     except Exception as e:
 
@@ -32,7 +34,7 @@ def do_import():
                 raise RuntimeError("Initialisation of GUI failed, even though "
                                    "panel is installed. Please update it "
                                    "to a more recent version (`conda install -c"
-                                   " conda-forge panel==0.5.1`).")
+                                   " conda-forge panel>=0.7.0`).")
     return GUI
 
 
diff --git a/intake/gui/base.py b/intake/gui/base.py
index 96287ed..6cb7314 100644
--- a/intake/gui/base.py
+++ b/intake/gui/base.py
@@ -111,12 +111,13 @@ class Base(object):
     def visible(self, visible):
         """When visible changed, do setup or unwatch and call visible_callback"""
         self._visible = visible
-        if visible and len(self._panel.objects) == 0:
+        pan = getattr(self._panel, '_layout', self._panel)
+        if visible and len(pan.objects) == 0:
             self.setup()
             self._panel.extend(self.children)
-        elif not visible and len(self._panel.objects) > 0:
+        elif not visible and len(pan.objects) > 0:
             self.unwatch()
-            self._panel.clear()
+            pan.clear()
         if self.visible_callback:
             self.visible_callback(visible)
 
diff --git a/intake/gui/catalog/add.py b/intake/gui/catalog/add.py
index b2b68e0..8ef0ef8 100644
--- a/intake/gui/catalog/add.py
+++ b/intake/gui/catalog/add.py
@@ -136,7 +136,7 @@ class FileSelector(Base):
             print(e)
 
         self.main.value = []
-        self.main.options = out
+        self.main.options = sorted(out)
 
     def move_down(self, *events):
         for event in events:
diff --git a/intake/gui/catalog/gui.py b/intake/gui/catalog/gui.py
index 057d6fc..6286b76 100644
--- a/intake/gui/catalog/gui.py
+++ b/intake/gui/catalog/gui.py
@@ -62,7 +62,7 @@ class CatGUI(Base):
             disabled=True,
             width=50)
 
-        self.controls  = [
+        self.controls = [
             self.add_widget,
             self.remove_widget,
             self.search_widget,
diff --git a/intake/gui/gui.py b/intake/gui/gui.py
index f83e5a5..b59cf7b 100644
--- a/intake/gui/gui.py
+++ b/intake/gui/gui.py
@@ -7,7 +7,7 @@
 import intake
 import panel as pn
 
-from .base import Base, MAX_WIDTH
+from .base import Base, MAX_WIDTH, ICONS
 from .catalog.gui import CatGUI
 from .source.gui import SourceGUI
 
@@ -35,21 +35,19 @@ class GUI(Base):
         watchers that are set on children - cleaned up when visible
         is set to false.
     """
-    def __init__(self, cats=None, logo=True):
+    def __init__(self, cats=None):
         self.source = SourceGUI()
         self.cat = CatGUI(cats=cats, done_callback=self.done_callback)
-        self.panel = pn.Column(name='GUI', width_policy='max', max_width=MAX_WIDTH)
-        self.visible = True
-        self.logo = logo
-
-    def setup(self):
-        self.children = [
+        self.panel = pn.Column(
             pn.Row(
+                pn.panel(ICONS['logo']),
                 pn.Column(
                     self.cat.select.panel,
                     self.cat.control_panel,
                     margin=0,
                 ),
+            ),
+            pn.Row(
                 pn.Column(
                     self.source.select.panel,
                     self.source.control_panel,
@@ -58,10 +56,10 @@ class GUI(Base):
                 self.source.description.panel,
                 margin=0,
             ),
-            self.cat.search.panel,
-            self.cat.add.panel,
-            self.source.plot.panel,
-        ]
+            pn.Row(self.cat.search.panel, self.cat.add.panel,
+                   self.source.plot.panel)
+        )
+        super(GUI, self).__init__()
 
     def done_callback(self, cats):
         self.source.select.cats = cats
diff --git a/intake/gui/source/defined_plots.py b/intake/gui/source/defined_plots.py
index 3fb499a..f5859c0 100644
--- a/intake/gui/source/defined_plots.py
+++ b/intake/gui/source/defined_plots.py
@@ -4,14 +4,27 @@
 #
 # The full license is in the LICENSE file, distributed with this software.
 #-----------------------------------------------------------------------------
-from copy import deepcopy
+from distutils.version import LooseVersion
+
+try:
+    import dfviz
+    assert LooseVersion(dfviz.__version__) >= LooseVersion("0.1.0")
+except ImportError:
+    dfviz = False
+
+try:
+    import xrviz
+    from xrviz.dashboard import Dashboard as XRViz
+    assert LooseVersion(xrviz.__version__) >= LooseVersion("0.1.1")
+except ImportError:
+    xrviz = False
 
 import panel as pn
 from ..base import BaseView
 from ...utils import pretty_describe
 
 
-class DefinedPlots(BaseView):
+class Plots(BaseView):
     """
     Panel for displaying pre-defined plots from catalog.
 
@@ -40,12 +53,11 @@ class DefinedPlots(BaseView):
         is set to false.
     """
     select = None
-    show_desc = None
 
     def __init__(self, source=None, **kwargs):
+        self.custom = pn.widgets.Button(name='Customize...', width_policy='min', align='center')
         self.source = source
         self.panel = pn.Column(name='Plot', width_policy='max', margin=0)
-        self._show_yaml = False
         super().__init__(**kwargs)
 
     def setup(self):
@@ -53,42 +65,39 @@ class DefinedPlots(BaseView):
             self.instructions_contents, align='center', width_policy='max')
         self.select = pn.widgets.Select(options=self.options, height=30,
                                         align='center', width=200)
-        self.desc = pn.pane.Str()
-        self.pane = pn.Column(self._plot_object(self.selected))
-        self.show_desc = pn.widgets.Checkbox(value=False,
-                                             width_policy='min',
-                                             align='center')
+        self.pane = pn.pane.HoloViews(self._plot_object(self.selected),
+                                      name="Plot")
 
         self.watchers = [
-            self.select.param.watch(self.callback, ['options','value']),
-            self.show_desc.param.watch(self._toggle_desc, 'value')
+            self.select.param.watch(self.callback, ['options', 'value']),
+            self.custom.param.watch(self.interact, ['clicks'])
         ]
+        self.out = pn.Row(self.pane, name="Plot")
 
         self.children = [
             pn.Row(
                 self.instructions,
                 self.select,
-                self.show_desc,
-                "show yaml",
+                self.custom
             ),
-            self.desc,
-            self.pane,
+            self.out,
         ]
 
-    @property
-    def show_yaml(self):
-        return self.show_desc.value if self.show_desc else self._show_yaml
-
-    @show_yaml.setter
-    def show_yaml(self, show):
-        self.show_desc.value = show
-
     @BaseView.source.setter
     def source(self, source):
-        """When the source gets updated, update the the options in the selector"""
+        """When the source gets updated, update the the options in
+        the selector"""
         BaseView.source.fset(self, source)
+        if source and isinstance(source, list):
+            source = source[0]
         if self.select:
             self.select.options = self.options
+        if source and dfviz and source.container == 'dataframe':
+            self.custom.disabled = False
+        elif source and xrviz and source.container in ['xarray', 'ndarray', 'numpy']:
+            self.custom.disabled = False
+        else:
+            self.custom.disabled = True
 
     @property
     def has_plots(self):
@@ -105,7 +114,7 @@ class DefinedPlots(BaseView):
     @property
     def options(self):
         """Plots options defined on the source"""
-        return self.source.plots if self.source is not None else []
+        return (['None'] + self.source.plots) if self.source is not None else []
 
     @property
     def selected(self):
@@ -114,43 +123,53 @@ class DefinedPlots(BaseView):
 
     @selected.setter
     def selected(self, selected):
-        """When plot is selected set, make sure widget stays uptodate"""
+        """When plot is selected set, make sure widget stays upto date"""
         self.select.value = selected
 
     def callback(self, *events):
         for event in events:
             if event.name == 'value':
-                if self.show_desc.value:
-                    self.desc.object = self._desc_contents(event.new)
-                self.pane[0] = pn.panel(self._plot_object(event.new))
+                self.pane.object = self._plot_object(event.new)
             if event.name == 'options':
                 self.instructions.object = self.instructions_contents
 
+    def interact(self, _):
+        # "customize" was pressed
+        if self.selected == 'None':
+            kwargs = {}
+        else:
+            kwargs = self.source.metadata['plots'][self.selected]
+        if self.source.container == 'dataframe':
+            df = self.source.to_dask()
+            if df.npartitions == 1:
+                df = df.compute()
+            viz = dfviz.DFViz(df, **kwargs)
+        elif self.source.container in ['xarray', 'ndarray', 'numpy']:
+            import xarray
+            try:
+                data = self.source.to_dask()
+            except NotImplemented:
+                data = self.source.read()
+            if not isinstance(data, (xarray.DataArray, xarray.Dataset)):
+                data = xarray.DataArray(data)
+            viz = XRViz(data, **kwargs)
+        else:
+            return
+        self.out[0] = viz.panel
+
     def _plot_object(self, selected):
-        if selected:
+        if selected and str(selected) != "None":
             plot_method = getattr(self.source.plot, selected)
+            self.out[0] = self.pane
             if plot_method:
                 return plot_method()
 
-    def _desc_contents(self, selected):
-        if selected:
-            contents = self.source.metadata['plots'][selected]
-            return pretty_describe(contents)
-
-    def _toggle_desc(self, event):
-        if event.new:
-            self.desc.object = self._desc_contents(self.selected)
-        else:
-            self.desc.object = None
-
-
     def __getstate__(self, include_source=True):
         """Serialize the current state of the object. Set include_source
         to False when using with another panel that will include source."""
         state = super().__getstate__(include_source)
         state.update({
             'selected': self.selected,
-            'show_yaml': self.show_yaml,
         })
         return state
 
@@ -161,5 +180,4 @@ class DefinedPlots(BaseView):
         super().__setstate__(state)
         if self.visible:
             self.selected = state.get('selected')
-            self.show_yaml = state.get('show_yaml', False)
         return self
diff --git a/intake/gui/source/gui.py b/intake/gui/source/gui.py
index 2c95b9a..f06713d 100644
--- a/intake/gui/source/gui.py
+++ b/intake/gui/source/gui.py
@@ -10,7 +10,7 @@ from intake.utils import remake_instance
 
 from ..base import Base, enable_widget, MAX_WIDTH
 from .select import SourceSelector
-from .defined_plots import DefinedPlots
+from .defined_plots import Plots
 from .description import Description
 
 
@@ -68,20 +68,22 @@ class SourceGUI(Base):
                                      done_callback=self.callback)
         self.description = Description(source=self.sources)
 
-        self.plot = DefinedPlots(source=self.sources,
-                                 visible=self.plot_widget.value,
-                                 visible_callback=partial(
-                                     setattr, self.plot_widget, 'value'))
+        self.plot = Plots(source=self.sources,
+                          visible=self.plot_widget.value,
+                          visible_callback=partial(
+                          setattr, self.plot_widget, 'value'))
 
         super().__init__(**kwargs)
 
-    def setup(self):
+    def _setup_watchers(self):
         self.watchers = [
             self.plot_widget.param.watch(self.on_click_plot_widget, 'value'),
             self.pars_widget.param.watch(self.on_click_pars_widget, 'value'),
             self.select.widget.link(self.description, value='source'),
         ]
 
+    def setup(self):
+        self._setup_watchers()
         self.children = [
             pn.Row(
                 pn.Column(
diff --git a/intake/source/cache.py b/intake/source/cache.py
index 282bf56..eda5b24 100644
--- a/intake/source/cache.py
+++ b/intake/source/cache.py
@@ -260,7 +260,6 @@ def _download(file_in, file_out, blocksize, output=False):
     """Read from input and write to output file in blocks"""
     with warnings.catch_warnings():
         warnings.filterwarnings('ignore')
-
         if output:
             try:
                 from tqdm.autonotebook import tqdm
@@ -268,31 +267,34 @@ def _download(file_in, file_out, blocksize, output=False):
                 logger.warn("Cache progress bar requires tqdm to be installed:"
                             " conda/pip install tqdm")
                 output = False
+        try:
+            file_size = file_in.fs.size(file_in.path)
+            pbar_disabled = not file_size
+        except ValueError as err:
+            logger.debug("File system error requesting size: {}".format(err))
+            file_size = 0
+            pbar_disabled = True
         if output:
-            try:
-                file_size = file_in.fs.size(file_in.path)
-                pbar_disabled = False
-            except ValueError as err:
-                logger.debug("File system error requesting size: {}".format(err))
-                file_size = 0
-                pbar_disabled = True
-            for i in range(100):
-                if i not in display:
-                    display.add(i)
-                    out = i
-                    break
-            pbar = tqdm(total=file_size // 2 ** 20, leave=False,
-                        disable=pbar_disabled,
-                        position=out, desc=os.path.basename(file_out.path),
-                        mininterval=0.1,
-                        bar_format=r'{n}/|/{l_bar}')
+            if not pbar_disabled:
+                for i in range(100):
+                    if i not in display:
+                        display.add(i)
+                        out = i
+                        break
+                pbar = tqdm(total=file_size // 2 ** 20, leave=False,
+                            disable=pbar_disabled,
+                            position=out, desc=os.path.basename(file_out.path),
+                            mininterval=0.1,
+                            bar_format=r'{n}/|/{l_bar}')
+            else:
+                output = False
 
         logger.debug("Caching {}".format(file_in.path))
         with file_in as f1:
             with file_out as f2:
                 data = True
                 while data:
-                    data = f1.read(blocksize)
+                    data = f1.read(blocksize if file_size else -1)
                     f2.write(data)
                     if output:
                         pbar.update(len(data) // 2**20)
@@ -317,13 +319,19 @@ class FileCache(BaseCache):
         from fsspec import open_files
 
         self._ensure_cache_dir()
-        subdir = self._hash(urlpath)
+        if isinstance(urlpath, (list, tuple)):
+            subdir = self._hash(":".join(urlpath))
+        else:
+            subdir = self._hash(urlpath)
         files_in = open_files(urlpath, 'rb', **self._storage_options)
         files_out = [open_files([self._path(f.path, subdir)], 'wb',
                                 **self._storage_options)[0]
                      for f in files_in]
         return files_in, files_out
 
+    def _from_metadata(self, urlpath):
+        return super()._from_metadata(urlpath)
+
 
 class DirCache(BaseCache):
     """Cache a complete directory tree
@@ -508,9 +516,12 @@ class CacheMetadata(collections.abc.MutableMapping):
         return len(self._metadata)
 
     def __keytransform__(self, key):
+        if isinstance(key, (list, tuple)):
+            key = ":".join(key)
         return key
 
     def update(self, key, cache_entry):
+        key = self.__keytransform__(key)
         entries = self._metadata.get(key, [])
         entries.append(cache_entry)
         self._metadata[key] = entries
@@ -551,7 +562,11 @@ def make_caches(driver, specs, catdir=None, cache_dir=None, storage_options={}):
     """
     if specs is None:
         return []
-    return [registry.get(spec['type'], FileCache)(
-        driver, spec, catdir=catdir, cache_dir=cache_dir,
-        storage_options=storage_options)
-        for spec in specs]
+    out = []
+    for spec in specs:
+        if 'type' in spec and spec['type'] not in registry:
+            raise IndexError(spec['type'])
+        out.append(registry.get(spec['type'], FileCache)(
+            driver, spec, catdir=catdir, cache_dir=cache_dir,
+            storage_options=storage_options))
+    return out
diff --git a/intake/source/npy.py b/intake/source/npy.py
index defdc6f..1fc3cab 100644
--- a/intake/source/npy.py
+++ b/intake/source/npy.py
@@ -135,7 +135,6 @@ class NumpyAccess(object):
         start = self.offset + block * self.dtype.itemsize
         shape = (first, ) + self.shape[1:]
         fn = copy.copy(self.f)  # makes local copy to avoid close while reading
-        print(start, count, shape)
         with fn as f:
             f.seek(start)
             data = f.read(count * self.dtype.itemsize)
diff --git a/scripts/ci/install.sh b/scripts/ci/install.sh
index c706889..913e49a 100755
--- a/scripts/ci/install.sh
+++ b/scripts/ci/install.sh
@@ -9,7 +9,7 @@ conda config --set auto_update_conda off
 
 echo "Installing test dependencies."
 conda install --yes appdirs dask==2 nodejs jinja2 numpy pyyaml requests msgpack-numpy pytest-cov coveralls \
-    pytest fsspec intake-parquet zarr notebook panel==0.5.1 hvplot==0.4.0 -c conda-forge -c defaults
+    pytest fsspec intake-parquet zarr notebook panel==0.8.1 hvplot==0.5.2 bokeh==1.4.0 -c conda-forge -c defaults
 pip install git+https://github.com/dask/dask --upgrade --no-deps
 npm install -g dat
 pip install -e . --no-deps
diff --git a/setup.py b/setup.py
index a451f31..762065e 100644
--- a/setup.py
+++ b/setup.py
@@ -14,7 +14,7 @@ requires = [line.strip() for line in open('requirements.txt').readlines()
             if not line.startswith("#")]
 extras_require = {
   'server': ['tornado', 'python-snappy'],
-  'plot': ['hvplot', 'panel >= 0.5.1'],
+  'plot': ['hvplot', 'panel >= 0.7.0', 'bokeh < 2.0'],
   'dataframe': ['dask[dataframe]', 'msgpack-numpy', 'pyarrow'],
 }
 extras_require['complete'] = sorted(set(sum(extras_require.values(), [])))


Test patch:
diff --git a/intake/catalog/tests/catalog_caching.yml b/intake/catalog/tests/catalog_caching.yml
index a3c6ea9..a206d35 100644
--- a/intake/catalog/tests/catalog_caching.yml
+++ b/intake/catalog/tests/catalog_caching.yml
@@ -14,6 +14,15 @@ sources:
         type: file
     args:
       urlpath: '{{ CATALOG_DIR }}/cache_data/states.csv'
+  test_cache_new:
+    description: cache a csv file from the local filesystem
+    driver: csv
+    args:
+      urlpath: 'cached://{{ CATALOG_DIR }}/cache_data/states.csv'
+      storage_options:
+        target_protocol: 'file'
+        cache_storage: "{{env(TEST_CACHE_DIR)}}"
+
   test_multiple_cache:
     description: testing what happens when there are multiple cache specs
     driver: csv
@@ -26,6 +35,15 @@ sources:
         type: file
     args:
       urlpath: '{{ CATALOG_DIR }}/cache_data/states.csv'
+  test_list_cache:
+    description: testing what happens when there are multiple cache specs
+    driver: csv
+    cache:
+      - argkey: urlpath
+        regex: '{{ CATALOG_DIR }}cache_data'
+        type: file
+    args:
+      urlpath: ['{{ CATALOG_DIR }}cache_data/states.csv', '{{ CATALOG_DIR }}cache_data/states.csv']
   test_bad_type_cache_spec:
     description: cache a csv file from the local filesystem
     driver: csv
diff --git a/intake/catalog/tests/test_caching_integration.py b/intake/catalog/tests/test_caching_integration.py
index 2bad9fc..bf3af92 100644
--- a/intake/catalog/tests/test_caching_integration.py
+++ b/intake/catalog/tests/test_caching_integration.py
@@ -22,36 +22,27 @@ def catalog_cache():
     return intake.open_catalog(os.path.join(path, 'catalog_caching.yml'))
 
 
-def test_load_csv(catalog_cache):
-    cat = catalog_cache['test_cache']
-    cache = cat.cache[0]
-
-    cache_paths = cache.load(cat._urlpath, output=False)
-    cache_path = cache_paths[-1]
-
-    assert cache._cache_dir in cache_path
-    assert os.path.isfile(cache_path)
-
-    cache_id = os.path.basename(os.path.dirname(cache_path))
-    # Checking for md5 hash
+def test_load_csv(catalog_cache, tempdir):
+    os.environ['TEST_CACHE_DIR'] = str(tempdir)
+
+    catalog_cache['test_cache_new'].read()
+    files = os.listdir(tempdir)
+    assert 'cache' in files
+    assert len(files) == 2
+    cache_id = [f for f in files if f != 'cache'][0]
     assert all(c in string.hexdigits for c in cache_id)
-    cache.clear_all()
 
 
-def test_bad_type_cache(catalog_cache):
-    cat = catalog_cache['test_bad_type_cache_spec']
-    cache = cat.cache[0]
-
-    cache_paths = cache.load(cat._urlpath, output=False)
-    cache_path = cache_paths[-1]
+def test_list_of_files(catalog_cache):
+    pd = pytest.importorskip('pandas')
+    s1 = catalog_cache['test_cache']
+    s2 = catalog_cache['test_list_cache']
+    assert s2.read().equals(pd.concat([s1.read(), s1.read()]))
 
-    assert cache._cache_dir in cache_path
-    assert os.path.isfile(cache_path)
 
-    cache_id = os.path.basename(os.path.dirname(cache_path))
-    # Checking for md5 hash
-    assert all(c in string.hexdigits for c in cache_id)
-    cache.clear_all()
+def test_bad_type_cache(catalog_cache):
+    with pytest.raises(IndexError):
+        catalog_cache['test_bad_type_cache_spec'].cache
 
 
 def test_load_textfile(catalog_cache):
diff --git a/intake/catalog/tests/test_gui.py b/intake/catalog/tests/test_gui.py
index 68bab77..86aed8e 100644
--- a/intake/catalog/tests/test_gui.py
+++ b/intake/catalog/tests/test_gui.py
@@ -31,7 +31,7 @@ def test_cat_no_panel_display_gui(catalog1):
 
 def test_cat_gui(catalog1):
     pytest.importorskip('panel')
-    assert repr(catalog1.gui).startswith('Row')
+    assert repr(catalog1.gui).startswith('Column')
 
 
 @pytest.mark.skipif(panel_importable(), reason="panel is importable, so skip")
@@ -47,4 +47,4 @@ def test_entry_no_panel_display_gui(catalog1):
 
 def test_entry_gui(catalog1):
     pytest.importorskip('panel')
-    assert repr(catalog1.entry1.gui).startswith('Row')
+    assert repr(catalog1.entry1.gui).startswith('Column')
diff --git a/intake/gui/source/tests/test_source_view.py b/intake/gui/source/tests/test_source_view.py
index 8b5a538..f17da4c 100644
--- a/intake/gui/source/tests/test_source_view.py
+++ b/intake/gui/source/tests/test_source_view.py
@@ -55,26 +55,6 @@ def test_description_clears_if_visible_is_set_to_false(description):
     assert len(description.panel.objects) == 0
 
 
-def test_description_source_with_plots(sources2):
-    from ..description import Description
-    description = Description(source=sources2[0])
-    assert description.source == sources2[0]
-    lines = (
-        'name: us_crime\n'
-        'container: dataframe\n'
-        "plugin: ['csv']\n"
-        'description: US Crime data [UCRDataTool](https://www.ucrdatatool.gov'
-        '/Search/Crime/State/StatebyState.cfm)\n'
-        'direct_access: forbid\n'
-        'metadata: \n'
-        'args:\nurlpath: {{ CATALOG_DIR }}../data/crime{{selector}}.csv'
-        '').split('\n')
-    for line in lines:
-        assert line in description.contents
-    assert 'plots' in description.contents
-    assert_panel_matches_contents(description)
-
-
 def assert_is_empty(plots, visible=True):
     assert plots.source is None
     assert plots.has_plots is False
@@ -83,10 +63,9 @@ def assert_is_empty(plots, visible=True):
     assert plots.selected is None
     if visible:
         assert plots.instructions.object == plots.instructions_contents
-        assert plots.desc.object is None
-        assert len(plots.pane)<2
-        assert len(plots.children) == 3
-        assert isinstance(plots.children[-1], pn.Column)
+        assert plots.pane.object is None
+        assert len(plots.children) == 2
+        assert isinstance(plots.children[-1][0][0], pn.pane.HoloViews)
         assert plots.panel.objects == plots.children
         assert len(plots.watchers) == 2
     else:
@@ -98,19 +77,14 @@ def assert_is_empty(plots, visible=True):
 def assert_plotting_source2_0_line(plots, visible=True, desc=False):
     assert plots.has_plots is True
     assert plots.instructions_contents == '**Select from the predefined plots:**'
-    assert plots.options == ['line_example', 'violin_example']
+    assert plots.options == ["None", 'line_example', 'violin_example']
     if visible:
-        assert plots.selected == 'line_example'
+        assert plots.selected == 'None'
+        plots.selected = 'line_example'
         assert plots.instructions.object == plots.instructions_contents
-        if desc:
-            assert plots.desc.object == ("kind: line\n"
-                                         "y: ['Robbery', 'Burglary']\n"
-                                         "x: Year")
-        else:
-            assert plots.desc.object == None
-        assert plots.pane.objects is not None
-        assert len(plots.children) == 3
-        assert isinstance(plots.children[-1], pn.Column)
+        assert plots.pane.object is not None
+        assert len(plots.children) == 2
+        assert isinstance(plots.children[-1][0][0], pn.pane.HoloViews)
         assert plots.panel.objects == plots.children
         assert len(plots.watchers) == 2
     else:
@@ -122,22 +96,14 @@ def assert_plotting_source2_0_line(plots, visible=True, desc=False):
 @pytest.fixture
 def defined_plots(sources2):
     pytest.importorskip('hvplot')
-    from ..defined_plots import DefinedPlots
-    return DefinedPlots(source=sources2[0])
-
-
-def test_defined_plots_toggle_desc(defined_plots, sources2):
-    assert defined_plots.source == sources2[0]
-    assert_plotting_source2_0_line(defined_plots, visible=True)
-
-    defined_plots.show_desc.value = True
-    assert_plotting_source2_0_line(defined_plots, visible=True, desc=True)
+    from ..defined_plots import Plots
+    return Plots(source=sources2[0])
 
 
 def test_defined_plots_init_empty_and_not_visible_set_source(sources2):
     pytest.importorskip('hvplot')
-    from ..defined_plots import DefinedPlots
-    defined_plots = DefinedPlots(source=[], visible=False)
+    from ..defined_plots import Plots
+    defined_plots = Plots(source=[], visible=False)
     defined_plots.source = sources2
     assert defined_plots.source == sources2[0]
     assert_plotting_source2_0_line(defined_plots, visible=False)
@@ -145,8 +111,8 @@ def test_defined_plots_init_empty_and_not_visible_set_source(sources2):
 
 def test_defined_plots_init_with_source_not_visible_make_visible(sources2):
     pytest.importorskip('hvplot')
-    from ..defined_plots import DefinedPlots
-    defined_plots = DefinedPlots(source=sources2, visible=False)
+    from ..defined_plots import Plots
+    defined_plots = Plots(source=sources2, visible=False)
     defined_plots.source = sources2
     assert defined_plots.source == sources2[0]
     assert_plotting_source2_0_line(defined_plots, visible=False)
@@ -156,14 +122,14 @@ def test_defined_plots_init_with_source_not_visible_make_visible(sources2):
 
 
 def test_defined_plots_init_empty_and_visible():
-    from ..defined_plots import DefinedPlots
-    defined_plots = DefinedPlots()
+    from ..defined_plots import Plots
+    defined_plots = Plots()
     assert_is_empty(defined_plots, visible=True)
 
 
 def test_defined_plots_init_empty_and_not_visible():
-    from ..defined_plots import DefinedPlots
-    defined_plots = DefinedPlots(visible=False)
+    from ..defined_plots import Plots
+    defined_plots = Plots(visible=False)
     assert_is_empty(defined_plots, visible=False)
 
 
@@ -181,7 +147,6 @@ def test_defined_plots_set_source_to_empty_list_and_visible_to_false(defined_plo
 @pytest.mark.skip(reason='This one is failing - but works in widget')
 def test_defined_plots_select_a_different_plot(defined_plots):
     defined_plots.selected = 'violin_example'
-    assert defined_plots.desc.object.startswith("kind: violin")
     assert len(defined_plots.children) == 2
     assert isinstance(defined_plots.children[1], pn.Column)
     assert str(defined_plots.children[1].objects) == str(defined_plots.pane.objects)
diff --git a/intake/gui/tests/test_gui.py b/intake/gui/tests/test_gui.py
index 7938090..edb63d0 100644
--- a/intake/gui/tests/test_gui.py
+++ b/intake/gui/tests/test_gui.py
@@ -59,7 +59,7 @@ def test_gui_open_plot_panel(gui, cat1, cat2, sources1, sources2):
     gui.source.plot_widget.value = True
     assert gui.source.plot.visible is True
     assert len(gui.source.plot.watchers) == 2
-    assert len(gui.source.plot.panel.objects) == 3
+    assert len(gui.source.plot.panel.objects) == 2
     assert gui.source.plot.source == sources1[0]
 
     gui.source.select.cats = [cat2]
@@ -69,7 +69,7 @@ def test_gui_open_plot_panel(gui, cat1, cat2, sources1, sources2):
 
     gui.source.plot_widget.value = True
     assert len(gui.source.plot.watchers) == 2
-    assert len(gui.source.plot.panel.objects) == 3
+    assert len(gui.source.plot.panel.objects) == 2
 
 
 def test_gui_open_search_panel(gui, cat1, cat2, sources1, sources2):
diff --git a/intake/gui/tests/test_init_gui.py b/intake/gui/tests/test_init_gui.py
index ce6211e..77a29e3 100644
--- a/intake/gui/tests/test_init_gui.py
+++ b/intake/gui/tests/test_init_gui.py
@@ -26,11 +26,11 @@ def test_no_panel_does_not_raise_errors(cat1_url):
 def test_no_panel_display_init_gui():
     import intake
     with pytest.raises(RuntimeError, match=('Please install panel to use the GUI '
-                                            '`conda install -c conda-forge panel==0.5.1`')):
+                                            '`conda install -c conda-forge panel')):
         repr(intake.gui)
 
 
 def test_display_init_gui():
     pytest.importorskip('panel')
     import intake
-    assert repr(intake.gui).startswith('Row')
+    assert repr(intake.gui).startswith('Column')
diff --git a/test_requirements.txt b/test_requirements.txt
index d0e4df8..c16f90f 100644
--- a/test_requirements.txt
+++ b/test_requirements.txt
@@ -1,5 +1,6 @@
 intake-parquet
 zarr
 notebook
-panel==0.5.1
-hvplot==0.4.0
+panel==0.8.0
+hvplot==0.5.2
+bokeh<2.0.0