diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2190399..d5c09eb3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ a button toolbar. The default buttons to appear is stored in the class constant `WebvizPluginABC.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. +for more information regarding downloading plugin data. ### Callbacks @@ -150,17 +150,36 @@ There are three fundamental additions to the minimal example without callbacks: There is a [data download button](#override-plugin-toolbar) provided by the `WebvizPluginABC` class. However, it will only appear if the corresponding -callback is set. A typical data download callback will look like +callback is set. A typical compressed data download callback will look like ```python @app.callback(self.plugin_data_output, - [self.plugin_data_requested]) + self.plugin_data_requested) def _user_download_data(data_requested): - return WebvizPluginABC.plugin_data_compress( - [{'filename': 'some_file.txt', + return WebvizPluginABC.plugin_compressed_data( + file_name="webviz-data.zip", + content=[{'filename': 'some_file.txt', 'content': 'Some download data'}] - ) if data_requested else '' + ) if data_requested else {} ``` + +A typical raw CSV data download will look like: +```python +@app.callback(self.plugin_data_output, + self.plugin_data_requested) +def _user_download_data(data_requested): + if data_requested: + byte_io = io.BytesIO() + byte_io.write(get_data(self.csv_file).to_csv().encode('ascii')) + byte_io.seek(0) + return { + "file_name": "file_name.csv", + "content": base64.b64encode(byte_io.read()).decode("ascii"), + "mime_type": "text/csv" + } + return {} +``` + By letting the plugin define the callback, the plugin author is able to utilize the whole callback machinery, including e.g. state of the individual components in the plugin. This way the data downloaded can e.g. depend on @@ -170,8 +189,8 @@ The attributes `self.plugin_data_output` and `self.plugin_data_requested` are Dash `Output` and `Input` instances respectively, and are provided by the base class `WebvizPluginABC` (i.e. include them as shown here). -The function `WebvizPluginABC.plugin_data_compress` is a utility function -which takes a list of dictionaries, giving filenames and corresponding data, +The function `WebvizPluginABC.plugin_compressed_data` is a utility function +which takes a file name and a list of dictionaries, containing file names and corresponding data, and compresses them to a zip archive which is then downloaded by the user. ### User provided arguments diff --git a/webviz_config/_plugin_abc.py b/webviz_config/_plugin_abc.py index 90ba9e5a..8232b975 100644 --- a/webviz_config/_plugin_abc.py +++ b/webviz_config/_plugin_abc.py @@ -2,15 +2,32 @@ import abc import base64 import zipfile +import warnings + from uuid import uuid4 from typing import List, Optional, Type, Union +try: + # Python 3.8+ + # pylint: disable=ungrouped-imports + from typing import TypedDict # type: ignore +except (ImportError, ModuleNotFoundError): + # Python < 3.8 + from typing_extensions import TypedDict # type: ignore + + import bleach from dash.development.base_component import Component from dash.dependencies import Input, Output import webviz_core_components as wcc +class FileDefinition(TypedDict, total=False): + file_name: str + content: str + mime_type: str + + class WebvizPluginABC(abc.ABC): """All webviz plugins need to subclass this abstract base class, e.g. @@ -40,7 +57,6 @@ def layout(self): TOOLBAR_BUTTONS = [ "screenshot", "expand", - "download_zip", "contact_person", "guided_tour", ] @@ -62,6 +78,7 @@ def __init__(self, screenshot_filename: str = "webviz-screenshot.png") -> None: self._plugin_uuid = uuid4() self._screenshot_filename = screenshot_filename + self._add_download_button = False def uuid(self, element: str) -> str: """Typically used to get a unique ID for some given element/component in @@ -94,10 +111,8 @@ def _plugin_wrapper_id(self) -> str: @property def plugin_data_output(self) -> Output: - # pylint: disable=attribute-defined-outside-init - # We do not have a __init__ method in this abstract base class self._add_download_button = True - return Output(self._plugin_wrapper_id, "zip_base64") + return Output(self._plugin_wrapper_id, "download") @property def plugin_data_requested(self) -> Input: @@ -110,16 +125,27 @@ def _reformat_tour_steps(steps: List[dict]) -> List[dict]: ] @staticmethod - def plugin_data_compress(content: List[dict]) -> str: + def plugin_compressed_data(file_name: str, content: List[FileDefinition]) -> FileDefinition: byte_io = io.BytesIO() with zipfile.ZipFile(byte_io, "w") as zipped_data: for data in content: - zipped_data.writestr(data["filename"], data["content"]) + zipped_data.writestr(data["file_name"], data["content"]) byte_io.seek(0) + return { + "file_name": file_name, + "content": base64.b64encode(byte_io.read()).decode("ascii"), + "mime_type": "application/zip" + } - return base64.b64encode(byte_io.read()).decode("ascii") + @staticmethod + def plugin_data_compress(content: List[dict]) -> FileDefinition: + warnings.warn( + "Use 'plugin_compressed_data' instead of 'plugin_data_compress'", + DeprecationWarning, + ) + return WebvizPluginABC.plugin_compressed_data("webviz-data.zip", content) def plugin_layout( self, contact_person: Optional[dict] = None @@ -145,8 +171,8 @@ def plugin_layout( for key in contact_person: contact_person[key] = bleach.clean(str(contact_person[key])) - if "download_zip" in buttons and not hasattr(self, "_add_download_button"): - buttons.remove("download_zip") + if self._add_download_button: + buttons.append("download") if buttons: # pylint: disable=no-member diff --git a/webviz_config/plugins/_example_data_download.py b/webviz_config/plugins/_example_data_download.py index 9a4e139a..51ac4ac1 100644 --- a/webviz_config/plugins/_example_data_download.py +++ b/webviz_config/plugins/_example_data_download.py @@ -16,12 +16,15 @@ def layout(self) -> html.H1: return html.H1(self.title) def set_callbacks(self, app: Dash) -> None: - @app.callback(self.plugin_data_output, [self.plugin_data_requested]) - def _user_download_data(data_requested: bool) -> str: + @app.callback(self.plugin_data_output, self.plugin_data_requested) + def _user_download_data(data_requested: bool) -> dict: return ( - WebvizPluginABC.plugin_data_compress( - [{"filename": "some_file.txt", "content": "Some download data"}] + WebvizPluginABC.plugin_compressed_data( + file_name="webviz-data.zip", + content=[ + {"filename": "some_file.txt", "content": "Some download data"} + ], ) if data_requested - else "" + else {} ) diff --git a/webviz_config/plugins/_table_plotter.py b/webviz_config/plugins/_table_plotter.py index 2fbcadd1..4bf71777 100644 --- a/webviz_config/plugins/_table_plotter.py +++ b/webviz_config/plugins/_table_plotter.py @@ -356,19 +356,20 @@ def plot_input_callbacks(self) -> List[Input]: return inputs def set_callbacks(self, app: Dash) -> None: - @app.callback(self.plugin_data_output, [self.plugin_data_requested]) - def _user_download_data(data_requested: Optional[int]) -> str: + @app.callback(self.plugin_data_output, self.plugin_data_requested) + def _user_download_data(data_requested: Optional[int]) -> dict: return ( - WebvizPluginABC.plugin_data_compress( - [ + WebvizPluginABC.plugin_compressed_data( + file_name="webviz-data.zip", + content=[ { "filename": "table_plotter.csv", "content": get_data(self.csv_file).to_csv(), } - ] + ], ) if data_requested - else "" + else {} ) @app.callback(self.plot_output_callbacks, self.plot_input_callbacks)