Skip to content

Commit

Permalink
Use webviz container component (#70)
Browse files Browse the repository at this point in the history
  • Loading branch information
anders-kiaer authored Aug 20, 2019
1 parent 8627581 commit ba704e3
Show file tree
Hide file tree
Showing 10 changed files with 184 additions and 7 deletions.
37 changes: 37 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ pages:
- container: ExampleContainer
```
### Override container toolbar
In the generated webviz application, your container will as default be given
a button toolbar. The default buttons to appear is stored in the class constant
`WebvizContainer.TOOLBAR_BUTTONS`. If you want to override which buttons should
appear, redefine this class constant in your subclass. To remove all buttons,
simply define it as an empty list. See [this section](#data-download-callback)
for more information regarding the `data_download` button.

### Callbacks

If you want to include user interactivity which triggers actions in the Python
Expand Down Expand Up @@ -123,6 +132,34 @@ There are three fundamental additions to the minimal example without callbacks:
[uuid.uuid4()](https://docs.python.org/3/library/uuid.html#uuid.uuid4),
as demonstrated in the example above.

#### Data download callback

There is a [data download button](#override-container-toolbar) provided by
the `WebvizContainer` class. However, it will only appear if the corresponding
callback is set. A typical data download callback will look like

```
@app.callback(self.container_data_output,
[self.container_data_requested])
def cb_user_download_data(data_requested):
return WebvizContainer.container_data_compress(
[{'filename': 'some_file.txt',
'content': 'Some download data'}]
) if data_requested else ''
```
By letting the container define the callback, the container author is able
to utilize the whole callback machinery, including e.g. state of the individual
components in the container. This way the data downloaded can e.g. depend on
the visual state or user selection.
The attributes `self.container_data_output` and `self.container_data_requested`
are Dash `Output` and `Input` instances respectively, and are provided by
the base class `WebvizContainer` (i.e. include them as shown here).
The function `WebvizContainer.container_data_compress` is a utility function
which takes a list of dictionaries, giving filenames and corresponding data,
and compresses them to a zip archive which is then downloaded by the user.
### User provided arguments
Since the containers are reusable and generic, they usually take in some
Expand Down
4 changes: 4 additions & 0 deletions examples/basic_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ pages:
content:
- container: TablePlotter
csv_file: ./example_data.csv
contact_person:
name: Ola Nordmann
phone: +47 12345678
email: [email protected]

- title: Plot a table (locked)
content:
Expand Down
3 changes: 2 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
'markdown~=3.0.1',
'pandas~=0.24.1',
'pyarrow~=0.11.1',
'pyyaml~=5.1'
'pyyaml~=5.1',
'webviz-core-components~=0.0.2'
],
tests_require=tests_requires,
extras_require={'tests': tests_requires},
Expand Down
28 changes: 23 additions & 5 deletions webviz_config/_config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def _is_webviz_container(obj):
inspect.getmembers(module, _is_webviz_container)]


def _call_signature(module, module_name, container_name,
container_settings, kwargs, config_folder):
def _call_signature(module, module_name, container_name, container_settings,
kwargs, config_folder, contact_person=None):
'''Takes as input the name of a container, the module it is located in,
together with user given arguments (originating from the configuration
file). Returns the equivalent Python code wrt. initiating an instance of
Expand Down Expand Up @@ -53,13 +53,30 @@ def _call_signature(module, module_name, container_name,
'file.'
'\033[0m')

for arg in kwargs:
for arg in list(kwargs):
if arg in SPECIAL_ARGS:
raise ParserError('\033[91m'
f'Container argument `{arg}` not allowed.'
'\033[0m')

if arg not in argspec.args:
if arg == 'contact_person':
if not isinstance(kwargs['contact_person'], dict):
raise ParserError('\033[91m'
f'The contact information provided for '
f'container `{container_name}` is '
f'not a dictionary. '
'\033[0m')
elif any(key not in ['name', 'phone', 'email']
for key in kwargs['contact_person']):
raise ParserError('\033[91m'
f'Unrecognized contact information key '
f'given to container `{container_name}`.'
f'Should be "name", "phone" and/or "email".'
'\033[0m')
else:
contact_person = kwargs.pop('contact_person')

elif arg not in argspec.args:
raise ParserError('\033[91m'
'Unrecognized argument. The container '
f'`{container_name}` does not take an '
Expand Down Expand Up @@ -89,7 +106,8 @@ def _call_signature(module, module_name, container_name,
if 'container_settings' in argspec.args:
kwargs['container_settings'] = container_settings

return f'{module_name}.{container_name}({special_args}**{kwargs})'
return (f'{module_name}.{container_name}({special_args}**{kwargs})'
f'.container_layout(app=app, contact_person={contact_person})')


class ParserError(Exception):
Expand Down
1 change: 1 addition & 0 deletions webviz_config/containers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from ._container_class import WebvizContainer

from ._example_container import ExampleContainer
from ._example_data_download import ExampleDataDownload
from ._example_assets import ExampleAssets
from ._example_portable import ExamplePortable
from ._banner_image import BannerImage
Expand Down
2 changes: 2 additions & 0 deletions webviz_config/containers/_banner_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class BannerImage(WebvizContainer):
* `shadow`: Set to `False` if you do not want text shadow for the title.
'''

TOOLBAR_BUTTONS = []

def __init__(self, image: Path, title: str = '', color: str = 'white',
shadow: bool = True):

Expand Down
84 changes: 84 additions & 0 deletions webviz_config/containers/_container_class.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import io
import abc
import base64
import zipfile
from uuid import uuid4
import bleach
from dash.dependencies import Input, Output
import webviz_core_components as wcc


class WebvizContainer(abc.ABC):
Expand All @@ -16,6 +23,20 @@ def layout(self):
```
'''

# This is the default set of buttons to show in the rendered container
# toolbar. If the list is empty, the subclass container layout will be
# used directly, without any visual encapsulation layout from this
# abstract base class. The containers subclassing this abstract base class
# can override this variable setting by defining a class constant with
# the same name.
#
# Some buttons will only appear if in addition necessary data is available.
# E.g. download of zip archive will only appear if the container also
# has defined the corresponding callback, and contact person will only
# appear if the user configuration file has this information.
TOOLBAR_BUTTONS = ['screenshot', 'expand',
'download_zip', 'contact_person']

# List of container specific assets which should be copied
# over to the ./assets folder in the generated webviz app.
# This is typically custom JavaScript and/or CSS files.
Expand All @@ -29,3 +50,66 @@ def layout(self):
the main Webviz application.
'''
pass

@property
def _container_wrapper_id(self):
if not hasattr(self, '_container_wrapper_uuid'):
self._container_wrapper_uuid = uuid4()
return f'container-wrapper-{self._container_wrapper_uuid}'

@property
def container_data_output(self):
self._add_download_button = True
return Output(self._container_wrapper_id, 'zip_base64')

@property
def container_data_requested(self):
return Input(self._container_wrapper_id, 'data_requested')

@staticmethod
def container_data_compress(content):
byte_io = io.BytesIO()

with zipfile.ZipFile(byte_io, 'w') as zipped_data:
for data in content:
zipped_data.writestr(data['filename'], data['content'])

byte_io.seek(0)

return base64.b64encode(byte_io.read()).decode('ascii')

def container_layout(self, app, contact_person=None):
'''This function returns (if the class constant SHOW_TOOLBAR is True,
the container layout wrapped into a common webviz config container
component, which provides some useful buttons like download of data,
show data contact person and download container content to png.
CSV download button will only appear if the container class a property
`csv_string` which should return the appropriate csv data as a string.
If TOOLBAR_BUTTONS is empty, this functions returns the same
dash layout as the container class provides directly.
'''

buttons = self.__class__.TOOLBAR_BUTTONS.copy()

if contact_person is None:
contact_person = {}
else:
# Sanitize the configuration user input
for key in contact_person:
contact_person[key] = bleach.clean(contact_person[key])

if 'download_zip' in buttons and \
not hasattr(self, '_add_download_button'):
buttons.remove('download_zip')

if buttons:
return wcc.WebvizContainerPlaceholder(
id=self._container_wrapper_id,
buttons=buttons,
contact_person=contact_person,
children=[self.layout]
)
else:
return self.layout
22 changes: 22 additions & 0 deletions webviz_config/containers/_example_data_download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import dash_html_components as html
from . import WebvizContainer


class ExampleDataDownload(WebvizContainer):

def __init__(self, app, title: str):
self.title = title
self.set_callbacks(app)

@property
def layout(self):
return html.H1(self.title)

def set_callbacks(self, app):
@app.callback(self.container_data_output,
[self.container_data_requested])
def _user_download_data(data_requested):
return WebvizContainer.container_data_compress(
[{'filename': 'some_file.txt',
'content': 'Some download data'}]
) if data_requested else ''
8 changes: 8 additions & 0 deletions webviz_config/containers/_table_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@ def plot_input_callbacks(self):
return inputs

def set_callbacks(self, app):
@app.callback(self.container_data_output,
[self.container_data_requested])
def _user_download_data(data_requested):
return WebvizContainer.container_data_compress(
[{'filename': 'table_plotter.csv',
'content': get_data(self.csv_file).to_csv()}]
) if data_requested else ''

@app.callback(
self.plot_output_callbacks,
self.plot_input_callbacks)
Expand Down
2 changes: 1 addition & 1 deletion webviz_config/templates/webviz_template.py.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ app.layout = dcc.Tabs(parent_className="layoutWrapper",
{%- if content is string -%}
dcc.Markdown(r'''{{ content }}''')
{%- else -%}
{{ content._call_signature }}.layout
{{ content._call_signature }}
{%- endif -%}
{{- '' if loop.last else ','}}
{% endfor -%}
Expand Down

0 comments on commit ba704e3

Please sign in to comment.