From 2ed34ab6a694da988c6247e84dc0d73f8b7c399b Mon Sep 17 00:00:00 2001 From: Andrew Schonfeld Date: Thu, 30 Apr 2020 20:28:11 -0400 Subject: [PATCH] 1.8.13 * #193: Support for JupyterHub Proxy --- .circleci/config.yml | 2 +- CHANGES.md | 3 + README.md | 56 +++++++- docker/dtale.env | 2 +- docs/source/conf.py | 4 +- dtale/app.py | 79 ++++++++--- dtale/dash_application/charts.py | 29 ++-- dtale/dash_application/layout.py | 25 ++-- dtale/dash_application/topojson_injections.py | 16 +++ dtale/dash_application/views.py | 17 ++- dtale/templates/dtale/base.html | 5 + dtale/templates/dtale/code_popup.html | 5 + dtale/utils.py | 10 ++ dtale/views.py | 10 +- package.json | 2 +- setup.py | 2 +- .../__tests__/iframe/DataViewer-root-test.jsx | 124 ++++++++++++++++++ static/actions/url-utils.js | 9 +- static/base_styles.js | 1 + static/dash/lib/custom.js | 15 ++- static/dash/lib/index.js | 2 + static/dash/lib/publicDashPath.js | 3 + static/dtale/DataViewerMenu.jsx | 5 +- static/dtale/dataViewerMenuUtils.jsx | 8 +- static/dtale/iframe/ColumnMenu.css | 0 static/dtale/iframe/ColumnMenu.jsx | 2 - static/fetcher.js | 6 + static/main.jsx | 12 +- static/polyfills.js | 1 + static/publicPath.js | 3 + test_env.js | 3 + tests/dtale/test_app.py | 99 +++++++++++++- tests/dtale/test_dash.py | 2 +- tests/dtale/test_views.py | 12 +- webpack.config.js | 4 +- 35 files changed, 507 insertions(+), 71 deletions(-) create mode 100644 dtale/dash_application/topojson_injections.py create mode 100644 static/__tests__/iframe/DataViewer-root-test.jsx create mode 100644 static/dash/lib/publicDashPath.js delete mode 100644 static/dtale/iframe/ColumnMenu.css create mode 100644 static/publicPath.js diff --git a/.circleci/config.yml b/.circleci/config.yml index df1e3903..fd64a772 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -66,7 +66,7 @@ python: &python CIRCLE_ARTIFACTS: /tmp/circleci-artifacts CIRCLE_TEST_REPORTS: /tmp/circleci-test-results CODECOV_TOKEN: b0d35139-0a75-427a-907b-2c78a762f8f0 - VERSION: 1.8.12 + VERSION: 1.8.13 PANDOC_RELEASES_URL: https://github.com/jgm/pandoc/releases steps: - attach_workspace: diff --git a/CHANGES.md b/CHANGES.md index f73976d8..73c898f7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,8 @@ ## Changelog +### 1.8.13 (2020-5-20) + * [#193](https://github.com/man-group/dtale/issues/193): Support for JupyterHub Proxy + ### 1.8.12 (2020-5-15) * [#196](https://github.com/man-group/dtale/issues/196): dataframes that have datatime indexes without a name * Added the ability to apply formats to all columns of same dtype diff --git a/README.md b/README.md index e4fa957a..f35b13e5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ D-Tale was the product of a SAS to Python conversion. What was originally a per - [Getting Started](#getting-started) - [Python Terminal](#python-terminal) - [Jupyter Notebook](#jupyter-notebook) + - [Jupyterhub w/ Jupyter Server Proxy](#jupyterhub-w-jupyter-server-proxy) - [Jupyterhub w/ Kubernetes](https://github.com/man-group/dtale/blob/master/docs/JUPYTERHUB_KUBERNETES.md) - [Google Colab & Kaggle](#google-colab--kaggle) - [R with Reticulate](#r-with-reticulate) @@ -147,6 +148,58 @@ One thing of note is that a lot of the modal popups you see in the standard brow |:------:|:------:|:------:|:------:|:------:| |![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/Column_menu.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/correlations_popup.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/describe_popup.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/histogram_popup.png)|![](https://raw.githubusercontent.com/aschonfeld/dtale-media/master/images/instances_popup.png)| +### JupyterHub w/ Jupyter Server Proxy + +JupyterHub has an extension that allows to proxy port for user, [JupyterHub Server Proxy](https://github.com/jupyterhub/jupyter-server-proxy) + +To me it seems like this extension might be the best solution to getting D-Tale running within kubernetes. Here's how to use it: + +```python +import pandas as pd + +import dtale +import dtale.app as dtale_app + +dtale_app.JUPYTER_SERVER_PROXY = True + +dtale.show(pd.DataFrame([1,2,3])) +``` + +Notice the command `dtale_app.JUPYTER_SERVER_PROXY = True` this will make sure that any D-Tale instance will be served with the jupyter server proxy application root prefix: + +`/user/{jupyter username}/proxy/{dtale instance port}/` + +One thing to note is that if you try to look at the `_main_url` of your D-Tale instance in your notebook it will not include the hostname or port: + +```python +import pandas as pd + +import dtale +import dtale.app as dtale_app + +dtale_app.JUPYTER_SERVER_PROXY = True + +d = dtale.show(pd.DataFrame([1,2,3])) +d._main_url # /user/johndoe/proxy/40000/dtale/main/1 +``` + + This is because it's very hard to promgramatically figure out the host/port that your notebook is running on. So if you want to look at `_main_url` please be sure to preface it with: + + `http[s]://[jupyterhub host]:[jupyterhub port]` + +If for some reason jupyterhub changes their API so that the application root changes you can also override D-Tale's application root by using the `app_root` parameter to the `show()` function: + +```python +import pandas as pd + +import dtale +import dtale.app as dtale_app + +dtale.show(pd.DataFrame([1,2,3]), app_root='/user/johndoe/proxy/40000/`) +``` + +Using this parameter will only apply the application root to that specific instance so you would have to include it on every call to `show()`. + ### JupyterHub w/ Kubernetes Please read this [post](https://github.com/man-group/dtale/blob/master/docs/JUPYTERHUB_KUBERNETES.md) @@ -157,7 +210,7 @@ These are hosted notebook sites and thanks to the work of [flask_ngrok](https:// **DISCLAIMER:** It is import that you set `USE_NGROK` to true when using D-Tale within these two services. Here is an example: -``` +```python import pandas as pd import dtale @@ -998,6 +1051,7 @@ Contributors: * [Fernando Saravia Rajal](https://github.com/fersarr) * [Dominik Christ](https://github.com/DominikMChrist) * [Reza Moshksar](https://github.com/reza1615) + * [Bertrand Nouvel](https://github.com/bnouvelbmll) * [Chris Boddy](https://github.com/cboddy) * [Jason Holden](https://github.com/jasonkholden) * [Tom Taylor](https://github.com/TomTaylorLondon) diff --git a/docker/dtale.env b/docker/dtale.env index e54c0410..93443af7 100644 --- a/docker/dtale.env +++ b/docker/dtale.env @@ -1,2 +1,2 @@ -VERSION=1.8.12 +VERSION=1.8.13 TZ=America/New_York \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 3a2f8019..cd77f91e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -64,9 +64,9 @@ # built documents. # # The short X.Y version. -version = u'1.8.12' +version = u'1.8.13' # The full version, including alpha/beta/rc tags. -release = u'1.8.12' +release = u'1.8.13' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/dtale/app.py b/dtale/app.py index a482f99c..e9e196c5 100644 --- a/dtale/app.py +++ b/dtale/app.py @@ -1,5 +1,6 @@ from __future__ import absolute_import, print_function +import getpass import os import random import socket @@ -23,7 +24,8 @@ from dtale import dtale from dtale.cli.clickutils import retrieve_meta_info_and_version, setup_logging from dtale.utils import (DuplicateDataError, build_shutdown_url, build_url, - dict_merge, get_host, running_with_flask_debug) + dict_merge, fix_url_path, get_host, + is_app_root_defined, running_with_flask_debug) from dtale.views import DtaleData, head_data_id, is_up, kill, startup if PY3: @@ -34,6 +36,7 @@ logger = getLogger(__name__) USE_NGROK = False +JUPYTER_SERVER_PROXY = False ACTIVE_HOST = None ACTIVE_PORT = None @@ -69,9 +72,8 @@ def get(self, *args, **kwargs): :param args: Optional arguments to be passed to :meth:`flask:flask.FlaskClient.get` :param kwargs: Optional keyword arguments to be passed to :meth:`flask:flask.FlaskClient.get` """ - return super(DtaleFlaskTesting, self).get( - base_url='http://{host}:{port}'.format(host=self.host, port=self.port), *args, **kwargs - ) + self.application.config['SERVER_NAME'] = '{host}:{port}'.format(host=self.host, port=self.port) + return super(DtaleFlaskTesting, self).get(url_scheme='http', *args, **kwargs) class DtaleFlask(Flask): @@ -86,7 +88,7 @@ class DtaleFlask(Flask): :param kwargs: Optional keyword arguments to be passed to :class:`flask:flask.Flask` """ - def __init__(self, import_name, reaper_on=True, url=None, *args, **kwargs): + def __init__(self, import_name, reaper_on=True, url=None, app_root=None, *args, **kwargs): """ Constructor method :param reaper_on: whether to run auto-reaper subprocess @@ -97,8 +99,21 @@ def __init__(self, import_name, reaper_on=True, url=None, *args, **kwargs): self.base_url = url self.shutdown_url = build_shutdown_url(url) self.port = None + self.app_root = app_root super(DtaleFlask, self).__init__(import_name, *args, **kwargs) + def update_template_context(self, context): + super(DtaleFlask, self).update_template_context(context) + if self.app_root is not None: + context['url_for'] = self.url_for + + def url_for(self, endpoint, *args, **kwargs): + if self.app_root is not None and endpoint == 'static': + if 'filename' in kwargs: + return fix_url_path('{}/{}'.format(self.app_root, kwargs["filename"])) + return fix_url_path('{}/{}'.format(self.app_root, args[0])) + return url_for(endpoint, *args, **kwargs) + def run(self, *args, **kwargs): """ :param args: Optional arguments to be passed to :meth:`flask:flask.run` @@ -110,7 +125,7 @@ def run(self, *args, **kwargs): self.build_reaper() super(DtaleFlask, self).run(use_reloader=kwargs.get('debug', False), *args, **kwargs) - def test_client(self, reaper_on=False, port=None, *args, **kwargs): + def test_client(self, reaper_on=False, port=None, app_root=None, *args, **kwargs): """ Overriding Flask's implementation of test_client so we can specify ports for testing and whether auto-reaper should be running @@ -125,6 +140,10 @@ def test_client(self, reaper_on=False, port=None, *args, **kwargs): :rtype: :class:`dtale.app.DtaleFlaskTesting` """ self.reaper_on = reaper_on + self.app_root = app_root + if app_root is not None: + self.config['APPLICATION_ROOT'] = app_root + self.jinja_env.globals['url_for'] = self.url_for self.test_client_class = DtaleFlaskTesting return super(DtaleFlask, self).test_client(*args, **dict_merge(kwargs, dict(port=port))) @@ -172,7 +191,7 @@ def get_send_file_max_age(self, name): return super(DtaleFlask, self).get_send_file_max_age(name) -def build_app(url, host=None, reaper_on=True, hide_shutdown=False, github_fork=False): +def build_app(url, host=None, reaper_on=True, hide_shutdown=False, github_fork=False, app_root=None): """ Builds :class:`flask:flask.Flask` application encapsulating endpoints for D-Tale's front-end @@ -180,13 +199,20 @@ def build_app(url, host=None, reaper_on=True, hide_shutdown=False, github_fork=F :rtype: :class:`dtale.app.DtaleFlask` """ - app = DtaleFlask('dtale', reaper_on=reaper_on, static_url_path='', url=url, instance_relative_config=False) + app = DtaleFlask('dtale', reaper_on=reaper_on, static_url_path='', url=url, instance_relative_config=False, + app_root=app_root) app.config['SECRET_KEY'] = 'Dtale' app.config['HIDE_SHUTDOWN'] = hide_shutdown app.config['GITHUB_FORK'] = github_fork app.jinja_env.trim_blocks = True app.jinja_env.lstrip_blocks = True + + if app_root is not None: + app.config['APPLICATION_ROOT'] = app_root + app.jinja_env.globals['url_for'] = app.url_for + app.jinja_env.globals['is_app_root_defined'] = is_app_root_defined + app.register_blueprint(dtale) compress = Compress() @@ -209,7 +235,7 @@ def favicon(): :return: image/png """ - return redirect(url_for('static', filename='images/favicon.ico')) + return redirect(app.url_for('static', filename='images/favicon.ico')) @app.route('/missing-js') def missing_js(): @@ -299,7 +325,7 @@ def has_no_empty_params(rule): # Filter out rules we can't navigate to in a browser # and rules that require parameters if "GET" in rule.methods and has_no_empty_params(rule): - url = url_for(rule.endpoint, **(rule.defaults or {})) + url = app.url_for(rule.endpoint, **(rule.defaults or {})) links.append((url, rule.endpoint)) return jsonify(links) @@ -404,9 +430,23 @@ def is_port_in_use(port): return base +def build_startup_url_and_app_root(app_root=None): + url = build_url(ACTIVE_PORT, ACTIVE_HOST) + final_app_root = app_root + if final_app_root is None and JUPYTER_SERVER_PROXY: + final_app_root = '/user/{}/proxy/'.format(getpass.getuser()) + if final_app_root is not None: + if JUPYTER_SERVER_PROXY: + final_app_root = fix_url_path('{}/{}'.format(final_app_root, ACTIVE_PORT)) + return final_app_root, final_app_root + else: + return fix_url_path('{}/{}'.format(url, final_app_root)), final_app_root + return url, final_app_root + + def show(data=None, host=None, port=None, name=None, debug=False, subprocess=True, data_loader=None, reaper_on=True, open_browser=False, notebook=False, force=False, context_vars=None, ignore_duplicate=False, - **kwargs): + app_root=None, **kwargs): """ Entry point for kicking off D-Tale :class:`flask:flask.Flask` process from python process @@ -452,7 +492,7 @@ def show(data=None, host=None, port=None, name=None, debug=False, subprocess=Tru ..link displayed in logging can be copied and pasted into any browser """ - global ACTIVE_HOST, ACTIVE_PORT, USE_NGROK + global ACTIVE_HOST, ACTIVE_PORT, USE_NGROK, JUPYTER_SERVER_PROXY try: logfile, log_level, verbose = map(kwargs.get, ['logfile', 'log_level', 'verbose']) @@ -469,10 +509,11 @@ def show(data=None, host=None, port=None, name=None, debug=False, subprocess=Tru else: initialize_process_props(host, port, force) - url = build_url(ACTIVE_PORT, ACTIVE_HOST) - instance = startup(url, data=data, data_loader=data_loader, name=name, context_vars=context_vars, + app_url = build_url(ACTIVE_PORT, ACTIVE_HOST) + startup_url, final_app_root = build_startup_url_and_app_root(app_root) + instance = startup(startup_url, data=data, data_loader=data_loader, name=name, context_vars=context_vars, ignore_duplicate=ignore_duplicate) - is_active = not running_with_flask_debug() and is_up(url) + is_active = not running_with_flask_debug() and is_up(app_url) if is_active: def _start(): if open_browser: @@ -484,7 +525,8 @@ def _start(): thread.start() def _start(): - app = build_app(url, reaper_on=reaper_on, host=ACTIVE_HOST) + app = build_app(app_url, reaper_on=reaper_on, host=ACTIVE_HOST, + app_root=final_app_root) if debug and not USE_NGROK: app.jinja_env.auto_reload = True app.config['TEMPLATES_AUTO_RELOAD'] = True @@ -513,7 +555,7 @@ def _start(): if notebook: instance.notebook() else: - logger.info('D-Tale started at: {}'.format(url)) + logger.info('D-Tale started at: {}'.format(app_url)) _start() return instance @@ -554,7 +596,8 @@ def get_instance(data_id): """ data_id_str = str(data_id) if global_state.get_data(data_id_str) is not None: - return DtaleData(data_id_str, build_url(ACTIVE_PORT, ACTIVE_HOST)) + startup_url, _ = build_startup_url_and_app_root() + return DtaleData(data_id_str, startup_url) return None diff --git a/dtale/dash_application/charts.py b/dtale/dash_application/charts.py index 49e09ac3..42a71e65 100644 --- a/dtale/dash_application/charts.py +++ b/dtale/dash_application/charts.py @@ -1,6 +1,7 @@ import json import math import os +import re import traceback import urllib from logging import getLogger @@ -25,10 +26,11 @@ ANIMATION_CHARTS, build_error, test_plotly_version, update_label_for_freq) +from dtale.dash_application.topojson_injections import INJECTIONS from dtale.utils import (build_code_export, classify_type, dict_merge, divide_chunks, export_to_csv_buffer, find_dtype, - find_dtype_formatter, flatten_lists, get_dtypes, - make_list, run_query) + find_dtype_formatter, fix_url_path, flatten_lists, + get_dtypes, make_list, run_query) logger = getLogger(__name__) @@ -272,16 +274,20 @@ def chart_wrapper(data_id, data, url_params=None): def _chart_wrapper(chart, group_filter=None): querystring = chart_url_querystring(url_params, data=data, group_filter=group_filter) + app_root = url_params.get('app_root') or '' + + def build_url(path, query): + return fix_url_path('{}/{}/{}?{}'.format(app_root, path, data_id, query)) popup_link = html.A( [html.I(className='far fa-window-restore mr-4'), html.Span('Popup Chart')], - href='/charts/{}?{}'.format(data_id, querystring), + href=build_url('/charts', querystring), target='_blank', className='mr-5' ) copy_link = html.Div( [html.A( [html.I(className='ico-link mr-4'), html.Span('Copy Link')], - href='/charts/{}?{}'.format(data_id, querystring), + href=build_url('/charts', querystring), target='_blank', className='mr-5 copy-link-btn' ), html.Div('Copied to clipboard', className="hoverable__content copy-tt-bottom") @@ -295,12 +301,12 @@ def _chart_wrapper(chart, group_filter=None): ) export_html_link = html.A( [html.I(className='fas fa-file-code mr-4'), html.Span('Export Chart')], - href='/dtale/chart-export/{}?{}'.format(data_id, querystring), + href=build_url('/dtale/chart-export', querystring), className='export-chart-btn mr-5' ) export_csv_link = html.A( [html.I(className='fas fa-file-csv mr-4'), html.Span('Export CSV')], - href='/dtale/chart-csv-export/{}?{}'.format(data_id, querystring), + href=build_url('/dtale/chart-csv-export', querystring), className='export-chart-btn' ) links = html.Div( @@ -1395,13 +1401,8 @@ def export_chart(data_id, params): def map_chart_post_processing(html_str, params): - topo_find = ( - '_.fetchTopojson=function(){var t=this,e=y.getTopojsonPath(t.topojsonURL,t.topojsonName);' - 'return new Promise(function(r,a){n.json(e,function(n,i){if(n)return 404===n.status?a(new Error(["plotly.js ' - 'could not find topojson file at",e,".","Make sure the *topojsonURL* plot config option","is set properly."]' - '.join(" "))):a(new Error(["unexpected error while fetching topojson file at",e].join(" ")));' - 'PlotlyGeoAssets.topojson[t.topojsonName]=i,r()})})}' - ) + plotly_version = next((v for v in re.findall(r" plotly.js v(\d+.\d+.\d+)", html_str)), None) + topo_find = INJECTIONS.get(plotly_version) or INJECTIONS[sorted(INJECTIONS.keys())[-1]] if topo_find in html_str: map_path = os.path.join(os.path.dirname(__file__), '../static/maps/') if params.get('map_type') == 'scattergeo': @@ -1410,7 +1411,7 @@ def map_chart_post_processing(html_str, params): topo_name = 'world_110m' else: topo_name = 'usa_110m' - topo_replace = ['_.fetchTopojson=function(){'] + topo_replace = ['.fetchTopojson=function(){'] with open(os.path.join(map_path, '{}.json'.format(topo_name)), 'r') as file: data = file.read().replace('\n', '') topo_replace.append("PlotlyGeoAssets.topojson['{}'] = {};".format(topo_name, data)) diff --git a/dtale/dash_application/layout.py b/dtale/dash_application/layout.py index c460cbe5..77d3c9f9 100644 --- a/dtale/dash_application/layout.py +++ b/dtale/dash_application/layout.py @@ -9,7 +9,7 @@ from dtale.charts.utils import YAXIS_CHARTS, ZAXIS_CHARTS, find_group_vals from dtale.utils import (ChartBuildingError, classify_type, dict_merge, flatten_lists, get_dtypes, inner_build_query, - make_list) + is_app_root_defined, make_list) def test_plotly_version(version_num): @@ -18,7 +18,7 @@ def test_plotly_version(version_num): return parse_version(plotly.__version__) >= parse_version(version_num) -def base_layout(github_fork, **kwargs): +def base_layout(github_fork, app_root, **kwargs): """ Base layout to be returned by :meth:`dtale.dash_application.views.DtaleDash.interpolate_index` @@ -29,7 +29,7 @@ def base_layout(github_fork, **kwargs): :return: HTML :rtype: str """ - back_to_data_padding, github_fork_html = ('', '') + back_to_data_padding, github_fork_html, webroot_html = ('', '', '') if github_fork: back_to_data_padding = 'padding-right: 125px' github_fork_html = ''' @@ -37,13 +37,20 @@ def base_layout(github_fork, **kwargs): Fork me on GitHub ''' + if is_app_root_defined(app_root): + webroot_html = ''' + + '''.format(app_root=app_root) return ''' + {webroot_html} {metas} D-Tale Charts - + {css} @@ -72,8 +79,8 @@ def base_layout(github_fork, **kwargs): {renderer} {css} @@ -88,7 +95,9 @@ def base_layout(github_fork, **kwargs): scripts=kwargs['scripts'], renderer=kwargs['renderer'], back_to_data_padding=back_to_data_padding, - github_fork_html=github_fork_html + webroot_html=webroot_html, + github_fork_html=github_fork_html, + app_root=app_root or '' ) @@ -189,7 +198,7 @@ def build_option(value, label=None): def build_img_src(proj, img_type='projections'): - return '/images/{}/{}.png'.format(img_type, '_'.join(proj.split(' '))) + return '../images/{}/{}.png'.format(img_type, '_'.join(proj.split(' '))) def build_proj_hover_children(proj): diff --git a/dtale/dash_application/topojson_injections.py b/dtale/dash_application/topojson_injections.py new file mode 100644 index 00000000..0257083d --- /dev/null +++ b/dtale/dash_application/topojson_injections.py @@ -0,0 +1,16 @@ +INJECTIONS = { + '1.53.1': ( + '.fetchTopojson=function(){var t=this,e=y.getTopojsonPath(t.topojsonURL,t.topojsonName);return new Promise(' + 'function(r,a){n.json(e,function(n,i){if(n)return 404===n.status?a(new Error(["plotly.js could not find ' + 'topojson file at",e,".","Make sure the *topojsonURL* plot config option","is set properly."].join(" "))):' + 'a(new Error(["unexpected error while fetching topojson file at",e].join(" ")));' + 'PlotlyGeoAssets.topojson[t.topojsonName]=i,r()})})}' + ), + '1.54.1': ( + '.fetchTopojson=function(){var t=this,e=x.getTopojsonPath(t.topojsonURL,t.topojsonName);return new Promise(' + '(function(r,a){n.json(e,(function(n,i){if(n)return 404===n.status?a(new Error(["plotly.js could not find ' + 'topojson file at",e,".","Make sure the *topojsonURL* plot config option","is set properly."].join(" "))):' + 'a(new Error(["unexpected error while fetching topojson file at",e].join(" ")));' + 'PlotlyGeoAssets.topojson[t.topojsonName]=i,r()}))}))}' + ) +} diff --git a/dtale/dash_application/views.py b/dtale/dash_application/views.py index f420ce22..48a45dfa 100644 --- a/dtale/dash_application/views.py +++ b/dtale/dash_application/views.py @@ -24,7 +24,7 @@ show_chart_per_group, show_input_handler, show_yaxis_ranges) -from dtale.utils import dict_merge, make_list, run_query +from dtale.utils import dict_merge, is_app_root_defined, make_list, run_query logger = getLogger(__name__) @@ -44,10 +44,21 @@ def __init__(self, *args, **kwargs): '/dash/components_bundle.js', '/dash/custom_bundle.js', '/dist/base_styles_bundle.js' ] + app_root = server.config.get('APPLICATION_ROOT') + if is_app_root_defined(app_root): + + def _prepend_app_root(v): + return '{}{}'.format(app_root, v) + kwargs['requests_pathname_prefix'] = _prepend_app_root(kwargs['routes_pathname_prefix']) + kwargs['external_stylesheets'] = [_prepend_app_root(v) for v in kwargs['external_stylesheets']] + kwargs['external_scripts'] = [_prepend_app_root(v) for v in kwargs['external_scripts']] + kwargs['assets_url_path'] = _prepend_app_root('') + kwargs['assets_external_path'] = _prepend_app_root('/assets') + super(DtaleDash, self).__init__(*args, **kwargs) def interpolate_index(self, **kwargs): - return base_layout(self.server.config['GITHUB_FORK'], **kwargs) + return base_layout(self.server.config['GITHUB_FORK'], self.server.config.get('APPLICATION_ROOT'), **kwargs) def add_dash(server): @@ -331,6 +342,8 @@ def on_data(_ts1, _ts2, _ts3, _ts4, pathname, inputs, chart_inputs, yaxis_data, all_inputs = dict_merge(inputs, chart_inputs, dict(yaxis=yaxis_data or {}), map_data) if all_inputs == last_chart_inputs: raise PreventUpdate + if is_app_root_defined(dash_app.server.config.get('APPLICATION_ROOT')): + all_inputs['app_root'] = dash_app.server.config['APPLICATION_ROOT'] charts, range_data, code = build_chart(get_data_id(pathname), **all_inputs) return charts, all_inputs, range_data, code, get_yaxis_type_tabs(make_list(inputs.get('y') or [])) diff --git a/dtale/templates/dtale/base.html b/dtale/templates/dtale/base.html index 9e07dbb2..38ed5202 100644 --- a/dtale/templates/dtale/base.html +++ b/dtale/templates/dtale/base.html @@ -5,6 +5,11 @@ + {% if is_app_root_defined(config.APPLICATION_ROOT) %} + + {% endif %} {{ title }} {% if missing_js is not defined %} diff --git a/dtale/templates/dtale/code_popup.html b/dtale/templates/dtale/code_popup.html index 34437d3f..dde7757c 100644 --- a/dtale/templates/dtale/code_popup.html +++ b/dtale/templates/dtale/code_popup.html @@ -5,6 +5,11 @@ + {% if is_app_root_defined(config.APPLICATION_ROOT) %} + + {% endif %} D-Tale Code Snippet diff --git a/dtale/utils.py b/dtale/utils.py index 0710dbc1..62b34ac1 100644 --- a/dtale/utils.py +++ b/dtale/utils.py @@ -779,3 +779,13 @@ def export_to_csv_buffer(data, tsv=False): data.to_csv(csv_buffer, **kwargs) csv_buffer.seek(0) return csv_buffer + + +def is_app_root_defined(app_root): + return app_root is not None and app_root != '/' + + +def fix_url_path(path): + while '//' in path: + path = path.replace('//', '/') + return path diff --git a/dtale/views.py b/dtale/views.py index e0a019a2..462cfbed 100644 --- a/dtale/views.py +++ b/dtale/views.py @@ -7,8 +7,8 @@ from builtins import map, range, str, zip from logging import getLogger -from flask import (json, make_response, redirect, render_template, request, - url_for) +from flask import (current_app, json, make_response, redirect, render_template, + request) import numpy as np import pandas as pd @@ -242,8 +242,10 @@ def notebook(self, route='/dtale/iframe/', params=None, width='100%', height=475 logger.info('in order to use this function, please install IPython') return self.data.__repr__() - while not self.is_up(): + retries = 0 + while not self.is_up() and retries < 10: time.sleep(0.01) + retries += 1 self._notebook_handle = display( self._build_iframe(route=route, params=params, width=width, height=height), display_id=True @@ -568,7 +570,7 @@ def base_render_template(template, data_id, **kwargs): - processes """ if not len(os.listdir('{}/static/dist'.format(os.path.dirname(__file__)))): - return redirect(url_for('missing_js')) + return redirect(current_app.url_for('missing_js')) curr_settings = global_state.get_settings(data_id) or {} _, version = retrieve_meta_info_and_version('dtale') return render_template( diff --git a/package.json b/package.json index b01a3753..c5da337d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dtale", - "version": "1.8.12", + "version": "1.8.13", "description": "Visualizer for Pandas Data Structures", "main": "main.js", "directories": { diff --git a/setup.py b/setup.py index d6f27463..a116e515 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ def run_tests(self): setup( name="dtale", - version="1.8.12", + version="1.8.13", author="MAN Alpha Technology", author_email="ManAlphaTech@man.com", description="Web Client for Visualizing Pandas Objects", diff --git a/static/__tests__/iframe/DataViewer-root-test.jsx b/static/__tests__/iframe/DataViewer-root-test.jsx new file mode 100644 index 00000000..cb267f7c --- /dev/null +++ b/static/__tests__/iframe/DataViewer-root-test.jsx @@ -0,0 +1,124 @@ +/* eslint max-lines: "off" */ +/* eslint max-statements: "off" */ +import { mount } from "enzyme"; +import _ from "lodash"; +import React from "react"; +import { Provider } from "react-redux"; + +import { expect, it } from "@jest/globals"; + +import mockPopsicle from "../MockPopsicle"; +import reduxUtils from "../redux-test-utils"; + +import { buildInnerHTML, clickMainMenuButton, findMainMenuButton, tickUpdate, withGlobalJquery } from "../test-utils"; + +import { clickColMenuButton, openColMenu } from "./iframe-utils"; + +const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); +const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"); + +class MockDateInput extends React.Component { + render() { + return null; + } +} +MockDateInput.displayName = "DateInput"; + +describe("DataViewer iframe tests", () => { + const { location, open, top, self, resourceBaseUrl } = window; + let result, DataViewer; + + beforeAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + value: 500, + }); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", { + configurable: true, + value: 500, + }); + + delete window.location; + delete window.open; + delete window.top; + delete window.self; + delete window.resourceBaseUrl; + window.location = { reload: jest.fn() }; + window.open = jest.fn(); + window.top = { location: { href: "http://test.com" } }; + window.self = { location: { href: "http://test/dtale/iframe" } }; + + const mockBuildLibs = withGlobalJquery(() => + mockPopsicle.mock(url => { + const { urlFetcher } = require("../redux-test-utils").default; + return urlFetcher(url); + }) + ); + + const mockChartUtils = withGlobalJquery(() => (ctx, cfg) => { + const chartCfg = { ctx, cfg, data: cfg.data, destroyed: false }; + chartCfg.destroy = () => (chartCfg.destroyed = true); + chartCfg.getElementsAtXAxis = _evt => [{ _index: 0 }]; + return chartCfg; + }); + + jest.mock("popsicle", () => mockBuildLibs); + jest.mock("chart.js", () => mockChartUtils); + jest.mock("chartjs-plugin-zoom", () => ({})); + jest.mock("chartjs-chart-box-and-violin-plot/build/Chart.BoxPlot.js", () => ({})); + jest.mock("@blueprintjs/datetime", () => ({ DateInput: MockDateInput })); + DataViewer = require("../../dtale/DataViewer").DataViewer; + }); + + beforeEach(async () => { + const store = reduxUtils.createDtaleStore(); + buildInnerHTML({ settings: "", iframe: "True" }, store); + result = mount( + + + , + { + attachTo: document.getElementById("content"), + } + ); + await tickUpdate(result); + }); + + afterAll(() => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + Object.defineProperty(HTMLElement.prototype, "offsetWidth", originalOffsetWidth); + window.location = location; + window.open = open; + window.top = top; + window.self = self; + window.resourceBaseUrl = resourceBaseUrl; + }); + + it("DataViewer: validate server calls", async () => { + window.resourceBaseUrl = "/test-route/"; + await openColMenu(result, 2); + clickColMenuButton(result, "Column Analysis"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe( + "/test-route/dtale/popup/column-analysis/1?selectedCol=col3" + ); + clickColMenuButton(result, "Describe"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe( + "/test-route/dtale/popup/describe/1?selectedCol=col3" + ); + clickMainMenuButton(result, "Describe"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe("/test-route/dtale/popup/describe/1"); + clickMainMenuButton(result, "Correlations"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe("/test-route/dtale/popup/correlations/1"); + clickMainMenuButton(result, "Charts"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe("/test-route/charts/1"); + clickMainMenuButton(result, "Instances 1"); + expect(window.open.mock.calls[window.open.mock.calls.length - 1][0]).toBe("/test-route/dtale/popup/instances/1"); + const exports = findMainMenuButton(result, "CSV", "div.btn-group"); + exports.find("button").first().simulate("click"); + let exportURL = window.open.mock.calls[window.open.mock.calls.length - 1][0]; + expect(_.startsWith(exportURL, "/test-route/dtale/data-export/1") && _.includes(exportURL, "tsv=false")).toBe(true); + exports.find("button").last().simulate("click"); + exportURL = window.open.mock.calls[window.open.mock.calls.length - 1][0]; + expect(_.startsWith(exportURL, "/test-route/dtale/data-export/1") && _.includes(exportURL, "tsv=true")).toBe(true); + }); +}); diff --git a/static/actions/url-utils.js b/static/actions/url-utils.js index 4597aa55..9aeda308 100644 --- a/static/actions/url-utils.js +++ b/static/actions/url-utils.js @@ -46,4 +46,11 @@ function saveColFilterUrl(dataId, column) { return `/dtale/save-column-filter/${dataId}/${column}`; } -export { buildURLParams, buildURLString, buildURL, dtypesUrl, saveColFilterUrl }; +function cleanupEndpoint(endpoint) { + while (_.includes(endpoint, "//")) { + endpoint = _.replace(endpoint, "//", "/"); + } + return endpoint; +} + +export { buildURLParams, buildURLString, buildURL, dtypesUrl, saveColFilterUrl, cleanupEndpoint }; diff --git a/static/base_styles.js b/static/base_styles.js index 90ce7c25..0e720fcf 100644 --- a/static/base_styles.js +++ b/static/base_styles.js @@ -1 +1,2 @@ +require("./publicPath"); require("@fortawesome/fontawesome-free/css/all.css"); diff --git a/static/dash/lib/custom.js b/static/dash/lib/custom.js index 0138f80a..45522219 100644 --- a/static/dash/lib/custom.js +++ b/static/dash/lib/custom.js @@ -1,15 +1,26 @@ +require("./publicDashPath"); + import $ from "jquery"; function openCodeSnippet(e) { e.preventDefault(); window.code_popup = { code: document.getElementById("chart-code").value, title: "Charts" }; - window.open("../dtale/code-popup", "_blank", `titlebar=1,location=1,status=1,width=700,height=450`); + + let path = "dtale/code-popup"; + if (window.resourceBaseUrl) { + path = `${window.resourceBaseUrl}/${path}`; + } + window.open(`${window.location.origin}${path}`, "_blank", `titlebar=1,location=1,status=1,width=700,height=450`); } function copy(e) { e.preventDefault(); const textCmp = document.getElementById("copy-text"); - const chartLink = $(e.target).parent().attr("href"); + let chartLink = $(e.target).parent().attr("href"); + const webRoot = window.resourceBaseUrl; + if (webRoot) { + chartLink = `${webRoot}/${chartLink}`; + } textCmp.value = `${window.location.origin}${chartLink}`; textCmp.select(); document.execCommand("copy"); diff --git a/static/dash/lib/index.js b/static/dash/lib/index.js index 615b5df9..2a9798a5 100644 --- a/static/dash/lib/index.js +++ b/static/dash/lib/index.js @@ -1,3 +1,5 @@ +require("./publicDashPath"); + import Wordcloud from "./components/Wordcloud.react"; export { Wordcloud }; diff --git a/static/dash/lib/publicDashPath.js b/static/dash/lib/publicDashPath.js new file mode 100644 index 00000000..8f798636 --- /dev/null +++ b/static/dash/lib/publicDashPath.js @@ -0,0 +1,3 @@ +const webRoot = window.resourceBaseUrl; +// eslint-disable-next-line camelcase,no-undef +__webpack_public_path__ = `${webRoot || ""}/dash/`; diff --git a/static/dtale/DataViewerMenu.jsx b/static/dtale/DataViewerMenu.jsx index 52b012ed..702f2277 100644 --- a/static/dtale/DataViewerMenu.jsx +++ b/static/dtale/DataViewerMenu.jsx @@ -42,7 +42,10 @@ class ReactDataViewerMenu extends React.Component { }; const heatmapActive = _.startsWith(this.props.backgroundMode, "heatmap"); const exportFile = tsv => () => - window.open(`/dtale/data-export/${dataId}?tsv=${tsv}&_id=${new Date().getTime()}`, "_blank"); + window.open( + `${menuFuncs.fullPath("/dtale/data-export", dataId)}?tsv=${tsv}&_id=${new Date().getTime()}`, + "_blank" + ); return (
!_.includes(selectedCols, col)); switch (dir) { @@ -33,7 +35,11 @@ function buildStyling(val, colType, styleProps) { } function fullPath(path, dataId = null) { - return dataId ? `${path}/${dataId}` : path; + const finalPath = dataId ? `${path}/${dataId}` : path; + if (window.resourceBaseUrl && !_.startsWith(finalPath, window.resourceBaseUrl)) { + return cleanupEndpoint(`${window.resourceBaseUrl}/${finalPath}`); + } + return finalPath; } function open(path, dataId, height = 450, width = 500) { diff --git a/static/dtale/iframe/ColumnMenu.css b/static/dtale/iframe/ColumnMenu.css deleted file mode 100644 index e69de29b..00000000 diff --git a/static/dtale/iframe/ColumnMenu.jsx b/static/dtale/iframe/ColumnMenu.jsx index 3b3ad997..e63f17e3 100644 --- a/static/dtale/iframe/ColumnMenu.jsx +++ b/static/dtale/iframe/ColumnMenu.jsx @@ -12,8 +12,6 @@ import menuFuncs from "../dataViewerMenuUtils"; import { exports as gu } from "../gridUtils"; import serverState from "../serverStateManagement"; -require("./ColumnMenu.css"); - const { ROW_HEIGHT, SORT_PROPS } = gu; const MOVE_COLS = [ ["step-backward", serverState.moveToFront, "Move Column To Front", {}], diff --git a/static/fetcher.js b/static/fetcher.js index d0ffebd2..bb8d174f 100644 --- a/static/fetcher.js +++ b/static/fetcher.js @@ -1,5 +1,7 @@ import * as popsicle from "popsicle"; +import { cleanupEndpoint } from "./actions/url-utils"; + function logException(e, callStack) { console.error(`${e.name}: ${e.message} (${e.fileName}:${e.lineNumber})`); console.error(e.stack); @@ -8,6 +10,10 @@ function logException(e, callStack) { // Useful for libraries that want a Promise. function fetchJsonPromise(url) { + const webRoot = window.resourceBaseUrl; + if (webRoot) { + url = cleanupEndpoint(`${webRoot}/${url}`); + } return popsicle.fetch(url).then(response => response.json()); } diff --git a/static/main.jsx b/static/main.jsx index 97483e96..e03fbc78 100644 --- a/static/main.jsx +++ b/static/main.jsx @@ -19,16 +19,22 @@ import { ReactReshape as Reshape } from "./popups/reshape/Reshape"; import app from "./reducers/dtale"; import { createStore } from "./reducers/store"; +require("./publicPath"); + const settingsElem = document.getElementById("settings"); const settings = settingsElem ? JSON.parse(settingsElem.value) : {}; -if (_.startsWith(window.location.pathname, "/dtale/popup")) { +let pathname = window.location.pathname; +if (window.resourceBaseUrl) { + pathname = _.replace(pathname, window.resourceBaseUrl, ""); +} +if (_.startsWith(pathname, "/dtale/popup")) { require("./dtale/DataViewer.css"); let rootNode = null; const dataId = app.getHiddenValue("data_id"); const chartData = _.assignIn(actions.getParams(), { visible: true }, settings.query ? { query: settings.query } : {}); - const pathSegs = _.split(window.location.pathname, "/"); + const pathSegs = _.split(pathname, "/"); const popupType = pathSegs[pathSegs.length - 1] === "code-popup" ? "code-popup" : pathSegs[3]; switch (popupType) { @@ -62,7 +68,7 @@ if (_.startsWith(window.location.pathname, "/dtale/popup")) { break; } ReactDOM.render(rootNode, document.getElementById("popup-content")); -} else if (_.startsWith(window.location.pathname, "/dtale/code-popup")) { +} else if (_.startsWith(pathname, "/dtale/code-popup")) { require("./dtale/DataViewer.css"); document.getElementById("code-title").innerHTML = `${window.opener.code_popup.title} Code Export`; ReactDOM.render(, document.getElementById("popup-content")); diff --git a/static/polyfills.js b/static/polyfills.js index a3c39e90..6df01808 100644 --- a/static/polyfills.js +++ b/static/polyfills.js @@ -1,3 +1,4 @@ +require("./publicPath"); require("babel-polyfill"); require("es6-object-assign").polyfill(); require("es6-promise").polyfill(); diff --git a/static/publicPath.js b/static/publicPath.js new file mode 100644 index 00000000..35793d13 --- /dev/null +++ b/static/publicPath.js @@ -0,0 +1,3 @@ +const webRoot = window.resourceBaseUrl; +// eslint-disable-next-line camelcase,no-undef +__webpack_public_path__ = `${webRoot || ""}/dist/`; diff --git a/test_env.js b/test_env.js index 6f3cd449..a4b6db46 100644 --- a/test_env.js +++ b/test_env.js @@ -10,3 +10,6 @@ require("./static/adapter-for-react-16"); // this file is compiled in an odd way so we need to mock it (react-syntax-highlighter) jest.mock("react-syntax-highlighter/dist/esm/styles/hljs", () => ({ docco: {} })); + +// this is required for webpack dynamic public path setup +global.__webpack_public_path__ = ""; diff --git a/tests/dtale/test_app.py b/tests/dtale/test_app.py index 8aa1ed12..c31577f8 100644 --- a/tests/dtale/test_app.py +++ b/tests/dtale/test_app.py @@ -1,3 +1,4 @@ +import getpass from collections import namedtuple from flask import Flask @@ -107,7 +108,7 @@ def test_show(unittest, builtin_pkg): class MockDtaleFlask(Flask): - def __init__(self, import_name, reaper_on=True, url=None, *args, **kwargs): + def __init__(self, import_name, reaper_on=True, url=None, app_root=None, *args, **kwargs): kwargs.pop('instance_relative_config', None) kwargs.pop('static_url_path', None) super(MockDtaleFlask, self).__init__(import_name, *args, **kwargs) @@ -221,7 +222,7 @@ def mock_requests_get(url, verify=True): class MockDtaleFlaskRunTest(Flask): - def __init__(self, import_name, reaper_on=True, url=None, *args, **kwargs): + def __init__(self, import_name, reaper_on=True, url=None, app_root=None, *args, **kwargs): kwargs.pop('instance_relative_config', None) kwargs.pop('static_url_path', None) super(MockDtaleFlaskRunTest, self).__init__(import_name, *args, **kwargs) @@ -249,7 +250,8 @@ def run(self, *args, **kwargs): _, kwargs = mock_build_app.call_args unittest.assertEqual( - {'host': 'localhost', 'reaper_on': True}, kwargs, 'build_app should be called with defaults' + {'app_root': None, 'host': 'localhost', 'reaper_on': True}, kwargs, + 'build_app should be called with defaults' ) # test adding duplicate column @@ -327,6 +329,64 @@ def import_mock(name, *args, **kwargs): show(data=test_data) +@pytest.mark.unit +def test_show_jupyter_server_proxy(unittest): + from dtale.app import show, get_instance, instances + import dtale.app as dtale_app + import dtale.views as views + import dtale.global_state as global_state + + test_data = pd.DataFrame([dict(a=1, b=2)]) + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.app.JUPYTER_SERVER_PROXY', True)) + mock_run = stack.enter_context(mock.patch('dtale.app.DtaleFlask.run', mock.Mock())) + stack.enter_context(mock.patch('dtale.app.is_up', mock.Mock(return_value=False))) + mock_requests = stack.enter_context(mock.patch('requests.get', mock.Mock())) + instance = show(data=test_data, subprocess=False, name='foo', ignore_duplicate=True) + assert '/user/{}/proxy/{}'.format(getpass.getuser(), dtale_app.ACTIVE_PORT) == instance._url + mock_run.assert_called_once() + + pdt.assert_frame_equal(instance.data, test_data) + tmp = test_data.copy() + tmp['biz'] = 2.5 + instance.data = tmp + unittest.assertEqual( + global_state.DTYPES[instance._data_id], + views.build_dtypes_state(tmp), + 'should update app data/dtypes' + ) + + instance2 = get_instance(instance._data_id) + assert instance2._url == instance._url + instances() + + assert get_instance(20) is None # should return None for invalid data ids + + instance.kill() + mock_requests.assert_called_once() + assert mock_requests.call_args[0][0] == '/user/{}/proxy/{}/shutdown'.format( + getpass.getuser(), dtale_app.ACTIVE_PORT + ) + assert global_state.METADATA['1']['name'] == 'foo' + + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.app.JUPYTER_SERVER_PROXY', True)) + mock_run = stack.enter_context(mock.patch('dtale.app.DtaleFlask.run', mock.Mock())) + stack.enter_context(mock.patch('dtale.app.is_up', mock.Mock(return_value=False))) + mock_requests = stack.enter_context(mock.patch('requests.get', mock.Mock())) + instance = show(data=test_data, subprocess=False, ignore_duplicate=True, app_root='/custom_root/') + assert '/custom_root/{}'.format(dtale_app.ACTIVE_PORT) == instance._url + mock_run.assert_called_once() + + instance2 = get_instance(instance._data_id) + # this is a known bug where get_instance will not work if you've specified an `app_root' in show() + assert not instance2._url == instance._url + instances() + instance.kill() + mock_requests.assert_called_once() + assert mock_requests.call_args[0][0] == '/custom_root/{}/shutdown'.format(dtale_app.ACTIVE_PORT) + + @pytest.mark.unit def test_DtaleFlask(): from dtale.app import DtaleFlask, REAPER_TIMEOUT @@ -375,3 +435,36 @@ def test_DtaleFlask(): mock_run.assert_called_once() assert not tmp.reaper_on mock_timer.assert_not_called() + + with ExitStack() as stack: + stack.enter_context(mock.patch('socket.gethostname', mock.Mock(return_value='test'))) + tmp = DtaleFlask('dtale', static_url_path='', url='http://test:9999', app_root='/test_route/') + assert tmp.url_for('static', 'test_path') == '/test_route/test_path' + assert tmp.url_for('static', 'test_path', filename='test_file') == '/test_route/test_file' + + +def test_build_startup_url_and_app_root(): + from dtale.app import build_startup_url_and_app_root + + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.app.JUPYTER_SERVER_PROXY', True)) + stack.enter_context(mock.patch('dtale.app.ACTIVE_PORT', 40000)) + stack.enter_context(mock.patch('dtale.app.ACTIVE_HOST', 'localhost')) + url, app_root = build_startup_url_and_app_root() + + assert url == '/user/{}/proxy/40000'.format(getpass.getuser()) + assert app_root == '/user/{}/proxy/40000'.format(getpass.getuser()) + url, app_root = build_startup_url_and_app_root('/test_route/') + assert url == '/test_route/40000' + assert app_root == '/test_route/40000' + + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.app.JUPYTER_SERVER_PROXY', False)) + stack.enter_context(mock.patch('dtale.app.ACTIVE_PORT', 40000)) + stack.enter_context(mock.patch('dtale.app.ACTIVE_HOST', 'localhost')) + url, app_root = build_startup_url_and_app_root() + assert url == 'http://localhost:40000' + assert app_root is None + url, app_root = build_startup_url_and_app_root('/test_route/') + assert url == 'http:/localhost:40000/test_route/' + assert app_root == '/test_route/' diff --git a/tests/dtale/test_dash.py b/tests/dtale/test_dash.py index 9f4cffc6..90b56bf1 100644 --- a/tests/dtale/test_dash.py +++ b/tests/dtale/test_dash.py @@ -193,7 +193,7 @@ def test_map_data(unittest): unittest.assertEqual(resp_data['map-loc-mode-input']['style'], {}) unittest.assertEqual(resp_data['map-lat-input']['style'], {'display': 'none'}) img_src = resp_data['proj-hover']['children'][1]['props']['children'][1]['props']['src'] - assert img_src == '/images/projections/hammer.png' + assert img_src == '../images/projections/hammer.png' @pytest.mark.unit diff --git a/tests/dtale/test_views.py b/tests/dtale/test_views.py index 58f5f05a..816395e3 100644 --- a/tests/dtale/test_views.py +++ b/tests/dtale/test_views.py @@ -1771,8 +1771,16 @@ def test_200(): with ExitStack() as stack: stack.enter_context(mock.patch('dtale.global_state.DATA', {c.port: None})) for path in paths: - response = c.get(path.format(port=c.port)) - assert response.status_code == 200, '{} should return 200 response'.format(path) + final_path = path.format(port=c.port) + response = c.get(final_path) + assert response.status_code == 200, '{} should return 200 response'.format(final_path) + with app.test_client(app_root='/test_route') as c: + with ExitStack() as stack: + stack.enter_context(mock.patch('dtale.global_state.DATA', {c.port: None})) + for path in paths: + final_path = path.format(port=c.port) + response = c.get(final_path) + assert response.status_code == 200, '{} should return 200 response'.format(final_path) @pytest.mark.unit diff --git a/webpack.config.js b/webpack.config.js index e86f28a0..202b1bf5 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -22,7 +22,7 @@ function createConfig(entry) { output: { path: path.resolve(__dirname, "./dtale/static/dist"), filename: entryName + "_bundle.js", - publicPath: "/dist/", + //publicPath: "/dist/", }, resolve: { extensions: [".js", ".jsx", ".css", ".scss"], @@ -147,7 +147,7 @@ function createDashConfig(entry) { output: { path: path.resolve(__dirname, "./dtale/static/dash"), filename: entryName + "_bundle.js", - publicPath: "/dash/", + //publicPath: "/dash/", library: entryName, libraryTarget: "window", },