529 lines
18 KiB
Python
529 lines
18 KiB
Python
|
import sys
|
||
|
import os
|
||
|
import time
|
||
|
import uuid
|
||
|
import shlex
|
||
|
import threading
|
||
|
import shutil
|
||
|
import subprocess
|
||
|
import logging
|
||
|
import inspect
|
||
|
import ctypes
|
||
|
|
||
|
import runpy
|
||
|
import requests
|
||
|
import psutil
|
||
|
|
||
|
# pylint: disable=no-member
|
||
|
import multiprocess
|
||
|
|
||
|
from dash.testing.errors import (
|
||
|
NoAppFoundError,
|
||
|
TestingTimeoutError,
|
||
|
ServerCloseError,
|
||
|
DashAppLoadingError,
|
||
|
)
|
||
|
from dash.testing import wait
|
||
|
|
||
|
logger = logging.getLogger(__name__)
|
||
|
|
||
|
|
||
|
def import_app(app_file, application_name="app"):
|
||
|
"""Import a dash application from a module. The import path is in dot
|
||
|
notation to the module. The variable named app will be returned.
|
||
|
|
||
|
:Example:
|
||
|
|
||
|
>>> app = import_app("my_app.app")
|
||
|
|
||
|
Will import the application in module `app` of the package `my_app`.
|
||
|
|
||
|
:param app_file: Path to the app (dot-separated).
|
||
|
:type app_file: str
|
||
|
:param application_name: The name of the dash application instance.
|
||
|
:raise: dash_tests.errors.NoAppFoundError
|
||
|
:return: App from module.
|
||
|
:rtype: dash.Dash
|
||
|
"""
|
||
|
try:
|
||
|
app_module = runpy.run_module(app_file)
|
||
|
app = app_module[application_name]
|
||
|
except KeyError as app_name_missing:
|
||
|
logger.exception("the app name cannot be found")
|
||
|
raise NoAppFoundError(
|
||
|
f"No dash `app` instance was found in {app_file}"
|
||
|
) from app_name_missing
|
||
|
return app
|
||
|
|
||
|
|
||
|
class BaseDashRunner:
|
||
|
"""Base context manager class for running applications."""
|
||
|
|
||
|
_next_port = 58050
|
||
|
|
||
|
def __init__(self, keep_open, stop_timeout):
|
||
|
self.port = 8050
|
||
|
self.started = None
|
||
|
self.keep_open = keep_open
|
||
|
self.stop_timeout = stop_timeout
|
||
|
self._tmp_app_path = None
|
||
|
|
||
|
def start(self, *args, **kwargs):
|
||
|
raise NotImplementedError # pragma: no cover
|
||
|
|
||
|
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)
|
||
|
|
||
|
def __enter__(self):
|
||
|
return self
|
||
|
|
||
|
def __exit__(self, exc_type, exc_val, traceback):
|
||
|
if self.started and not self.keep_open:
|
||
|
try:
|
||
|
logger.info("killing the app runner")
|
||
|
self.stop()
|
||
|
except TestingTimeoutError as cannot_stop_server:
|
||
|
raise ServerCloseError(
|
||
|
f"Cannot stop server within {self.stop_timeout}s timeout"
|
||
|
) from cannot_stop_server
|
||
|
logger.info("__exit__ complete")
|
||
|
|
||
|
@property
|
||
|
def url(self):
|
||
|
"""The default server url."""
|
||
|
return f"http://localhost:{self.port}"
|
||
|
|
||
|
@property
|
||
|
def is_windows(self):
|
||
|
return sys.platform == "win32"
|
||
|
|
||
|
@property
|
||
|
def tmp_app_path(self):
|
||
|
return self._tmp_app_path
|
||
|
|
||
|
|
||
|
class KillerThread(threading.Thread):
|
||
|
def __init__(self, **kwargs):
|
||
|
super().__init__(**kwargs)
|
||
|
self._old_threads = list(threading._active.keys()) # pylint: disable=W0212
|
||
|
|
||
|
def kill(self):
|
||
|
# Kill all the new threads.
|
||
|
for thread_id in list(threading._active): # pylint: disable=W0212
|
||
|
if thread_id in self._old_threads:
|
||
|
continue
|
||
|
|
||
|
res = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||
|
ctypes.c_long(thread_id), ctypes.py_object(SystemExit)
|
||
|
)
|
||
|
if res == 0:
|
||
|
raise ValueError(f"Invalid thread id: {thread_id}")
|
||
|
if res > 1:
|
||
|
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
||
|
ctypes.c_long(thread_id), None
|
||
|
)
|
||
|
raise SystemExit("Stopping thread failure")
|
||
|
|
||
|
|
||
|
class ThreadedRunner(BaseDashRunner):
|
||
|
"""Runs a dash application in a thread.
|
||
|
|
||
|
This is the default flavor to use in dash integration tests.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, keep_open=False, stop_timeout=3):
|
||
|
super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
|
||
|
self.thread = None
|
||
|
|
||
|
def running_and_accessible(self, url):
|
||
|
if self.thread.is_alive():
|
||
|
return self.accessible(url)
|
||
|
raise DashAppLoadingError("Thread is not alive.")
|
||
|
|
||
|
# pylint: disable=arguments-differ
|
||
|
def start(self, app, start_timeout=3, **kwargs):
|
||
|
"""Start the app server in threading flavor."""
|
||
|
|
||
|
def run():
|
||
|
app.scripts.config.serve_locally = True
|
||
|
app.css.config.serve_locally = True
|
||
|
|
||
|
options = kwargs.copy()
|
||
|
|
||
|
if "port" not in kwargs:
|
||
|
options["port"] = self.port = BaseDashRunner._next_port
|
||
|
BaseDashRunner._next_port += 1
|
||
|
else:
|
||
|
self.port = options["port"]
|
||
|
|
||
|
try:
|
||
|
app.run(threaded=True, **options)
|
||
|
except SystemExit:
|
||
|
logger.info("Server stopped")
|
||
|
except Exception as error:
|
||
|
logger.exception(error)
|
||
|
raise error
|
||
|
|
||
|
retries = 0
|
||
|
|
||
|
while not self.started and retries < 3:
|
||
|
try:
|
||
|
if self.thread:
|
||
|
if self.thread.is_alive():
|
||
|
self.stop()
|
||
|
else:
|
||
|
self.thread.kill()
|
||
|
|
||
|
self.thread = KillerThread(target=run)
|
||
|
self.thread.daemon = True
|
||
|
self.thread.start()
|
||
|
# wait until server is able to answer http request
|
||
|
wait.until(
|
||
|
lambda: self.running_and_accessible(self.url), timeout=start_timeout
|
||
|
)
|
||
|
self.started = self.thread.is_alive()
|
||
|
except Exception as err: # pylint: disable=broad-except
|
||
|
logger.exception(err)
|
||
|
self.started = False
|
||
|
retries += 1
|
||
|
time.sleep(1)
|
||
|
|
||
|
self.started = self.thread.is_alive()
|
||
|
if not self.started:
|
||
|
raise DashAppLoadingError("threaded server failed to start")
|
||
|
|
||
|
def stop(self):
|
||
|
self.thread.kill()
|
||
|
self.thread.join()
|
||
|
wait.until_not(self.thread.is_alive, self.stop_timeout)
|
||
|
self.started = False
|
||
|
|
||
|
|
||
|
class MultiProcessRunner(BaseDashRunner):
|
||
|
def __init__(self, keep_open=False, stop_timeout=3):
|
||
|
super().__init__(keep_open, stop_timeout)
|
||
|
self.proc = None
|
||
|
|
||
|
# pylint: disable=arguments-differ
|
||
|
def start(self, app, start_timeout=3, **kwargs):
|
||
|
self.port = kwargs.get("port", 8050)
|
||
|
|
||
|
def target():
|
||
|
app.scripts.config.serve_locally = True
|
||
|
app.css.config.serve_locally = True
|
||
|
|
||
|
options = kwargs.copy()
|
||
|
|
||
|
try:
|
||
|
app.run(threaded=True, **options)
|
||
|
except SystemExit:
|
||
|
logger.info("Server stopped")
|
||
|
raise
|
||
|
except Exception as error:
|
||
|
logger.exception(error)
|
||
|
raise error
|
||
|
|
||
|
self.proc = multiprocess.Process(target=target) # pylint: disable=not-callable
|
||
|
self.proc.start()
|
||
|
|
||
|
wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
|
||
|
self.started = True
|
||
|
|
||
|
def stop(self):
|
||
|
process = psutil.Process(self.proc.pid)
|
||
|
|
||
|
for proc in process.children(recursive=True):
|
||
|
try:
|
||
|
proc.kill()
|
||
|
except psutil.NoSuchProcess:
|
||
|
pass
|
||
|
|
||
|
try:
|
||
|
process.kill()
|
||
|
except psutil.NoSuchProcess:
|
||
|
pass
|
||
|
|
||
|
try:
|
||
|
process.wait(1)
|
||
|
except (psutil.TimeoutExpired, psutil.NoSuchProcess):
|
||
|
pass
|
||
|
|
||
|
|
||
|
class ProcessRunner(BaseDashRunner):
|
||
|
"""Runs a dash application in a waitress-serve subprocess.
|
||
|
|
||
|
This flavor is closer to production environment but slower.
|
||
|
"""
|
||
|
|
||
|
def __init__(self, keep_open=False, stop_timeout=3):
|
||
|
super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
|
||
|
self.proc = None
|
||
|
|
||
|
# pylint: disable=arguments-differ
|
||
|
def start(
|
||
|
self,
|
||
|
app_module=None,
|
||
|
application_name="app",
|
||
|
raw_command=None,
|
||
|
port=8050,
|
||
|
start_timeout=3,
|
||
|
):
|
||
|
"""Start the server with waitress-serve in process flavor."""
|
||
|
if not (app_module or raw_command): # need to set a least one
|
||
|
logging.error(
|
||
|
"the process runner needs to start with at least one valid command"
|
||
|
)
|
||
|
return
|
||
|
self.port = port
|
||
|
args = shlex.split(
|
||
|
raw_command
|
||
|
if raw_command
|
||
|
else f"waitress-serve --listen=0.0.0.0:{port} {app_module}:{application_name}.server",
|
||
|
posix=not self.is_windows,
|
||
|
)
|
||
|
|
||
|
logger.debug("start dash process with %s", args)
|
||
|
|
||
|
try:
|
||
|
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
|
||
|
args, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||
|
)
|
||
|
# wait until server is able to answer http request
|
||
|
wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
|
||
|
|
||
|
except (OSError, ValueError):
|
||
|
logger.exception("process server has encountered an error")
|
||
|
self.started = False
|
||
|
self.stop()
|
||
|
return
|
||
|
|
||
|
self.started = True
|
||
|
|
||
|
def stop(self):
|
||
|
if self.proc:
|
||
|
try:
|
||
|
logger.info("proc.terminate with pid %s", self.proc.pid)
|
||
|
self.proc.terminate()
|
||
|
if self.tmp_app_path and os.path.exists(self.tmp_app_path):
|
||
|
logger.debug("removing temporary app path %s", self.tmp_app_path)
|
||
|
shutil.rmtree(self.tmp_app_path)
|
||
|
|
||
|
_except = subprocess.TimeoutExpired # pylint:disable=no-member
|
||
|
self.proc.communicate(
|
||
|
timeout=self.stop_timeout # pylint: disable=unexpected-keyword-arg
|
||
|
)
|
||
|
except _except:
|
||
|
logger.exception(
|
||
|
"subprocess terminate not success, trying to kill "
|
||
|
"the subprocess in a safe manner"
|
||
|
)
|
||
|
self.proc.kill()
|
||
|
self.proc.communicate()
|
||
|
logger.info("process stop completes!")
|
||
|
|
||
|
|
||
|
class RRunner(ProcessRunner):
|
||
|
def __init__(self, keep_open=False, stop_timeout=3):
|
||
|
super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
|
||
|
self.proc = None
|
||
|
|
||
|
# pylint: disable=arguments-differ
|
||
|
def start(self, app, start_timeout=2, cwd=None):
|
||
|
"""Start the server with subprocess and Rscript."""
|
||
|
|
||
|
if os.path.isfile(app) and os.path.exists(app):
|
||
|
# app is already a file in a dir - use that as cwd
|
||
|
if not cwd:
|
||
|
cwd = os.path.dirname(app)
|
||
|
logger.info("RRunner inferred cwd from app path: %s", cwd)
|
||
|
else:
|
||
|
# app is a string chunk, we make a temporary folder to store app.R
|
||
|
# and its relevant assets
|
||
|
self._tmp_app_path = os.path.join(
|
||
|
"/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex
|
||
|
)
|
||
|
try:
|
||
|
os.mkdir(self.tmp_app_path)
|
||
|
except OSError:
|
||
|
logger.exception("cannot make temporary folder %s", self.tmp_app_path)
|
||
|
path = os.path.join(self.tmp_app_path, "app.R")
|
||
|
|
||
|
logger.info("RRunner start => app is R code chunk")
|
||
|
logger.info("make a temporary R file for execution => %s", path)
|
||
|
logger.debug("content of the dashR app")
|
||
|
logger.debug("%s", app)
|
||
|
|
||
|
with open(path, "w", encoding="utf-8") as fp:
|
||
|
fp.write(app)
|
||
|
|
||
|
app = path
|
||
|
|
||
|
# try to find the path to the calling script to use as cwd
|
||
|
if not cwd:
|
||
|
for entry in inspect.stack():
|
||
|
if "/dash/testing/" not in entry[1].replace("\\", "/"):
|
||
|
cwd = os.path.dirname(os.path.realpath(entry[1]))
|
||
|
logger.warning("get cwd from inspect => %s", cwd)
|
||
|
break
|
||
|
if cwd:
|
||
|
logger.info("RRunner inferred cwd from the Python call stack: %s", cwd)
|
||
|
|
||
|
# try copying all valid sub folders (i.e. assets) in cwd to tmp
|
||
|
# note that the R assets folder name can be any valid folder name
|
||
|
assets = [
|
||
|
os.path.join(cwd, _)
|
||
|
for _ in os.listdir(cwd)
|
||
|
if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
|
||
|
]
|
||
|
|
||
|
for asset in assets:
|
||
|
target = os.path.join(self.tmp_app_path, os.path.basename(asset))
|
||
|
if os.path.exists(target):
|
||
|
logger.debug("delete existing target %s", target)
|
||
|
shutil.rmtree(target)
|
||
|
logger.debug("copying %s => %s", asset, self.tmp_app_path)
|
||
|
shutil.copytree(asset, target)
|
||
|
logger.debug("copied with %s", os.listdir(target))
|
||
|
|
||
|
else:
|
||
|
logger.warning(
|
||
|
"RRunner found no cwd in the Python call stack. "
|
||
|
"You may wish to specify an explicit working directory "
|
||
|
"using something like: "
|
||
|
"dashr.run_server(app, cwd=os.path.dirname(__file__))"
|
||
|
)
|
||
|
|
||
|
logger.info("Run dashR app with Rscript => %s", app)
|
||
|
|
||
|
args = shlex.split(
|
||
|
f"Rscript -e 'source(\"{os.path.realpath(app)}\")'",
|
||
|
posix=not self.is_windows,
|
||
|
)
|
||
|
logger.debug("start dash process with %s", args)
|
||
|
|
||
|
try:
|
||
|
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
|
||
|
args,
|
||
|
stdout=subprocess.PIPE,
|
||
|
stderr=subprocess.PIPE,
|
||
|
cwd=self.tmp_app_path if self.tmp_app_path else cwd,
|
||
|
)
|
||
|
# wait until server is able to answer http request
|
||
|
wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
|
||
|
|
||
|
except (OSError, ValueError):
|
||
|
logger.exception("process server has encountered an error")
|
||
|
self.started = False
|
||
|
return
|
||
|
|
||
|
self.started = True
|
||
|
|
||
|
|
||
|
class JuliaRunner(ProcessRunner):
|
||
|
def __init__(self, keep_open=False, stop_timeout=3):
|
||
|
super().__init__(keep_open=keep_open, stop_timeout=stop_timeout)
|
||
|
self.proc = None
|
||
|
|
||
|
# pylint: disable=arguments-differ
|
||
|
def start(self, app, start_timeout=30, cwd=None):
|
||
|
"""Start the server with subprocess and julia."""
|
||
|
|
||
|
if os.path.isfile(app) and os.path.exists(app):
|
||
|
# app is already a file in a dir - use that as cwd
|
||
|
if not cwd:
|
||
|
cwd = os.path.dirname(app)
|
||
|
logger.info("JuliaRunner inferred cwd from app path: %s", cwd)
|
||
|
else:
|
||
|
# app is a string chunk, we make a temporary folder to store app.jl
|
||
|
# and its relevant assets
|
||
|
self._tmp_app_path = os.path.join(
|
||
|
"/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex
|
||
|
)
|
||
|
try:
|
||
|
os.mkdir(self.tmp_app_path)
|
||
|
except OSError:
|
||
|
logger.exception("cannot make temporary folder %s", self.tmp_app_path)
|
||
|
path = os.path.join(self.tmp_app_path, "app.jl")
|
||
|
|
||
|
logger.info("JuliaRunner start => app is Julia code chunk")
|
||
|
logger.info("make a temporary Julia file for execution => %s", path)
|
||
|
logger.debug("content of the Dash.jl app")
|
||
|
logger.debug("%s", app)
|
||
|
|
||
|
with open(path, "w", encoding="utf-8") as fp:
|
||
|
fp.write(app)
|
||
|
|
||
|
app = path
|
||
|
|
||
|
# try to find the path to the calling script to use as cwd
|
||
|
if not cwd:
|
||
|
for entry in inspect.stack():
|
||
|
if "/dash/testing/" not in entry[1].replace("\\", "/"):
|
||
|
cwd = os.path.dirname(os.path.realpath(entry[1]))
|
||
|
logger.warning("get cwd from inspect => %s", cwd)
|
||
|
break
|
||
|
if cwd:
|
||
|
logger.info(
|
||
|
"JuliaRunner inferred cwd from the Python call stack: %s", cwd
|
||
|
)
|
||
|
|
||
|
# try copying all valid sub folders (i.e. assets) in cwd to tmp
|
||
|
# note that the R assets folder name can be any valid folder name
|
||
|
assets = [
|
||
|
os.path.join(cwd, _)
|
||
|
for _ in os.listdir(cwd)
|
||
|
if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
|
||
|
]
|
||
|
|
||
|
for asset in assets:
|
||
|
target = os.path.join(self.tmp_app_path, os.path.basename(asset))
|
||
|
if os.path.exists(target):
|
||
|
logger.debug("delete existing target %s", target)
|
||
|
shutil.rmtree(target)
|
||
|
logger.debug("copying %s => %s", asset, self.tmp_app_path)
|
||
|
shutil.copytree(asset, target)
|
||
|
logger.debug("copied with %s", os.listdir(target))
|
||
|
|
||
|
else:
|
||
|
logger.warning(
|
||
|
"JuliaRunner found no cwd in the Python call stack. "
|
||
|
"You may wish to specify an explicit working directory "
|
||
|
"using something like: "
|
||
|
"dashjl.run_server(app, cwd=os.path.dirname(__file__))"
|
||
|
)
|
||
|
|
||
|
logger.info("Run Dash.jl app with julia => %s", app)
|
||
|
|
||
|
args = shlex.split(
|
||
|
f"julia --project {os.path.realpath(app)}", posix=not self.is_windows
|
||
|
)
|
||
|
logger.debug("start Dash.jl process with %s", args)
|
||
|
|
||
|
try:
|
||
|
self.proc = subprocess.Popen( # pylint: disable=consider-using-with
|
||
|
args,
|
||
|
stdout=subprocess.PIPE,
|
||
|
stderr=subprocess.PIPE,
|
||
|
cwd=self.tmp_app_path if self.tmp_app_path else cwd,
|
||
|
)
|
||
|
# wait until server is able to answer http request
|
||
|
wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
|
||
|
|
||
|
except (OSError, ValueError):
|
||
|
logger.exception("process server has encountered an error")
|
||
|
self.started = False
|
||
|
return
|
||
|
|
||
|
self.started = True
|