417 lines
14 KiB
Python
417 lines
14 KiB
Python
import ast
|
||
import contextlib
|
||
import logging
|
||
import os
|
||
import re
|
||
from typing import ClassVar, Sequence
|
||
|
||
import panel as pn
|
||
|
||
from .core import OpenFile, get_filesystem_class, split_protocol
|
||
from .registry import known_implementations
|
||
|
||
pn.extension()
|
||
logger = logging.getLogger("fsspec.gui")
|
||
|
||
|
||
class SigSlot:
|
||
"""Signal-slot mixin, for Panel event passing
|
||
|
||
Include this class in a widget manager's superclasses to be able to
|
||
register events and callbacks on Panel widgets managed by that class.
|
||
|
||
The method ``_register`` should be called as widgets are added, and external
|
||
code should call ``connect`` to associate callbacks.
|
||
|
||
By default, all signals emit a DEBUG logging statement.
|
||
"""
|
||
|
||
# names of signals that this class may emit each of which must be
|
||
# set by _register for any new instance
|
||
signals: ClassVar[Sequence[str]] = []
|
||
# names of actions that this class may respond to
|
||
slots: ClassVar[Sequence[str]] = []
|
||
|
||
# each of which must be a method name
|
||
|
||
def __init__(self):
|
||
self._ignoring_events = False
|
||
self._sigs = {}
|
||
self._map = {}
|
||
self._setup()
|
||
|
||
def _setup(self):
|
||
"""Create GUI elements and register signals"""
|
||
self.panel = pn.pane.PaneBase()
|
||
# no signals to set up in the base class
|
||
|
||
def _register(
|
||
self, widget, name, thing="value", log_level=logging.DEBUG, auto=False
|
||
):
|
||
"""Watch the given attribute of a widget and assign it a named event
|
||
|
||
This is normally called at the time a widget is instantiated, in the
|
||
class which owns it.
|
||
|
||
Parameters
|
||
----------
|
||
widget : pn.layout.Panel or None
|
||
Widget to watch. If None, an anonymous signal not associated with
|
||
any widget.
|
||
name : str
|
||
Name of this event
|
||
thing : str
|
||
Attribute of the given widget to watch
|
||
log_level : int
|
||
When the signal is triggered, a logging event of the given level
|
||
will be fired in the dfviz logger.
|
||
auto : bool
|
||
If True, automatically connects with a method in this class of the
|
||
same name.
|
||
"""
|
||
if name not in self.signals:
|
||
raise ValueError(f"Attempt to assign an undeclared signal: {name}")
|
||
self._sigs[name] = {
|
||
"widget": widget,
|
||
"callbacks": [],
|
||
"thing": thing,
|
||
"log": log_level,
|
||
}
|
||
wn = "-".join(
|
||
[
|
||
getattr(widget, "name", str(widget)) if widget is not None else "none",
|
||
thing,
|
||
]
|
||
)
|
||
self._map[wn] = name
|
||
if widget is not None:
|
||
widget.param.watch(self._signal, thing, onlychanged=True)
|
||
if auto and hasattr(self, name):
|
||
self.connect(name, getattr(self, name))
|
||
|
||
def _repr_mimebundle_(self, *args, **kwargs):
|
||
"""Display in a notebook or a server"""
|
||
try:
|
||
return self.panel._repr_mimebundle_(*args, **kwargs)
|
||
except (ValueError, AttributeError) as exc:
|
||
raise NotImplementedError(
|
||
"Panel does not seem to be set up properly"
|
||
) from exc
|
||
|
||
def connect(self, signal, slot):
|
||
"""Associate call back with given event
|
||
|
||
The callback must be a function which takes the "new" value of the
|
||
watched attribute as the only parameter. If the callback return False,
|
||
this cancels any further processing of the given event.
|
||
|
||
Alternatively, the callback can be a string, in which case it means
|
||
emitting the correspondingly-named event (i.e., connect to self)
|
||
"""
|
||
self._sigs[signal]["callbacks"].append(slot)
|
||
|
||
def _signal(self, event):
|
||
"""This is called by a an action on a widget
|
||
|
||
Within an self.ignore_events context, nothing happens.
|
||
|
||
Tests can execute this method by directly changing the values of
|
||
widget components.
|
||
"""
|
||
if not self._ignoring_events:
|
||
wn = "-".join([event.obj.name, event.name])
|
||
if wn in self._map and self._map[wn] in self._sigs:
|
||
self._emit(self._map[wn], event.new)
|
||
|
||
@contextlib.contextmanager
|
||
def ignore_events(self):
|
||
"""Temporarily turn off events processing in this instance
|
||
|
||
(does not propagate to children)
|
||
"""
|
||
self._ignoring_events = True
|
||
try:
|
||
yield
|
||
finally:
|
||
self._ignoring_events = False
|
||
|
||
def _emit(self, sig, value=None):
|
||
"""An event happened, call its callbacks
|
||
|
||
This method can be used in tests to simulate message passing without
|
||
directly changing visual elements.
|
||
|
||
Calling of callbacks will halt whenever one returns False.
|
||
"""
|
||
logger.log(self._sigs[sig]["log"], f"{sig}: {value}")
|
||
for callback in self._sigs[sig]["callbacks"]:
|
||
if isinstance(callback, str):
|
||
self._emit(callback)
|
||
else:
|
||
try:
|
||
# running callbacks should not break the interface
|
||
ret = callback(value)
|
||
if ret is False:
|
||
break
|
||
except Exception as e:
|
||
logger.exception(
|
||
"Exception (%s) while executing callback for signal: %s",
|
||
e,
|
||
sig,
|
||
)
|
||
|
||
def show(self, threads=False):
|
||
"""Open a new browser tab and display this instance's interface"""
|
||
self.panel.show(threads=threads, verbose=False)
|
||
return self
|
||
|
||
|
||
class SingleSelect(SigSlot):
|
||
"""A multiselect which only allows you to select one item for an event"""
|
||
|
||
signals = ["_selected", "selected"] # the first is internal
|
||
slots = ["set_options", "set_selection", "add", "clear", "select"]
|
||
|
||
def __init__(self, **kwargs):
|
||
self.kwargs = kwargs
|
||
super().__init__()
|
||
|
||
def _setup(self):
|
||
self.panel = pn.widgets.MultiSelect(**self.kwargs)
|
||
self._register(self.panel, "_selected", "value")
|
||
self._register(None, "selected")
|
||
self.connect("_selected", self.select_one)
|
||
|
||
def _signal(self, *args, **kwargs):
|
||
super()._signal(*args, **kwargs)
|
||
|
||
def select_one(self, *_):
|
||
with self.ignore_events():
|
||
val = [self.panel.value[-1]] if self.panel.value else []
|
||
self.panel.value = val
|
||
self._emit("selected", self.panel.value)
|
||
|
||
def set_options(self, options):
|
||
self.panel.options = options
|
||
|
||
def clear(self):
|
||
self.panel.options = []
|
||
|
||
@property
|
||
def value(self):
|
||
return self.panel.value
|
||
|
||
def set_selection(self, selection):
|
||
self.panel.value = [selection]
|
||
|
||
|
||
class FileSelector(SigSlot):
|
||
"""Panel-based graphical file selector widget
|
||
|
||
Instances of this widget are interactive and can be displayed in jupyter by having
|
||
them as the output of a cell, or in a separate browser tab using ``.show()``.
|
||
"""
|
||
|
||
signals = [
|
||
"protocol_changed",
|
||
"selection_changed",
|
||
"directory_entered",
|
||
"home_clicked",
|
||
"up_clicked",
|
||
"go_clicked",
|
||
"filters_changed",
|
||
]
|
||
slots = ["set_filters", "go_home"]
|
||
|
||
def __init__(self, url=None, filters=None, ignore=None, kwargs=None):
|
||
"""
|
||
|
||
Parameters
|
||
----------
|
||
url : str (optional)
|
||
Initial value of the URL to populate the dialog; should include protocol
|
||
filters : list(str) (optional)
|
||
File endings to include in the listings. If not included, all files are
|
||
allowed. Does not affect directories.
|
||
If given, the endings will appear as checkboxes in the interface
|
||
ignore : list(str) (optional)
|
||
Regex(s) of file basename patterns to ignore, e.g., "\\." for typical
|
||
hidden files on posix
|
||
kwargs : dict (optional)
|
||
To pass to file system instance
|
||
"""
|
||
if url:
|
||
self.init_protocol, url = split_protocol(url)
|
||
else:
|
||
self.init_protocol, url = "file", os.getcwd()
|
||
self.init_url = url
|
||
self.init_kwargs = (kwargs if isinstance(kwargs, str) else str(kwargs)) or "{}"
|
||
self.filters = filters
|
||
self.ignore = [re.compile(i) for i in ignore or []]
|
||
self._fs = None
|
||
super().__init__()
|
||
|
||
def _setup(self):
|
||
self.url = pn.widgets.TextInput(
|
||
name="url",
|
||
value=self.init_url,
|
||
align="end",
|
||
sizing_mode="stretch_width",
|
||
width_policy="max",
|
||
)
|
||
self.protocol = pn.widgets.Select(
|
||
options=sorted(known_implementations),
|
||
value=self.init_protocol,
|
||
name="protocol",
|
||
align="center",
|
||
)
|
||
self.kwargs = pn.widgets.TextInput(
|
||
name="kwargs", value=self.init_kwargs, align="center"
|
||
)
|
||
self.go = pn.widgets.Button(name="⇨", align="end", width=45)
|
||
self.main = SingleSelect(size=10)
|
||
self.home = pn.widgets.Button(name="🏠", width=40, height=30, align="end")
|
||
self.up = pn.widgets.Button(name="‹", width=30, height=30, align="end")
|
||
|
||
self._register(self.protocol, "protocol_changed", auto=True)
|
||
self._register(self.go, "go_clicked", "clicks", auto=True)
|
||
self._register(self.up, "up_clicked", "clicks", auto=True)
|
||
self._register(self.home, "home_clicked", "clicks", auto=True)
|
||
self._register(None, "selection_changed")
|
||
self.main.connect("selected", self.selection_changed)
|
||
self._register(None, "directory_entered")
|
||
self.prev_protocol = self.protocol.value
|
||
self.prev_kwargs = self.storage_options
|
||
|
||
self.filter_sel = pn.widgets.CheckBoxGroup(
|
||
value=[], options=[], inline=False, align="end", width_policy="min"
|
||
)
|
||
self._register(self.filter_sel, "filters_changed", auto=True)
|
||
|
||
self.panel = pn.Column(
|
||
pn.Row(self.protocol, self.kwargs),
|
||
pn.Row(self.home, self.up, self.url, self.go, self.filter_sel),
|
||
self.main.panel,
|
||
)
|
||
self.set_filters(self.filters)
|
||
self.go_clicked()
|
||
|
||
def set_filters(self, filters=None):
|
||
self.filters = filters
|
||
if filters:
|
||
self.filter_sel.options = filters
|
||
self.filter_sel.value = filters
|
||
else:
|
||
self.filter_sel.options = []
|
||
self.filter_sel.value = []
|
||
|
||
@property
|
||
def storage_options(self):
|
||
"""Value of the kwargs box as a dictionary"""
|
||
return ast.literal_eval(self.kwargs.value) or {}
|
||
|
||
@property
|
||
def fs(self):
|
||
"""Current filesystem instance"""
|
||
if self._fs is None:
|
||
cls = get_filesystem_class(self.protocol.value)
|
||
self._fs = cls(**self.storage_options)
|
||
return self._fs
|
||
|
||
@property
|
||
def urlpath(self):
|
||
"""URL of currently selected item"""
|
||
return (
|
||
(f"{self.protocol.value}://{self.main.value[0]}")
|
||
if self.main.value
|
||
else None
|
||
)
|
||
|
||
def open_file(self, mode="rb", compression=None, encoding=None):
|
||
"""Create OpenFile instance for the currently selected item
|
||
|
||
For example, in a notebook you might do something like
|
||
|
||
.. code-block::
|
||
|
||
[ ]: sel = FileSelector(); sel
|
||
|
||
# user selects their file
|
||
|
||
[ ]: with sel.open_file('rb') as f:
|
||
... out = f.read()
|
||
|
||
Parameters
|
||
----------
|
||
mode: str (optional)
|
||
Open mode for the file.
|
||
compression: str (optional)
|
||
The interact with the file as compressed. Set to 'infer' to guess
|
||
compression from the file ending
|
||
encoding: str (optional)
|
||
If using text mode, use this encoding; defaults to UTF8.
|
||
"""
|
||
if self.urlpath is None:
|
||
raise ValueError("No file selected")
|
||
return OpenFile(self.fs, self.urlpath, mode, compression, encoding)
|
||
|
||
def filters_changed(self, values):
|
||
self.filters = values
|
||
self.go_clicked()
|
||
|
||
def selection_changed(self, *_):
|
||
if self.urlpath is None:
|
||
return
|
||
if self.fs.isdir(self.urlpath):
|
||
self.url.value = self.fs._strip_protocol(self.urlpath)
|
||
self.go_clicked()
|
||
|
||
def go_clicked(self, *_):
|
||
if (
|
||
self.prev_protocol != self.protocol.value
|
||
or self.prev_kwargs != self.storage_options
|
||
):
|
||
self._fs = None # causes fs to be recreated
|
||
self.prev_protocol = self.protocol.value
|
||
self.prev_kwargs = self.storage_options
|
||
listing = sorted(
|
||
self.fs.ls(self.url.value, detail=True), key=lambda x: x["name"]
|
||
)
|
||
listing = [
|
||
l
|
||
for l in listing
|
||
if not any(i.match(l["name"].rsplit("/", 1)[-1]) for i in self.ignore)
|
||
]
|
||
folders = {
|
||
"📁 " + o["name"].rsplit("/", 1)[-1]: o["name"]
|
||
for o in listing
|
||
if o["type"] == "directory"
|
||
}
|
||
files = {
|
||
"📄 " + o["name"].rsplit("/", 1)[-1]: o["name"]
|
||
for o in listing
|
||
if o["type"] == "file"
|
||
}
|
||
if self.filters:
|
||
files = {
|
||
k: v
|
||
for k, v in files.items()
|
||
if any(v.endswith(ext) for ext in self.filters)
|
||
}
|
||
self.main.set_options(dict(**folders, **files))
|
||
|
||
def protocol_changed(self, *_):
|
||
self._fs = None
|
||
self.main.options = []
|
||
self.url.value = ""
|
||
|
||
def home_clicked(self, *_):
|
||
self.protocol.value = self.init_protocol
|
||
self.kwargs.value = self.init_kwargs
|
||
self.url.value = self.init_url
|
||
self.go_clicked()
|
||
|
||
def up_clicked(self, *_):
|
||
self.url.value = self.fs._parent(self.url.value)
|
||
self.go_clicked()
|