From 8a67fbedee3c3bd38a227b75016c582598c99f48 Mon Sep 17 00:00:00 2001 From: byron Date: Wed, 17 Jul 2019 18:00:12 -0400 Subject: [PATCH 1/9] :construction: demo purpose for ryan --- dash/testing/application_runners.py | 54 +++++++++++++++++++++++++++++ dash/testing/plugin.py | 20 +++++++---- tests/integration/test_r.py | 8 +++++ 3 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 tests/integration/test_r.py diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 5dc2d3fa8f..16b3626ecb 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -214,3 +214,57 @@ def stop(self): ) self.proc.kill() self.proc.communicate() + + +class RRunner(BaseDashRunner): + """Runs a dashR application in process + """ + + def __init__(self, keep_open=False, stop_timeout=3): + super(RRunner, self).__init__( + keep_open=keep_open, stop_timeout=stop_timeout + ) + self.proc = None + + # pylint: disable=arguments-differ + def start(self): + """Start the server with waitress-serve in process flavor """ + # entrypoint = "{}:{}.server".format(app_module, application_name) + # self.port = port + + args = shlex.split( + "Rscript /Users/byron/code/demo.R", + posix=sys.platform != "win32", + ) + logger.debug("start dash process with %s", args) + + try: + self.proc = subprocess.Popen( + args, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + except (OSError, ValueError): + logger.exception("process server has encountered an error") + self.started = False + return + + self.started = True + + def stop(self): + if self.proc: + try: + self.proc.terminate() + if six.PY3: + # pylint:disable=no-member + _except = subprocess.TimeoutExpired + # pylint: disable=unexpected-keyword-arg + self.proc.communicate(timeout=self.stop_timeout) + else: + _except = OSError + self.proc.communicate() + except _except: + logger.exception( + "subprocess terminate not success, trying to kill " + "the subprocess in a safe manner" + ) + self.proc.kill() + self.proc.communicate() diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index 1fba6b8da1..e257c2270a 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -4,7 +4,11 @@ try: import pytest - from dash.testing.application_runners import ThreadedRunner, ProcessRunner + from dash.testing.application_runners import ( + ThreadedRunner, + ProcessRunner, + RRunner, + ) from dash.testing.browser import Browser from dash.testing.composite import DashComposite except ImportError: @@ -26,9 +30,7 @@ def pytest_addoption(parser): ) dash.addoption( - "--headless", - action="store_true", - help="Run tests in headless mode", + "--headless", action="store_true", help="Run tests in headless mode" ) @@ -79,13 +81,19 @@ def dash_process_server(): yield starter +@pytest.fixture +def dashr_server(): + with RRunner() as starter: + yield starter + + @pytest.fixture def dash_br(request, tmpdir): with Browser( browser=request.config.getoption("webdriver"), headless=request.config.getoption("headless"), options=request.config.hook.pytest_setup_options(), - download_path=tmpdir.mkdir('download').strpath + download_path=tmpdir.mkdir("download").strpath, ) as browser: yield browser @@ -97,6 +105,6 @@ def dash_duo(request, dash_thread_server, tmpdir): browser=request.config.getoption("webdriver"), headless=request.config.getoption("headless"), options=request.config.hook.pytest_setup_options(), - download_path=tmpdir.mkdir('download').strpath + download_path=tmpdir.mkdir("download").strpath, ) as dc: yield dc diff --git a/tests/integration/test_r.py b/tests/integration/test_r.py new file mode 100644 index 0000000000..efb8028e5d --- /dev/null +++ b/tests/integration/test_r.py @@ -0,0 +1,8 @@ + + +def test_r_sample(dashr_server, dash_br): + dashr_server() + import time + time.sleep(2.5) + dash_br.server_url = dashr_server.url + time.sleep(600) From 85af20f0a6fb6c929358e5251c6ae233cca67ec7 Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 22:40:34 -0400 Subject: [PATCH 2/9] :sparkles: add fixtures for dashr app testing --- dash/testing/application_runners.py | 83 +++++++++++++++-------------- dash/testing/composite.py | 14 +++++ dash/testing/plugin.py | 14 ++++- 3 files changed, 71 insertions(+), 40 deletions(-) diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 16b3626ecb..7f2f9ecca2 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -1,6 +1,7 @@ from __future__ import print_function import sys +import os import uuid import shlex import threading @@ -68,6 +69,14 @@ def start(self, *args, **kwargs): def stop(self): raise NotImplementedError # pragma: no cover + @staticmethod + def accessible(url): + try: + requests.get(url) + except requests.exceptions.RequestException: + return False + return True + def __call__(self, *args, **kwargs): return self.start(*args, **kwargs) @@ -91,6 +100,10 @@ def url(self): """the default server url""" return "http://localhost:{}".format(self.port) + @property + def is_windows(self): + return sys.platform == "win32" + class ThreadedRunner(BaseDashRunner): """Runs a dash application in a thread @@ -133,7 +146,8 @@ def run(): kwargs["port"] = self.port else: self.port = kwargs["port"] - app.run_server(threaded=True, **kwargs) + + app.run_server(threaded=True, **kwargs) self.thread = threading.Thread(target=run) self.thread.daemon = True @@ -145,15 +159,8 @@ def run(): self.started = self.thread.is_alive() - def accessible(): - try: - requests.get(self.url) - except requests.exceptions.RequestException: - return False - return True - # wait until server is able to answer http request - wait.until(accessible, timeout=1) + wait.until(lambda: self.accessible(self.url), timeout=1) def stop(self): requests.get("{}{}".format(self.url, self.stop_route)) @@ -180,7 +187,7 @@ def start(self, app_module, application_name="app", port=8050): args = shlex.split( "waitress-serve --listen=0.0.0.0:{} {}".format(port, entrypoint), - posix=sys.platform != "win32", + posix=not self.is_windows, ) logger.debug("start dash process with %s", args) @@ -216,10 +223,7 @@ def stop(self): self.proc.communicate() -class RRunner(BaseDashRunner): - """Runs a dashR application in process - """ - +class RRunner(ProcessRunner): def __init__(self, keep_open=False, stop_timeout=3): super(RRunner, self).__init__( keep_open=keep_open, stop_timeout=stop_timeout @@ -227,14 +231,32 @@ def __init__(self, keep_open=False, stop_timeout=3): self.proc = None # pylint: disable=arguments-differ - def start(self): + def start(self, app): """Start the server with waitress-serve in process flavor """ - # entrypoint = "{}:{}.server".format(app_module, application_name) - # self.port = port + # app is a R string chunk + if not (os.path.isfile(app) and os.path.exists(app)): + path = ( + "/tmp/app_{}.R".format(uuid.uuid4().hex) + if not self.is_windows + else os.path.join( + (os.getenv("TEMP"), "app_{}.R".format(uuid.uuid4().hex)) + ) + ) + logger.info("RRuner start => app is R code chunk") + logger.info("make a temporay R file for execution=> %s", path) + logger.debug("the content of dashR app") + logger.debug("%s", app) + + with open(path, "w") as fp: + fp.write(app) + + app = path + + logger.info("Run dashR app with Rscript => %s", app) args = shlex.split( - "Rscript /Users/byron/code/demo.R", - posix=sys.platform != "win32", + "Rscript {}".format(os.path.realpath(app)), + posix=not self.is_windows, ) logger.debug("start dash process with %s", args) @@ -242,29 +264,12 @@ def start(self): self.proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + # wait until server is able to answer http request + wait.until(lambda: self.accessible(self.url), timeout=2) + except (OSError, ValueError): logger.exception("process server has encountered an error") self.started = False return self.started = True - - def stop(self): - if self.proc: - try: - self.proc.terminate() - if six.PY3: - # pylint:disable=no-member - _except = subprocess.TimeoutExpired - # pylint: disable=unexpected-keyword-arg - self.proc.communicate(timeout=self.stop_timeout) - else: - _except = OSError - self.proc.communicate() - except _except: - logger.exception( - "subprocess terminate not success, trying to kill " - "the subprocess in a safe manner" - ) - self.proc.kill() - self.proc.communicate() diff --git a/dash/testing/composite.py b/dash/testing/composite.py index 626be6a8b5..d88b41e097 100644 --- a/dash/testing/composite.py +++ b/dash/testing/composite.py @@ -14,3 +14,17 @@ def start_server(self, app, **kwargs): # set the default server_url, it implicitly call wait_for_page self.server_url = self.server.url + + +class DashRComposite(Browser): + def __init__(self, server, **kwargs): + super(DashRComposite, self).__init__(**kwargs) + self.server = server + + def start_server(self, app): + + # start server with dashR app, the dash arguments are hardcoded + self.server(app) + + # set the default server_url, it implicitly call wait_for_page + self.server_url = self.server.url diff --git a/dash/testing/plugin.py b/dash/testing/plugin.py index e257c2270a..a42a644f3f 100644 --- a/dash/testing/plugin.py +++ b/dash/testing/plugin.py @@ -10,7 +10,7 @@ RRunner, ) from dash.testing.browser import Browser - from dash.testing.composite import DashComposite + from dash.testing.composite import DashComposite, DashRComposite except ImportError: warnings.warn("run `pip install dash[testing]` if you need dash.testing") @@ -108,3 +108,15 @@ def dash_duo(request, dash_thread_server, tmpdir): download_path=tmpdir.mkdir("download").strpath, ) as dc: yield dc + + +@pytest.fixture +def dashr(request, dashr_server, tmpdir): + with DashRComposite( + dashr_server, + browser=request.config.getoption("webdriver"), + headless=request.config.getoption("headless"), + options=request.config.hook.pytest_setup_options(), + download_path=tmpdir.mkdir("download").strpath, + ) as dc: + yield dc From 0b1309e58854639ee181e2fb80929e6aca75002f Mon Sep 17 00:00:00 2001 From: Byron Zhu Date: Thu, 18 Jul 2019 22:43:08 -0400 Subject: [PATCH 3/9] Delete test_r.py --- tests/integration/test_r.py | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 tests/integration/test_r.py diff --git a/tests/integration/test_r.py b/tests/integration/test_r.py deleted file mode 100644 index efb8028e5d..0000000000 --- a/tests/integration/test_r.py +++ /dev/null @@ -1,8 +0,0 @@ - - -def test_r_sample(dashr_server, dash_br): - dashr_server() - import time - time.sleep(2.5) - dash_br.server_url = dashr_server.url - time.sleep(600) From 7b17a514b53ec67998d3cee25f3ba25a19645d48 Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 23:15:37 -0400 Subject: [PATCH 4/9] :bug: fix one typo indent, add wait.until for process server --- dash/testing/application_runners.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dash/testing/application_runners.py b/dash/testing/application_runners.py index 7f2f9ecca2..7456bff34f 100644 --- a/dash/testing/application_runners.py +++ b/dash/testing/application_runners.py @@ -146,8 +146,7 @@ def run(): kwargs["port"] = self.port else: self.port = kwargs["port"] - - app.run_server(threaded=True, **kwargs) + app.run_server(threaded=True, **kwargs) self.thread = threading.Thread(target=run) self.thread.daemon = True @@ -195,6 +194,9 @@ def start(self, app_module, application_name="app", port=8050): self.proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE ) + # wait until server is able to answer http request + wait.until(lambda: self.accessible(self.url), timeout=3) + except (OSError, ValueError): logger.exception("process server has encountered an error") self.started = False From b3e1471103bb3d049b9fbaec99984fa51d997c2c Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 23:15:52 -0400 Subject: [PATCH 5/9] :white_mark_check: add smoke test for r server --- tests/unit/test_app_runners.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 4bd4ed43d1..8b640f2fca 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -27,7 +27,23 @@ def test_threaded_server_smoke(dash_thread_server): ) def test_process_server_smoke(dash_process_server): dash_process_server("simple_app") - time.sleep(2.5) r = requests.get(dash_process_server.url) assert r.status_code == 200, "the server is reachable" assert 'id="react-entry-point"' in r.text, "the entrypoint is present" + + +rapp = """ +library(dash) +library(dashHtmlComponents) +app <- Dash$new() + +app$layout(htmlDiv(list(htmlDiv(id='test',children='hello test')))) +app$run_server() +""" + + +def test_r_server_smoke(dashr_server): + dashr_server(rapp) + r = requests.get(dashr_server.url) + assert r.status_code == 200, "the server is reachable" + assert 'id="react-entry-point"' in r.text, "the entrypoint is present" From 57a7ac4c83f9914814063d2e617ad299d888bbd1 Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 23:22:06 -0400 Subject: [PATCH 6/9] :alembic: check the unit error --- .circleci/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index e9f625b012..555d48affa 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,7 +55,7 @@ jobs: command: | . venv/bin/activate mkdir test-reports - PYTHONPATH=~/dash/tests/assets pytest --junitxml=test-reports/junit.xml tests/unit + PYTHONPATH=~/dash/tests/assets pytest --log-cli-level DEBUG -vv --junitxml=test-reports/junit.xml tests/unit - store_test_results: path: test-reports - store_artifacts: From fe26e4f0a250b81ef23f09250327976e5fbc889b Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 23:25:35 -0400 Subject: [PATCH 7/9] :minus: the r smoke should be put in dashR tests/unit --- .circleci/config.yml | 2 +- tests/unit/test_app_runners.py | 17 ----------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 555d48affa..e9f625b012 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -55,7 +55,7 @@ jobs: command: | . venv/bin/activate mkdir test-reports - PYTHONPATH=~/dash/tests/assets pytest --log-cli-level DEBUG -vv --junitxml=test-reports/junit.xml tests/unit + PYTHONPATH=~/dash/tests/assets pytest --junitxml=test-reports/junit.xml tests/unit - store_test_results: path: test-reports - store_artifacts: diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index 8b640f2fca..ad56b1435b 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -30,20 +30,3 @@ def test_process_server_smoke(dash_process_server): r = requests.get(dash_process_server.url) assert r.status_code == 200, "the server is reachable" assert 'id="react-entry-point"' in r.text, "the entrypoint is present" - - -rapp = """ -library(dash) -library(dashHtmlComponents) -app <- Dash$new() - -app$layout(htmlDiv(list(htmlDiv(id='test',children='hello test')))) -app$run_server() -""" - - -def test_r_server_smoke(dashr_server): - dashr_server(rapp) - r = requests.get(dashr_server.url) - assert r.status_code == 200, "the server is reachable" - assert 'id="react-entry-point"' in r.text, "the entrypoint is present" From 1c6381af4da111bec878ec96baa8a85df774e547 Mon Sep 17 00:00:00 2001 From: byron Date: Thu, 18 Jul 2019 23:26:28 -0400 Subject: [PATCH 8/9] remove time as wait is now done within the fixture --- tests/unit/test_app_runners.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/test_app_runners.py b/tests/unit/test_app_runners.py index ad56b1435b..22bca7407a 100644 --- a/tests/unit/test_app_runners.py +++ b/tests/unit/test_app_runners.py @@ -1,4 +1,3 @@ -import time import sys import requests import pytest From 339141a1196c4a8af96fd7147b23e07c39866003 Mon Sep 17 00:00:00 2001 From: byron Date: Mon, 22 Jul 2019 16:29:03 -0400 Subject: [PATCH 9/9] :pencil2: add changelog entry --- dash/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dash/CHANGELOG.md b/dash/CHANGELOG.md index cc5be65cdf..69669d4489 100644 --- a/dash/CHANGELOG.md +++ b/dash/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### Added + +- [#827](https://github.com/plotly/dash/pull/827) Adds support for dashR testing using pytest framework + ## [1.0.2] - 2019-07-15 ### Fixed - [#821](https://github.com/plotly/dash/pull/821) Fix a bug with callback error reporting, [#791](https://github.com/plotly/dash/issues/791).