wg-backend-django/dell-env/lib/python3.11/site-packages/dash/testing/application_runners.py
2023-10-30 14:40:43 +07:00

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