diff --git a/CHANGELOG.md b/CHANGELOG.md index 56fcf614c..a3229f88d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - [#854](https://github.com/plotly/dash-core-components/pull/854) Used `persistenceTransforms` to strip the time part of the datetime in the persited props of DatePickerSingle (date) and DatePickerRange (end_date, start_date), fixing [dcc#700](https://github.com/plotly/dash-core-components/issues/700). ### Added +- [#863](https://github.com/plotly/dash-core-components/pull/863) Added new `Download` component. - [#850](https://github.com/plotly/dash-core-components/pull/850) Add property `prependData` to `Graph` to support `Plotly.prependTraces` + refactored the existing `extendTraces` API to be a single `mergeTraces` API that can handle both `prepend` as well as `extend`. diff --git a/NAMESPACE b/NAMESPACE index f3b8d93bd..7aab12e73 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -5,6 +5,7 @@ export(dccConfirmDialog) export(dccConfirmDialogProvider) export(dccDatePickerRange) export(dccDatePickerSingle) +export(dccDownload) export(dccDropdown) export(dccGraph) export(dccInput) diff --git a/dash_core_components_base/__init__.py b/dash_core_components_base/__init__.py index a16ba8ac9..e508fff2b 100644 --- a/dash_core_components_base/__init__.py +++ b/dash_core_components_base/__init__.py @@ -1,4 +1,7 @@ from __future__ import print_function as _ +from ._imports_ import * # noqa: F401, F403 +from ._imports_ import __all__ # noqa: E402 +from .express import * # noqa: F401, F403 import json import os as _os @@ -6,130 +9,144 @@ import dash as _dash _basepath = _os.path.dirname(__file__) -_filepath = _os.path.abspath(_os.path.join(_basepath, 'package-info.json')) +_filepath = _os.path.abspath(_os.path.join(_basepath, "package-info.json")) with open(_filepath) as f: package = json.load(f) -package_name = package['name'].replace(' ', '_').replace('-', '_') -__version__ = package['version'] +package_name = package["name"].replace(" ", "_").replace("-", "_") +__version__ = package["version"] # Module imports trigger a dash.development import, need to check this first -if not hasattr(_dash, 'development'): - print("Dash was not successfully imported. Make sure you don't have a file " - "named \n'dash.py' in your current directory.", file=_sys.stderr) +if not hasattr(_dash, "development"): + print( + "Dash was not successfully imported. Make sure you don't have a file " + "named \n'dash.py' in your current directory.", + file=_sys.stderr, + ) _sys.exit(1) # Must update to dash>=0.23.1 to use this version of dash-core-components -if not hasattr(_dash.development.base_component, '_explicitize_args'): - print("Please update the `dash` module to >= 0.23.1 to use this " - "version of dash_core_components.\n" - "You are using version {:s}".format(_dash.version.__version__), - file=_sys.stderr) +if not hasattr(_dash.development.base_component, "_explicitize_args"): + print( + "Please update the `dash` module to >= 0.23.1 to use this " + "version of dash_core_components.\n" + "You are using version {:s}".format(_dash.version.__version__), + file=_sys.stderr, + ) _sys.exit(1) -from ._imports_ import * # noqa: F401, F403 -from ._imports_ import __all__ # noqa: E402 - _current_path = _os.path.dirname(_os.path.abspath(__file__)) _this_module = _sys.modules[__name__] async_resources = [ - 'datepicker', - 'dropdown', - 'graph', - 'highlight', - 'markdown', - 'slider', - 'upload' + "datepicker", + "dropdown", + "graph", + "highlight", + "markdown", + "slider", + "upload", ] _js_dist = [] -_js_dist.extend([{ - 'relative_package_path': 'async-{}.js'.format(async_resource), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/async-{}.js' - ).format(__version__, async_resource), - 'namespace': 'dash_core_components', - 'async': True -} for async_resource in async_resources]) +_js_dist.extend( + [ + { + "relative_package_path": "async-{}.js".format(async_resource), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/async-{}.js" + ).format(__version__, async_resource), + "namespace": "dash_core_components", + "async": True, + } + for async_resource in async_resources + ] +) -_js_dist.extend([{ - 'relative_package_path': 'async-{}.js.map'.format(async_resource), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/async-{}.js.map' - ).format(__version__, async_resource), - 'namespace': 'dash_core_components', - 'dynamic': True -} for async_resource in async_resources]) +_js_dist.extend( + [ + { + "relative_package_path": "async-{}.js.map".format(async_resource), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/async-{}.js.map" + ).format(__version__, async_resource), + "namespace": "dash_core_components", + "dynamic": True, + } + for async_resource in async_resources + ] +) -_js_dist.extend([ - { - 'relative_package_path': '{}.min.js'.format(__name__), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/dash_core_components.min.js' - ).format(__version__), - 'namespace': 'dash_core_components' - }, - { - 'relative_package_path': '{}.min.js.map'.format(__name__), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/dash_core_components.min.js.map' - ).format(__version__), - 'namespace': 'dash_core_components', - 'dynamic': True - }, - { - 'relative_package_path': '{}-shared.js'.format(__name__), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/dash_core_components-shared.js' - ).format(__version__), - 'namespace': 'dash_core_components' - }, - { - 'relative_package_path': '{}-shared.js.map'.format(__name__), - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/dash_core_components-shared.js.map' - ).format(__version__), - 'namespace': 'dash_core_components', - 'dynamic': True - }, - { - 'relative_package_path': 'plotly.min.js', - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/plotly.min.js' - ).format(__version__), - 'namespace': 'dash_core_components', - 'async': 'eager' - }, - { - 'relative_package_path': 'async-plotlyjs.js', - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/async-plotlyjs.js' - ).format(__version__), - 'namespace': 'dash_core_components', - 'async': 'lazy' - }, - { - 'relative_package_path': 'async-plotlyjs.js.map', - 'external_url': ( - 'https://unpkg.com/dash-core-components@{}' - '/dash_core_components/async-plotlyjs.js.map' - ).format(__version__), - 'namespace': 'dash_core_components', - 'dynamic': True - }, -]) +_js_dist.extend( + [ + { + "relative_package_path": "{}.min.js".format(__name__), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/dash_core_components.min.js" + ).format(__version__), + "namespace": "dash_core_components", + }, + { + "relative_package_path": "{}.min.js.map".format(__name__), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/dash_core_components.min.js.map" + ).format(__version__), + "namespace": "dash_core_components", + "dynamic": True, + }, + { + "relative_package_path": "{}-shared.js".format(__name__), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/dash_core_components-shared.js" + ).format(__version__), + "namespace": "dash_core_components", + }, + { + "relative_package_path": "{}-shared.js.map".format(__name__), + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/dash_core_components-shared.js.map" + ).format(__version__), + "namespace": "dash_core_components", + "dynamic": True, + }, + { + "relative_package_path": "plotly.min.js", + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/plotly.min.js" + ).format(__version__), + "namespace": "dash_core_components", + "async": "eager", + }, + { + "relative_package_path": "async-plotlyjs.js", + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/async-plotlyjs.js" + ).format(__version__), + "namespace": "dash_core_components", + "async": "lazy", + }, + { + "relative_package_path": "async-plotlyjs.js.map", + "external_url": ( + "https://unpkg.com/dash-core-components@{}" + "/dash_core_components/async-plotlyjs.js.map" + ).format(__version__), + "namespace": "dash_core_components", + "dynamic": True, + }, + ] +) for _component in __all__: - setattr(locals()[_component], '_js_dist', _js_dist) + setattr(locals()[_component], "_js_dist", _js_dist) diff --git a/dash_core_components_base/express.py b/dash_core_components_base/express.py new file mode 100644 index 000000000..792acbe1c --- /dev/null +++ b/dash_core_components_base/express.py @@ -0,0 +1,107 @@ +import io +import ntpath +import base64 + + +# region Utils for Download component + + +def send_file(path, filename=None, type=None): + """ + Convert a file into the format expected by the Download component. + :param path: path to the file to be sent + :param filename: name of the file, if not provided the original filename is used + :param type: type of the file (optional, passed to Blob in the javascript layer) + :return: dict of file content (base64 encoded) and meta data used by the Download component + """ + # If filename is not set, read it from the path. + if filename is None: + filename = ntpath.basename(path) + # Read the file contents and send it. + with open(path, "rb") as f: + return send_bytes(f.read(), filename, type) + + +def send_bytes(src, filename, type=None, **kwargs): + """ + Convert data written to BytesIO into the format expected by the Download component. + :param src: array of bytes or a writer that can write to BytesIO + :param filename: the name of the file + :param type: type of the file (optional, passed to Blob in the javascript layer) + :return: dict of data frame content (base64 encoded) and meta data used by the Download component + """ + content = src if isinstance(src, bytes) else _io_to_str(io.BytesIO(), src, **kwargs) + return dict( + content=base64.b64encode(content).decode(), + filename=filename, + type=type, + base64=True, + ) + + +def send_string(src, filename, type=None, **kwargs): + """ + Convert data written to StringIO into the format expected by the Download component. + :param src: a string or a writer that can write to StringIO + :param filename: the name of the file + :param type: type of the file (optional, passed to Blob in the javascript layer) + :return: dict of data frame content (NOT base64 encoded) and meta data used by the Download component + """ + content = src if isinstance(src, str) else _io_to_str(io.StringIO(), src, **kwargs) + return dict(content=content, filename=filename, type=type, base64=False) + + +def _io_to_str(data_io, writer, **kwargs): + # Some pandas writers try to close the IO, we do not want that. + data_io_close = data_io.close + data_io.close = lambda: None + # Write data content. + writer(data_io, **kwargs) + data_value = data_io.getvalue() + data_io_close() + return data_value + + +def send_data_frame(writer, filename, type=None, **kwargs): + """ + Convert data frame into the format expected by the Download component. + :param writer: a data frame writer + :param filename: the name of the file + :param type: type of the file (optional, passed to Blob in the javascript layer) + :return: dict of data frame content (base64 encoded) and meta data used by the Download component + + Examples + -------- + + >>> df = pd.DataFrame({'a': [1, 2, 3, 4], 'b': [2, 1, 5, 6], 'c': ['x', 'x', 'y', 'y']}) + ... + >>> send_data_frame(df.to_csv, "mydf.csv") # download as csv + >>> send_data_frame(df.to_json, "mydf.json") # download as json + >>> send_data_frame(df.to_excel, "mydf.xls", index=False) # download as excel + >>> send_data_frame(df.to_pkl, "mydf.pkl") # download as pickle + + """ + name = writer.__name__ + # Check if the provided writer is known. + if name not in _data_frame_senders.keys(): + raise ValueError( + "The provided writer ({}) is not supported, " + "try calling send_string or send_bytes directly.".format(name) + ) + # Send data frame using the appropriate send function. + return _data_frame_senders[name](writer, filename, type, **kwargs) + + +_data_frame_senders = { + "to_csv": send_string, + "to_json": send_string, + "to_html": send_string, + "to_excel": send_bytes, + "to_feather": send_bytes, + "to_parquet": send_bytes, + "to_msgpack": send_bytes, + "to_stata": send_bytes, + "to_pickle": send_bytes, +} + +# endregion diff --git a/dev-requirements.txt b/dev-requirements.txt index 71cc5a1c1..69927ea4e 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,5 @@ +numpy pandas -xlrd +pyarrow mimesis;python_version>="3.6" -virtualenv;python_version=="2.7" \ No newline at end of file +virtualenv;python_version=="2.7" diff --git a/package-lock.json b/package-lock.json index 8f65483be..2665b1c7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3518,8 +3518,7 @@ "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", - "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==", - "dev": true + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" }, "bcrypt-pbkdf": { "version": "1.0.2", @@ -6797,6 +6796,11 @@ "flat-cache": "^2.0.1" } }, + "file-saver": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.2.tgz", + "integrity": "sha512-Wz3c3XQ5xroCxd1G8b7yL0Ehkf0TC9oYC6buPFkNnU9EnaPlifeAFCyCh+iewXTyFRcg0a6j3J7FmJsIhlhBdw==" + }, "fileset": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/fileset/-/fileset-2.0.3.tgz", diff --git a/package.json b/package.json index 1eecc13be..9c11a3209 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,10 @@ "maintainer": "Ryan Patrick Kyle ", "license": "MIT", "dependencies": { + "base64-js": "^1.3.1", "color": "^3.1.0", "fast-isnumeric": "^1.1.3", + "file-saver": "^2.0.2", "highlight.js": "^9.17.1", "moment": "^2.20.1", "plotly.js": "1.55.1", diff --git a/src/components/Download.react.js b/src/components/Download.react.js new file mode 100644 index 000000000..3339935d4 --- /dev/null +++ b/src/components/Download.react.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types'; +import {Component} from 'react'; +import {toByteArray} from 'base64-js'; +import {saveAs} from 'file-saver'; + +const getValue = (src, fallback, key) => + key in src ? src[key] : fallback[key]; + +/** + * The Download component opens a download dialog when the data property changes. + */ +export default class Download extends Component { + componentDidUpdate(prevProps) { + const {data} = this.props; + // If the data hasn't changed, do nothing. + if (!data || data === prevProps.data) { + return; + } + // Extract options from data if provided, fallback to props. + const type = getValue(data, this.props, 'type'); + const base64 = getValue(data, this.props, 'base64'); + // Invoke the download using a Blob. + const content = base64 ? toByteArray(data.content) : data.content; + const blob = new Blob([content], {type: type}); + saveAs(blob, data.filename); + } + + render() { + return null; + } +} + +Download.propTypes = { + /** + * The ID of this component, used to identify dash components in callbacks. + */ + id: PropTypes.string, + + /** + * On change, a download is invoked. + */ + data: PropTypes.exact({ + /** + * Suggested filename in the download dialogue. + */ + filename: PropTypes.string.isRequired, + /** + * File content. + */ + content: PropTypes.string.isRequired, + /** + * Set to true, when data is base64 encoded. + */ + base64: PropTypes.bool, + /** + * Blob type, usually a MIME-type. + */ + type: PropTypes.string, + }), + + /** + * Default value for base64, used when not set as part of the data property. + */ + base64: PropTypes.bool, + + /** + * Default value for type, used when not set as part of the data property. + */ + type: PropTypes.string, + + /** + * Dash-supplied function for updating props. + */ + setProps: PropTypes.func, +}; + +Download.defaultProps = { + type: 'text/plain', + base64: false, +}; diff --git a/src/index.js b/src/index.js index 6d1edde13..7b67c46fc 100644 --- a/src/index.js +++ b/src/index.js @@ -17,6 +17,7 @@ import Textarea from './components/Textarea.react'; import DatePickerSingle from './components/DatePickerSingle.react'; import DatePickerRange from './components/DatePickerRange.react'; import Upload from './components/Upload.react'; +import Download from './components/Download.react'; import Tabs from './components/Tabs.react'; import Tab from './components/Tab.react'; import Store from './components/Store.react'; @@ -48,4 +49,5 @@ export { Upload, Store, LogoutButton, + Download }; diff --git a/tests/dash_core_components_page.py b/tests/dash_core_components_page.py index b7cc962f4..cec6637f3 100644 --- a/tests/dash_core_components_page.py +++ b/tests/dash_core_components_page.py @@ -28,14 +28,10 @@ def is_month_valid(elem): self._wait_until_day_is_clickable() days = self.find_elements(self.date_picker_day_locator) if day: - filtered = [ - _ for _ in days if _.text == str(day) and is_month_valid(_) - ] + filtered = [_ for _ in days if _.text == str(day) and is_month_valid(_)] if not filtered or len(filtered) > 1: logger.error( - "cannot find the matched day with index=%s, day=%s", - index, - day, + "cannot find the matched day with index=%s, day=%s", index, day, ) matched = filtered[0] else: @@ -92,9 +88,7 @@ def get_date_range(self, compid): def _wait_until_day_is_clickable(self, timeout=1): WebDriverWait(self.driver, timeout).until( - EC.element_to_be_clickable( - (By.CSS_SELECTOR, self.date_picker_day_locator) - ) + EC.element_to_be_clickable((By.CSS_SELECTOR, self.date_picker_day_locator)) ) @property diff --git a/tests/integration/download/download-assets/Lenna.jpeg b/tests/integration/download/download-assets/Lenna.jpeg new file mode 100644 index 000000000..a48d60f48 Binary files /dev/null and b/tests/integration/download/download-assets/Lenna.jpeg differ diff --git a/tests/integration/download/test_download.py b/tests/integration/download/test_download.py new file mode 100644 index 000000000..f86f04ed7 --- /dev/null +++ b/tests/integration/download/test_download.py @@ -0,0 +1,32 @@ +import os +import time +import dash +import dash_core_components as dcc +import dash_html_components as html + +from dash.dependencies import Input, Output + + +def test_download_text(dash_dcc): + text = "Hello, world!" + filename = "hello.txt" + # Create app. + app = dash.Dash(__name__) + app.layout = html.Div( + [dcc.Store(id="content", data=text), dcc.Download(id="download")] + ) + + @app.callback(Output("download", "data"), [Input("content", "data")]) + def download(content): + return dcc.send_string(content, filename) + + # Check that there is nothing before starting the app + fp = os.path.join(dash_dcc.download_path, filename) + assert not os.path.isfile(fp) + # Run the app. + dash_dcc.start_server(app) + time.sleep(0.5) + # Check that a file has been download, and that it's content matches the original text. + with open(fp, "r") as f: + content = f.read() + assert content == text diff --git a/tests/integration/download/test_download_dataframe.py b/tests/integration/download/test_download_dataframe.py new file mode 100644 index 000000000..60e71ba77 --- /dev/null +++ b/tests/integration/download/test_download_dataframe.py @@ -0,0 +1,52 @@ +import os +import time +import dash +import dash_core_components as dcc +import dash_html_components as html +import pytest +import pandas as pd +import numpy as np + +from dash.dependencies import Input, Output + + +@pytest.mark.parametrize( + "fmt", ("csv", "json", "html", "feather", "parquet", "stata", "pickle") +) +def test_download_dataframe(fmt, dash_dcc): + df = pd.DataFrame({"a": [1, 2, 3, 4], "b": [2, 1, 5, 6], "c": ["x", "x", "y", "y"]}) + reader = getattr(pd, "read_{}".format(fmt)) # e.g. read_csv + writer = getattr(df, "to_{}".format(fmt)) # e.g. to_csv + filename = "df.{}".format(fmt) + # Create app. + app = dash.Dash(__name__) + app.layout = html.Div( + [html.Button("Click me", id="btn"), dcc.Download(id="download")] + ) + + @app.callback(Output("download", "data"), [Input("btn", "n_clicks")]) + def download(n_clicks): + # For csv and html, the index must be removed to preserve the structure. + if fmt in ["csv", "html", "excel"]: + return dcc.send_data_frame(writer, filename, index=False) + # For csv and html, the index must be removed to preserve the structure. + if fmt in ["stata"]: + a = dcc.send_data_frame(writer, filename, write_index=False) + return a + # For other formats, no modifications are needed. + return dcc.send_data_frame(writer, filename) + + # Check that there is nothing before starting the app + fp = os.path.join(dash_dcc.download_path, filename) + assert not os.path.isfile(fp) + # Run the app. + dash_dcc.start_server(app) + time.sleep(0.5) + # Check that a file has been download, and that it's content matches the original data frame. + df_download = reader(fp) + if isinstance(df_download, list): + df_download = df_download[0] + # For stata data, pandas equals fails. Hence, a custom equals is used instead. + assert df.columns.equals(df_download.columns) + assert df.index.equals(df_download.index) + np.testing.assert_array_equal(df.values, df_download.values) diff --git a/tests/integration/download/test_download_file.py b/tests/integration/download/test_download_file.py new file mode 100644 index 000000000..d27c456e5 --- /dev/null +++ b/tests/integration/download/test_download_file.py @@ -0,0 +1,34 @@ +import os +import time +import dash +import dash_core_components as dcc +import dash_html_components as html + +from dash.dependencies import Input, Output + + +def test_download_file(dash_dcc): + filename = "Lenna.jpeg" + asset_folder = os.path.join(os.path.dirname(__file__), "download-assets") + # Create app. + app = dash.Dash(__name__) + app.layout = html.Div( + [dcc.Input(id="input", value=filename), dcc.Download(id="download")] + ) + + @app.callback(Output("download", "data"), [Input("input", "value")]) + def download(value): + return dcc.send_file(os.path.join(asset_folder, value)) + + # Check that there is nothing before starting the app + fp = os.path.join(dash_dcc.download_path, filename) + assert not os.path.isfile(fp) + # Run the app. + dash_dcc.start_server(app) + time.sleep(0.5) + # Check that a file has been download, and that it's content matches the original. + with open(fp, "rb") as f: + content = f.read() + with open(os.path.join(asset_folder, filename), "rb") as f: + original = f.read() + assert content == original