Skip to content

Commit 4fe0caa

Browse files
DanSavaanders-kiaer
authored andcommitted
Support generic type of download file
1 parent 0ec2f4d commit 4fe0caa

7 files changed

+109
-45
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ now has search functionality (using [`docsify` full text search](https://docsify
1414
enforced by `webviz-config` (inline script hashes are added automatically).
1515

1616
### Changed
17+
- [#294](https://github.com/equinor/webviz-config/pull/294) - Plugin authors can now define file type to download
18+
(including specifying MIME type). Before only `.zip` archives were supported.
1719
- [#281](https://github.com/equinor/webviz-config/pull/281) - Now uses `importlib` instead of `pkg_resources` for
1820
detecting plugin entry points and package versions.
1921

CONTRIBUTING.md

+32-9
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ a button toolbar. The default buttons to appear is stored in the class constant
8383
`WebvizPluginABC.TOOLBAR_BUTTONS`. If you want to override which buttons should
8484
appear, redefine this class constant in your subclass. To remove all buttons,
8585
simply define it as an empty list. See [this section](#data-download-callback)
86-
for more information regarding the `data_download` button.
86+
for more information regarding downloading plugin data.
8787

8888
### Callbacks
8989

@@ -150,17 +150,40 @@ There are three fundamental additions to the minimal example without callbacks:
150150

151151
There is a [data download button](#override-plugin-toolbar) provided by
152152
the `WebvizPluginABC` class. However, it will only appear if the corresponding
153-
callback is set. A typical data download callback will look like
153+
callback is set. A typical compressed data download callback will look like
154154

155155
```python
156156
@app.callback(self.plugin_data_output,
157-
[self.plugin_data_requested])
157+
self.plugin_data_requested)
158158
def _user_download_data(data_requested):
159-
return WebvizPluginABC.plugin_data_compress(
160-
[{'filename': 'some_file.txt',
161-
'content': 'Some download data'}]
162-
) if data_requested else ''
159+
return (
160+
WebvizPluginABC.plugin_compressed_data(
161+
filename="webviz-data.zip",
162+
content=[{"filename": "some_file.txt", "content": "Some download data"}],
163+
)
164+
if data_requested
165+
else None
166+
)
163167
```
168+
169+
A typical CSV data download from e.g. a `pandas.DataFrame` will look like:
170+
```python
171+
@app.callback(self.plugin_data_output,
172+
self.plugin_data_requested)
173+
def _user_download_data(data_requested):
174+
return (
175+
{
176+
"filename": "some-file.csv",
177+
"content": base64.b64encode(
178+
some_pandas_dataframe.to_csv().encode()
179+
).decode("ascii"),
180+
"mime_type": "text/csv",
181+
}
182+
if data_requested
183+
else None
184+
)
185+
```
186+
164187
By letting the plugin define the callback, the plugin author is able
165188
to utilize the whole callback machinery, including e.g. state of the individual
166189
components in the plugin. This way the data downloaded can e.g. depend on
@@ -170,8 +193,8 @@ The attributes `self.plugin_data_output` and `self.plugin_data_requested`
170193
are Dash `Output` and `Input` instances respectively, and are provided by
171194
the base class `WebvizPluginABC` (i.e. include them as shown here).
172195

173-
The function `WebvizPluginABC.plugin_data_compress` is a utility function
174-
which takes a list of dictionaries, giving filenames and corresponding data,
196+
The function `WebvizPluginABC.plugin_compressed_data` is a utility function
197+
which takes a file name and a list of dictionaries, containing file names and corresponding data,
175198
and compresses them to a zip archive which is then downloaded by the user.
176199

177200
### User provided arguments

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"tqdm>=4.8",
4949
"importlib-metadata>=1.7; python_version<'3.8'",
5050
"typing-extensions>=3.7; python_version<'3.8'",
51-
"webviz-core-components>=0.0.19",
51+
"webviz-core-components>=0.1.0",
5252
],
5353
tests_require=TESTS_REQUIRES,
5454
extras_require={"tests": TESTS_REQUIRES},

webviz_config/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ._theme_class import WebvizConfigTheme
99
from ._localhost_token import LocalhostToken
1010
from ._is_reload_process import is_reload_process
11-
from ._plugin_abc import WebvizPluginABC
11+
from ._plugin_abc import WebvizPluginABC, EncodedFile, ZipFileMember
1212
from ._shared_settings_subscriptions import SHARED_SETTINGS_SUBSCRIPTIONS
1313

1414
try:

webviz_config/_plugin_abc.py

+49-15
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,39 @@
22
import abc
33
import base64
44
import zipfile
5+
import warnings
6+
57
from uuid import uuid4
68
from typing import List, Optional, Type, Union
79

10+
try:
11+
# Python 3.8+
12+
# pylint: disable=ungrouped-imports
13+
from typing import TypedDict # type: ignore
14+
except (ImportError, ModuleNotFoundError):
15+
# Python < 3.8
16+
from typing_extensions import TypedDict # type: ignore
17+
18+
819
import bleach
920
from dash.development.base_component import Component
1021
from dash.dependencies import Input, Output
1122
import webviz_core_components as wcc
1223

1324

25+
class ZipFileMember(TypedDict):
26+
filename: str # Filename to be given within the archive
27+
content: str # String of file to be added
28+
29+
30+
class EncodedFile(ZipFileMember):
31+
# Same keys as in ZipFileMember, with the following changes:
32+
# - filename is now name of the actual downloaded file.
33+
# - content is now a base64 encoded ASCII string.
34+
# - mime_type needs to be added as well.
35+
mime_type: str
36+
37+
1438
class WebvizPluginABC(abc.ABC):
1539
"""All webviz plugins need to subclass this abstract base class,
1640
e.g.
@@ -40,7 +64,6 @@ def layout(self):
4064
TOOLBAR_BUTTONS = [
4165
"screenshot",
4266
"expand",
43-
"download_zip",
4467
"contact_person",
4568
"guided_tour",
4669
]
@@ -62,6 +85,7 @@ def __init__(self, screenshot_filename: str = "webviz-screenshot.png") -> None:
6285

6386
self._plugin_uuid = uuid4()
6487
self._screenshot_filename = screenshot_filename
88+
self._add_download_button = False
6589

6690
def uuid(self, element: str) -> str:
6791
"""Typically used to get a unique ID for some given element/component in
@@ -94,10 +118,8 @@ def _plugin_wrapper_id(self) -> str:
94118

95119
@property
96120
def plugin_data_output(self) -> Output:
97-
# pylint: disable=attribute-defined-outside-init
98-
# We do not have a __init__ method in this abstract base class
99121
self._add_download_button = True
100-
return Output(self._plugin_wrapper_id, "zip_base64")
122+
return Output(self._plugin_wrapper_id, "download")
101123

102124
@property
103125
def plugin_data_requested(self) -> Input:
@@ -110,16 +132,28 @@ def _reformat_tour_steps(steps: List[dict]) -> List[dict]:
110132
]
111133

112134
@staticmethod
113-
def plugin_data_compress(content: List[dict]) -> str:
114-
byte_io = io.BytesIO()
115-
116-
with zipfile.ZipFile(byte_io, "w") as zipped_data:
117-
for data in content:
118-
zipped_data.writestr(data["filename"], data["content"])
119-
120-
byte_io.seek(0)
135+
def plugin_compressed_data(
136+
filename: str, content: List[ZipFileMember]
137+
) -> EncodedFile:
138+
with io.BytesIO() as bytes_io:
139+
with zipfile.ZipFile(bytes_io, "w") as zipped_data:
140+
for data in content:
141+
zipped_data.writestr(data["filename"], data["content"])
142+
143+
bytes_io.seek(0)
144+
return EncodedFile(
145+
filename=filename,
146+
content=base64.b64encode(bytes_io.read()).decode("ascii"),
147+
mime_type="application/zip",
148+
)
121149

122-
return base64.b64encode(byte_io.read()).decode("ascii")
150+
@staticmethod
151+
def plugin_data_compress(content: List[ZipFileMember]) -> EncodedFile:
152+
warnings.warn(
153+
"Use 'plugin_compressed_data' instead of 'plugin_data_compress'",
154+
DeprecationWarning,
155+
)
156+
return WebvizPluginABC.plugin_compressed_data("webviz-data.zip", content)
123157

124158
def plugin_layout(
125159
self, contact_person: Optional[dict] = None
@@ -145,8 +179,8 @@ def plugin_layout(
145179
for key in contact_person:
146180
contact_person[key] = bleach.clean(str(contact_person[key]))
147181

148-
if "download_zip" in buttons and not hasattr(self, "_add_download_button"):
149-
buttons.remove("download_zip")
182+
if self._add_download_button:
183+
buttons.append("download")
150184

151185
if buttons:
152186
# pylint: disable=no-member

webviz_config/plugins/_example_data_download.py

+11-6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from typing import Optional
2+
13
import dash_html_components as html
24
from dash import Dash
35

4-
from .. import WebvizPluginABC
6+
from .. import WebvizPluginABC, EncodedFile
57

68

79
class ExampleDataDownload(WebvizPluginABC):
@@ -16,12 +18,15 @@ def layout(self) -> html.H1:
1618
return html.H1(self.title)
1719

1820
def set_callbacks(self, app: Dash) -> None:
19-
@app.callback(self.plugin_data_output, [self.plugin_data_requested])
20-
def _user_download_data(data_requested: bool) -> str:
21+
@app.callback(self.plugin_data_output, self.plugin_data_requested)
22+
def _user_download_data(data_requested: bool) -> Optional[EncodedFile]:
2123
return (
22-
WebvizPluginABC.plugin_data_compress(
23-
[{"filename": "some_file.txt", "content": "Some download data"}]
24+
WebvizPluginABC.plugin_compressed_data(
25+
filename="webviz-data.zip",
26+
content=[
27+
{"filename": "some_file.txt", "content": "Some download data"}
28+
],
2429
)
2530
if data_requested
26-
else ""
31+
else None
2732
)

webviz_config/plugins/_table_plotter.py

+13-13
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import base64
2+
import inspect
13
from pathlib import Path
24
from collections import OrderedDict
35
from typing import Optional, List, Dict, Any
4-
import inspect
56

67
import numpy as np
78
import pandas as pd
@@ -12,7 +13,7 @@
1213
from dash import Dash
1314
import webviz_core_components as wcc
1415

15-
from .. import WebvizPluginABC
16+
from .. import WebvizPluginABC, EncodedFile
1617
from ..webviz_store import webvizstore
1718
from ..common_cache import CACHE
1819

@@ -356,19 +357,18 @@ def plot_input_callbacks(self) -> List[Input]:
356357
return inputs
357358

358359
def set_callbacks(self, app: Dash) -> None:
359-
@app.callback(self.plugin_data_output, [self.plugin_data_requested])
360-
def _user_download_data(data_requested: Optional[int]) -> str:
360+
@app.callback(self.plugin_data_output, self.plugin_data_requested)
361+
def _user_download_data(data_requested: Optional[int]) -> Optional[EncodedFile]:
361362
return (
362-
WebvizPluginABC.plugin_data_compress(
363-
[
364-
{
365-
"filename": "table_plotter.csv",
366-
"content": get_data(self.csv_file).to_csv(),
367-
}
368-
]
369-
)
363+
{
364+
"filename": "table-plotter.csv",
365+
"content": base64.b64encode(
366+
get_data(self.csv_file).to_csv().encode()
367+
).decode("ascii"),
368+
"mime_type": "text/csv",
369+
}
370370
if data_requested
371-
else ""
371+
else None
372372
)
373373

374374
@app.callback(self.plot_output_callbacks, self.plot_input_callbacks)

0 commit comments

Comments
 (0)