import base64
import logging
import os
import re
import importlib.metadata
import inspect
from string import Template
from ipywidgets import DOMWidget
from traitlets import Unicode, observe
from .viewer import PerspectiveViewer
__version__ = re.sub(".dev[0-9]+", "", importlib.metadata.version("perspective-python"))
__all__ = ["PerspectiveWidget"]
__doc__ = """
`PerspectiveWidget` is a JupyterLab widget that implements the same API as
`<perspective-viewer>`, allowing for fast, intuitive
transformations/visualizations of various data formats within JupyterLab.
`PerspectiveWidget` is compatible with Jupyterlab 3 and Jupyter Notebook 6 via a
[prebuilt extension](https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html#prebuilt-extensions).
To use it, simply install `perspective-python` and the extensions should be
available.
`perspective-python`'s JupyterLab extension also provides convenient builtin
viewers for `csv`, `json`, or `arrow` files. Simply right-click on a file with
this extension and choose the appropriate `Perpective` option from the context
menu.
## `PerspectiveWidget`
Building on top of the API provided by `perspective.Table`, the
`PerspectiveWidget` is a JupyterLab plugin that offers the entire functionality
of Perspective within the Jupyter environment. It supports the same API
semantics of `<perspective-viewer>`, along with the additional data types
supported by `perspective.Table`. `PerspectiveWidget` takes keyword arguments
for the managed `View`:
```python
from perspective.widget import PerspectiveWidget
w = perspective.PerspectiveWidget(
data,
plugin="X Bar",
aggregates={"datetime": "any"},
sort=[["date", "desc"]]
)
```
### Creating a widget
A widget is created through the `PerspectiveWidget` constructor, which takes as
its first, required parameter a `perspective.Table`, a dataset, a schema, or
`None`, which serves as a special value that tells the Widget to defer loading
any data until later. In maintaining consistency with the Javascript API,
Widgets cannot be created with empty dictionaries or lists—`None` should be used
if the intention is to await data for loading later on. A widget can be
constructed from a dataset:
```python
from perspective.widget import PerspectiveWidget
PerspectiveWidget(data, group_by=["date"])
```
.. or a schema:
```python
PerspectiveWidget({"a": int, "b": str})
```
.. or an instance of a `perspective.Table`:
```python
table = perspective.table(data)
PerspectiveWidget(table)
```
"""
class PerspectiveWidget(DOMWidget, PerspectiveViewer):
_model_name = Unicode("PerspectiveModel").tag(sync=True)
_model_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True)
_model_module_version = Unicode("~{}".format(__version__)).tag(sync=True)
_view_name = Unicode("PerspectiveView").tag(sync=True)
_view_module = Unicode("@perspective-dev/jupyterlab").tag(sync=True)
_view_module_version = Unicode("~{}".format(__version__)).tag(sync=True)
def __init__(
self,
data,
index=None,
limit=None,
binding_mode="server",
**kwargs,
):
self.binding_mode = binding_mode
self._options = {}
if index is not None and limit is not None:
raise TypeError("Index and Limit cannot be set at the same time!")
super(PerspectiveWidget, self).__init__(**kwargs)
self.on_msg(self.handle_message)
self._sessions = {}
if data is None:
if index is not None or limit is not None:
raise TypeError(
"Cannot initialize PerspectiveWidget `index` or `limit` without a Table, data, or schema!"
)
else:
if index is not None:
self._options.update({"index": index})
if limit is not None:
self._options.update({"limit": limit})
loading = self.load(data, **self._options)
if inspect.isawaitable(loading):
import asyncio
asyncio.create_task(loading)
def load(self, data, **options):
return super(PerspectiveWidget, self).load(data, **options)
def update(self, data):
return super(PerspectiveWidget, self).update(data)
def clear(self):
return super(PerspectiveWidget, self).clear()
def replace(self, data):
return super(PerspectiveWidget, self).replace(data)
def delete(self, delete_table=True):
ret = super(PerspectiveWidget, self).delete(delete_table)
self.close()
return ret
@observe("value")
def handle_message(self, widget, content, buffers):
if content["type"] == "connect":
client_id = content["client_id"]
logging.debug("view {} connected", client_id)
def send_response(msg):
self.send({"type": "binary_msg", "client_id": client_id}, [msg])
self._sessions[client_id] = self.new_proxy_session(send_response)
elif content["type"] == "binary_msg":
[binary_msg] = buffers
client_id = content["client_id"]
session = self._sessions[client_id]
if session is not None:
import asyncio
asyncio.create_task(session.handle_request_async(binary_msg))
else:
logging.error("No session for client_id {}".format(client_id))
elif content["type"] == "hangup":
client_id = content["client_id"]
logging.debug("view {} hangup", client_id)
session = self._sessions.pop(client_id, None)
if session:
session.close()
def _repr_mimebundle_(self, **kwargs):
super_bundle = super(DOMWidget, self)._repr_mimebundle_(**kwargs)
if not _jupyter_html_export_enabled():
return super_bundle
viewer_attrs = self.save()
data = self.table.view().to_arrow()
b64_data = base64.encodebytes(data)
template_path = os.path.join(
os.path.dirname(__file__), "../templates/exported_widget.html.template"
)
with open(template_path, "r") as template_data:
template = Template(template_data.read())
def psp_cdn(module, path=None):
if path is None:
path = f"cdn/{module}.js"
return f"https://cdn.jsdelivr.net/npm/@perspective-dev/{module}@{__version__}/dist/{path}"
return super(DOMWidget, self)._repr_mimebundle_(**kwargs) | {
"text/html": template.substitute(
psp_cdn_perspective=psp_cdn("perspective"),
psp_cdn_perspective_viewer=psp_cdn("perspective-viewer"),
psp_cdn_perspective_viewer_datagrid=psp_cdn(
"perspective-viewer-datagrid"
),
psp_cdn_perspective_viewer_d3fc=psp_cdn("perspective-viewer-d3fc"),
psp_cdn_perspective_viewer_themes=psp_cdn(
"perspective-viewer-themes", "css/themes.css"
),
viewer_id=self.model_id,
viewer_attrs=viewer_attrs,
b64_data=b64_data.decode("utf-8"),
)
}
def _jupyter_html_export_enabled():
return os.environ.get("PSP_JUPYTER_HTML_EXPORT", None) == "1"
def set_jupyter_html_export(val):
os.environ["PSP_JUPYTER_HTML_EXPORT"] = "1" if val else "0"