Skip to content

Commit ebac7d4

Browse files
authored
Add JuliaRunner to support Dash.jl integration tests (#1239)
1 parent 066e571 commit ebac7d4

File tree

4 files changed

+173
-27
lines changed

4 files changed

+173
-27
lines changed

Diff for: dash/development/_r_components_generation.py

+14-7
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,6 @@ def generate_class_string(name, props, project_shortname, prefix):
185185
props = reorder_props(props=props)
186186

187187
prop_keys = list(props.keys())
188-
prop_keys_wc = list(props.keys())
189188

190189
wildcards = ""
191190
wildcard_declaration = ""
@@ -194,8 +193,8 @@ def generate_class_string(name, props, project_shortname, prefix):
194193
default_argtext = ""
195194
accepted_wildcards = ""
196195

197-
if any(key.endswith("-*") for key in prop_keys_wc):
198-
accepted_wildcards = get_wildcards_r(prop_keys_wc)
196+
if any(key.endswith("-*") for key in prop_keys):
197+
accepted_wildcards = get_wildcards_r(prop_keys)
199198
wildcards = ", ..."
200199
wildcard_declaration = wildcard_template.format(
201200
accepted_wildcards.replace("-*", "")
@@ -222,6 +221,9 @@ def generate_class_string(name, props, project_shortname, prefix):
222221

223222
default_argtext += ", ".join("{}=NULL".format(p) for p in prop_keys)
224223

224+
if wildcards == ", ...":
225+
default_argtext += ", ..."
226+
225227
# pylint: disable=C0301
226228
default_paramtext += ", ".join(
227229
"{0}={0}".format(p) if p != "children" else "{}=children".format(p)
@@ -380,15 +382,20 @@ def write_help_file(name, props, description, prefix, rpkg_data):
380382
funcname = format_fn_name(prefix, name)
381383
file_name = funcname + ".Rd"
382384

385+
wildcards = ""
383386
default_argtext = ""
384387
item_text = ""
388+
accepted_wildcards = ""
385389

386390
# the return value of all Dash components should be the same,
387391
# in an abstract sense -- they produce a list
388392
value_text = "named list of JSON elements corresponding to React.js properties and their values" # noqa:E501
389393

390394
prop_keys = list(props.keys())
391-
prop_keys_wc = list(props.keys())
395+
396+
if any(key.endswith("-*") for key in prop_keys):
397+
accepted_wildcards = get_wildcards_r(prop_keys)
398+
wildcards = ", ..."
392399

393400
# Filter props to remove those we don't want to expose
394401
for item in prop_keys[:]:
@@ -413,9 +420,9 @@ def write_help_file(name, props, description, prefix, rpkg_data):
413420
if "**Example Usage**" in description:
414421
description = description.split("**Example Usage**")[0].rstrip()
415422

416-
if any(key.endswith("-*") for key in prop_keys_wc):
417-
default_argtext += ", ..."
418-
item_text += wildcard_help_template.format(get_wildcards_r(prop_keys_wc))
423+
if wildcards == ", ...":
424+
default_argtext += wildcards
425+
item_text += wildcard_help_template.format(accepted_wildcards)
419426

420427
# in R, the online help viewer does not properly wrap lines for
421428
# the usage string -- we will hard wrap at 60 characters using

Diff for: dash/testing/application_runners.py

+119-17
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ def stop(self):
221221
try:
222222
logger.info("proc.terminate with pid %s", self.proc.pid)
223223
self.proc.terminate()
224+
if self.tmp_app_path and os.path.exists(self.tmp_app_path):
225+
logger.debug("removing temporary app path %s",
226+
self.tmp_app_path)
227+
shutil.rmtree(self.tmp_app_path)
224228
if utils.PY3:
225229
# pylint:disable=no-member
226230
_except = subprocess.TimeoutExpired
@@ -285,6 +289,24 @@ def start(self, app, start_timeout=2, cwd=None):
285289
break
286290
if cwd:
287291
logger.info("RRunner inferred cwd from the Python call stack: %s", cwd)
292+
293+
# try copying all valid sub folders (i.e. assets) in cwd to tmp
294+
# note that the R assets folder name can be any valid folder name
295+
assets = [
296+
os.path.join(cwd, _)
297+
for _ in os.listdir(cwd)
298+
if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
299+
]
300+
301+
for asset in assets:
302+
target = os.path.join(self.tmp_app_path, os.path.basename(asset))
303+
if os.path.exists(target):
304+
logger.debug("delete existing target %s", target)
305+
shutil.rmtree(target)
306+
logger.debug("copying %s => %s", asset, self.tmp_app_path)
307+
shutil.copytree(asset, target)
308+
logger.debug("copied with %s", os.listdir(target))
309+
288310
else:
289311
logger.warning(
290312
"RRunner found no cwd in the Python call stack. "
@@ -293,23 +315,6 @@ def start(self, app, start_timeout=2, cwd=None):
293315
"dashr.run_server(app, cwd=os.path.dirname(__file__))"
294316
)
295317

296-
# try copying all valid sub folders (i.e. assets) in cwd to tmp
297-
# note that the R assets folder name can be any valid folder name
298-
assets = [
299-
os.path.join(cwd, _)
300-
for _ in os.listdir(cwd)
301-
if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
302-
]
303-
304-
for asset in assets:
305-
target = os.path.join(self.tmp_app_path, os.path.basename(asset))
306-
if os.path.exists(target):
307-
logger.debug("delete existing target %s", target)
308-
shutil.rmtree(target)
309-
logger.debug("copying %s => %s", asset, self.tmp_app_path)
310-
shutil.copytree(asset, target)
311-
logger.debug("copied with %s", os.listdir(target))
312-
313318
logger.info("Run dashR app with Rscript => %s", app)
314319

315320
args = shlex.split(
@@ -334,3 +339,100 @@ def start(self, app, start_timeout=2, cwd=None):
334339
return
335340

336341
self.started = True
342+
343+
344+
class JuliaRunner(ProcessRunner):
345+
def __init__(self, keep_open=False, stop_timeout=3):
346+
super(JuliaRunner, self).__init__(keep_open=keep_open, stop_timeout=stop_timeout)
347+
self.proc = None
348+
349+
# pylint: disable=arguments-differ
350+
def start(self, app, start_timeout=30, cwd=None):
351+
"""Start the server with subprocess and julia."""
352+
353+
if os.path.isfile(app) and os.path.exists(app):
354+
# app is already a file in a dir - use that as cwd
355+
if not cwd:
356+
cwd = os.path.dirname(app)
357+
logger.info("JuliaRunner inferred cwd from app path: %s", cwd)
358+
else:
359+
# app is a string chunk, we make a temporary folder to store app.jl
360+
# and its relevants assets
361+
self._tmp_app_path = os.path.join(
362+
"/tmp" if not self.is_windows else os.getenv("TEMP"), uuid.uuid4().hex
363+
)
364+
try:
365+
os.mkdir(self.tmp_app_path)
366+
except OSError:
367+
logger.exception("cannot make temporary folder %s", self.tmp_app_path)
368+
path = os.path.join(self.tmp_app_path, "app.jl")
369+
370+
logger.info("JuliaRunner start => app is Julia code chunk")
371+
logger.info("make a temporary Julia file for execution => %s", path)
372+
logger.debug("content of the Dash.jl app")
373+
logger.debug("%s", app)
374+
375+
with open(path, "w") as fp:
376+
fp.write(app)
377+
378+
app = path
379+
380+
# try to find the path to the calling script to use as cwd
381+
if not cwd:
382+
for entry in inspect.stack():
383+
if "/dash/testing/" not in entry[1].replace("\\", "/"):
384+
cwd = os.path.dirname(os.path.realpath(entry[1]))
385+
logger.warning("get cwd from inspect => %s", cwd)
386+
break
387+
if cwd:
388+
logger.info("JuliaRunner inferred cwd from the Python call stack: %s", cwd)
389+
390+
# try copying all valid sub folders (i.e. assets) in cwd to tmp
391+
# note that the R assets folder name can be any valid folder name
392+
assets = [
393+
os.path.join(cwd, _)
394+
for _ in os.listdir(cwd)
395+
if not _.startswith("__") and os.path.isdir(os.path.join(cwd, _))
396+
]
397+
398+
for asset in assets:
399+
target = os.path.join(self.tmp_app_path, os.path.basename(asset))
400+
if os.path.exists(target):
401+
logger.debug("delete existing target %s", target)
402+
shutil.rmtree(target)
403+
logger.debug("copying %s => %s", asset, self.tmp_app_path)
404+
shutil.copytree(asset, target)
405+
logger.debug("copied with %s", os.listdir(target))
406+
407+
else:
408+
logger.warning(
409+
"JuliaRunner found no cwd in the Python call stack. "
410+
"You may wish to specify an explicit working directory "
411+
"using something like: "
412+
"dashjl.run_server(app, cwd=os.path.dirname(__file__))"
413+
)
414+
415+
logger.info("Run Dash.jl app with julia => %s", app)
416+
417+
args = shlex.split(
418+
"julia {}".format(os.path.realpath(app)),
419+
posix=not self.is_windows,
420+
)
421+
logger.debug("start Dash.jl process with %s", args)
422+
423+
try:
424+
self.proc = subprocess.Popen(
425+
args,
426+
stdout=subprocess.PIPE,
427+
stderr=subprocess.PIPE,
428+
cwd=self.tmp_app_path if self.tmp_app_path else cwd,
429+
)
430+
# wait until server is able to answer http request
431+
wait.until(lambda: self.accessible(self.url), timeout=start_timeout)
432+
433+
except (OSError, ValueError):
434+
logger.exception("process server has encountered an error")
435+
self.started = False
436+
return
437+
438+
self.started = True

Diff for: dash/testing/composite.py

+14
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,17 @@ def start_server(self, app, cwd=None):
2929

3030
# set the default server_url, it implicitly call wait_for_page
3131
self.server_url = self.server.url
32+
33+
34+
class DashJuliaComposite(Browser):
35+
def __init__(self, server, **kwargs):
36+
super(DashJuliaComposite, self).__init__(**kwargs)
37+
self.server = server
38+
39+
def start_server(self, app, cwd=None):
40+
# start server with Dash.jl app. The app sets its own run_server args
41+
# on the Julia side, but we support overriding the automatic cwd
42+
self.server(app, cwd=cwd)
43+
44+
# set the default server_url, it implicitly call wait_for_page
45+
self.server_url = self.server.url

Diff for: dash/testing/plugin.py

+26-3
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44

55

66
try:
7-
from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner
7+
from dash.testing.application_runners import ThreadedRunner, ProcessRunner, RRunner, JuliaRunner
88
from dash.testing.browser import Browser
9-
from dash.testing.composite import DashComposite, DashRComposite
9+
from dash.testing.composite import DashComposite, DashRComposite, DashJuliaComposite
1010
except ImportError:
1111
pass
1212

@@ -78,7 +78,7 @@ def pytest_runtest_makereport(item, call): # pylint: disable=unused-argument
7878
if rep.when == "call" and rep.failed and hasattr(item, "funcargs"):
7979
for name, fixture in item.funcargs.items():
8080
try:
81-
if name in {"dash_duo", "dash_br", "dashr"}:
81+
if name in {"dash_duo", "dash_br", "dashr", "dashjl"}:
8282
fixture.take_snapshot(item.name)
8383
except Exception as e: # pylint: disable=broad-except
8484
print(e)
@@ -109,6 +109,12 @@ def dashr_server():
109109
yield starter
110110

111111

112+
@pytest.fixture
113+
def dashjl_server():
114+
with JuliaRunner() as starter:
115+
yield starter
116+
117+
112118
@pytest.fixture
113119
def dash_br(request, tmpdir):
114120
with Browser(
@@ -157,3 +163,20 @@ def dashr(request, dashr_server, tmpdir):
157163
pause=request.config.getoption("pause"),
158164
) as dc:
159165
yield dc
166+
167+
168+
@pytest.fixture
169+
def dashjl(request, dashjl_server, tmpdir):
170+
with DashJuliaComposite(
171+
dashjl_server,
172+
browser=request.config.getoption("webdriver"),
173+
remote=request.config.getoption("remote"),
174+
remote_url=request.config.getoption("remote_url"),
175+
headless=request.config.getoption("headless"),
176+
options=request.config.hook.pytest_setup_options(),
177+
download_path=tmpdir.mkdir("download").strpath,
178+
percy_assets_root=request.config.getoption("percy_assets"),
179+
percy_finalize=request.config.getoption("nopercyfinalize"),
180+
pause=request.config.getoption("pause"),
181+
) as dc:
182+
yield dc

0 commit comments

Comments
 (0)