Skip to content

Provide support for hot reloading in Dash for R #127

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 59 commits into from
Nov 1, 2019
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
c9dd851
:sparkles: logic to track state and set hashes
Sep 9, 2019
90d4f1a
:watch: use integer modtime, not hash
Sep 9, 2019
4db6184
:see_no_evil: fix bindings
Sep 9, 2019
1daa216
Revert ":see_no_evil: fix bindings"
Sep 9, 2019
78577de
:see_no_evil: use self$config
Sep 9, 2019
9f98709
insert reload_hash initial route
Sep 10, 2019
d996f53
:sparkles: track modified files
Sep 12, 2019
704a8e6
:sparkles: add custom logger
Sep 13, 2019
63266de
:hammer: update logger
Sep 15, 2019
4611541
:see_no_evil: remove browser()
Sep 15, 2019
8391448
:hammer: refactor file tracking
Sep 17, 2019
3b0c9b3
:see_no_evil: fixes for asset tracking
Sep 18, 2019
23e80af
:sparkles: hot reload app.R
Sep 18, 2019
8b34a1f
:see_no_evil: hot reload can be NULL
Sep 18, 2019
35ec809
:hammer: properly detect changes recursively
Sep 18, 2019
0328055
use modtimeFromPath recursively
Sep 18, 2019
2fd459a
:see_no_evil: allow reloading without assets
Sep 18, 2019
38f7094
Sep 18, 2019
961418a
Sep 18, 2019
401860d
Sep 18, 2019
63d2a1c
:hocho: getwd(), FALSE if not sourced/Rscripted
Oct 11, 2019
8d39b1e
:crossed_fingers: hot reloading v2.0
Oct 11, 2019
69f4374
handle app sourced outside its dir
Oct 11, 2019
6fbe421
Merge branch 'dev' into 125-hot-reloading
rpkyle Oct 11, 2019
a22f62b
:hocho: os.chdir
Oct 11, 2019
b2c26c7
fix missing parenthesis
Oct 11, 2019
54d2c5c
test os.chdir
Oct 11, 2019
e8c533a
Add line number context to stack traces when srcrefs are available (#…
rpkyle Oct 17, 2019
84264dd
:hammer: hot-reloading fixes
Oct 23, 2019
7bdf888
:hocho: components_cache and cache-control
Oct 25, 2019
f358fbc
:pencil2: use generateReloadHash
Oct 25, 2019
61a0e03
:hammer: refactor modtimeFromPath and its usage
Oct 25, 2019
c4eb68a
:hammer: use conditional logic for path vs. file
Oct 25, 2019
a9db490
:warning: politely require source() for :hotsprings:
Oct 25, 2019
9a749ac
:see_no_evil: clear field for next refresh
Oct 25, 2019
3acd451
:art: add unicode emoji to improve messaging
Oct 25, 2019
982a1cc
return value, perform assignment outside of fn
Oct 25, 2019
bafdd28
:rotating_light: initial test for :hotsprings:
Oct 25, 2019
7d55590
:art: add unicode emoji to improve messaging
Oct 25, 2019
f289083
:hammer: relocate reload interval check
Oct 26, 2019
ffbb633
update branch
Oct 28, 2019
9077838
update config
Oct 28, 2019
f2f1d8f
add artifacts
Oct 28, 2019
27909ee
fix hot reload test v1
Oct 28, 2019
f03418e
fix hot reload test v2, callback
Oct 28, 2019
3655b1b
:pencil2: cleanup & omit assets from update
Oct 29, 2019
3f0b6ae
:rotating_light: fix test!
Oct 29, 2019
f3b88cc
:see_no_evil: remove :zzz:
Oct 29, 2019
4f14816
:see_no_evil: need to handle case without assets
Oct 30, 2019
735baa2
:pencil2: fix comments
Oct 30, 2019
c928f9f
:deciduous_tree: remove dead branch
Oct 30, 2019
9979346
:rotating_light: add hard reload test, rename to clarify
Oct 30, 2019
4d374ac
:truck: rename files to make app :runner: happy
Oct 30, 2019
d7039e1
Update CHANGELOG.md
rpkyle Oct 30, 2019
da30cb2
Update CHANGELOG.md
rpkyle Oct 30, 2019
5228dd4
:pencil2: fix path to work in CI and locally
Oct 30, 2019
b2fd947
Merge branch '125-hot-reloading' of github.com:plotly/dashR into 125-…
Oct 30, 2019
1461ee7
Update CHANGELOG.md
rpkyle Oct 30, 2019
24c254a
:books: add documentation and some fixes
Oct 31, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,15 @@ jobs:
command: |
python -m venv venv
. venv/bin/activate
git clone --depth 1 https://github.com/plotly/dash.git
cd dash && pip install -e .[testing] --quiet && cd ..
pip install -e git+https://github.com/plotly/dash.git#egg=dash[testing]
export PATH=$PATH:/home/circleci/.local/bin/
pytest tests/integration/
pytest --log-cli-level DEBUG --junitxml=test-reports/dashr.xml tests/integration/
- store_artifacts:
path: test-reports
- store_test_results:
path: test-reports
- store_artifacts:
path: /tmp/dash_artifacts

- run:
name: 🔎 Unit tests
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
# Change Log for Dash for R
All notable changes to this project will be documented in this file.

## Unreleased
### Added
- Hot reloading now supported in debug mode [#127](https://github.com/plotly/dashR/pull/127)
- Support for displaying Dash for R applications within RStudio's viewer pane when `use_viewer = TRUE`
- Clientside callbacks written in JavaScript are now supported [#130](https://github.com/plotly/dashR/pull/130)
- Multiple outputs are now supported [#119](https://github.com/plotly/dashR/pull/119)
- Selective callback updates to properties now supported with `dashNoUpdate()` [#111](https://github.com/plotly/dashR/pull/111)
- Additional line number context inserted when available within stack traces [#133](https://github.com/plotly/dashR/pull/133)
- Integration and unit tests are now performed when commits are made to open pull requests

### Changed
- Dash for R no longer requires forked `reqres`, patch applied upstream [#9](https://github.com/thomasp85/reqres/pull/9)
- The `pruned_errors` parameter has been renamed to `dev_tools_prune_errors` [#113](https://github.com/plotly/dashR/pull/113)

### Fixed
- Patch for `reqres` package to handle cookies containing multiple "=" [#122](https://github.com/plotly/dashR/pull/122)
- Handling for user-defined errors in callbacks implemented [#116](https://github.com/plotly/dashR/pull/116)

## [0.1.0] - 2019-07-10
### Added
- Initial release
Expand Down
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Remotes: plotly/dash-html-components@17da1f4,
License: MIT + file LICENSE
Encoding: UTF-8
LazyData: true
KeepSource: true
RoxygenNote: 6.1.1
Roxygen: list(markdown = TRUE)
URL: https://github.com/plotly/dashR
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export(clientsideFunction)
export(input)
export(output)
export(state)
export(dashLogger)
import(dashCoreComponents)
import(dashHtmlComponents)
importFrom(R6,R6Class)
Expand Down
353 changes: 303 additions & 50 deletions R/dash.R

Large diffs are not rendered by default.

199 changes: 189 additions & 10 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ encode_plotly <- function(layout_objs) {
# so that it is pretty printed to stderr()
printCallStack <- function(call_stack, header=TRUE) {
if (header) {
write(crayon::yellow$bold(" ### DashR Traceback (most recent/innermost call last) ###"), stderr())
write(crayon::yellow$bold(" ### Dash for R Traceback (most recent/innermost call last) ###"), stderr())
}
write(
crayon::white(
Expand All @@ -694,7 +694,9 @@ printCallStack <- function(call_stack, header=TRUE) {
call_stack
),
": ",
call_stack
call_stack,
" ",
lapply(call_stack, attr, "flineref")
)
),
stderr()
Expand All @@ -707,7 +709,7 @@ stackTraceToHTML <- function(call_stack,
if(is.null(call_stack)) {
return(NULL)
}
header <- " ### DashR Traceback (most recent/innermost call last) ###"
header <- " ### Dash for R Traceback (most recent/innermost call last) ###"

formattedStack <- c(paste0(
" ",
Expand All @@ -716,6 +718,8 @@ stackTraceToHTML <- function(call_stack,
),
": ",
call_stack,
" ",
lapply(call_stack, attr, "lineref"),
collapse="<br>"
)
)
Expand Down Expand Up @@ -761,7 +765,19 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
}

functionsAsList <- lapply(calls, function(completeCall) {
currentCall <- completeCall[[1]]
# avoid attempting to cast closures as strings, which will fail
# some calls in the stack are symbol (name) objects, while others
# are calls, which must be deparsed; the first element in the vector
# should be the function signature
if (is.name(completeCall[[1]]))
currentCall <- as.character(completeCall[[1]])
else if (is.call(completeCall[[1]]))
currentCall <- deparse(completeCall)[1]
else
currentCall <- completeCall[[1]]

attr(currentCall, "flineref") <- getLineWithError(completeCall, formatted=TRUE)
attr(currentCall, "lineref") <- getLineWithError(completeCall, formatted=FALSE)

if (is.function(currentCall) & !is.primitive(currentCall)) {
constructedCall <- paste0("<anonymous> function(",
Expand Down Expand Up @@ -813,18 +829,16 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
functionsAsList <- removeHandlers(functionsAsList)
}

# use deparse in case the call throwing the error is a symbol,
# since this cannot be "printed" without deparsing the call
warning(call. = FALSE, immediate. = TRUE, sprintf("Execution error in %s: %s",
deparse(functionsAsList[[length(functionsAsList)]]),
functionsAsList[[length(functionsAsList)]],
conditionMessage(e)))

stack_message <- stackTraceToHTML(functionsAsList,
deparse(functionsAsList[[length(functionsAsList)]]),
functionsAsList[[length(functionsAsList)]],
conditionMessage(e))

assign("stack_message", value=stack_message,
envir=sys.frame(1)$private)
envir=sys.frame(countEnclosingFrames("private"))$private)

printCallStack(functionsAsList)
}
Expand All @@ -836,8 +850,22 @@ getStackTrace <- function(expr, debug = FALSE, prune_errors = TRUE) {
)
} else {
evalq(expr)
}
}
}

getLineWithError <- function(currentCall, formatted=TRUE) {
srcref <- attr(currentCall, "srcref", exact = TRUE)
if (!is.null(srcref) & !(getAppPath()==FALSE)) {
# filename
srcfile <- attr(srcref, "srcfile", exact = TRUE)
# line number
context <- sprintf("-- %s, Line %s", srcfile$filename, srcref[[1]])
if (formatted)
context <- crayon::yellow$italic(context)
return(context)
} else
""
}

# This helper function drops error
# handling functions from the call
Expand Down Expand Up @@ -920,6 +948,157 @@ getIdProps <- function(output) {
return(list(ids=ids, props=props))
}

modtimeFromPath <- function(path, recursive = FALSE, asset_path="") {
# ensure path is properly formatted
path <- normalizePath(path)

if (is.null(path)) {
return(NULL)
}

if (recursive) {
if (asset_path != "") {
all_files <- file.info(list.files(path, recursive = TRUE))
# need to exclude files which are in assets directory so we don't always hard reload
initpath <- vapply(strsplit(rownames(all_files), split = .Platform$file.sep), `[`, FUN.VALUE=character(1), 1)
# now subset the modtimes, and identify the most recently modified file
modtime <- as.integer(max(all_files$mtime[which(initpath != asset_path)], na.rm = TRUE))
} else {
# now identify the most recently modified file
all_files <- list.files(path, recursive = TRUE, full.names = TRUE)
modtime <- as.integer(max(file.info(all_files)$mtime, na.rm=TRUE))
}
} else {
# check if the path is for a directory or file, and handle accordingly
if (dir.exists(path))
modtime <- as.integer(max(file.info(list.files(path, full.names = TRUE))$mtime, na.rm=TRUE))
else
modtime <- as.integer(file.info(path)$mtime)
}

return(modtime)
}

getAppPath <- function() {
# attempt to retrieve path for Dash apps served via
# Rscript or source()
cmd_args <- commandArgs(trailingOnly = FALSE)
file_argument <- "--file="
matched_arg <- grep(file_argument, cmd_args)

# if app is instantiated via Rscript, cmd_args should contain path
if (length(matched_arg) > 0) {
# Rscript
return(normalizePath(sub(file_argument, "", cmd_args[matched_arg])))
}
# if app is instantiated via source(), sys.frames should contain path
else if (!is.null(sys.frames()[[1]]$ofile)) {
return(normalizePath(sys.frames()[[1]]$ofile))
}
else {
return(FALSE)
}
}

# this function enables Dash to set file modification times
# as attributes on the vectors stored within the asset map
#
# this permits storing additional information on the object
# without dramatically modifying the existing API, and makes
# it somewhat trivial to request the set of modification times
setModtimeAsAttr <- function(path) {
if (!is.null(path)) {
mtime <- modtimeFromPath(path)
attributes(path)$modtime <- mtime
return(path)
} else {
return(NULL)
}
}

countEnclosingFrames <- function(object) {
for (i in 1:sys.nframe()) {
objs <- ls(envir=sys.frame(i))
if (object %in% objs)
return(i)
}
}

changedAssets <- function(before, after) {
# identify files that used to exist in the asset map,
# but which have been removed
deletedElements <- before[which(is.na(match(before, after)))]

# identify files which were added since the last refresh
addedElements <- after[which(is.na(match(after, before)))]

# identify any items that have been updated since the last
# refresh based on modification time attributes set in map
#
# in R, attributes are discarded when subsetting, so it's
# necessary to subset the attributes being compared instead.
# here we only compare objects which overlap
before_modtimes <-attributes(before)$modtime[before %in% after]
after_modtimes <- attributes(after)$modtime[after %in% before]

changedElements <- after[which(after_modtimes > before_modtimes)]

if (length(deletedElements) == 0) {
deletedElements <- NULL
}
if (length(changedElements) == 0) {
changedElements <- NULL
}
if (length(addedElements) == 0) {
addedElements <- NULL
}
invisible(return(
list(deleted = deletedElements,
changed = changedElements,
new = addedElements)
)
)
}

dashLogger <- function(event = NULL,
message = NULL,
request = NULL,
time = Sys.time(),
...) {
orange <- crayon::make_style("orange")

# dashLogger is being called from within fiery, and the Fire() object generator
# is called from a private method within the Dash() R6 class; this makes
# accessing variables set within Dash's private fields somewhat complicated
#
# the following line retrieves the value of the silence_route_logging parameter,
# which is nearly 20 frames up the stack; if it's not found, we'll assume FALSE
silence_routes_logging <- dynGet("self", ifnotfound = FALSE)$config$silence_routes_logging

if (!is.null(event)) {
msg <- sprintf("%s: %s", event, message)

msg <- switch(event, error = crayon::red(msg), warning = crayon::yellow(msg),
message = crayon::blue(msg), msg)

# assign the status group for color coding
if (event == "request") {
status_group <- as.integer(cut(request$respond()$status,
breaks = c(100, 200, 300, 400, 500, 600), right = FALSE))

msg <- switch(status_group, crayon::blue$bold(msg), crayon::green$bold(msg),
crayon::cyan$bold(msg), orange$bold(msg), crayon::red$bold(msg))
}

# if log messages are suppressed, report only server stop/start messages, errors, and warnings
# otherwise, print everything to console
if (event %in% c("start", "stop", "error", "warning") || !(silence_routes_logging)) {
cat(msg, file = stdout(), append = TRUE)
cat("\n", file = stdout(), append = TRUE)
}
}
}

clientsideFunction <- function(namespace, function_name) {
return(list(namespace=namespace, function_name=function_name))
}
3 changes: 3 additions & 0 deletions tests/integration/devtools/assets/hot_reload.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#hot-reload-content {
background-color: blue;
}
26 changes: 26 additions & 0 deletions tests/integration/devtools/hard_reload/app.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@

library(dash)
library(dashHtmlComponents)
library(dashCoreComponents)
app <- Dash$new()

app$layout(htmlDiv(list(
htmlH3("Test hard reloading (when modifying any non-CSS resources)"),
dccInput(id='input'),
htmlDiv(id='output-serverside')
),
id="hot-reload-content"
)
)

app$callback(
output(id = "output-serverside", property = "children"),
params = list(
input(id = "input", property = "value")
),
function(value) {
sprintf("Pre-reloading test output should be %s", value)
}
)

app$run_server(dev_tools_hot_reload=TRUE, dev_tools_hot_reload_interval=0.1, dev_tools_silence_routes_logging=TRUE)
Loading